From 66aca010f1375eab3b699b58cd708129887718b6 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Thu, 10 Sep 2026 14:23:31 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=94=E6=AC=BE=E5=87=AD=E8=AF=81=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E4=BF=AE=E5=A4=8D=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=9B=9E?= =?UTF-8?q?=E7=AD=94=E4=BB=BB=E5=8A=A1=E4=B8=93=E5=B1=9E=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/main.py | 163 +++++++++++++++++++++---- backend/app/models.py | 9 ++ backend/schema.sql | 2 + frontend/src/AnswerUserPortal.jsx | 25 ++-- frontend/src/CreateAnswerTasksPage.jsx | 44 ++++++- frontend/src/RefundUploadPage.jsx | 38 +++++- frontend/src/TaskBoardPage.jsx | 9 +- frontend/src/admin.css | 110 ++++++++++++++++- frontend/src/main.jsx | 6 +- 9 files changed, 353 insertions(+), 53 deletions(-) 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
{labels.map((label, index) => { const step = index + 1; const done = step < active || (active === 4 && ['已通过', '已完成'].includes(status)); return
{done ? : step}{label}
})}
} -function Login({ api, onLogin }) { +function Login({ api, onLogin, linkToken }) { const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); const [busy, setBusy] = useState(false); const [sending, setSending] = useState(false); const [cooldown, setCooldown] = useState(0); const [error, setError] = useState(''); const [devCode, setDevCode] = useState('') useEffect(() => { if (!cooldown) return undefined; const timer = window.setInterval(() => setCooldown(value => Math.max(0, value - 1)), 1000); return () => window.clearInterval(timer) }, [cooldown]) async function sendCode() { if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码') setSending(true); setError('') - try { const result = await api('/api/answer/auth/send-code', { method: 'POST', body: JSON.stringify({ phone }) }); setCooldown(result.data.retry_after || 60); setDevCode(result.data.dev_code || '') } + try { const result = await api('/api/answer/auth/send-code', { method: 'POST', body: JSON.stringify({ phone, owner_token: linkToken || undefined }) }); setCooldown(result.data.retry_after || 60); setDevCode(result.data.dev_code || '') } catch (err) { setError(err.message) } finally { setSending(false) } } async function submit(event) { @@ -34,13 +34,13 @@ function Login({ api, onLogin }) { if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码') if (!/^\d{6}$/.test(code)) return setError('请输入6位验证码') setBusy(true) - try { const result = await api('/api/answer/auth/phone-login', { method: 'POST', body: JSON.stringify({ phone, code }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) } + try { const result = await api('/api/answer/auth/phone-login', { method: 'POST', body: JSON.stringify({ phone, code, owner_token: linkToken || undefined }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) } catch (err) { setError(err.message) } finally { setBusy(false) } } return
淘宝问答运营官方承接通道官方认证

手机号登录

使用短信验证码安全登录

{devCode && 开发环境验证码:{devCode}}{error &&
{error}
}
} -function UploadStage({ api, session, onUploaded, setError }) { +function UploadStage({ api, session, onUploaded, setError, linkToken }) { const [files, setFiles] = useState([]); const [busy, setBusy] = useState(false); const inputRef = useRef(null) const previews = useMemo(() => files.map(file => ({ file, url: URL.createObjectURL(file) })), [files]) useEffect(() => () => previews.forEach(item => URL.revokeObjectURL(item.url)), [previews]) @@ -48,6 +48,7 @@ function UploadStage({ api, session, onUploaded, setError }) { async function submit() { if (!files.length) return setError('请至少上传一张被邀请回答截图') setBusy(true); setError(''); const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file)) + if (linkToken) body.append('owner_token', linkToken) try { await api('/api/answer/me/submissions', { method: 'POST', body }); await onUploaded() } catch (err) { setError(err.message) } finally { setBusy(false) } } return <>
✨ 任务认领与核验

上传被邀请回答截图

提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。

上传被邀请回答截图 截图 · 必传

用于确认您的账号可回答指定问题(支持上传多张)。

{files.length}/10
{previews.map(({ file, url }) =>
{file.name}/
{file.name}
)}{files.length < 10 && }
addFiles(event.target.files)}/>

▧ 请确保凭证清晰、信息完整、无遮挡

@@ -62,7 +63,7 @@ function ReviewStage({ latest, reload, busy, startNew }) { return <>

已进入任务派发审核 截图审核中

截图已提交,任务派发审核界面已创建。运营正在核验截图并整理可回答问题,识别完成后会自动显示在后台。

提交时间{formatTime(latest.created_at)}当前状态等待运营审核
页面每 10 秒自动检查最新审核结果
等待运营确认并派发问题

截图已进入审核队列,无需停留等待识别完成;问题整理好后会自动开放任务。

还可以继续提交新的任务

当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。

} -function AssignmentStage({ api, session, latest, reload, setError }) { +function AssignmentStage({ api, session, latest, reload, setError, linkToken }) { const selected = latest.questions.filter(question => question.selected); const progressKey = `answer_progress_${latest.id}` const [done, setDone] = useState(() => { try { return JSON.parse(localStorage.getItem(progressKey) || '[]') } catch { return [] } }) const [tab, setTab] = useState('list'); const [files, setFiles] = useState([]); const [payFiles, setPayFiles] = useState([]); const [busy, setBusy] = useState(false) @@ -77,7 +78,7 @@ function AssignmentStage({ api, session, latest, reload, setError }) { async function copy(question) { try { await navigator.clipboard.writeText(question.answer || ''); if (!done.includes(question.id)) saveDone([...done, question.id]) } catch { setError('复制失败,请长按参考回答手动复制') } } async function complete(event) { event.preventDefault(); setError(''); if (!files.length) return setError('请至少上传一张完成回答截图'); if (!payFiles.length) return setError('请上传收款二维码后再提交') - const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file)); payFiles.forEach(file => body.append('payment_files', file)); setBusy(true) + const body = new FormData(); body.append('token', session.token); if (linkToken) body.append('owner_token', linkToken); files.forEach(file => body.append('files', file)); payFiles.forEach(file => body.append('payment_files', file)); setBusy(true) try { const result = await api(`/api/answer/me/submissions/${latest.id}/completion`, { method: 'POST', body }); await reload(result.data) } catch (err) { setError(err.message) } finally { setBusy(false) } } return <>
✓ 任务已派发 · 完成任务阶段

完成问答并上传任务凭证

请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。

{tab === 'list' ?

待回答问题清单

点击复制参考回答即可前往淘宝粘贴提交

回答进度 {completed}/{selected.length}
完成所有问题后,进入“任务上传” 
{selected.map((question, index) =>
{String(index + 1).padStart(2, '0')}

{question.question}

#{question.product_title || '商品问题'}{question.operator_images?.length > 0 &&
运营指定图片(请在回答本题时添加)
{question.operator_images.map((image, imageIndex) => {`运营指定回答图片${imageIndex)}
}
▱ 参考回答:

{question.answer}

{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}
)}
:

上传任务完成凭证

请上传已发布回答的截图;收款二维码为必传材料,用于后续核验。

} @@ -124,7 +125,7 @@ function HistoryPage({ records, close, onContinue }) { return

任务状态 / 上传历史({records.length})

{records.length === 0 ?
暂无历史记录
: records.map(record =>
{record.submission_no}{STATUS_TEXT[record.status] || record.status}

{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张

{record.invited_screenshots.slice(0, 4).map(image => { const src = image.url || image.data_url; return })}
{canContinue(record.status) && }
)}
} -export default function AnswerUserPortalV2({ api }) { +export default function AnswerUserPortalV2({ api, linkToken = '' }) { const [session, setSession] = useState(() => { try { return JSON.parse(localStorage.getItem(SESSION_KEY) || 'null') } catch { return null } }) const [records, setRecords] = useState([]); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const [loaded, setLoaded] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [retrying, setRetrying] = useState(false); const [newTaskMode, setNewTaskMode] = useState(false); const [activeRecordId, setActiveRecordId] = useState(null); const [optimisticStatuses, setOptimisticStatuses] = useState({}) const imageLoadIds = useRef(new Set()) @@ -134,7 +135,8 @@ export default function AnswerUserPortalV2({ api }) { const version = ++loadVersion.current if (!silent) setBusy(true) try { - const result = await api(`/api/answer/me/submissions?token=${encodeURIComponent(session.token)}&include_images=false`) + const ownerQuery = linkToken ? `&owner_token=${encodeURIComponent(linkToken)}` : '' + const result = await api(`/api/answer/me/submissions?token=${encodeURIComponent(session.token)}&include_images=false${ownerQuery}`) if (version !== loadVersion.current) return setRecords(current => result.data.map(record => { const previous = current.find(item => item.id === record.id) @@ -182,7 +184,8 @@ export default function AnswerUserPortalV2({ api }) { if (imageLoadIds.current.has(loadKey)) return imageLoadIds.current.add(loadKey) try { - const result = await api(`/api/answer/me/submissions/${record.id}?token=${encodeURIComponent(session.token)}`) + const ownerQuery = linkToken ? `&owner_token=${encodeURIComponent(linkToken)}` : '' + const result = await api(`/api/answer/me/submissions/${record.id}?token=${encodeURIComponent(session.token)}${ownerQuery}`) setRecords(current => current.map(item => item.id === record.id ? { ...item, ...result.data, _imagesLoaded: true } : item)) } catch (err) { imageLoadIds.current.delete(loadKey); setError(err.message) } } @@ -210,7 +213,7 @@ export default function AnswerUserPortalV2({ api }) { if (!historyOpen) return records.slice(0, 20).forEach(record => loadSubmissionImages(record)) }, [historyOpen, records.length]) - if (!session) return - return
{!historyOpen && }
{error &&
{error}
}{!loaded ?
正在读取任务状态…
: historyOpen ? setHistoryOpen(false)} onContinue={record => { setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false) }}/> : !visibleLatest ? { setNewTaskMode(false); setActiveRecordId(null); await load() }} setError={setError}/> : ['待筛选', '待运营派发', '已派发'].includes(visibleLatest.status) ? { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/> : visibleLatest.status === '待用户完成' || (visibleLatest.status === '未通过' && retrying) ? { if (submitted) { applySubmittedRecord(submitted); setRetrying(false); load(true); return } await load(); setRetrying(false) }} setError={setError}/> : setRetrying(true)} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/>}
{visibleLatest && }
+ if (!session) return + return
{!historyOpen && }
{error &&
{error}
}{!loaded ?
正在读取任务状态…
: historyOpen ? setHistoryOpen(false)} onContinue={record => { setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false) }}/> : !visibleLatest ? { setNewTaskMode(false); setActiveRecordId(null); await load() }} setError={setError}/> : ['待筛选', '待运营派发', '已派发'].includes(visibleLatest.status) ? { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/> : visibleLatest.status === '待用户完成' || (visibleLatest.status === '未通过' && retrying) ? { if (submitted) { applySubmittedRecord(submitted); setRetrying(false); load(true); return } await load(); setRetrying(false) }} setError={setError}/> : setRetrying(true)} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/>}
{visibleLatest && }
} diff --git a/frontend/src/CreateAnswerTasksPage.jsx b/frontend/src/CreateAnswerTasksPage.jsx index c42f232..79e6923 100644 --- a/frontend/src/CreateAnswerTasksPage.jsx +++ b/frontend/src/CreateAnswerTasksPage.jsx @@ -1,12 +1,34 @@ import React, { useEffect, useMemo, useState } from 'react' -export default function CreateAnswerTasksPageV2({ api }) { +export default function CreateAnswerTasksPageV2({ api, user }) { const [records, setRecords] = useState([]) const [expanded, setExpanded] = useState({}) const [busy, setBusy] = useState('') const [detailBusy, setDetailBusy] = useState('') const [error, setError] = useState('') const [rejecting, setRejecting] = useState(null) + const [shopNames, setShopNames] = useState({}) + const [answerLink, setAnswerLink] = useState('') + const [linkCopied, setLinkCopied] = useState(false) + + useEffect(() => { + let active = true + api('/api/admin/auth/answer-link').then(result => { + if (active && result.data?.path) setAnswerLink(new URL(result.data.path, window.location.origin).toString()) + }).catch(() => {}) + return () => { active = false } + }, [api, user?.id]) + + async function copyAnswerLink() { + if (!answerLink) return + try { + await navigator.clipboard.writeText(answerLink) + setLinkCopied(true) + window.setTimeout(() => setLinkCopied(false), 1600) + } catch { + setError('复制失败,请手动复制专属链接') + } + } async function load() { try { @@ -25,6 +47,13 @@ export default function CreateAnswerTasksPageV2({ api }) { })), } })) + setShopNames(current => { + const next = { ...current } + result.data.forEach(record => { + if (next[record.id] === undefined) next[record.id] = record.shop_name || '' + }) + return next + }) setExpanded(current => { const next = { ...current } result.data.forEach((record, index) => { @@ -104,6 +133,12 @@ export default function CreateAnswerTasksPageV2({ api }) { } async function publish(record) { + const shopName = String(shopNames[record.id] ?? record.shop_name ?? '').trim() + if (!shopName) { + setError(`请先为用户 ${record.user_id} 填写店铺名`) + setExpanded(current => ({ ...current, [record.id]: true })) + return + } if (!record.questions.some(question => question.selected)) { setError(`请先在用户 ${record.user_id} 下选择至少一个问题`) setExpanded(current => ({ ...current, [record.id]: true })) @@ -112,7 +147,10 @@ export default function CreateAnswerTasksPageV2({ api }) { setBusy(record.id) setError('') try { - await api(`/api/admin/answer-task-drafts/${record.id}/publish`, { method: 'POST' }) + await api(`/api/admin/answer-task-drafts/${record.id}/publish`, { + method: 'POST', + body: JSON.stringify({ shop_name: shopName }), + }) await load() } catch (err) { setError(err.message) @@ -161,6 +199,7 @@ export default function CreateAnswerTasksPageV2({ api }) {
ⓘ 回答截图审核用户提交截图后会立即进入此审核队列。按「用户ID」分组核对识别的问题,勾选需要派发的问题,回答可重新生成或直接编辑,确认后生成任务。
+ {answerLink &&
我的回答任务专属链接长期固定,用户通过此链接提交的截图只会进入您的审核队列
event.target.select()} />
} {error &&
{error}
}
@@ -179,6 +218,7 @@ export default function CreateAnswerTasksPageV2({ api }) {
{record.user_id}用户_{record.user_id.slice(1)}{record.submission_no}已选 {selected}/{record.questions.length} 题
+ {identifying && 正在识别,请等待…} diff --git a/frontend/src/RefundUploadPage.jsx b/frontend/src/RefundUploadPage.jsx index 7e9c0b8..49570b8 100644 --- a/frontend/src/RefundUploadPage.jsx +++ b/frontend/src/RefundUploadPage.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; export default function RefundUploadPage({ api }) { const token = window.location.pathname.split("/").filter(Boolean).pop() || ""; @@ -7,6 +7,8 @@ export default function RefundUploadPage({ api }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [done, setDone] = useState(false); + const cameraInputRef = useRef(null); + const fileInputRef = useRef(null); useEffect(() => { if (!token) return; @@ -42,6 +44,13 @@ export default function RefundUploadPage({ api }) { } } + function chooseFile(event) { + setError(""); + setFile(event.target.files?.[0] || null); + // Allow choosing the same file again after replacing/removing it. + event.target.value = ""; + } + return (
@@ -51,16 +60,35 @@ export default function RefundUploadPage({ api }) { {error ?
{error}
: null} {session && !done ? (
-
)} -
- {isAskTask && +
+ {task.shop_name && 店铺名称{task.shop_name || "--"} } @@ -701,7 +701,7 @@ export default function TaskBoardPage({ api }) { toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} /> - + {children.length > 0 && (