From cf83de291cc9a80886a77f7f62f78a12e15a95d4 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Thu, 3 Sep 2026 18:20:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dbug=EF=BC=8C=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E9=A1=B5=E9=9D=A2=E5=B8=83=E5=B1=80=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=A7=BB=E5=8A=A8=E7=AB=AF=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/llm.py | 43 +++ backend/app/main.py | 356 +++++++++++++++++++++---- frontend/index.html | 13 +- frontend/src/AnswerUserPortal.jsx | 7 +- frontend/src/AskUserPortal.jsx | 25 +- frontend/src/CreateAnswerTasksPage.jsx | 24 ++ frontend/src/CreateAskTasksPage.jsx | 51 +++- frontend/src/ReviewCenterPage.jsx | 9 +- frontend/src/TaskBoardPage.jsx | 93 ++++++- frontend/src/admin.css | 317 ++++++++++++++++++++-- frontend/src/main.jsx | 276 +++++++++++++++---- frontend/src/productIntent.js | 49 ++++ frontend/src/styles.css | 21 ++ 13 files changed, 1116 insertions(+), 168 deletions(-) create mode 100644 frontend/src/productIntent.js diff --git a/backend/app/llm.py b/backend/app/llm.py index a9b12f8..fe06059 100644 --- a/backend/app/llm.py +++ b/backend/app/llm.py @@ -88,6 +88,49 @@ 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]: + """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,无法生成真实提问") + prompt = f"""你是电商问大家提问策划。请根据商品信息生成 {count} 个真实、具体、互不重复的消费者提问。 +商品名称:{payload.get('product_name') or '未提供'} +商品品类:{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')} +真实问答参考:{reference_qa or '未提供'} +只返回严格 JSON,不要 Markdown 或解释,格式:{{"questions":["问题1","问题2"]}}。问题应像真实买家提问,尽量不超过30字,不要虚构具体参数或功效。""" + url = settings.llm_base_url.rstrip("/") + "/chat/completions" + body = {"model": settings.llm_model, "temperature": 0.85, "messages": [{"role": "system", "content": "你只输出合法 JSON。"}, {"role": "user", "content": prompt}]} + request = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.llm_api_key}"}, method="POST") + try: + with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response: + result = json.loads(response.read().decode("utf-8")) + content = result["choices"][0]["message"]["content"] + cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", str(content).strip(), flags=re.I | re.S).strip() + parsed = json.loads(cleaned) + questions = parsed.get("questions") if isinstance(parsed, dict) else parsed + if not isinstance(questions, list): + raise ValueError("questions is not a list") + output = [] + for value in questions: + text = str(value).strip() + if text and text not in output: + output.append(text[:200]) + if not output: + raise ValueError("empty questions") + return output[:count] + except urllib.error.HTTPError as exc: + logger.error("LLM question generation HTTP error: status=%s url=%s", exc.code, url) + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"大模型服务返回错误(HTTP {exc.code})") from exc + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError, TypeError, ValueError) as exc: + logger.exception("LLM question generation failed") + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型提问生成失败,请稍后重试") from exc + + 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 d8befb6..a4bd437 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,11 +4,15 @@ import base64 import hashlib import json import random +import re import secrets import time import urllib.parse import urllib.request import uuid +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.middleware.cors import CORSMiddleware @@ -17,7 +21,7 @@ from sqlalchemy.orm import Session, noload, selectinload from .config import settings from .database import Base, SessionLocal, engine, get_db -from .llm import extract_questions_from_images, generate_answer_for_question, generate_qa +from .llm import extract_questions_from_images, generate_answer_for_question, generate_questions, generate_qa from .models import AdminLoginLog, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QALibraryEntry from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QAGenerateRequest, RegisterRequest from .security import create_session_token, decode_session_token, hash_password, verify_password @@ -32,6 +36,82 @@ app.add_middleware( allow_headers=["Content-Type"], ) +# A question allocation is temporary until the user submits a completion +# batch. Stale, unfinished allocations are released on the next task/status +# read so the questions can flow back to the same published task link. +ASK_ASSIGNMENT_TIMEOUT = timedelta(hours=5) + + +def _release_expired_ask_assignments(db: Session, task: AskTask | None = None) -> int: + """Release unsubmitted ask-task allocations older than five hours. + + Submitted batches are intentionally preserved (including rejected batches) + because they are part of the review/history flow. Only assignments that + have never submitted a batch can be safely returned to the task pool. + """ + cutoff = datetime.utcnow() - ASK_ASSIGNMENT_TIMEOUT + statement = select(AskAssignment).where( + AskAssignment.status.in_(["待选择", "进行中"]), + # The five-hour window starts when the questions are claimed, not + # when the user later changes a selection inside that reservation. + AskAssignment.created_at < cutoff, + ~AskAssignment.batches.any(), + ) + if task is not None: + statement = statement.where(AskAssignment.task_id == task.id) + expired = db.scalars(statement).unique().all() + for assignment in expired: + db.delete(assignment) + if expired: + db.flush() + return len(expired) + + +def _product_intent_error(values: list[object]) -> str | None: + """Reject obvious placeholder input before creating an AI task.""" + text = " ".join(str(value or "").strip() for value in values if str(value or "").strip()) + if not text: + return "请至少填写一项商品信息" + compact = re.sub(r"[\s\W_]+", "", text, flags=re.UNICODE) + if not compact: + return "商品信息不能只有符号,请填写真实的商品内容" + if compact.isdigit(): + return "商品信息看起来像占位内容,请填写真实的商品名称、品类、功效或卖点" + if re.fullmatch(r"(.{1,3})\1+", compact, flags=re.UNICODE): + return "商品信息看起来是重复的占位内容,请补充真实的商品信息" + if not re.search(r"[\u4e00-\u9fffA-Za-z]", compact): + return "请填写包含文字的真实商品信息" + return 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", "单题回答条数")) + missing = [label for key, label in required if not str(payload.get(key, "")).strip()] + if missing: + return f"请填写{'、'.join(missing)}" + try: + question_count = int(payload.get("question_count")) + except (TypeError, ValueError): + 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 + # 回答用户链接的 MVP 数据暂存于内存:截图只在当前服务进程中保留,后续接入对象存储/持久化表。 ANSWER_TASKS = { @@ -492,18 +572,22 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d 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] + 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 {"待审核", "未通过"}) 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, "status_label": {"已发布": "进行中", "已撤销": "已撤销", "已完成": "已完成"}.get(task.status, task.status), - "qa_count": len([q for q in questions if q.selected]), "claimed_count": 0, "submitted_count": 0, - "pending_review_count": 0, "completed_count": 0, "total_count": len(questions), + "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(), - "can_cancel": task.status == "已发布", "can_delete": True, + "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 questions if q.selected] if include_questions else []), + "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, "detail_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}" if not include_questions else None, } @@ -630,11 +714,16 @@ def ask_send_code(payload: dict): def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) task = _published_ask_task(task_no, db, lock=True) + released = _release_expired_ask_assignments(db, task) + if released: + db.commit() + db.expire_all() + task = _published_ask_task(task_no, db, lock=True) assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) if not assignment: - # Allocation is permanent as soon as the task link assigns the - # questions. Exclude every question already allocated to any user, - # even if that user has not selected or submitted it yet. + # Keep each question reserved while the user is working. Expired + # unfinished reservations have already been removed above, so these + # are the currently allocated questions that must stay excluded. allocated_ids = set(db.scalars( select(AskAssignmentQuestion.question_id) .join(AskAssignment, AskAssignmentQuestion.assignment_id == AskAssignment.id) @@ -672,6 +761,8 @@ def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)): @app.get("/api/ask/me/assignments") def public_ask_assignments(token: str, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) + if _release_expired_ask_assignments(db): + db.commit() records = db.scalars( select(AskAssignment) .where(AskAssignment.user_id == user.id) @@ -691,6 +782,8 @@ def public_ask_assignments(token: str, db: Session = Depends(get_db)): def select_public_ask_questions(task_no: str, payload: dict, db: Session = Depends(get_db)): user = _answer_user_from_token(str(payload.get("token", "")), db) task = _published_ask_task(task_no, db) + if _release_expired_ask_assignments(db, task): + db.commit() assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) if not assignment: raise HTTPException(status_code=409, detail="请先领取提问任务") @@ -726,6 +819,8 @@ async def submit_public_ask_task( ): user = _answer_user_from_token(token, db) task = _published_ask_task(task_no, db) + if _release_expired_ask_assignments(db, task): + db.commit() assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) if not assignment: raise HTTPException(status_code=404, detail="提问任务不存在") @@ -969,52 +1064,62 @@ 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/{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) + if not record: + raise HTTPException(status_code=404, detail="回答任务不存在") + if record.status not in {"待筛选", "待运营派发"}: + raise HTTPException(status_code=409, detail="当前任务不能打回") + db.delete(record) + db.commit() + return {"success": True, "data": {"id": submission_id}} + + @app.post("/api/admin/ask-tasks/generate") def generate_ask_task(payload: dict, _: 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="商品链接为必填项") - if not product_name and not any(str(payload.get(key, "")).strip() for key in ("category", "benefits", "selling_points")): - raise HTTPException(status_code=400, detail="请至少填写一项商品信息") + intent_error = _product_intent_error([product_name, payload.get("category"), payload.get("benefits"), payload.get("selling_points")]) + if intent_error: + raise HTTPException(status_code=400, detail=intent_error) + options_error = _generation_options_error(payload, include_answers=False) + if options_error: + raise HTTPException(status_code=400, detail=options_error) try: count = int(payload.get("question_count", 5)) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="提问生成条数必须是正整数") if count < 1: raise HTTPException(status_code=400, detail="提问生成条数必须大于 0") - 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="草稿") - db.add(task); db.flush() - templates = [ - "这款商品适合什么人群使用?", - "使用起来方便吗?效果怎么样?", - "日常使用有什么需要注意的吗?", - "这款商品和同类产品相比有什么特点?", - "新手使用是否容易上手?", - "这款商品建议多久使用一次?", - "它的质地和实际使用感怎么样?", - "使用后吸收速度快吗?会不会有黏腻感?", - "敏感人群可以使用这款商品吗?", - "不同季节使用时效果会有区别吗?", - "它在日常使用步骤中应该放在哪一步?", - "每次使用多少量比较合适?", - "通常需要坚持使用多久才能感受到效果?", - "这款商品可以和其他同类商品搭配使用吗?", - "包装是否方便携带和日常使用?", - "开封后应该如何保存?", - "这款商品的性价比怎么样?", - "第一次购买建议选择哪种规格?", - "使用过程中有哪些容易忽略的小细节?", - "实际体验后值得再次购买吗?", - ] + 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: + if task.status != "草稿": + raise HTTPException(status_code=409, detail="已发布任务不能重新生成") + task.product_link = product_link + task.product_name = product_name or "待补充商品" + task.category = str(payload.get("category", "")) + task.benefits = str(payload.get("benefits", "")) + task.selling_points = str(payload.get("selling_points", "")) + task.language_style = str(payload.get("language_style", "")) + task.target_group = str(payload.get("target_group", "")) + task.usage_scene = str(payload.get("usage_scene", "")) + task.purchase_channel = str(payload.get("purchase_channel", "")) + 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="草稿") + db.add(task); db.flush() questions = payload.get("questions") if isinstance(payload.get("questions"), list) else [] reference_text = str(payload.get("reference_qa", ""))[:20000] - if reference_text: - for line in reference_text.splitlines(): - clean = line.strip().lstrip("-•*0123456789. ") - if clean.startswith(("问题:", "问题:", "Q:", "Q:")): - clean = clean.split(":", 1)[1].strip() - if clean and "回答" not in clean and len(clean) <= 200: - questions.append(clean) + if not questions: + questions = generate_questions(payload, count, reference_text) # 自定义问题优先,不足的数量用候选问题补齐,保证返回数量与运营填写值一致。 generated: list[str] = [] for value in questions: @@ -1023,18 +1128,8 @@ def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: S generated.append(question) if len(generated) == count: break - template_index = 0 - while len(generated) < count: - base = templates[template_index % len(templates)] - round_no = template_index // len(templates) - if round_no == 0: - question = base - else: - scene = str(payload.get("usage_scene", "日常")).strip() or "日常" - question = f"在{scene}场景的第{round_no + 1}种使用情况下,{base}" - if question not in generated: - generated.append(question) - template_index += 1 + if len(generated) < count: + generated.extend(generate_questions(payload, count - len(generated), reference_text)) for index, question in enumerate(generated): db.add(AskQuestion(task_id=task.id, sequence_no=index + 1, question_text=question, selected=True)) db.commit(); db.refresh(task) return {"success": True, "data": _ask_task_data(task)} @@ -1085,6 +1180,20 @@ def cancel_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Sess 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)): + task = db.get(AskTask, task_id) + if not task: + raise HTTPException(status_code=404, detail="提问任务不存在") + if task.status != "已发布": + raise HTTPException(status_code=409, detail="当前任务不能完成归档") + task.status = "已完成" + task.updated_at = datetime.utcnow() + db.commit() + db.refresh(task) + return {"success": True, "data": _ask_task_data(task)} + + @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)): question = db.get(AnswerRecognizedQuestion, question_id) @@ -1109,6 +1218,8 @@ 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)): + if _release_expired_ask_assignments(db): + db.commit() task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"] records = db.scalars( select(AnswerSubmission).options( @@ -1172,6 +1283,12 @@ def admin_task_board_delete_ask_task(task_id: int, _: AdminUser = Depends(curren task = db.get(AskTask, task_id) if not task: raise HTTPException(status_code=404, detail="提问任务不存在") + # 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 + # deletable without leaving invalid foreign keys behind. + for assignment in list(task.assignments): + db.delete(assignment) db.delete(task) db.commit() return {"success": True, "data": {"id": task_id}} @@ -1469,9 +1586,22 @@ def review_decision(task_id: str, payload: dict, _: AdminUser = Depends(current_ @app.post("/api/admin/qa/generate") def qa_generate(payload: QAGenerateRequest, _: AdminUser = Depends(current_user)): - if not any(value.strip() for value in (payload.product_name, payload.category, payload.benefits, payload.selling_points)): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少填写一项商品信息") - return {"success": True, "data": {"items": generate_qa(payload)}} + intent_error = _product_intent_error([payload.product_name, payload.category, payload.benefits, payload.selling_points]) + if intent_error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=intent_error) + options_error = _generation_options_error(payload.model_dump(), include_answers=True) + if options_error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=options_error) + try: + return {"success": True, "data": {"items": generate_qa(payload)}} + except HTTPException: + raise + except Exception as exc: + # Do not leak a raw 500 to the UI. Unexpected provider/parser errors + # are surfaced as a retryable upstream error with a server traceback. + import logging + logging.getLogger(__name__).exception("QA generation failed") + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="问答生成服务异常,请检查大模型配置后重试") from exc def _qa_library_data(entry: QALibraryEntry) -> dict: @@ -1610,6 +1740,16 @@ def update_qa_library(entry_id: int, payload: dict, user: AdminUser = Depends(cu return {"success": True, "data": _qa_library_data(entry)} +@app.delete("/api/admin/qa-library/{entry_id}") +def delete_qa_library(entry_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + entry = db.get(QALibraryEntry, entry_id) + if not entry: + raise HTTPException(status_code=404, detail="问答记录不存在") + db.delete(entry) + db.commit() + return {"success": True, "data": {"id": entry_id}} + + @app.post("/api/admin/auth/login") def login(payload: LoginRequest, request: Request, response: Response, db: Session = Depends(get_db)): user = db.scalar(select(AdminUser).where(AdminUser.username == payload.username)) @@ -1624,6 +1764,110 @@ def login(payload: LoginRequest, request: Request, response: Response, db: Sessi return {"success": True, "data": {"user": public_user(user)}} +@app.post("/api/admin/reference-qa/parse") +async def parse_reference_qa(file: UploadFile = File(...), _: AdminUser = Depends(current_user)): + """Extract readable question/answer text from an uploaded XLSX workbook. + + XLSX is a ZIP/XML format; parsing it on the server avoids treating the + binary workbook as UTF-8 text in the browser (which produces mojibake). + """ + filename = (file.filename or "").lower() + if not filename.endswith(".xlsx"): + raise HTTPException(status_code=400, detail="仅支持解析 .xlsx 文件,请另存为 XLSX 后上传") + raw = await file.read() + try: + try: + from openpyxl import load_workbook + workbook = load_workbook(io.BytesIO(raw), read_only=True, data_only=True) + sheet = workbook.active + rows = [[str(value).strip() if value is not None else "" for value in row] for row in sheet.iter_rows(values_only=True)] + rows = [row for row in rows if any(row)] + workbook.close() + except ImportError: + rows = None + if rows is not None: + if not rows: + raise ValueError("工作表中没有可读取内容") + header = [str(value).lower() for value in rows[0]] + question_index = next((i for i, value in enumerate(header) if any(key in value for key in ("问题", "question", "q"))), None) + answer_index = next((i for i, value in enumerate(header) if any(key in value for key in ("回答", "问答", "answer", "a"))), None) + data_rows = rows[1:] if (question_index is not None or answer_index is not None) else rows + lines = [] + for values in data_rows: + if question_index is not None and question_index < len(values): + question = values[question_index] + answer = values[answer_index] if answer_index is not None and answer_index < len(values) else "" + if question or answer: + lines.append(f"问题:{question}\n回答:{answer}") + else: + lines.append("\t".join(values)) + text = "\n".join(lines).strip()[:20000] + return {"success": True, "data": {"text": text, "filename": file.filename}} + archive = zipfile.ZipFile(io.BytesIO(raw)) + ns = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} + def xml_root(path: str): + data = archive.read(path) + # Some Windows exports declare UTF-8 but actually store Chinese + # XML text as GB18030. Decode the XML bytes before parsing so the + # original Chinese strings are not replaced with � characters. + try: + decoded = data.decode("utf-8") + if "�" in decoded: + decoded = data.decode("gb18030") + except UnicodeDecodeError: + decoded = data.decode("gb18030") + return ET.fromstring(decoded) + shared = [] + if "xl/sharedStrings.xml" in archive.namelist(): + root = xml_root("xl/sharedStrings.xml") + shared = ["".join(node.itertext()) for node in root.findall("x:si", ns)] + sheet_name = "xl/worksheets/sheet1.xml" + if sheet_name not in archive.namelist(): + raise ValueError("工作簿缺少首个工作表") + root = xml_root(sheet_name) + rows = [] + for row in root.findall(".//x:sheetData/x:row", ns): + values = [] + for cell in row.findall("x:c", ns): + cell_type = cell.attrib.get("t", "") + value = cell.find("x:v", ns) + if cell_type == "s" and value is not None: + text = shared[int(value.text or "0")] if shared else "" + elif cell_type == "inlineStr": + text = "".join(cell.itertext()) + else: + text = value.text if value is not None and value.text else "" + values.append(text.strip()) + if any(values): + rows.append(values) + if not rows: + raise ValueError("工作表中没有可读取内容") + header = [str(value).lower() for value in rows[0]] + question_index = next((i for i, value in enumerate(header) if any(key in value for key in ("问题", "question", "q"))), None) + answer_index = next((i for i, value in enumerate(header) if any(key in value for key in ("回答", "问答", "answer", "a"))), None) + lines = [] + header = [str(value).lower() for value in rows[0]] + question_index = next((i for i, value in enumerate(header) if any(key in value for key in ("问题", "question", "q"))), None) + answer_index = next((i for i, value in enumerate(header) if any(key in value for key in ("回答", "问答", "answer", "a"))), None) + data_rows = rows[1:] if (question_index is not None or answer_index is not None) else rows + for values in data_rows: + if question_index is not None and question_index < len(values): + question = values[question_index] + answer = values[answer_index] if answer_index is not None and answer_index < len(values) else "" + if question or answer: + lines.append(f"问题:{question}\n回答:{answer}") + else: + lines.append("\t".join(values)) + text = "\n".join(lines).strip()[:20000] + return {"success": True, "data": {"text": text, "filename": file.filename}} + except (KeyError, ValueError, IndexError, ET.ParseError, zipfile.BadZipFile, UnicodeError) as exc: + raise HTTPException(status_code=400, detail="无法读取该 XLSX 文件,请确认文件未损坏") from exc + except Exception as exc: + import logging + logging.getLogger(__name__).exception("Reference XLSX parsing failed") + raise HTTPException(status_code=400, detail="读取参考文件失败,请确认文件格式正确后重试") from exc + + @app.get("/api/admin/auth/dingtalk/config") def dingtalk_login_config(): return {"success": True, "data": {"enabled": _dingtalk_configured(), "redirect_uri": settings.dingtalk_redirect_uri}} diff --git a/frontend/index.html b/frontend/index.html index fb91b48..8ec41b6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1 +1,12 @@ -
+ + + + + + 问大家 + + +
+ + + diff --git a/frontend/src/AnswerUserPortal.jsx b/frontend/src/AnswerUserPortal.jsx index adc0097..7f926dc 100644 --- a/frontend/src/AnswerUserPortal.jsx +++ b/frontend/src/AnswerUserPortal.jsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { Check, ClipboardCheck, Clock3, Copy, FileCheck2, History, ImagePlus, LogOut, MessageCircle, RefreshCw, ShieldCheck } from 'lucide-react' const SESSION_KEY = 'answer_user_session' -const STATUS_TEXT = { '待筛选': '截图审核中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '未通过': '审核不通过', '已撤销': '任务已撤销' } +const STATUS_TEXT = { '待筛选': '截图审核中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '已完成': '已返款', '未通过': '审核不通过', '已撤销': '任务已撤销' } const maskPhone = (phone = '') => phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--' const fileKey = file => `${file.name}-${file.size}-${file.lastModified}` @@ -84,6 +84,7 @@ function AssignmentStage({ api, session, latest, reload, setError }) { } function FinalStage({ latest, reload, busy, reopen, startNew }) { + const paid = latest.status === '已完成' const passed = ['已通过', '已完成'].includes(latest.status) const rejected = latest.status === '未通过' const cancelled = latest.status === '已撤销' @@ -107,8 +108,8 @@ function FinalStage({ latest, reload, busy, reopen, startNew }) { return <>
-
{passed ? : cancelled ? '!' : }

{passed ? '审核已完成' : cancelled ? '任务已撤销' : '完成凭证审核中'} {STATUS_TEXT[latest.status]}

{passed ? '运营已审核通过您提交的任务凭证,当前进入打款流程,请耐心等待打款。' : cancelled ? '运营已撤销本次任务,您无需继续提交。' : '您提交的回答完成截图正在审核,请耐心等待运营确认。'}

- {passed &&
审核已完成,等待打款款项将按运营安排发放,请留意收款账户。
} +
{passed ? : cancelled ? '!' : }

{paid ? '已返款' : passed ? '审核已完成' : cancelled ? '任务已撤销' : '完成凭证审核中'} {STATUS_TEXT[latest.status]}

{paid ? '运营已完成本次任务返款,请留意您的收款账户。' : passed ? '运营已审核通过您提交的任务凭证,当前进入打款流程,请耐心等待打款。' : cancelled ? '运营已撤销本次任务,您无需继续提交。' : '您提交的回答完成截图正在审核,请耐心等待运营确认。'}

+ {passed &&
{paid ? '返款已完成' : '审核已完成,等待打款'}{paid ? '款项已由运营确认发放,请留意收款账户。' : '款项将按运营安排发放,请留意收款账户。'}
}
提交编号:{latest.submission_no}
{latest.completion_screenshots?.length > 0 && } diff --git a/frontend/src/AskUserPortal.jsx b/frontend/src/AskUserPortal.jsx index 4d4fed6..013654b 100644 --- a/frontend/src/AskUserPortal.jsx +++ b/frontend/src/AskUserPortal.jsx @@ -746,6 +746,7 @@ export default function AskUserPortal({ api }) { async function loadAssignment(silent = false) { if (!session) return; + const previousAssignmentId = assignment?.assignment_id; if (!silent) setBusy(true); try { const result = await api( @@ -756,13 +757,18 @@ export default function AskUserPortal({ api }) { // Keep the current view. In particular, polling a pending review must // not pull the user back to the audit screen after they chose to return // to the claim page. - setSelected( - result.data.questions - // Questions already submitted for review are consumed and must not - // be counted as a new selection when the user returns to claim more. - .filter((item) => item.selected && !item.used) - .map((item) => item.id), - ); + // Keep unsaved checkbox choices while a user is on the claim screen. + // If the five-hour reservation expires, the assignment id changes and + // the fresh allocation is selected instead. + if (!silent || result.data.assignment_id !== previousAssignmentId || assignment?.status !== "待选择") { + setSelected( + result.data.questions + // Questions already submitted for review are consumed and must not + // be counted as a new selection when the user returns to claim more. + .filter((item) => item.selected && !item.used) + .map((item) => item.id), + ); + } setError(""); } catch (requestError) { if (requestError.message === EXHAUSTED_MESSAGE) { @@ -791,8 +797,9 @@ export default function AskUserPortal({ api }) { loadAssignment(); }, [session?.token, taskNo]); useEffect(() => { - if (assignment?.status !== "待审核") return undefined; - const timer = window.setInterval(() => loadAssignment(true), 10000); + if (!assignment || !["待审核", "待选择", "进行中"].includes(assignment.status)) return undefined; + const interval = assignment.status === "待审核" ? 10000 : 60000; + const timer = window.setInterval(() => loadAssignment(true), interval); return () => window.clearInterval(timer); }, [assignment?.status, session?.token, taskNo]); diff --git a/frontend/src/CreateAnswerTasksPage.jsx b/frontend/src/CreateAnswerTasksPage.jsx index 36e7322..d330545 100644 --- a/frontend/src/CreateAnswerTasksPage.jsx +++ b/frontend/src/CreateAnswerTasksPage.jsx @@ -6,6 +6,7 @@ export default function CreateAnswerTasksPageV2({ api }) { const [busy, setBusy] = useState('') const [detailBusy, setDetailBusy] = useState('') const [error, setError] = useState('') + const [rejecting, setRejecting] = useState(null) async function load() { try { @@ -119,6 +120,21 @@ export default function CreateAnswerTasksPageV2({ api }) { } } + async function reject(record) { + if (!record || busy) return + setBusy(record.id) + setError('') + try { + await api(`/api/admin/answer-task-drafts/${record.id}/reject`, { method: 'POST' }) + setRecords(current => current.filter(item => item.id !== record.id)) + setRejecting(null) + } catch (err) { + setError(err.message) + } finally { + setBusy('') + } + } + return <>

创建任务

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

@@ -146,6 +162,7 @@ export default function CreateAnswerTasksPageV2({ api }) { {identifying && 正在识别,请等待…} + {open && record.questions.map(question =>
@@ -163,5 +180,12 @@ export default function CreateAnswerTasksPageV2({ api }) { })}
+ {rejecting &&
event.target === event.currentTarget && setRejecting(null)}> +
+

确认打回

+

确认打回用户 {rejecting.user_id} 的这条回答截图吗?打回后将直接从生成任务列表删除。

+
+
+
} } diff --git a/frontend/src/CreateAskTasksPage.jsx b/frontend/src/CreateAskTasksPage.jsx index 35e8194..626adf9 100644 --- a/frontend/src/CreateAskTasksPage.jsx +++ b/frontend/src/CreateAskTasksPage.jsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import { Check, Link2, Plus, Sparkles } from "lucide-react"; +import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js"; const blankForm = { product_link: "", @@ -7,15 +8,21 @@ const blankForm = { category: "", benefits: "", selling_points: "", - language_style: "口语(亲切随性)", - target_group: "女性", - usage_scene: "居家", - purchase_channel: "电商平台", - question_count: 5, + language_style: "", + target_group: "", + usage_scene: "", + purchase_channel: "", + question_count: "", }; -async function readReferenceFile(file) { + async function readReferenceFile(file, requestApi) { if (!file) return ""; + if (/\.xlsx$/i.test(file.name)) { + const body = new FormData(); + body.append("file", file); + const result = await requestApi("/api/admin/reference-qa/parse", { method: "POST", body }); + return result.data.text || ""; + } return (await file.text()).slice(0, 20000); } @@ -31,15 +38,31 @@ export default function CreateAskTasksPage({ api }) { setForm((current) => ({ ...current, [key]: value })); async function generate() { + const intentError = analyzeProductIntent([ + form.product_name, + form.category, + form.benefits, + form.selling_points, + ]); + if (intentError) { + setError(intentError); + return; + } + const optionsError = validateGenerationOptions(form, false); + if (optionsError) { + setError(optionsError); + return; + } setBusy(true); setError(""); try { - const reference_qa = await readReferenceFile(referenceFile); + const reference_qa = await readReferenceFile(referenceFile, api); const result = await api("/api/admin/ask-tasks/generate", { method: "POST", body: JSON.stringify({ ...form, question_count: Number(form.question_count), + ...(task ? { task_id: task.id } : {}), reference_qa, }), }); @@ -51,7 +74,7 @@ export default function CreateAskTasksPage({ api }) { })), ); } catch (err) { - setError(err.message); + setError(err.message || "生成失败,请检查参考文件和大模型配置后重试"); } finally { setBusy(false); } @@ -246,8 +269,9 @@ export default function CreateAskTasksPage({ api }) {

生成偏好与数量

@@ -259,6 +283,7 @@ export default function CreateAskTasksPage({ api }) { update("language_style", event.target.value) } > + @@ -270,6 +295,7 @@ export default function CreateAskTasksPage({ api }) { value={form.target_group} onChange={(event) => update("target_group", event.target.value)} > + @@ -292,6 +318,7 @@ export default function CreateAskTasksPage({ api }) { value={form.usage_scene} onChange={(event) => update("usage_scene", event.target.value)} > + @@ -305,6 +332,7 @@ export default function CreateAskTasksPage({ api }) { update("purchase_channel", event.target.value) } > + @@ -346,6 +374,9 @@ export default function CreateAskTasksPage({ api }) { 添加提问 +
-
- - 系统智能助判 - - ✓ 商品一致 ✓ 内容与要求匹配 ✓ 无重复提交 ✓ 无违规内容 - -
{record.latest_review && !pending && (
function TaskDetail({ task, close, onConfirmRefund }) { const [refundOpen, setRefundOpen] = useState(false); const [refundBusy, setRefundBusy] = useState(false); + const [linkCopied, setLinkCopied] = useState(false); const isAskTask = task.task_type === "ask" || task.task_type_label === "提问任务"; + const taskLink = isAskTask + ? `${window.location.origin}/ask/${task.task_id}` + : ""; const proofScreenshots = [ ...(task.question_screenshots || []), ...(task.completion_screenshots || []), @@ -45,6 +49,37 @@ function TaskDetail({ task, close, onConfirmRefund }) { setRefundBusy(false); } } + async function copyTaskLink() { + if (!taskLink) return; + let copied = false; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(taskLink); + copied = true; + } + } catch { + copied = false; + } + if (!copied) { + const textarea = document.createElement("textarea"); + textarea.value = taskLink; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + try { + copied = document.execCommand("copy"); + } catch { + copied = false; + } + document.body.removeChild(textarea); + } + if (copied) { + setLinkCopied(true); + window.setTimeout(() => setLinkCopied(false), 1800); + } + } return (
+ {taskLink && ( +
+ 提问链接 + event.target.select()} + /> + +
+ )}
商品名称{task.product_name} @@ -209,6 +262,7 @@ export default function TaskBoardPage({ api }) { const [busy, setBusy] = useState(""); const [batchBusy, setBatchBusy] = useState(false); const [batchConfirm, setBatchConfirm] = useState(null); + const [completeConfirm, setCompleteConfirm] = useState(null); async function load(showLoading = taskBoardCache === null) { if (showLoading) setLoading(true); try { @@ -366,6 +420,21 @@ export default function TaskBoardPage({ api }) { setBusy(""); } } + async function complete(task) { + if (!completeConfirm || busy) return; + setBusy(task.id); + try { + await api(`/api/admin/ask-tasks/${task.id}/complete`, { method: "POST" }); + setCompleteConfirm(null); + setSelectedIds((current) => current.filter((key) => key !== rowKey(task))); + await load(); + if (detail?.id === task.id) setDetail(null); + } catch (err) { + setError(err.message); + } finally { + setBusy(""); + } + } async function confirmRefund(task) { const result = await api(task.refund_endpoint, { method: "POST" }); const updatedDetail = { @@ -444,7 +513,7 @@ export default function TaskBoardPage({ api }) {
{error &&
{error}
} @@ -517,6 +586,15 @@ export default function TaskBoardPage({ api }) { {busy === task.id ? "撤销中" : "撤销"} )} + {task.can_complete && task.task_type === "ask" && ( + + )}
)} + {completeConfirm && ( +
event.target === event.currentTarget && setCompleteConfirm(null)}> +
+
✓
+

确认完成任务?

+

该提问任务当前已完成 {completeConfirm.completed_count || 0} / {completeConfirm.total_count || completeConfirm.qa_count || 0} 条。确认后将强制归档到“已完成”,用户将不能继续提交。

+
+ + +
+
+
+ )} ); } diff --git a/frontend/src/admin.css b/frontend/src/admin.css index 7b623b7..9752ecf 100644 --- a/frontend/src/admin.css +++ b/frontend/src/admin.css @@ -1806,7 +1806,7 @@ .board-table-head, .board-table-row { display: grid; - grid-template-columns: 34px 1.05fr 1.05fr 1.9fr 0.75fr 0.55fr 0.75fr 0.75fr 1fr 0.7fr; + grid-template-columns: 34px 1.05fr 1.05fr 1.9fr 0.75fr 0.55fr 0.75fr 0.75fr 1fr minmax(150px, 1.2fr); align-items: center; gap: 10px; padding: 13px 14px; @@ -1928,12 +1928,19 @@ } .board-actions { display: flex; - gap: 9px; + align-items: center; + flex-wrap: nowrap; + gap: 8px; + min-width: max-content; + white-space: nowrap; } .board-actions button { + flex: 0 0 auto; padding: 4px 0; background: transparent; color: #1764e8; + white-space: nowrap; + word-break: keep-all; } .board-actions button.cancel { color: #d93450; @@ -1990,6 +1997,48 @@ background: transparent; color: #8fa0b6; } +.task-detail-share { + display: flex; + align-items: center; + gap: 10px; + margin-top: 14px; + padding: 10px 12px; + border: 1px solid #dce5ef; + border-radius: 9px; + background: #f7faff; +} +.task-detail-share > span { + flex: 0 0 auto; + color: #536b88; + font-size: 12px; + font-weight: 700; +} +.task-detail-share input { + min-width: 0; + flex: 1; + padding: 8px 10px; + border: 1px solid #cddceb; + border-radius: 7px; + background: #fff; + color: #345777; + font-size: 12px; + text-overflow: ellipsis; +} +.task-detail-share button { + flex: 0 0 auto; + padding: 8px 12px; + border: 1px solid #b9d1f7; + border-radius: 7px; + background: #f4f8ff; + color: #1764e8; + font-size: 12px; + white-space: nowrap; +} +.task-detail-share button.copied { + border-color: #9fdfbd; + background: #ecfbf3; + color: #118453; +} .task-detail-meta { display: grid; grid-template-columns: 1.5fr 1fr 1fr 1.1fr; @@ -2219,6 +2268,17 @@ .task-detail-meta { grid-template-columns: 1fr 1fr; } + .task-detail-share { + align-items: stretch; + flex-wrap: wrap; + } + .task-detail-share input { + flex-basis: 100%; + order: 2; + } + .task-detail-share button { + margin-left: auto; + } .task-detail-table { overflow-x: auto; } @@ -3015,11 +3075,14 @@ .answer-v2-shell { padding: 0; background: #f3f7fc; + overflow-x: hidden; } .answer-v2-app { + width: 100%; min-height: 100vh; border-radius: 0; box-shadow: none; + overflow: visible; } .answer-v2-body { padding: 14px 10px 76px; @@ -3054,6 +3117,12 @@ .status-meta { grid-template-columns: 1fr 1fr; } + .answer-v2-footer { + position: sticky; + bottom: 0; + z-index: 5; + padding-bottom: calc(10px + env(safe-area-inset-bottom)); + } } @media (max-width: 380px) { .answer-v2-top > span { @@ -3322,21 +3391,6 @@ color: #8ca0b8; font-size: 11px; } -.review-v2-assist { - display: flex; - align-items: center; - gap: 8px; - margin-top: 18px; - padding: 12px; - border: 1px solid #8de3bb; - border-radius: 9px; - background: #edfff6; - color: #098852; - font-size: 11px; -} -.review-v2-assist span { - margin-left: auto; -} .review-v2-result { margin-top: 15px; padding: 12px; @@ -3470,13 +3524,6 @@ align-items: stretch; flex-direction: column; } - .review-v2-assist { - align-items: flex-start; - flex-direction: column; - } - .review-v2-assist span { - margin: 0; - } } .ask-selected-card article span { @@ -3589,6 +3636,51 @@ color: #118453; font-size: 12px; } +.adopt-success-backdrop { + position: fixed; + z-index: 75; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgba(8, 18, 37, 0.56); +} +.adopt-success-modal { + width: min(380px, 100%); + padding: 30px 28px 24px; + border: 1px solid #dce8f2; + border-radius: 16px; + background: #fff; + text-align: center; + box-shadow: 0 20px 65px rgba(12, 33, 67, 0.24); +} +.adopt-success-icon { + display: grid; + place-items: center; + width: 52px; + height: 52px; + margin: 0 auto 14px; + border-radius: 50%; + background: #e8f9f0; + color: #07945c; + font-size: 28px; + font-weight: 800; +} +.adopt-success-modal h2 { + margin: 0 0 9px; + color: #142d4d; + font-size: 19px; +} +.adopt-success-modal p { + margin: 0; + color: #6d829d; + font-size: 13px; + line-height: 1.7; +} +.adopt-success-modal > button { + min-width: 110px; + margin-top: 22px; +} .qa-library-toolbar { display: flex; @@ -3663,6 +3755,69 @@ .qa-library-card-tools button:hover { background: #e8f1ff; } +.qa-library-card-tools .danger-button { + border-color: #ffc2c8; + background: #fff5f6; + color: #d9364f; +} +.qa-library-card-tools .danger-button:hover { + background: #ffe8eb; +} +.qa-library-delete-modal { + width: min(420px, calc(100vw - 32px)); +} +.qa-library-delete-modal > p { + margin: 0 0 12px; + color: #536b88; + font-size: 13px; + line-height: 1.6; +} +.qa-delete-preview { + margin-bottom: 18px; + padding: 11px 12px; + border-radius: 8px; + background: #f6f9fd; + color: #294969; + font-size: 13px; + line-height: 1.5; +} +.danger-solid-button { + padding: 9px 16px; + border: 1px solid #d9364f; + border-radius: 8px; + background: #d9364f; + color: #fff; + cursor: pointer; +} +.danger-solid-button:hover { background: #bf2840; } +.danger-solid-button:disabled { opacity: .65; cursor: wait; } +.draft-reject-button { + padding: 8px 13px; + border: 1px solid #ffc2c8; + border-radius: 8px; + background: #fff5f6; + color: #d9364f; + cursor: pointer; +} +.draft-reject-button:hover { background: #ffe8eb; } +.draft-reject-button:disabled { opacity: .6; cursor: not-allowed; } +.draft-reject-modal { width: min(430px, calc(100vw - 32px)); } +.draft-reject-modal > p { margin: 0 0 18px; color: #536b88; font-size: 13px; line-height: 1.7; } +.board-actions .complete { + color: #158d57; +} +.board-actions .complete:hover { + background: #e8f9f0; +} +.complete-icon { + background: #e8f9f0; + color: #158d57; +} +.confirm-complete { + border-color: #1764e8 !important; + background: #1764e8 !important; + color: #fff; +} .qa-library-edit-modal { max-height: min(720px, calc(100vh - 32px)); overflow-y: auto; @@ -4119,6 +4274,7 @@ body img { padding: 32px; background: rgba(4, 12, 28, .82); cursor: zoom-out; + overflow: hidden; } .global-image-viewer img { @@ -4130,7 +4286,114 @@ body img { object-fit: contain; border-radius: 10px; box-shadow: 0 20px 80px rgba(0, 0, 0, .45); - cursor: default; + cursor: grab; + touch-action: none; + transform-origin: center center; + user-select: none; +} + +.global-image-viewer img.dragging { + cursor: grabbing; +} + +.global-image-viewer-tools { + position: fixed; + z-index: 2; + left: 50%; + bottom: 22px; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: 1px solid rgba(255, 255, 255, .2); + border-radius: 10px; + background: rgba(13, 28, 51, .88); + color: #fff; + transform: translateX(-50%); +} + +.global-image-viewer-tools button { + min-width: 32px; + height: 30px; + padding: 0 8px; + border: 0; + border-radius: 6px; + background: rgba(255, 255, 255, .12); + color: #fff; + font-size: 18px; + line-height: 1; + cursor: pointer; +} + +.global-image-viewer-tools button:hover { + background: rgba(255, 255, 255, .24); +} + +.global-image-viewer-tools button.reset { + font-size: 12px; +} + +.global-image-viewer-tools span { + min-width: 48px; + color: rgba(255, 255, 255, .86); + font-size: 12px; + text-align: center; +} + +/* Mobile public task portals: use the full viewport and let the document + scroll naturally instead of clipping content inside the desktop card. */ +@media (max-width: 700px) { + .answer-v2-shell, + .ask-user-shell { + width: 100%; + min-height: 100dvh; + padding: 0; + overflow-x: hidden; + } + + .answer-v2-app, + .ask-user-app { + width: 100%; + min-height: 100dvh; + margin: 0; + overflow: visible; + border-radius: 0; + box-shadow: none; + } + + .answer-v2-body { + min-height: 0; + padding-bottom: 86px; + } + + .answer-v2-footer { + padding-bottom: calc(10px + env(safe-area-inset-bottom)); + } + + .ask-user-topbar { + padding-top: calc(13px + env(safe-area-inset-top)); + } + + .ask-user-profile, + .ask-user-task-id { + margin-inline: 10px; + } + + .ask-question-section, + .ask-confirm-body { + padding-inline: 10px; + padding-bottom: 110px; + } + + .ask-audit-body { + padding-inline: 10px; + } + + .ask-user-sticky { + position: fixed; + z-index: 10; + padding-bottom: calc(12px + env(safe-area-inset-bottom)); + } } .global-image-viewer-close { @@ -4158,4 +4421,8 @@ body img { top: 12px; right: 12px; } + + .global-image-viewer-tools { + bottom: 14px; + } } diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 7cd5515..c7ab7fd 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { Bell, @@ -23,6 +23,7 @@ import TaskBoardPage from "./TaskBoardPage.jsx"; import ReviewCenterPage from "./ReviewCenterPage.jsx"; import CreateAskTasksPage from "./CreateAskTasksPage.jsx"; import AskUserPortal from "./AskUserPortal.jsx"; +import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js"; // 兼容公共回答链接末尾有无斜杠,避免 /answer 被识别为运营后台路由。 if (window.location.pathname === "/answer") @@ -51,12 +52,21 @@ async function api(path, options = {}) { ...options, }); const data = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(data.detail || "\u8bf7\u6c42\u5931\u8d25"); + if (!response.ok) { + const message = data.detail || `请求失败(HTTP ${response.status})`; + throw new Error(message); + } return data; } -async function readReferenceFile(file) { +async function readReferenceFile(file, requestApi = api) { if (!file) return ""; + if (/\.xlsx$/i.test(file.name)) { + const body = new FormData(); + body.append("file", file); + const result = await requestApi("/api/admin/reference-qa/parse", { method: "POST", body }); + return result.data.text || ""; + } const text = await file.text(); if (file.name.toLowerCase().endsWith(".json")) { try { @@ -363,14 +373,14 @@ function AssistantPage() { category: "", benefits: "", selling_points: "", - language_style: "口语(亲切随性)", - target_group: "女性", - usage_scene: "居家", - purchase_channel: "电商平台", - question_count: 5, - answers_per_question: 2, + language_style: "", + target_group: "", + usage_scene: "", + purchase_channel: "", + question_count: "", + answers_per_question: "", answer_requirement: "", - seed_style: true, + seed_style: false, }); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); @@ -381,10 +391,26 @@ function AssistantPage() { const update = (key, value) => setForm((current) => ({ ...current, [key]: value })); async function generate() { + const intentError = analyzeProductIntent([ + form.product_name, + form.category, + form.benefits, + form.selling_points, + ]); + if (intentError) { + setError(intentError); + setItems([]); + return; + } + const optionsError = validateGenerationOptions(form); + if (optionsError) { + setError(optionsError); + return; + } setError(""); setLoading(true); try { - const reference_qa = referenceFile ? await readReferenceFile(referenceFile) : referenceQa; + const reference_qa = referenceFile ? await readReferenceFile(referenceFile, api) : referenceQa; const result = await api("/api/admin/qa/generate", { method: "POST", body: JSON.stringify({ @@ -457,7 +483,7 @@ function AssistantPage() { <>
@@ -503,8 +529,9 @@ function AssistantPage() {

生成偏好与数量

@@ -517,6 +544,7 @@ function AssistantPage() { update("language_style", event.target.value) } > + @@ -528,6 +556,7 @@ function AssistantPage() { value={form.target_group} onChange={(event) => update("target_group", event.target.value)} > + @@ -568,6 +597,7 @@ function AssistantPage() { value={form.usage_scene} onChange={(event) => update("usage_scene", event.target.value)} > + @@ -583,6 +613,7 @@ function AssistantPage() { update("purchase_channel", event.target.value) } > + @@ -663,12 +694,6 @@ function AssistantPage() { > ✓ 批量采纳 - - +

Q:{record.question}

@@ -1346,6 +1402,22 @@ function QALibraryPage() { }} /> )} + {deleting && ( +
event.target === event.currentTarget && setDeleting(null)}> +
+
+

确认删除

+ +
+

确定要删除这条问答吗?删除后将无法恢复。

+
Q:{deleting.question}
+
+ + +
+
+
+ )} ); } @@ -1415,30 +1487,47 @@ function AssistantPageFinal() { category: "", benefits: "", selling_points: "", - language_style: "口语(亲切随性)", - target_group: "女性", - usage_scene: "居家", - purchase_channel: "电商平台", - question_count: 5, - answers_per_question: 2, + language_style: "", + target_group: "", + usage_scene: "", + purchase_channel: "", + question_count: "", + answers_per_question: "", answer_requirement: "", - seed_style: true, + seed_style: false, }); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); const [savedMessage, setSavedMessage] = useState(""); + const [adoptedDialog, setAdoptedDialog] = useState(null); const [manualOpen, setManualOpen] = useState(false); const [referenceFile, setReferenceFile] = useState(null); const [referenceQa, setReferenceQa] = useState(""); const update = (key, value) => setForm((current) => ({ ...current, [key]: value })); async function generate() { + const intentError = analyzeProductIntent([ + form.product_name, + form.category, + form.benefits, + form.selling_points, + ]); + if (intentError) { + setError(intentError); + setItems([]); + return; + } + const optionsError = validateGenerationOptions(form); + if (optionsError) { + setError(optionsError); + return; + } setError(""); setLoading(true); try { - const reference_qa = referenceFile ? await readReferenceFile(referenceFile) : referenceQa; + const reference_qa = referenceFile ? await readReferenceFile(referenceFile, api) : referenceQa; const result = await api("/api/admin/qa/generate", { method: "POST", body: JSON.stringify({ @@ -1456,7 +1545,7 @@ function AssistantPageFinal() { setLoading(false); } } - async function adopt(targetItems) { + async function adopt(targetItems, showDialog = false) { if (!targetItems.length || saving) return; setSaving(true); setSavedMessage(""); @@ -1484,6 +1573,7 @@ function AssistantPageFinal() { ), ); const { added_count: added, skipped_count: skipped } = result.data; + if (showDialog) setAdoptedDialog({ count: targetItems.length, added, skipped }); setSavedMessage( added > 0 ? `已将 ${added} 条问答存入问答库${skipped ? `,${skipped} 条重复内容已跳过` : ""}` @@ -1502,7 +1592,7 @@ function AssistantPageFinal() { if (target.accepted) return; adopt([target]); } - const acceptAll = () => adopt(items); + const acceptAll = () => adopt(items, true); const save = (id, question, answers) => setItems((current) => current.map((item) => @@ -1532,7 +1622,7 @@ function AssistantPageFinal() { <>
@@ -1578,8 +1668,9 @@ function AssistantPageFinal() {

生成偏好与数量

@@ -1592,6 +1683,7 @@ function AssistantPageFinal() { update("language_style", event.target.value) } > + @@ -1603,6 +1695,7 @@ function AssistantPageFinal() { value={form.target_group} onChange={(event) => update("target_group", event.target.value)} > + @@ -1643,6 +1736,7 @@ function AssistantPageFinal() { value={form.usage_scene} onChange={(event) => update("usage_scene", event.target.value)} > + @@ -1658,6 +1752,7 @@ function AssistantPageFinal() { update("purchase_channel", event.target.value) } > + @@ -1722,12 +1817,6 @@ function AssistantPageFinal() { > {saving ? "正在存入…" : "✓ 批量采纳"} - +
+
+ )} ); } @@ -3535,11 +3637,17 @@ function CompletionReviewPage() { } function GlobalImageViewer() { const [src, setSrc] = useState(""); + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [dragging, setDragging] = useState(false); + const dragStart = useRef(null); useEffect(() => { const handleClick = (event) => { const image = event.target?.closest?.("img"); if (!image || !image.src) return; setSrc(image.src); + setScale(1); + setPosition({ x: 0, y: 0 }); }; const handleKeyDown = (event) => { if (event.key === "Escape") setSrc(""); @@ -3552,10 +3660,68 @@ function GlobalImageViewer() { }; }, []); if (!src) return null; + const changeScale = (delta) => { + setScale((current) => { + const next = Math.min(5, Math.max(0.5, current + delta)); + if (next <= 1) setPosition({ x: 0, y: 0 }); + return next; + }); + }; + const resetView = () => { + setScale(1); + setPosition({ x: 0, y: 0 }); + }; + const beginDrag = (event) => { + if (event.button !== 0) return; + event.currentTarget.setPointerCapture?.(event.pointerId); + dragStart.current = { + x: event.clientX - position.x, + y: event.clientY - position.y, + }; + setDragging(true); + }; + const moveDrag = (event) => { + if (!dragStart.current) return; + setPosition({ + x: event.clientX - dragStart.current.x, + y: event.clientY - dragStart.current.y, + }); + }; + const endDrag = () => { + dragStart.current = null; + setDragging(false); + }; return ( -
setSrc("")}> +
setSrc("")} + onWheel={(event) => { + event.preventDefault(); + changeScale(event.deltaY > 0 ? -0.15 : 0.15); + }} + > - 大图预览 event.stopPropagation()} /> +
event.stopPropagation()}> + + {Math.round(scale * 100)}% + + +
+ 大图预览 event.stopPropagation()} + onPointerDown={beginDrag} + onPointerMove={moveDrag} + onPointerUp={endDrag} + onPointerCancel={endDrag} + />
); } diff --git a/frontend/src/productIntent.js b/frontend/src/productIntent.js new file mode 100644 index 0000000..29dc362 --- /dev/null +++ b/frontend/src/productIntent.js @@ -0,0 +1,49 @@ +const REPEATED_PATTERN = /^(.{1,3})\1+$/u; + +/** + * Lightweight intent guard used before calling the generation APIs. It keeps + * obvious placeholder input (for example, "111" or "aaaa") from being sent + * to the model while allowing normal Chinese/English product descriptions. + */ +export function analyzeProductIntent(values) { + const text = values + .map((value) => String(value || "").trim()) + .filter(Boolean) + .join(" "); + if (!text) return "请至少填写一项商品信息"; + + const compact = text.replace(/[\s\p{P}\p{S}]+/gu, ""); + if (!compact) return "商品信息不能只有符号,请填写真实的商品内容"; + if (/^\d+$/u.test(compact)) { + return "商品信息看起来像占位内容,请填写真实的商品名称、品类、功效或卖点"; + } + if (REPEATED_PATTERN.test(compact)) { + return "商品信息看起来是重复的占位内容,请补充真实的商品信息"; + } + if (!/[\u4e00-\u9fffA-Za-z]/u.test(compact)) { + return "请填写包含文字的真实商品信息"; + } + return ""; +} + +export function validateGenerationOptions(form, includeAnswers = true) { + const required = [ + ["language_style", "语言风格"], + ["target_group", "目标人群"], + ["question_count", "提问生成条数"], + ["usage_scene", "使用场景"], + ["purchase_channel", "购买渠道"], + ]; + if (includeAnswers) required.splice(3, 0, ["answers_per_question", "单题回答条数"]); + const missing = required + .filter(([key]) => !String(form[key] ?? "").trim()) + .map(([, label]) => label); + if (missing.length) return `请填写${missing.join("、")}`; + if (!Number.isInteger(Number(form.question_count)) || Number(form.question_count) < 1) { + return "提问生成条数必须是大于 0 的整数"; + } + if (includeAnswers && (!Number.isInteger(Number(form.answers_per_question)) || Number(form.answers_per_question) < 1)) { + return "单题回答条数必须是大于 0 的整数"; + } + return ""; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4c13e9a..1767679 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -219,6 +219,27 @@ button { color: #8296ae; font-size: 11px; } +.reference-qa-upload input[type="file"] { + color: #466181; +} +.reference-qa-upload input[type="file"]::file-selector-button { + margin-right: 10px; + padding: 7px 11px; + border: 1px solid #c7d8eb; + border-radius: 6px; + background: #fff; + color: #1764e8; + cursor: pointer; +} +.reference-qa-clear { + width: max-content; + padding: 6px 10px; + border: 1px solid #f1a9b5; + border-radius: 7px; + background: #fff3f5; + color: #cc3150; + font-size: 11px; +} .login-hint { margin-top: 24px; color: #9aabc1;