From 20ca5384fc5d4190c12f157305f5fc61fa0c56dc Mon Sep 17 00:00:00 2001 From: kx_guo Date: Sat, 5 Sep 2026 15:17:53 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=82=E9=85=8D=E6=89=8B=E6=9C=BA=E7=AB=AF?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E7=BB=84=E4=BB=B6=EF=BC=8C=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=9D=83=E9=99=90=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/llm.py | 67 +++++- backend/app/main.py | 308 +++++++++++++++++-------- backend/app/models.py | 6 + backend/app/schemas.py | 2 +- backend/schema.sql | 3 + frontend/src/AnswerUserPortal.jsx | 26 ++- frontend/src/CreateAnswerTasksPage.jsx | 28 ++- frontend/src/CreateAskTasksPage.jsx | 80 +++++-- frontend/src/ReviewCenterPage.jsx | 37 ++- frontend/src/TaskBoardPage.jsx | 149 ++++++------ frontend/src/admin.css | 229 ++++++++++++++++++ frontend/src/main.jsx | 69 ++++-- frontend/src/pageCache.js | 26 +++ frontend/src/productIntent.js | 17 +- frontend/src/styles.css | 2 +- 15 files changed, 821 insertions(+), 228 deletions(-) create mode 100644 frontend/src/pageCache.js diff --git a/backend/app/llm.py b/backend/app/llm.py index fe06059..a6d660a 100644 --- a/backend/app/llm.py +++ b/backend/app/llm.py @@ -21,10 +21,10 @@ def _prompt(payload: QAGenerateRequest) -> str: 商品品类:{payload.category or '未提供'} 商品功效:{payload.benefits or '未提供'} 商品卖点:{payload.selling_points or '未提供'} -语言风格:{payload.language_style} -目标人群:{payload.target_group} -使用场景:{payload.usage_scene} -购买渠道:{payload.purchase_channel} +语言风格:{payload.language_style or '客观自然'} +目标人群:{payload.target_group or '普通消费者'} +使用场景:{payload.usage_scene or '日常使用'} +购买渠道:{payload.purchase_channel or '电商平台'} 回答要求:{payload.answer_requirement or '直接回应问题,避免夸大功效'} 回答语气:{style} 真实问答参考:{payload.reference_qa or '未提供'} @@ -65,7 +65,7 @@ def _parse_content(content: str, expected_answers: int) -> list[dict]: return items -def generate_qa(payload: QAGenerateRequest) -> list[dict]: +def _generate_qa_batch(payload: QAGenerateRequest) -> list[dict]: if not settings.llm_api_key: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置大模型 API Key,请先设置 LLM_API_KEY") url = settings.llm_base_url.rstrip("/") + "/chat/completions" @@ -88,7 +88,33 @@ def generate_qa(payload: QAGenerateRequest) -> list[dict]: return _parse_content(content, payload.answers_per_question) -def generate_questions(payload: dict, count: int, reference_qa: str = "") -> list[str]: +def generate_qa(payload: QAGenerateRequest) -> list[dict]: + """Generate the requested amount in small batches so large requests are not truncated.""" + target = payload.question_count + items: list[dict] = [] + seen: set[str] = set() + attempts = 0 + max_attempts = max((target + 19) // 20 + 2, 3) + while len(items) < target and attempts < max_attempts: + batch_size = min(20, target - len(items)) + batch_payload = payload.model_copy(update={"question_count": batch_size}) + for item in _generate_qa_batch(batch_payload): + key = item["question"].strip() + if key in seen: + continue + seen.add(key) + items.append(item) + if len(items) >= target: + break + attempts += 1 + if len(items) < target: + logger.warning("LLM returned %s/%s unique QA items", len(items), target) + for index, item in enumerate(items[:target], start=1): + item["id"] = f"Q{index:03d}" + return items[:target] + + +def _generate_questions_batch(payload: dict, count: int, reference_qa: str = "") -> list[str]: """Generate only consumer questions for the ask-task workflow.""" if not settings.llm_api_key: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置大模型 API Key,无法生成真实提问") @@ -97,10 +123,10 @@ def generate_questions(payload: dict, count: int, reference_qa: str = "") -> lis 商品品类:{payload.get('category') or '未提供'} 商品功效:{payload.get('benefits') or '未提供'} 商品卖点:{payload.get('selling_points') or '未提供'} -语言风格:{payload.get('language_style')} -目标人群:{payload.get('target_group')} -使用场景:{payload.get('usage_scene')} -购买渠道:{payload.get('purchase_channel')} +语言风格:{payload.get('language_style') or '客观自然'} +目标人群:{payload.get('target_group') or '普通消费者'} +使用场景:{payload.get('usage_scene') or '日常使用'} +购买渠道:{payload.get('purchase_channel') or '电商平台'} 真实问答参考:{reference_qa or '未提供'} 只返回严格 JSON,不要 Markdown 或解释,格式:{{"questions":["问题1","问题2"]}}。问题应像真实买家提问,尽量不超过30字,不要虚构具体参数或功效。""" url = settings.llm_base_url.rstrip("/") + "/chat/completions" @@ -131,6 +157,27 @@ def generate_questions(payload: dict, count: int, reference_qa: str = "") -> lis raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型提问生成失败,请稍后重试") from exc +def generate_questions(payload: dict, count: int, reference_qa: str = "") -> list[str]: + """Generate questions in batches and merge them until the requested count is reached.""" + target = int(count) + output: list[str] = [] + seen: set[str] = set() + attempts = 0 + max_attempts = max((target + 19) // 20 + 2, 3) + while len(output) < target and attempts < max_attempts: + batch = _generate_questions_batch(payload, min(20, target - len(output)), reference_qa) + for question in batch: + if question not in seen: + seen.add(question) + output.append(question) + if len(output) >= target: + break + attempts += 1 + if len(output) < target: + logger.warning("LLM returned %s/%s unique questions", len(output), target) + return output[:target] + + def generate_answer_for_question(product_title: str, question: str) -> str: """Generate one concise answer. Falls back to a safe draft during the simulated-OCR MVP.""" if not settings.llm_api_key: diff --git a/backend/app/main.py b/backend/app/main.py index a4bd437..ead4cf2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,7 +16,7 @@ import xml.etree.ElementTree as ET from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status from fastapi.middleware.cors import CORSMiddleware -from sqlalchemy import func, or_, select +from sqlalchemy import func, inspect, or_, select, text from sqlalchemy.orm import Session, noload, selectinload from .config import settings @@ -85,15 +85,8 @@ def _product_intent_error(values: list[object]) -> str | None: def _generation_options_error(payload: dict, include_answers: bool = True) -> str | None: - required = [ - ("language_style", "语言风格"), - ("target_group", "目标人群"), - ("question_count", "提问生成条数"), - ("usage_scene", "使用场景"), - ("purchase_channel", "购买渠道"), - ] - if include_answers: - required.insert(3, ("answers_per_question", "单题回答条数")) + # 语言风格、目标人群、场景等生成偏好均可留空,仅限制生成条数必填。 + required = [("question_count", "提问生成条数")] missing = [label for key, label in required if not str(payload.get(key, "")).strip()] if missing: return f"请填写{'、'.join(missing)}" @@ -103,13 +96,6 @@ def _generation_options_error(payload: dict, include_answers: bool = True) -> st return "提问生成条数必须是大于 0 的整数" if question_count < 1: return "提问生成条数必须是大于 0 的整数" - if include_answers: - try: - answers_count = int(payload.get("answers_per_question")) - except (TypeError, ValueError): - return "单题回答条数必须是大于 0 的整数" - if answers_count < 1: - return "单题回答条数必须是大于 0 的整数" return None @@ -171,6 +157,21 @@ def seed_users(db: Session): @app.on_event("startup") def startup(): Base.metadata.create_all(bind=engine) + # Add fields introduced after the initial SQLite database was created. + columns = {column["name"] for column in inspect(engine).get_columns("ask_tasks")} + if "claim_questions_per_user" not in columns: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN claim_questions_per_user INTEGER NOT NULL DEFAULT 5")) + if "owner_id" not in columns: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN owner_id INTEGER NULL")) + 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: + connection.execute(text("ALTER TABLE answer_recognized_questions ADD COLUMN operator_image_screenshot_id INTEGER NULL")) + if "operator_image_screenshot_ids" not in question_columns: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE answer_recognized_questions ADD COLUMN operator_image_screenshot_ids TEXT NULL")) db = next(get_db()) try: seed_users(db) @@ -353,6 +354,20 @@ def _dingtalk_login(code: str, db: Session, response: Response) -> AdminUser: return user +def _can_access_ask_task(task: AskTask, user: AdminUser) -> bool: + return user.role_code == "super_admin" or task.owner_id == user.id + + +def _require_ask_task_access(task: AskTask | None, user: AdminUser) -> AskTask: + if not task or not _can_access_ask_task(task, user): + raise HTTPException(status_code=404, detail="提问任务不存在") + return task + + +def _ask_task_owner_clause(user: AdminUser): + return True if user.role_code == "super_admin" else AskTask.owner_id == user.id + + def _published_ask_task(task_no: str, db: Session, *, lock: bool = False) -> AskTask: if task_no == "latest": statement = select(AskTask).where(AskTask.status == "已发布").order_by(AskTask.created_at.desc()) @@ -521,6 +536,10 @@ def _submission_data(record: AnswerSubmission, include_images: bool = True) -> d "id": q.id, "sequence_no": q.sequence_no, "product_title": q.product_title, "product_image": _image_url(images[q.product_image_screenshot_id]) if include_images and q.product_image_screenshot_id in images else None, "source_image": _image_url(images[q.source_screenshot_id]) if include_images and q.source_screenshot_id in images else None, + "operator_images": [ + _image_url(images[int(image_id)]) for image_id in ((q.operator_image_screenshot_ids or str(q.operator_image_screenshot_id or "")).split(",")) + if image_id.strip().isdigit() and int(image_id) in images + ] if include_images else [], "question": q.question_text, "answer": q.answer_text, "answer_status": q.answer_status, "selected": q.selected, } for q in sorted(record.questions, key=lambda value: value.sequence_no)], } @@ -570,25 +589,106 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d } +def _ask_assignment_child_data(assignment: AskAssignment) -> dict: + """Return a compact user-claim row nested under its system ask task.""" + task = assignment.task + selected_ids = { + item.question_id for item in assignment.items if item.selected + } + used_ids = {item.question_id for item in assignment.used_questions} + latest_batch = max(assignment.batches, key=lambda value: value.updated_at or value.created_at, default=None) + batch_selected_ids = set() + if latest_batch and latest_batch.selected_question_ids: + batch_selected_ids = { + int(value) for value in latest_batch.selected_question_ids.split(",") if value.strip().isdigit() + } + question_count = len(batch_selected_ids or selected_ids or used_ids) + refund_completed = bool(latest_batch and latest_batch.status == "paid") or assignment.status == "已完成" + refund_pending = bool(latest_batch and latest_batch.status == "approved") or assignment.status == "已通过" + if refund_completed: + status_label = "已完成" + elif refund_pending: + status_label = "待返款" + elif assignment.status == "待审核" or (latest_batch and latest_batch.status == "pending"): + status_label = "待审核" + elif assignment.status == "已撤销": + status_label = "已撤销" + else: + status_label = "进行中" + detail_endpoint = None + refund_endpoint = None + if latest_batch and latest_batch.status in {"approved", "paid"}: + detail_endpoint = f"/api/admin/task-board/ask-batches/{latest_batch.id}" + if not refund_completed: + refund_endpoint = f"/api/admin/task-board/ask-batches/{latest_batch.id}/confirm-refund" + elif assignment.status in {"已通过", "已完成"} and not assignment.batches: + detail_endpoint = f"/api/admin/task-board/ask-assignments/{assignment.id}" + if not refund_completed: + refund_endpoint = f"/api/admin/task-board/ask-assignments/{assignment.id}/confirm-refund" + return { + "id": assignment.id, + "board_key": f"ask-assignment-{assignment.id}", + "record_kind": "ask_assignment", + "is_child": True, + "task_id": task.task_no, + "task_type": "ask", + "task_type_label": "提问任务", + "product_name": task.product_name, + "product_link": task.product_link, + "status": assignment.status, + "status_label": status_label, + "user_id": assignment.user.public_id, + "phone": assignment.user.phone, + "qa_count": question_count, + "claimed_count": question_count, + "submitted_count": len(used_ids), + "pending_review_count": 1 if status_label == "待审核" else 0, + "completed_count": len(used_ids) if used_ids else (question_count if refund_completed else 0), + "total_count": question_count, + "creator_name": task.owner.display_name if task.owner else "运营后台", + "created_at": assignment.created_at.isoformat(), + "updated_at": assignment.updated_at.isoformat(), + "can_cancel": False, + "can_complete": False, + "can_delete": False, + "detail_endpoint": detail_endpoint, + "refund_endpoint": refund_endpoint, + "assigned_users": [{"user_id": assignment.user.public_id, "phone": assignment.user.phone, "status": status_label}], + "selected_questions": [], + "question_screenshots": [], + "completion_screenshots": [], + "payment_screenshots": [], + "latest_review": None, + } + + def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: questions = sorted(task.questions, key=lambda value: value.sequence_no) selected_questions = [q for q in questions if q.selected] + claimed_tasks = sorted( + (_ask_assignment_child_data(assignment) for assignment in task.assignments), + key=lambda value: value.get("updated_at") or value.get("created_at") or "", + reverse=True, + ) assigned_count = len(task.assignments) completed_count = sum(len(assignment.used_questions) for assignment in task.assignments) - pending_count = sum(1 for assignment in task.assignments if assignment.status in {"待审核", "未通过"}) + pending_count = sum(child["pending_review_count"] for child in claimed_tasks) return { "id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task", "task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务", "product_name": task.product_name, "product_link": task.product_link, "status": task.status, + "claim_questions_per_user": task.claim_questions_per_user or 5, "status_label": {"已发布": "进行中", "已撤销": "已撤销", "已完成": "已完成"}.get(task.status, task.status), "qa_count": len(selected_questions), "claimed_count": assigned_count, "submitted_count": completed_count, "pending_review_count": pending_count, "completed_count": completed_count, "total_count": len(selected_questions), - "creator_name": "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(), + "creator_name": task.owner.display_name if task.owner else "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(), "can_cancel": task.status == "已发布", "can_complete": task.status == "已发布", "can_delete": True, "delete_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}", "user_id": "--", "phone": "", "questions": [], "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name} for q in selected_questions] if include_questions else []), - "assigned_users": [], "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None, + "assigned_users": [child["assigned_users"][0] for child in claimed_tasks if child.get("assigned_users")], + "claimed_tasks": claimed_tasks, + "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None, "detail_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}" if not include_questions else None, } @@ -739,7 +839,8 @@ def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)): status_code=409, detail="本次任务已被领取完,请关注下一个任务!", ) - assigned = random.sample(available, min(5, len(available))) + claim_limit = task.claim_questions_per_user or 5 + assigned = random.sample(available, min(claim_limit, len(available))) assignment = AskAssignment(task_id=task.id, user_id=user.id, status="待选择") db.add(assignment); db.flush() for question in assigned: @@ -856,17 +957,19 @@ async def submit_public_ask_task( @app.get("/api/admin/ask-completion-reviews") -def ask_completion_reviews(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def ask_completion_reviews(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batches = db.scalars( - select(AskAssignmentBatch) + select(AskAssignmentBatch).join(AskAssignmentBatch.assignment).join(AskAssignment.task) .where(AskAssignmentBatch.status == "pending") + .where(_ask_task_owner_clause(user)) .order_by(AskAssignmentBatch.updated_at.desc()) ).unique().all() legacy = db.scalars( - select(AskAssignment).where( + select(AskAssignment).join(AskAssignment.task).where( AskAssignment.status == "待审核", ~AskAssignment.batches.any(), AskAssignment.screenshots.any(), + _ask_task_owner_clause(user), ).order_by(AskAssignment.updated_at.desc()) ).unique().all() data = [_ask_batch_data(batch, include_images=False) for batch in batches] @@ -875,19 +978,20 @@ def ask_completion_reviews(_: AdminUser = Depends(current_user), db: Session = D @app.get("/api/admin/review-pending-count") -def review_pending_count(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +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 == "待审核") ) or 0 ask_batch_count = db.scalar( - select(func.count(AskAssignmentBatch.id)).where(AskAssignmentBatch.status == "pending") + select(func.count(AskAssignmentBatch.id)).join(AskAssignmentBatch.assignment).join(AskAssignment.task).where(AskAssignmentBatch.status == "pending", _ask_task_owner_clause(user)) ) or 0 legacy_ask_count = db.scalar( - select(func.count(AskAssignment.id)).where( + select(func.count(AskAssignment.id)).join(AskAssignment.task).where( AskAssignment.status == "待审核", ~AskAssignment.batches.any(), AskAssignment.screenshots.any(), + _ask_task_owner_clause(user), ) ) or 0 ask_count = ask_batch_count + legacy_ask_count @@ -902,20 +1006,22 @@ def review_pending_count(_: AdminUser = Depends(current_user), db: Session = Dep @app.get("/api/admin/ask-completion-reviews/{assignment_id}") -def ask_completion_review_detail(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def ask_completion_review_detail(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batch = db.get(AskAssignmentBatch, assignment_id) if batch: + _require_ask_task_access(batch.assignment.task, user) return {"success": True, "data": _ask_batch_data(batch)} assignment = db.get(AskAssignment, assignment_id) - if not assignment: + if not assignment or not _can_access_ask_task(assignment.task, user): raise HTTPException(status_code=404, detail="提问审核任务不存在") return {"success": True, "data": _ask_assignment_data(assignment)} @app.post("/api/admin/ask-completion-reviews/{assignment_id}/decision") -def ask_completion_decision(assignment_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def ask_completion_decision(assignment_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batch = db.get(AskAssignmentBatch, assignment_id) if batch: + _require_ask_task_access(batch.assignment.task, user) if batch.status != "pending": raise HTTPException(status_code=409, detail="review already completed") approved = bool(payload.get("approved")) @@ -1064,6 +1170,43 @@ def admin_answer_task_draft_detail(submission_id: int, _: AdminUser = Depends(cu return {"success": True, "data": _submission_data(record, include_images=True)} +@app.post("/api/admin/answer-task-drafts/questions/{question_id}/operator-image") +async def upload_answer_operator_images( + question_id: int, + files: list[UploadFile] = File(...), + _: 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 {"待筛选", "待运营派发", "已派发"}: + raise HTTPException(status_code=404, detail="回答问题不存在或已经发布") + if not files or len(files) > 3: + raise HTTPException(status_code=400, detail="每道问题最多上传3张运营指定图片") + old_ids = [value for value in (question.operator_image_screenshot_ids or "").split(",") if value.strip().isdigit()] + for old_id in old_ids: + old = db.get(AnswerScreenshot, int(old_id)) + if old: + db.delete(old) + new_ids = [] + for file in files: + if file.content_type not in {"image/jpeg", "image/png", "image/webp"}: + db.rollback() + raise HTTPException(status_code=400, detail="运营指定图片仅支持 JPG、PNG、WEBP") + image_data = await file.read() + if not image_data: + db.rollback() + raise HTTPException(status_code=400, detail="不能上传空图片") + image = AnswerScreenshot(submission_id=record.id, stage="operator", filename=file.filename or "operator-image", content_type=file.content_type, image_data=image_data) + db.add(image); db.flush(); new_ids.append(str(image.id)) + question.operator_image_screenshot_ids = ",".join(new_ids) + question.operator_image_screenshot_id = int(new_ids[0]) if new_ids else None + db.commit() + db.refresh(record) + return {"success": True, "data": _submission_data(record, include_images=True)} + + @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)): record = db.get(AnswerSubmission, submission_id) @@ -1077,7 +1220,7 @@ def reject_answer_task_draft(submission_id: int, _: AdminUser = Depends(current_ @app.post("/api/admin/ask-tasks/generate") -def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): product_link = str(payload.get("product_link", "")).strip() product_name = str(payload.get("product_name", "")).strip() if not product_link: raise HTTPException(status_code=400, detail="商品链接为必填项") @@ -1093,12 +1236,23 @@ def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: S raise HTTPException(status_code=400, detail="提问生成条数必须是正整数") if count < 1: raise HTTPException(status_code=400, detail="提问生成条数必须大于 0") + raw_claim_limit = payload.get("claim_questions_per_user", "") + if str(raw_claim_limit).strip(): + try: + claim_limit = int(raw_claim_limit) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="每个用户可认领的问题数量必须是 2-5 的整数") + if claim_limit < 2 or claim_limit > 5: + raise HTTPException(status_code=400, detail="每个用户可认领的问题数量必须在 2-5 条之间") + else: + claim_limit = 5 existing_task_id = payload.get("task_id") try: task = db.get(AskTask, int(existing_task_id)) if existing_task_id else None except (TypeError, ValueError): task = None if task: + _require_ask_task_access(task, user) if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能重新生成") task.product_link = product_link @@ -1110,11 +1264,12 @@ def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: S task.target_group = str(payload.get("target_group", "")) task.usage_scene = str(payload.get("usage_scene", "")) task.purchase_channel = str(payload.get("purchase_channel", "")) + task.claim_questions_per_user = claim_limit for question in list(task.questions): db.delete(question) db.flush() else: - task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", product_link=product_link, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), status="草稿") + task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, product_link=product_link, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), claim_questions_per_user=claim_limit, status="草稿") db.add(task); db.flush() questions = payload.get("questions") if isinstance(payload.get("questions"), list) else [] reference_text = str(payload.get("reference_qa", ""))[:20000] @@ -1136,9 +1291,9 @@ def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: S @app.patch("/api/admin/ask-tasks/{task_id}/questions/{question_id}") -def update_ask_question(task_id: int, question_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def update_ask_question(task_id: int, question_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): question = db.scalar(select(AskQuestion).where(AskQuestion.id == question_id, AskQuestion.task_id == task_id)) - if not question: raise HTTPException(status_code=404, detail="提问不存在") + if not question or not _can_access_ask_task(question.task, user): raise HTTPException(status_code=404, detail="提问不存在") if question.task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能修改") if "question" in payload: value = str(payload.get("question", "")).strip() @@ -1150,10 +1305,10 @@ def update_ask_question(task_id: int, question_id: int, payload: dict, _: AdminU @app.post("/api/admin/ask-tasks/{task_id}/questions") -def add_ask_question(task_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def add_ask_question(task_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): task = db.get(AskTask, task_id) text = str(payload.get("question", "")).strip() - if not task: raise HTTPException(status_code=404, detail="提问任务不存在") + _require_ask_task_access(task, user) if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能添加提问") if not text: raise HTTPException(status_code=400, detail="提问内容不能为空") question = AskQuestion(task_id=task.id, sequence_no=len(task.questions) + 1, question_text=text, selected=True) @@ -1162,9 +1317,9 @@ def add_ask_question(task_id: int, payload: dict, _: AdminUser = Depends(current @app.post("/api/admin/ask-tasks/{task_id}/publish") -def publish_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def publish_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): task = db.get(AskTask, task_id) - if not task: raise HTTPException(status_code=404, detail="提问任务不存在") + _require_ask_task_access(task, user) if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布") if not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问") task.status = "已发布"; db.commit(); db.refresh(task) @@ -1172,19 +1327,18 @@ def publish_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Ses @app.post("/api/admin/ask-tasks/{task_id}/cancel") -def cancel_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def cancel_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): task = db.get(AskTask, task_id) - if not task: raise HTTPException(status_code=404, detail="提问任务不存在") + _require_ask_task_access(task, user) if task.status != "已发布": raise HTTPException(status_code=409, detail="当前任务不能撤销") task.status = "已撤销"; db.commit(); db.refresh(task) return {"success": True, "data": _ask_task_data(task)} @app.post("/api/admin/ask-tasks/{task_id}/complete") -def complete_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def complete_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): task = db.get(AskTask, task_id) - if not task: - raise HTTPException(status_code=404, detail="提问任务不存在") + _require_ask_task_access(task, user) if task.status != "已发布": raise HTTPException(status_code=409, detail="当前任务不能完成归档") task.status = "已完成" @@ -1217,7 +1371,7 @@ def publish_answer_task(submission_id: int, _: AdminUser = Depends(current_user) @app.get("/api/admin/task-board") -def admin_task_board(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def admin_task_board(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): if _release_expired_ask_assignments(db): db.commit() task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"] @@ -1231,58 +1385,28 @@ def admin_task_board(_: AdminUser = Depends(current_user), db: Session = Depends .where(AnswerSubmission.status.in_(task_statuses)) .order_by(AnswerSubmission.updated_at.desc()) ).unique().all() - ask_tasks = db.scalars(select(AskTask).where(AskTask.status.in_(["已发布", "已完成", "已撤销"])).order_by(AskTask.updated_at.desc())).unique().all() - approved_ask_batches = db.scalars( - select(AskAssignmentBatch) - .options( - selectinload(AskAssignmentBatch.assignment).selectinload(AskAssignment.task), - selectinload(AskAssignmentBatch.assignment).selectinload(AskAssignment.user), - noload(AskAssignmentBatch.screenshots), - noload(AskAssignmentBatch.review_decisions), - ) - .where(AskAssignmentBatch.status.in_(["approved", "paid"])) - .order_by(AskAssignmentBatch.updated_at.desc()) - ).unique().all() - approved_legacy_ask_assignments = db.scalars( - select(AskAssignment) - .options( - selectinload(AskAssignment.task), - selectinload(AskAssignment.user), - selectinload(AskAssignment.items).selectinload(AskAssignmentQuestion.question), - selectinload(AskAssignment.used_questions), - noload(AskAssignment.screenshots), - noload(AskAssignment.review_decisions), - ) - .where( - AskAssignment.status.in_(["已通过", "已完成"]), - ~AskAssignment.batches.any(), - AskAssignment.screenshots.any(), - ) - .order_by(AskAssignment.updated_at.desc()) - ).unique().all() + ask_statement = select(AskTask).options(selectinload(AskTask.owner)).where(AskTask.status.in_(["已发布", "已完成", "已撤销"]), _ask_task_owner_clause(user)).order_by(AskTask.updated_at.desc()) + ask_tasks = db.scalars(ask_statement).unique().all() data = ( [_task_board_data(record, include_images=False) for record in records] + [_ask_task_data(task, include_questions=False) for task in ask_tasks] - + [_ask_review_board_data(batch, include_images=False) for batch in approved_ask_batches] - + [_ask_review_board_data(assignment, include_images=False) for assignment in approved_legacy_ask_assignments] ) data.sort(key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True) return {"success": True, "data": data} @app.get("/api/admin/task-board/ask-tasks/{task_id}") -def admin_task_board_ask_task_detail(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def admin_task_board_ask_task_detail(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): task = db.get(AskTask, task_id) - if not task or task.status not in {"已发布", "已完成", "已撤销"}: + if not task or task.status not in {"已发布", "已完成", "已撤销"} or not _can_access_ask_task(task, user): raise HTTPException(status_code=404, detail="提问任务不存在") return {"success": True, "data": _ask_task_data(task, include_questions=True)} @app.delete("/api/admin/task-board/ask-tasks/{task_id}") -def admin_task_board_delete_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def admin_task_board_delete_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): task = db.get(AskTask, task_id) - if not task: - raise HTTPException(status_code=404, detail="提问任务不存在") + _require_ask_task_access(task, user) # AskTask's assignment relationship is intentionally not orphan-cascaded # because assignments may be created by users. Remove those dependent # records explicitly before deleting the task to keep every status @@ -1295,17 +1419,17 @@ def admin_task_board_delete_ask_task(task_id: int, _: AdminUser = Depends(curren @app.get("/api/admin/task-board/ask-batches/{batch_id}") -def admin_task_board_ask_batch_detail(batch_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def admin_task_board_ask_batch_detail(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batch = db.get(AskAssignmentBatch, batch_id) - if not batch or batch.status not in {"approved", "paid"}: + if not batch or batch.status not in {"approved", "paid"} or not _can_access_ask_task(batch.assignment.task, user): raise HTTPException(status_code=404, detail="待返款提问记录不存在") return {"success": True, "data": _ask_review_board_data(batch, include_images=True)} @app.delete("/api/admin/task-board/ask-batches/{batch_id}") -def admin_task_board_delete_ask_batch(batch_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def admin_task_board_delete_ask_batch(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batch = db.get(AskAssignmentBatch, batch_id) - if not batch: + if not batch or not _can_access_ask_task(batch.assignment.task, user): raise HTTPException(status_code=404, detail="提问提交记录不存在") db.delete(batch) db.commit() @@ -1313,17 +1437,17 @@ def admin_task_board_delete_ask_batch(batch_id: int, _: AdminUser = Depends(curr @app.get("/api/admin/task-board/ask-assignments/{assignment_id}") -def admin_task_board_ask_assignment_detail(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def admin_task_board_ask_assignment_detail(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): assignment = db.get(AskAssignment, assignment_id) - if not assignment or assignment.status not in {"已通过", "已完成"}: + if not assignment or assignment.status not in {"已通过", "已完成"} or not _can_access_ask_task(assignment.task, user): raise HTTPException(status_code=404, detail="待返款提问记录不存在") return {"success": True, "data": _ask_review_board_data(assignment, include_images=True)} @app.delete("/api/admin/task-board/ask-assignments/{assignment_id}") -def admin_task_board_delete_ask_assignment(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def admin_task_board_delete_ask_assignment(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): assignment = db.get(AskAssignment, assignment_id) - if not assignment: + if not assignment or not _can_access_ask_task(assignment.task, user): raise HTTPException(status_code=404, detail="提问分配记录不存在") db.delete(assignment) db.commit() @@ -1331,9 +1455,9 @@ def admin_task_board_delete_ask_assignment(assignment_id: int, _: AdminUser = De @app.post("/api/admin/task-board/ask-batches/{batch_id}/confirm-refund") -def confirm_ask_batch_refund(batch_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def confirm_ask_batch_refund(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batch = db.get(AskAssignmentBatch, batch_id) - if not batch: + if not batch or not _can_access_ask_task(batch.assignment.task, user): raise HTTPException(status_code=404, detail="待返款提问记录不存在") if batch.status == "paid": raise HTTPException(status_code=409, detail="该任务已经完成返款") @@ -1346,9 +1470,9 @@ def confirm_ask_batch_refund(batch_id: int, _: AdminUser = Depends(current_user) @app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund") -def confirm_ask_assignment_refund(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def confirm_ask_assignment_refund(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): assignment = db.get(AskAssignment, assignment_id) - if not assignment: + if not assignment or not _can_access_ask_task(assignment.task, user): raise HTTPException(status_code=404, detail="待返款提问记录不存在") if assignment.status == "已完成": raise HTTPException(status_code=409, detail="该任务已经完成返款") diff --git a/backend/app/models.py b/backend/app/models.py index e143a52..24f9d4d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -20,6 +20,7 @@ class AdminUser(Base): updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) login_logs: Mapped[list["AdminLoginLog"]] = relationship(back_populates="user") + ask_tasks: Mapped[list["AskTask"]] = relationship(back_populates="owner") class AdminLoginLog(Base): @@ -106,6 +107,8 @@ class AnswerRecognizedQuestion(Base): sequence_no: Mapped[int] = mapped_column(Integer) product_title: Mapped[str] = mapped_column(String(255)) product_image_screenshot_id: Mapped[int | None] = mapped_column(ForeignKey("answer_screenshots.id"), nullable=True) + operator_image_screenshot_id: Mapped[int | None] = mapped_column(ForeignKey("answer_screenshots.id"), nullable=True) + operator_image_screenshot_ids: Mapped[str | None] = mapped_column(Text, nullable=True) question_text: Mapped[str] = mapped_column(Text) answer_text: Mapped[str] = mapped_column(Text, default="") answer_status: Mapped[str] = mapped_column(String(32), default="已生成") @@ -134,6 +137,7 @@ class AskTask(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) task_no: Mapped[str] = mapped_column(String(40), unique=True, index=True) + owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True) product_link: Mapped[str] = mapped_column(String(1000)) product_name: Mapped[str] = mapped_column(String(255)) category: Mapped[str] = mapped_column(String(100), default="") @@ -143,12 +147,14 @@ class AskTask(Base): target_group: Mapped[str] = mapped_column(String(100), default="女性") usage_scene: Mapped[str] = mapped_column(String(100), default="居家") purchase_channel: Mapped[str] = mapped_column(String(100), default="电商平台") + claim_questions_per_user: Mapped[int] = mapped_column(Integer, default=5, nullable=False) status: Mapped[str] = mapped_column(String(32), default="草稿", index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) questions: Mapped[list["AskQuestion"]] = relationship(back_populates="task", cascade="all, delete-orphan", lazy="selectin") assignments: Mapped[list["AskAssignment"]] = relationship(back_populates="task", cascade="all, delete-orphan") + owner: Mapped[AdminUser | None] = relationship(back_populates="ask_tasks") class AskQuestion(Base): diff --git a/backend/app/schemas.py b/backend/app/schemas.py index eca56e5..35f85a3 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -35,7 +35,7 @@ class QAGenerateRequest(BaseModel): target_group: str = Field(default="女性", max_length=100) usage_scene: str = Field(default="居家", max_length=100) purchase_channel: str = Field(default="电商平台", max_length=100) - question_count: int = Field(default=5, ge=1, le=20) + question_count: int = Field(default=5, ge=1) answers_per_question: int = Field(default=2, ge=1, le=50) answer_requirement: str = Field(default="", max_length=1000) reference_qa: str = Field(default="", max_length=20000) diff --git a/backend/schema.sql b/backend/schema.sql index 954c43a..6785902 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -31,3 +31,6 @@ CREATE TABLE IF NOT EXISTS admin_login_logs ( KEY idx_admin_login_logs_created (created_at), CONSTRAINT fk_admin_login_logs_user FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 提问任务归属运营账号;历史数据允许为空,超级管理员仍可查看全部任务。 +-- 新环境建表时请将 owner_id 一并加入 ask_tasks;已有环境由应用启动时自动补列。 diff --git a/frontend/src/AnswerUserPortal.jsx b/frontend/src/AnswerUserPortal.jsx index 7f926dc..5f912e1 100644 --- a/frontend/src/AnswerUserPortal.jsx +++ b/frontend/src/AnswerUserPortal.jsx @@ -80,7 +80,7 @@ function AssignmentStage({ api, session, latest, reload, 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) 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.answer}

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

上传任务完成凭证

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

} + 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) ? '已复制并标记完成' : '待复制进入淘宝回答'}
)}
:

上传任务完成凭证

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

} } function FinalStage({ latest, reload, busy, reopen, startNew }) { @@ -139,6 +139,19 @@ export default function AnswerUserPortalV2({ api }) { return previous ? { ...previous, ...record, + // The polling endpoint is intentionally lightweight and returns + // null image fields. Preserve images already loaded from the detail + // endpoint so polling/clicking never makes task instructions vanish. + questions: record.questions.map(question => { + const oldQuestion = previous.questions?.find(item => item.id === question.id) + return { + ...oldQuestion, + ...question, + product_image: question.product_image || oldQuestion?.product_image || null, + source_image: question.source_image || oldQuestion?.source_image || null, + operator_images: question.operator_images?.length ? question.operator_images : (oldQuestion?.operator_images || []), + } + }), invited_screenshots: record.invited_screenshots?.length ? record.invited_screenshots : previous.invited_screenshots, completion_screenshots: record.completion_screenshots?.length ? record.completion_screenshots : previous.completion_screenshots, payment_screenshots: record.payment_screenshots?.length ? record.payment_screenshots : previous.payment_screenshots, @@ -161,12 +174,14 @@ export default function AnswerUserPortalV2({ api }) { useEffect(() => { if (!session) return undefined; load(); const timer = window.setInterval(() => load(true), 10000); return () => window.clearInterval(timer) }, [session?.token]) useEffect(() => { if (historyOpen) setError('') }, [historyOpen]) async function loadSubmissionImages(record) { - if (!record?.id || imageLoadIds.current.has(record.id)) return - imageLoadIds.current.add(record.id) + if (!record?.id) return + const loadKey = `${record.id}:${record.status}` + 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)}`) setRecords(current => current.map(item => item.id === record.id ? { ...item, ...result.data, _imagesLoaded: true } : item)) - } catch (err) { imageLoadIds.current.delete(record.id); setError(err.message) } + } catch (err) { imageLoadIds.current.delete(loadKey); setError(err.message) } } const latestRecord = records.find(record => record.id === activeRecordId) || records[0] const latest = latestRecord && optimisticStatuses[latestRecord.id] ? { ...latestRecord, status: optimisticStatuses[latestRecord.id] } : latestRecord @@ -186,7 +201,7 @@ export default function AnswerUserPortalV2({ api }) { useEffect(() => { if (!visibleLatest || historyOpen || newTaskMode) return loadSubmissionImages(visibleLatest) - }, [visibleLatest?.id, historyOpen, newTaskMode]) + }, [visibleLatest?.id, visibleLatest?.status, historyOpen, newTaskMode]) useEffect(() => { if (!historyOpen) return records.slice(0, 20).forEach(record => loadSubmissionImages(record)) @@ -194,3 +209,4 @@ export default function AnswerUserPortalV2({ api }) { 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 d330545..c42f232 100644 --- a/frontend/src/CreateAnswerTasksPage.jsx +++ b/frontend/src/CreateAnswerTasksPage.jsx @@ -21,6 +21,7 @@ export default function CreateAnswerTasksPageV2({ api }) { ...question, product_image: question.product_image || previousQuestions.get(question.id)?.product_image || null, source_image: question.source_image || previousQuestions.get(question.id)?.source_image || null, + operator_images: question.operator_images?.length ? question.operator_images : (previousQuestions.get(question.id)?.operator_images || []), })), } })) @@ -135,6 +136,25 @@ export default function CreateAnswerTasksPageV2({ api }) { } } + async function uploadOperatorImage(question, event) { + const files = Array.from(event.target.files || []) + event.target.value = '' + if (!files.length) return + if (files.length > 3) return setError('每道问题最多上传3张运营指定图片') + const body = new FormData() + files.forEach(file => body.append('files', file)) + setBusy(question.id); setError('') + try { + const result = await api(`/api/admin/answer-task-drafts/questions/${question.id}/operator-image`, { method: 'POST', body }) + const updated = result.data.questions?.find(item => item.id === question.id) + setQuestionLocal(question.id, { operator_images: updated?.operator_images || [] }) + } catch (err) { + setError(err.message) + } finally { + setBusy('') + } + } + return <>

创建任务

通过顶部切换创建「提问任务」或「回答任务」。

@@ -164,7 +184,7 @@ export default function CreateAnswerTasksPageV2({ api }) { - {open && record.questions.map(question =>
+ {open && record.questions.map(question =>

Q{question.sequence_no}:{question.question}

@@ -176,7 +196,11 @@ export default function CreateAnswerTasksPageV2({ api }) {