{question.question}
{question.answer}
diff --git a/backend/app/main.py b/backend/app/main.py index eedb6a2..40845e2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,7 +15,7 @@ import io import zipfile import xml.etree.ElementTree as ET -from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status +from fastapi import BackgroundTasks, Body, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import func, inspect, or_, select, text from sqlalchemy.orm import Session, noload, selectinload @@ -192,6 +192,7 @@ def public_user(user: AdminUser) -> dict: "dingtalk_dept_id": user.dingtalk_dept_id, "dingtalk_dept_name": user.dingtalk_dept_name, "is_dept_manager": user.is_dept_manager, + "answer_public_token": user.answer_public_token, } @@ -250,6 +251,7 @@ def startup(): "dingtalk_dept_path": "VARCHAR(1000) NULL", "dingtalk_title": "VARCHAR(255) NULL", "is_dept_manager": "BOOLEAN NOT NULL DEFAULT 0", + "answer_public_token": "VARCHAR(96) NULL", } for column_name, column_type in admin_user_migrations.items(): if column_name not in admin_user_columns: @@ -259,6 +261,9 @@ def startup(): if "owner_id" not in answer_submission_columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE answer_submissions ADD COLUMN owner_id INTEGER NULL")) + if "shop_name" not in answer_submission_columns: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE answer_submissions ADD COLUMN shop_name VARCHAR(255) NOT NULL DEFAULT ''")) question_columns = {column["name"] for column in inspect(engine).get_columns("answer_recognized_questions")} if "operator_image_screenshot_id" not in question_columns: with engine.begin() as connection: @@ -280,6 +285,31 @@ def startup(): with engine.begin() as connection: connection.execute(text("CREATE UNIQUE INDEX ix_ask_tasks_public_token ON ask_tasks (public_token)")) seed_users(db) + # Every operator keeps one stable public answer-task URL. Generate + # tokens after seeding so the default accounts receive one as well. + answer_users = db.scalars( + select(AdminUser).where( + AdminUser.answer_public_token.is_(None), + AdminUser.role_code != "super_admin", + ) + ).all() + for operator in answer_users: + operator.answer_public_token = _new_answer_public_token(db) + if answer_users: + db.commit() + admin_user_indexes = inspect(engine).get_indexes("admin_users") + admin_user_unique_constraints = inspect(engine).get_unique_constraints("admin_users") + answer_token_index_exists = any( + index.get("name") == "ix_admin_users_answer_public_token" + or index.get("column_names") == ["answer_public_token"] + for index in admin_user_indexes + ) or any( + constraint.get("column_names") == ["answer_public_token"] + for constraint in admin_user_unique_constraints + ) + if not answer_token_index_exists: + with engine.begin() as connection: + connection.execute(text("CREATE UNIQUE INDEX ix_admin_users_answer_public_token ON admin_users (answer_public_token)")) finally: db.close() @@ -680,7 +710,10 @@ def _can_access_answer_submission(record: AnswerSubmission | None, user: AdminUs if user.role_code == "super_admin" or record.owner_id == user.id: return True if record.owner_id is None: - return user.role_code == "dept_manager" + # Records created before fixed operator links have no owner. Keep + # those legacy rows operable during migration; every new submission + # made through an operator link is assigned an owner before insert. + return True if user.role_code != "dept_manager" or not user.dingtalk_dept_id or not record.owner: return False return f",{user.dingtalk_dept_id}," in (record.owner.dingtalk_dept_path or "") @@ -690,7 +723,9 @@ def _answer_owner_clause(user: AdminUser): if user.role_code == "super_admin": return True if user.role_code != "dept_manager" or not user.dingtalk_dept_id: - return AnswerSubmission.owner_id == user.id + # Include legacy unassigned submissions while isolating all rows that + # carry a fixed-link owner. + return or_(AnswerSubmission.owner_id == user.id, AnswerSubmission.owner_id.is_(None)) descendant_owners = select(AdminUser.id).where( or_( AdminUser.id == user.id, @@ -776,6 +811,28 @@ def _new_ask_public_token(db: Session) -> str: return token +def _new_answer_public_token(db: Session) -> str: + while True: + token = secrets.token_urlsafe(32) + if not db.scalar(select(AdminUser.id).where(AdminUser.answer_public_token == token)): + return token + + +def _answer_link_owner(token: str, db: Session) -> AdminUser: + normalized = str(token or "").strip() + if not normalized: + raise HTTPException(status_code=400, detail="回答任务专属链接无效") + owner = db.scalar( + select(AdminUser).where( + AdminUser.answer_public_token == normalized, + AdminUser.is_active.is_(True), + ) + ) + if not owner: + raise HTTPException(status_code=404, detail="回答任务专属链接无效或已停用") + return owner + + def _published_ask_task(public_token: str, db: Session, *, lock: bool = False) -> AskTask: statement = select(AskTask).where( # New links use a random public token. Keep the task number as a @@ -943,6 +1000,7 @@ def _submission_data(record: AnswerSubmission, include_images: bool = True, incl "submission_no": record.submission_no, "user_id": record.user.public_id, "phone": record.user.phone, + "shop_name": record.shop_name or "", "status": record.status, "created_at": record.created_at.isoformat(), "updated_at": record.updated_at.isoformat(), @@ -978,6 +1036,7 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d "submission_no": record.submission_no, "user_id": record.user.public_id, "phone": record.user.phone, + "shop_name": record.shop_name or "", "status": record.status, "created_at": record.created_at.isoformat(), "updated_at": record.updated_at.isoformat(), @@ -1007,10 +1066,11 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d **summary, "board_key": f"answer-{record.id}", "record_kind": "answer_submission", "task_id": record.submission_no, "task_type": "answer", "task_type_label": "回答任务", - "status_label": status_labels.get(record.status, record.status), "product_name": " / ".join(product_titles) or "待识别商品", + "status_label": status_labels.get(record.status, record.status), "shop_name": record.shop_name or "", "product_name": " / ".join(product_titles) or "待识别商品", + "creator_name": record.owner.display_name if record.owner else "运营后台", "qa_count": len(selected), "claimed_count": 1, "submitted_count": 1 if record.status in {"待审核", "已通过", "已完成"} else 0, "pending_review_count": 1 if record.status == "待审核" else 0, "completed_count": completed_count, "total_count": len(selected), - "creator_name": "运营后台", "can_cancel": record.status in {"待用户完成", "待审核", "未通过"}, + "can_cancel": record.status in {"待用户完成", "待审核", "未通过"}, "can_delete": True, "delete_endpoint": f"/api/admin/task-board/{record.id}", "refund_endpoint": f"/api/admin/task-board/{record.id}/confirm-refund" if record.status == "已通过" else None, @@ -1259,13 +1319,38 @@ def _ask_review_board_data(record: AskAssignmentBatch | AskAssignment, include_i } +@app.get("/api/admin/auth/answer-link") +def admin_answer_link(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + """Return the current operator's stable public answer-task URL token.""" + stored_user = db.get(AdminUser, user.id) + if not stored_user: + raise HTTPException(status_code=401, detail="登录状态已失效,请重新登录") + if not stored_user.answer_public_token: + stored_user.answer_public_token = _new_answer_public_token(db) + db.commit() + db.refresh(stored_user) + return {"success": True, "data": {"token": stored_user.answer_public_token, "path": f"/answer/{stored_user.answer_public_token}"}} + + @app.post("/api/answer/auth/phone-login") def answer_phone_login(payload: dict, db: Session = Depends(get_db)): - return {"success": True, "data": _public_phone_session(_phone_login(payload, db))} + # Validate the link before issuing a user session. The token is carried + # by the client on subsequent requests so a phone number can safely use + # more than one operator's link without changing the user account model. + owner_token = str(payload.get("owner_token") or payload.get("operator_token") or "").strip() + if owner_token: + _answer_link_owner(owner_token, db) + session = _public_phone_session(_phone_login(payload, db)) + if owner_token: + session["owner_token"] = owner_token + return {"success": True, "data": session} @app.post("/api/answer/auth/send-code") -def answer_send_code(payload: dict): +def answer_send_code(payload: dict, db: Session = Depends(get_db)): + owner_token = str(payload.get("owner_token") or payload.get("operator_token") or "").strip() + if owner_token: + _answer_link_owner(owner_token, db) return {"success": True, "data": _send_phone_code(payload)} @@ -1470,7 +1555,10 @@ def ask_completion_reviews(user: AdminUser = Depends(current_user), db: Session def review_pending_count(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): """Return lightweight sidebar counts without loading review rows or images.""" answer_count = db.scalar( - select(func.count(AnswerSubmission.id)).where(AnswerSubmission.status == "待审核") + select(func.count(AnswerSubmission.id)).where( + AnswerSubmission.status == "待审核", + _answer_owner_clause(user), + ) ) or 0 ask_batch_count = db.scalar( select(func.count(AskAssignmentBatch.id)).join(AskAssignmentBatch.assignment).join(AskAssignment.task).where(AskAssignmentBatch.status == "pending", _ask_task_owner_clause(user)) @@ -1547,18 +1635,24 @@ def ask_completion_decision(assignment_id: int, payload: dict, user: AdminUser = @app.get("/api/answer/me/submissions") -def answer_my_submissions(token: str, include_images: bool = False, db: Session = Depends(get_db)): +def answer_my_submissions(token: str, owner_token: str = "", include_images: bool = False, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) - records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.user_id == user.id).order_by(AnswerSubmission.created_at.desc())).unique().all() + statement = select(AnswerSubmission).where(AnswerSubmission.user_id == user.id) + if owner_token.strip(): + owner = _answer_link_owner(owner_token, db) + statement = statement.where(AnswerSubmission.owner_id == owner.id) + records = db.scalars(statement.order_by(AnswerSubmission.created_at.desc())).unique().all() return {"success": True, "data": [_submission_data(record, include_images=include_images) for record in records]} @app.get("/api/answer/me/submissions/{submission_id}") -def answer_submission_detail(submission_id: int, token: str, db: Session = Depends(get_db)): +def answer_submission_detail(submission_id: int, token: str, owner_token: str = "", db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) record = db.get(AnswerSubmission, submission_id) if not record or record.user_id != user.id: raise HTTPException(status_code=404, detail="任务不存在") + if owner_token.strip() and record.owner_id != _answer_link_owner(owner_token, db).id: + raise HTTPException(status_code=404, detail="任务不存在") return {"success": True, "data": _submission_data(record, include_images=True, include_refund_images=True)} @@ -1646,10 +1740,12 @@ def _process_answer_submission(submission_id: int): async def answer_create_submission( background_tasks: BackgroundTasks, token: str = Form(...), + owner_token: str = Form(default=""), files: list[UploadFile] = File(...), db: Session = Depends(get_db), ): user = _answer_user_from_token(token, db) + owner = _answer_link_owner(owner_token, db) if owner_token.strip() else None user_id = user.id db.rollback() if not files or len(files) > 10: @@ -1661,7 +1757,7 @@ async def answer_create_submission( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片") uploaded.append((upload.filename or "screenshot", upload.content_type, await upload.read())) # Persist only after all asynchronous file reads have completed. - record = AnswerSubmission(submission_no=f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", user_id=user_id, status="待筛选") + record = AnswerSubmission(submission_no=f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", user_id=user_id, owner_id=owner.id if owner else None, status="待筛选") db.add(record); db.flush() for filename, content_type, image_data in uploaded: db.add(AnswerScreenshot(submission_id=record.id, stage="invited", filename=filename, content_type=content_type, image_data=image_data)) @@ -1672,15 +1768,15 @@ async def answer_create_submission( @app.get("/api/admin/answer-task-drafts") -def admin_answer_task_drafts(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)): - records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.status.in_(["待筛选", "待运营派发", "已派发", "待用户完成"])).order_by(AnswerSubmission.created_at.desc())).unique().all() +def admin_answer_task_drafts(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.status.in_(["待筛选", "待运营派发", "已派发", "待用户完成"])).where(_answer_owner_clause(user)).order_by(AnswerSubmission.created_at.desc())).unique().all() return {"success": True, "data": [_submission_data(record, include_images=False) for record in records]} @app.get("/api/admin/answer-task-drafts/{submission_id}") -def admin_answer_task_draft_detail(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def admin_answer_task_draft_detail(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) - if not record or record.status not in {"待筛选", "待运营派发", "已派发", "待用户完成"}: + if not record or record.status not in {"待筛选", "待运营派发", "已派发", "待用户完成"} or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="回答任务不存在") return {"success": True, "data": _submission_data(record, include_images=True)} @@ -1689,13 +1785,13 @@ def admin_answer_task_draft_detail(submission_id: int, _: AdminUser = Depends(cu async def upload_answer_operator_images( question_id: int, files: list[UploadFile] = File(...), - _: AdminUser = Depends(current_user), + user: AdminUser = Depends(current_user), db: Session = Depends(get_db), ): """Attach up to three optional operation-specified images to one question.""" question = db.get(AnswerRecognizedQuestion, question_id) record = db.get(AnswerSubmission, question.submission_id) if question else None - if not question or not record or record.status not in {"待筛选", "待运营派发", "已派发"}: + if not question or not record or record.status not in {"待筛选", "待运营派发", "已派发"} or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="回答问题不存在或已经发布") if not files or len(files) > 3: raise HTTPException(status_code=400, detail="每道问题最多上传3张运营指定图片") @@ -1712,7 +1808,7 @@ async def upload_answer_operator_images( uploaded.append((file.filename or "operator-image", file.content_type, image_data)) question = db.get(AnswerRecognizedQuestion, question_id_value) record = db.get(AnswerSubmission, record_id) - if not question or not record or record.status not in {"待筛选", "待运营派发", "已派发"}: + if not question or not record or record.status not in {"待筛选", "待运营派发", "已派发"} or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="回答问题不存在或已经发布") old_ids = [value for value in (question.operator_image_screenshot_ids or "").split(",") if value.strip().isdigit()] for old_id in old_ids: @@ -1731,9 +1827,9 @@ async def upload_answer_operator_images( @app.post("/api/admin/answer-task-drafts/{submission_id}/reject") -def reject_answer_task_draft(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def reject_answer_task_draft(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) - if not record: + if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="回答任务不存在") if record.status not in {"待筛选", "待运营派发"}: raise HTTPException(status_code=409, detail="当前任务不能打回") @@ -1931,9 +2027,11 @@ def complete_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: @app.patch("/api/admin/answer-task-drafts/questions/{question_id}") -def update_answer_question(question_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def update_answer_question(question_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): question = db.get(AnswerRecognizedQuestion, question_id) - if not question: raise HTTPException(status_code=404, detail="问题不存在") + record = db.get(AnswerSubmission, question.submission_id) if question else None + if not question or not record or not _can_access_answer_submission(record, user): + raise HTTPException(status_code=404, detail="问题不存在") if payload.get("regenerate"): product_title, question_text = question.product_title, question.question_text selected = bool(payload["selected"]) if "selected" in payload else None @@ -1941,7 +2039,8 @@ def update_answer_question(question_id: int, payload: dict, _: AdminUser = Depen db.rollback() answer_text = generate_answer_for_question(product_title, question_text) question = db.get(AnswerRecognizedQuestion, question_id) - if not question: + record = db.get(AnswerSubmission, question.submission_id) if question else None + if not question or not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="问题不存在") if selected is not None: question.selected = selected @@ -1954,11 +2053,17 @@ def update_answer_question(question_id: int, payload: dict, _: AdminUser = Depen @app.post("/api/admin/answer-task-drafts/{submission_id}/publish") -def publish_answer_task(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def publish_answer_task(submission_id: int, payload: dict = Body(default={}), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) - if not record: raise HTTPException(status_code=404, detail="提交记录不存在") + if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="提交记录不存在") if record.status not in {"待筛选", "待运营派发", "已派发"}: raise HTTPException(status_code=409, detail="当前任务已经发布或不能发布") if not any(q.selected for q in record.questions): raise HTTPException(status_code=400, detail="请至少选择一个问题") + shop_name = str(payload.get("shop_name", "")).strip() + if not shop_name: + raise HTTPException(status_code=400, detail="派发前必须填写店铺名称") + if len(shop_name) > 255: + raise HTTPException(status_code=400, detail="店铺名称不能超过255个字符") + record.shop_name = shop_name if record.owner_id is None: record.owner_id = user.id record.status = "待用户完成"; db.commit() @@ -2250,9 +2355,11 @@ def admin_task_board_cancel(submission_id: int, user: AdminUser = Depends(curren @app.post("/api/answer/me/submissions/{submission_id}/completion") -async def answer_completion(submission_id: int, token: str = Form(...), files: list[UploadFile] = File(...), payment_files: list[UploadFile] = File(default=[]), db: Session = Depends(get_db)): +async def answer_completion(submission_id: int, token: str = Form(...), owner_token: str = Form(default=""), files: list[UploadFile] = File(...), payment_files: list[UploadFile] = File(default=[]), db: Session = Depends(get_db)): user = _answer_user_from_token(token, db); record = db.get(AnswerSubmission, submission_id) if not record or record.user_id != user.id: raise HTTPException(status_code=404, detail="任务不存在") + if owner_token.strip() and record.owner_id != _answer_link_owner(owner_token, db).id: + raise HTTPException(status_code=404, detail="任务不存在") record_status = record.status if record_status not in ("待用户完成", "未通过"): raise HTTPException(status_code=400, detail="当前任务不能提交完成截图") if not files or len(files) > 10: raise HTTPException(status_code=400, detail="最多上传10张截图") @@ -2268,6 +2375,8 @@ async def answer_completion(submission_id: int, token: str = Form(...), files: l record = db.get(AnswerSubmission, submission_id) if not record or record.user_id != user.id or record.status != record_status: raise HTTPException(status_code=409, detail="任务状态已发生变化,请刷新后重试") + if owner_token.strip() and record.owner_id != _answer_link_owner(owner_token, db).id: + raise HTTPException(status_code=404, detail="任务不存在") if record_status == "未通过": for old_screenshot in list(record.screenshots): if old_screenshot.stage in {"completion", "payment"}: diff --git a/backend/app/models.py b/backend/app/models.py index 4e074c1..94d1a7c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -23,6 +23,10 @@ class AdminUser(Base): dingtalk_dept_path: Mapped[str | None] = mapped_column(String(1000), nullable=True) dingtalk_title: Mapped[str | None] = mapped_column(String(255), nullable=True) is_dept_manager: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + # Stable public entry point for answer-task submissions created by this + # operator. The token is intentionally separate from the admin session + # cookie and is safe to embed in a long-lived URL. + answer_public_token: Mapped[str | None] = mapped_column(String(96), unique=True, index=True, nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) @@ -83,6 +87,11 @@ class AnswerSubmission(Base): submission_no: Mapped[str] = mapped_column(String(40), unique=True, index=True) user_id: Mapped[int] = mapped_column(ForeignKey("answer_users.id"), index=True) owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True) + # Store name is entered by the operator when this answer task is + # dispatched. It is kept on the submission because one operator may + # handle multiple stores and the same user can submit through different + # operator links. + shop_name: Mapped[str] = mapped_column(String(255), default="") # 用户上传受邀截图后立即进入运营筛选队列。 status: Mapped[str] = mapped_column(String(32), default="待筛选", index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) diff --git a/backend/schema.sql b/backend/schema.sql index ba958d8..f3b8699 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -7,12 +7,14 @@ CREATE TABLE IF NOT EXISTS admin_users ( password_hash VARCHAR(255) NOT NULL, display_name VARCHAR(64) NOT NULL, role_code VARCHAR(32) NOT NULL DEFAULT 'operator', + answer_public_token VARCHAR(96) NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, last_login_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_admin_users_username (username), + UNIQUE KEY uk_admin_users_answer_public_token (answer_public_token), KEY idx_admin_users_role (role_code), KEY idx_admin_users_status (is_active) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/frontend/src/AnswerUserPortal.jsx b/frontend/src/AnswerUserPortal.jsx index cc18e95..df49d5e 100644 --- a/frontend/src/AnswerUserPortal.jsx +++ b/frontend/src/AnswerUserPortal.jsx @@ -20,13 +20,13 @@ function Progress({ status }) { return
提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。
用于确认您的账号可回答指定问题(支持上传多张)。
▧ 请确保凭证清晰、信息完整、无遮挡
截图已提交,任务派发审核界面已创建。运营正在核验截图并整理可回答问题,识别完成后会自动显示在后台。
截图已进入审核队列,无需停留等待识别完成;问题整理好后会自动开放任务。
当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。
请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。
点击复制参考回答即可前往淘宝粘贴提交
{question.answer}
{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张
{record.submission_no}已选 {selected}/{record.questions.length} 题