diff --git a/backend/app/llm.py b/backend/app/llm.py index 11c0037..f2b9820 100644 --- a/backend/app/llm.py +++ b/backend/app/llm.py @@ -4,6 +4,7 @@ import io import logging import re import time +import unicodedata import urllib.error import urllib.request from concurrent.futures import ThreadPoolExecutor, as_completed @@ -19,6 +20,20 @@ from .schemas import QABulkAnswerRequest, QAGenerateRequest logger = logging.getLogger(__name__) +def _natural_question(value: object) -> str: + """Keep generated questions conversational and free of forced punctuation.""" + text = re.sub(r"\s+", " ", str(value or "")).strip() + return re.sub(r"[??。.!!]+$", "", text).rstrip() + + +def _natural_answer(value: object) -> str: + text = re.sub(r"\s+", " ", str(value or "")).strip() + text = re.sub(r"^(?:回答|答复|参考回答)\s*[::]\s*", "", text).strip() + while text and (unicodedata.category(text[-1]).startswith("P") or text[-1] in "~~"): + text = text[:-1].rstrip() + return text + + def _prompt(payload: QAGenerateRequest) -> str: style = "带真实体验的轻度种草语气" if payload.seed_style else "客观自然,不刻意种草" return f"""你是电商问大家内容策划。请根据商品条件生成真实、具体、互不重复的消费者提问和参考回答。 @@ -35,7 +50,9 @@ def _prompt(payload: QAGenerateRequest) -> str: 真实问答参考:{payload.reference_qa or '未提供'} 请生成 {payload.question_count} 个问题,每个问题生成 {payload.answers_per_question} 个不同角度的参考回答。 -约束:问题尽量不超过20字,回答尽量不超过50字;不虚构成分、检测、认证和医疗功效;表达像真实买家;不要重复商品名称作为开头。 +提问口吻:像买家随手输入,简短、口语化,允许省略主语;不要每句都用“请问、是否、怎么样、如何、能不能”这类标准句式;句式和长短要有变化;问题末尾不要加问号或其他标点。 +回答口吻:像普通买家在手机上简短回复,可自然使用“我用着、感觉、还行、挺、不会”等日常表达;不要写成客服说明、产品说明书、广告文案或整齐统一的完整句;不同回答的语气和长度要有变化;回答末尾一律不要加任何标点符号。 +约束:问题尽量不超过20字,回答尽量不超过50字;不虚构成分、检测、认证、医疗功效或未提供的使用事实;不要重复商品名称作为开头。 如果提供了真实问答参考,请优先学习其中的提问角度、真实语气和回答结构,但不要照抄原文,不要把参考中的其他商品信息误套到当前商品。 只返回严格 JSON,不要 Markdown 代码块或额外解释,格式必须是: {{"items":[{{"question":"问题文本","answers":["回答1","回答2"]}}]}}""" @@ -62,9 +79,11 @@ def _parse_content(content: str, expected_answers: int) -> list[dict]: answers = raw.get("answers", []) if isinstance(answers, str): answers = [answers] - answers = [str(answer).strip() for answer in answers if str(answer).strip()][:expected_answers] + answers = [cleaned for answer in answers if (cleaned := _natural_answer(answer))][:expected_answers] if answers: - items.append({"id": f"Q{index + 1:03d}", "question": str(raw["question"]).strip(), "answers": answers, "source": "AI生成", "status": "待采纳", "accepted": False}) + question = _natural_question(raw["question"]) + if question: + items.append({"id": f"Q{index + 1:03d}", "question": question, "answers": answers, "source": "AI生成", "status": "待采纳", "accepted": False}) if not items: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型未返回有效问答内容") return items @@ -137,7 +156,7 @@ def _bulk_answers_prompt(payload: QABulkAnswerRequest, questions: list[str]) -> 问题列表: {question_lines} -每个问题生成 {payload.answers_per_question} 条不同角度的回答。回答尽量不超过50字,不虚构成分、检测、认证和医疗功效。 +每个问题生成 {payload.answers_per_question} 条不同角度的回答。回答要像普通买家在手机上随手回复,简短口语化,可自然使用“我用着、感觉、还行、挺、不会”等日常表达;避免客服腔、说明书语气、广告词和整齐统一的句式。不同回答的开头、长短和语气要有变化。回答末尾一律不要加任何标点符号。回答尽量不超过50字,不虚构成分、检测、认证、医疗功效或未提供的使用事实。 只返回严格 JSON,不要 Markdown 或解释。必须按问题序号原顺序返回,格式: {{"items":[{{"index":1,"answers":["回答1","回答2"]}}]}}""" @@ -180,7 +199,7 @@ def _generate_bulk_answers_batch(payload: QABulkAnswerRequest, questions: list[s answers = raw.get("answers") or [] if isinstance(answers, str): answers = [answers] - clean_answers = [str(answer).strip() for answer in answers if str(answer).strip()] + clean_answers = [cleaned for answer in answers if (cleaned := _natural_answer(answer))] if len(clean_answers) >= payload.answers_per_question: by_index[index] = clean_answers[:payload.answers_per_question] if any(index not in by_index for index in range(1, len(questions) + 1)): @@ -220,11 +239,8 @@ def generate_answers_for_questions(payload: QABulkAnswerRequest, questions: list return items -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,无法生成真实提问") - prompt = f"""你是电商问大家提问策划。请根据商品信息生成 {count} 个真实、具体、互不重复的消费者提问。 +def _questions_prompt(payload: dict, count: int, reference_qa: str = "") -> str: + return f"""你是电商问大家提问策划。请根据商品信息生成 {count} 个真实、具体、互不重复的消费者提问。 商品名称:{payload.get('product_name') or '未提供'} 商品品类:{payload.get('category') or '未提供'} 商品功效:{payload.get('benefits') or '未提供'} @@ -234,7 +250,15 @@ def _generate_questions_batch(payload: dict, count: int, reference_qa: str = "") 使用场景:{payload.get('usage_scene') or '日常使用'} 购买渠道:{payload.get('purchase_channel') or '电商平台'} 真实问答参考:{reference_qa or '未提供'} -只返回严格 JSON,不要 Markdown 或解释,格式:{{"questions":["问题1","问题2"]}}。问题应像真实买家提问,尽量不超过30字,不要虚构具体参数或功效。""" +提问必须像买家随手输入:简短、口语化,允许省略主语;不要每句都用“请问、是否、怎么样、如何、能不能”这类标准句式;句式和长短要有变化;末尾不要加问号或其他标点。尽量不超过30字,不要虚构具体参数、功效或使用事实。 +只返回严格 JSON,不要 Markdown 或解释,格式:{{"questions":["问题1","问题2"]}}。""" + + +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,无法生成真实提问") + prompt = _questions_prompt(payload, count, reference_qa) 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") @@ -249,7 +273,7 @@ def _generate_questions_batch(payload: dict, count: int, reference_qa: str = "") raise ValueError("questions is not a list") output = [] for value in questions: - text = str(value).strip() + text = _natural_question(value) if text and text not in output: output.append(text[:200]) if not output: @@ -287,19 +311,19 @@ def generate_questions(payload: dict, count: int, reference_qa: str = "") -> lis def generate_answer_for_question(product_title: str, question: str) -> str: """Generate one concise answer for an OCR-extracted question.""" if not settings.llm_api_key: - return f"结合{product_title}的使用体验来看,建议按自身肤质先少量试用,确认合适后再正常使用。" + return "我用着还行,建议先少量试试,适合自己再继续用" url = settings.llm_base_url.rstrip("/") + "/chat/completions" - prompt = f"商品:{product_title}\n问题:{question}\n请生成一条50字以内、自然真实、不夸大功效的淘宝问大家回答。只输出回答正文。" + prompt = f"商品:{product_title}\n问题:{question}\n请生成一条50字以内的淘宝问大家回答。像普通买家在手机上随手回复,简短口语化,避免客服腔、说明书语气、广告词和过于完整规范的句式;不要虚构未提供的使用事实或夸大功效;回答末尾一律不要加任何标点符号。只输出回答正文。" request_body = {"model": settings.llm_model, "temperature": 0.7, "messages": [{"role": "system", "content": "你是电商问大家回答助手。"}, {"role": "user", "content": prompt}]} request = urllib.request.Request(url, data=json.dumps(request_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")) - answer = str(result["choices"][0]["message"]["content"]).strip() + answer = _natural_answer(result["choices"][0]["message"]["content"]) return answer[:300] except Exception: logger.exception("Answer generation failed for OCR question") - return f"结合{product_title}的使用体验来看,建议按自身肤质先少量试用,确认合适后再正常使用。" + return "我用着还行,建议先少量试试,适合自己再继续用" def _parse_ocr_content(content: str) -> list[dict]: diff --git a/backend/app/main.py b/backend/app/main.py index ab6caa4..a766589 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -31,7 +31,7 @@ from sqlalchemy.orm import Session, defer, noload, object_session, 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_answers_batch, generate_answers_for_questions, generate_questions, generate_qa -from .models import AdminLoginLog, AdminUsageEntry, AdminUser, AlipayRefundOrder, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QABulkGenerationItem, QABulkGenerationJob, QALibraryEntry, RefundUploadSession +from .models import AdminLoginLog, AdminUsageEntry, AdminUser, AlipayRefundOrder, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QABulkGenerationItem, QABulkGenerationJob, QALibraryEntry from .alipay_provider import AlipayTransferError from .schemas import AdminDingTalkUserCreate, AdminDingTalkUserLookup, AdminUserCreate, AdminUserUpdate, LoginRequest, QABulkAnswerRequest, QAGenerateRequest, RegisterRequest from .security import create_session_token, decode_session_token, hash_password, verify_password @@ -1151,24 +1151,6 @@ def _answer_owner_clause(user: AdminUser): return AnswerSubmission.owner_id.in_(descendant_owners) -REFUND_UPLOAD_SESSION_TTL = timedelta(minutes=10) -REFUND_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"} - - -def _refund_session_target(session: RefundUploadSession, db: Session): - """Resolve the exact payable record carried by a temporary upload token.""" - if session.target_kind == "ask_batch": - record = db.get(AskAssignmentBatch, session.target_id) - return record, record.status == "approved" if record else False - if session.target_kind == "ask_assignment": - record = db.get(AskAssignment, session.target_id) - return record, record.status == "已通过" if record else False - if session.target_kind == "answer_submission": - record = db.get(AnswerSubmission, session.target_id) - return record, record.status == "已通过" if record else False - return None, False - - def _refund_session_target_for_payload(target_kind: str, target_id: int, db: Session): # Avoid constructing a transient ORM object just to resolve the target; # this also keeps the query helper easy to reuse in tests. @@ -1184,41 +1166,6 @@ def _refund_session_target_for_payload(target_kind: str, target_id: int, db: Ses return None, False -def _refund_session_expired(session: RefundUploadSession) -> bool: - return session.expires_at <= datetime.utcnow() - - -def _refund_screenshot_count(record, kind: str) -> int: - screenshots = list(record.screenshots) - return sum(1 for image in screenshots if image.stage == "refund") - - -async def _read_refund_file(refund_file: UploadFile | None) -> tuple[bytes, str, str]: - if refund_file is None: - raise HTTPException(status_code=400, detail="请上传返款凭证,或先用手机扫码上传") - if refund_file.content_type not in REFUND_IMAGE_TYPES: - raise HTTPException(status_code=400, detail="返款凭证仅支持 JPG、PNG、WEBP 图片") - data = await refund_file.read() - if not data: - raise HTTPException(status_code=400, detail="返款凭证不能为空") - if len(data) > 10 * 1024 * 1024: - raise HTTPException(status_code=400, detail="返款凭证不能超过 10MB") - return data, refund_file.content_type, (refund_file.filename or "refund")[:255] - - -def _refund_session_payload(session: RefundUploadSession, record) -> dict: - return { - "token": session.token, - "target_kind": session.target_kind, - "target_id": session.target_id, - "expires_at": session.expires_at.isoformat(), - "uploaded": bool(session.uploaded_at) or _refund_screenshot_count(record, session.target_kind) > 0, - "used": bool(session.used_at), - } - - - - def _new_ask_public_token(db: Session) -> str: while True: token = secrets.token_urlsafe(24) @@ -1304,7 +1251,6 @@ def _refund_recipient_data(record, target_kind: str) -> dict: def _ask_assignment_data( assignment: AskAssignment, include_images: bool = True, - include_refund_images: bool = False, image_endpoints: bool = False, ) -> dict: items = sorted(assignment.items, key=lambda item: item.question.sequence_no) @@ -1312,10 +1258,9 @@ def _ask_assignment_data( # List endpoints must not touch the screenshot relationship. The image # BLOBs are large and loading them for every row was the main cause of the # multi-second task-board/review-center delay. - screenshots = list(assignment.screenshots) if include_images or (include_refund_images and assignment.status == "已完成") else [] + screenshots = list(assignment.screenshots) if include_images else [] question_screenshots = [item for item in screenshots if item.stage == "question"] payment_screenshots = [item for item in screenshots if item.stage == "payment"] - refund_screenshots = [item for item in screenshots if item.stage == "refund"] latest_review = ( max(assignment.review_decisions, key=lambda item: item.created_at) if assignment.review_decisions @@ -1357,8 +1302,7 @@ def _ask_assignment_data( ), "question_screenshots": [{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "assignment", image_endpoints)} for image in question_screenshots] if include_images else [], "payment_screenshots": [{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "assignment", image_endpoints)} for image in payment_screenshots] if include_images else [], - "refund_screenshots": [{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "assignment", image_endpoints)} for image in refund_screenshots] if include_images or include_refund_images else [], - "refund_completed": assignment.status == "已完成" or bool(refund_screenshots), + "refund_completed": assignment.status == "已完成", "latest_review": ({ "approved": latest_review.approved, "reason_code": latest_review.reason_code, @@ -1378,14 +1322,13 @@ def _ask_assignment_data( def _ask_batch_data( batch: AskAssignmentBatch, include_images: bool = True, - include_refund_images: bool = False, image_endpoints: bool = False, ) -> dict: """Serialize one independent submission batch for review/history views.""" assignment = batch.assignment # Batch screenshots are authoritative for batch history. Avoid touching the # legacy assignment screenshot relationship while serializing a batch. - parent = _ask_assignment_data(assignment, include_images=False, include_refund_images=False) + parent = _ask_assignment_data(assignment, include_images=False) try: selected_ids = {int(value) for value in (batch.selected_question_ids or "").split(",") if value} except ValueError: @@ -1396,10 +1339,9 @@ def _ask_batch_data( if batch.review_decisions else None ) - screenshots = list(batch.screenshots) if include_images or (include_refund_images and batch.status == "paid") else [] + screenshots = list(batch.screenshots) if include_images else [] question_images = [item for item in screenshots if item.stage == "question"] payment_images = [item for item in screenshots if item.stage == "payment"] - refund_images = [item for item in screenshots if item.stage == "refund"] questions = [] for item in sorted(assignment.items, key=lambda value: value.question.sequence_no): questions.append({ @@ -1429,11 +1371,7 @@ def _ask_batch_data( {"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "batch", image_endpoints)} for image in payment_images ] if include_images else [], - "refund_screenshots": [ - {"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "batch", image_endpoints)} - for image in refund_images - ] if include_images or include_refund_images else [], - "refund_completed": batch.status == "paid" or bool(refund_images), + "refund_completed": batch.status == "paid", "latest_review": ({ "approved": latest_review.approved, "reason_code": latest_review.reason_code, @@ -1486,17 +1424,15 @@ def _has_legacy_fake_ocr_questions(record: AnswerSubmission) -> bool: def _submission_data( record: AnswerSubmission, include_images: bool = True, - include_refund_images: bool = False, image_endpoints: bool = False, ) -> dict: # List endpoints deliberately omit screenshot BLOBs. Do not even touch # the lazy relationship in that mode, otherwise SQLAlchemy fetches every # image from remote MySQL before returning the lightweight response. - screenshots = list(record.screenshots) if include_images or (include_refund_images and record.status == "已完成") else [] + screenshots = list(record.screenshots) if include_images else [] invited = [x for x in screenshots if x.stage == "invited"] completion = [x for x in screenshots if x.stage == "completion"] payment = [x for x in screenshots if x.stage == "payment"] - refund = [x for x in screenshots if x.stage == "refund"] images = {x.id: x for x in screenshots} latest_review = max(record.review_decisions, key=lambda value: value.created_at) if record.review_decisions else None return { @@ -1514,8 +1450,7 @@ def _submission_data( "invited_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in invited] if include_images else [], "completion_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in completion] if include_images else [], "payment_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in payment] if include_images else [], - "refund_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in refund] if include_images or include_refund_images else [], - "refund_completed": record.status == "已完成" or bool(refund), + "refund_completed": record.status == "已完成", "latest_review": ({ "approved": latest_review.approved, "reason_code": latest_review.reason_code, @@ -1551,7 +1486,6 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True, imag "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], - "refund_screenshots": [], "refund_completed": record.status == "已完成", "latest_review": None, "questions": [], @@ -1582,7 +1516,6 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True, imag "can_delete": True, "delete_endpoint": f"/api/admin/task-board/{record.id}", "refund_endpoint": f"/api/admin/task-board/{record.id}/confirm-refund" if record.status == "已通过" else None, - "refund_screenshot_count": len(summary.get("refund_screenshots") or []) or (1 if record.status == "已完成" else 0), "assigned_users": [{"user_id": _public_user_id(record.user), "phone": _public_user_account(record.user), "taobao_id": record.user.taobao_id, "status": status_labels.get(record.status, record.status)}], "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": q.answer_text, "product_title": q.product_title} for q in selected] if include_images else []), } @@ -1596,10 +1529,6 @@ def _ask_assignment_child_data(assignment: AskAssignment) -> dict: } 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) - # A batch can only enter ``paid`` through the refund endpoint, which now - # requires a refund screenshot. Keep the board list lightweight and do - # not load image BLOBs just to render this indicator. - refund_proof_uploaded = bool(latest_batch and latest_batch.status == "paid") or assignment.status == "已完成" batch_selected_ids = set() if latest_batch and latest_batch.selected_question_ids: batch_selected_ids = { @@ -1665,9 +1594,6 @@ def _ask_assignment_child_data(assignment: AskAssignment) -> dict: "question_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], - "refund_screenshots": [], - "refund_proof_uploaded": refund_proof_uploaded, - "refund_proof_count": 1 if refund_proof_uploaded else 0, "latest_review": None, } @@ -1767,9 +1693,6 @@ def _ask_batch_child_data(batch: AskAssignmentBatch) -> dict: "question_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], - "refund_screenshots": [], - "refund_proof_uploaded": refund_completed, - "refund_proof_count": 1 if refund_completed else 0, "latest_review": None, } @@ -1855,7 +1778,6 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: "status_label": status_label, "qa_count": len(selected_questions), "claimed_count": assigned_count, "submitted_count": submitted_count, "pending_review_count": pending_count, "completed_count": completed_count, "total_count": len(selected_questions), - "refund_screenshot_count": sum(child.get("refund_proof_count", 0) for child in claimed_tasks), "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": False, "can_delete": True, "delete_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}", @@ -1924,7 +1846,7 @@ def _ask_review_board_data( is_batch = isinstance(record, AskAssignmentBatch) assignment = record.assignment if is_batch else record if include_images: - data = _ask_batch_data(record, include_images=True, include_refund_images=True, image_endpoints=image_endpoints) if is_batch else _ask_assignment_data(record, include_images=True, include_refund_images=True, image_endpoints=image_endpoints) + data = _ask_batch_data(record, include_images=True, image_endpoints=image_endpoints) if is_batch else _ask_assignment_data(record, include_images=True, image_endpoints=image_endpoints) selected = [question for question in data["questions"] if question["selected"]] elif is_batch: selected_ids = {int(value) for value in (record.selected_question_ids or "").split(",") if value} @@ -1948,7 +1870,7 @@ def _ask_review_board_data( "latest_review": None, } else: - data = _ask_assignment_data(record, include_images=False, include_refund_images=True) + data = _ask_assignment_data(record, include_images=False) selected = [question for question in data["questions"] if question["selected"]] selected_ids = {question["id"] for question in selected} data["questions"] = [] @@ -2183,7 +2105,7 @@ def public_ask_task(task_no: str, token: str, refresh: bool = False, db: Session # the current selection. Do not replace it with the previous submitted # batch or a refresh would appear to undo the user's choices. if latest_batch and assignment.status not in {"待选择", "进行中"}: - data = _ask_batch_data(latest_batch, include_images=False, include_refund_images=latest_batch.status == "paid") + data = _ask_batch_data(latest_batch, include_images=False) db.rollback() return {"success": True, "data": data} data = _ask_assignment_data(assignment, include_images=False) @@ -2219,9 +2141,9 @@ def public_ask_assignments(token: str, db: Session = Depends(get_db)): for record in records: batches = sorted(record.batches, key=lambda value: value.created_at, reverse=True) if batches: - data.extend(_ask_batch_data(batch, include_images=False, include_refund_images=batch.status == "paid") for batch in batches) + data.extend(_ask_batch_data(batch, include_images=False) for batch in batches) else: - data.append(_ask_assignment_data(record, include_images=False, include_refund_images=record.status == "已完成")) + data.append(_ask_assignment_data(record, include_images=False)) db.rollback() return {"success": True, "data": data} @@ -2513,7 +2435,7 @@ def answer_submission_detail(submission_id: int, token: str, owner_token: str, d record = db.get(AnswerSubmission, submission_id) if not record or record.user_id != user.id or record.owner_id != owner.id: raise HTTPException(status_code=404, detail="任务不存在") - return {"success": True, "data": _submission_data(record, include_images=True, include_refund_images=True)} + return {"success": True, "data": _submission_data(record, include_images=True)} def _process_answer_submission(submission_id: int): @@ -3477,97 +3399,6 @@ def _save_refund_recipient(db, kind, target_id, account, name): recipient.payee_name = name.strip() -@app.post("/api/admin/refund-upload-sessions") -def create_refund_upload_session(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): - """Create a short-lived, record-bound upload URL for the operator modal.""" - target_kind = str(payload.get("target_kind") or "").strip() - try: - target_id = int(payload.get("target_id")) - except (TypeError, ValueError): - raise HTTPException(status_code=400, detail="返款记录无效") - target, payable = _refund_session_target_for_payload(target_kind, target_id, db) - if not target or not payable: - raise HTTPException(status_code=409, detail="当前任务不在待返款状态") - if target_kind in {"ask_batch", "ask_assignment"}: - if not _can_access_ask_task(target.assignment.task if target_kind == "ask_batch" else target.task, user): - raise HTTPException(status_code=404, detail="无权操作该返款记录") - elif not _can_access_answer_submission(target, user): - raise HTTPException(status_code=404, detail="无权操作该返款记录") - # 24 random bytes keep the full LAN upload URL inside a compact QR - # version while still providing 192 bits of entropy. - token = secrets.token_urlsafe(24) - session = RefundUploadSession( - token=token, - target_kind=target_kind, - target_id=target_id, - created_by_id=user.id, - expires_at=datetime.utcnow() + REFUND_UPLOAD_SESSION_TTL, - ) - db.add(session) - db.commit() - return {"success": True, "data": { - "token": token, - "upload_url": f"/refund-upload/{token}", - "upload_url_hint": f"手机浏览器打开:/refund-upload/{token}", - "expires_at": session.expires_at.isoformat(), - "expires_in": int(REFUND_UPLOAD_SESSION_TTL.total_seconds()), - "target_kind": target_kind, - "target_id": target_id, - }} - - -@app.get("/api/refund-upload-sessions/{token}") -def get_refund_upload_session(token: str, db: Session = Depends(get_db)): - session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == token)) - if not session: - raise HTTPException(status_code=404, detail="二维码无效或已失效") - target, payable = _refund_session_target(session, db) - if _refund_session_expired(session): - raise HTTPException(status_code=410, detail="二维码已过期,请让运营重新生成") - if not target or not payable: - raise HTTPException(status_code=410, detail="该返款任务已完成或不可操作") - return {"success": True, "data": _refund_session_payload(session, target)} - - -@app.post("/api/refund-upload-sessions/{token}/upload") -async def upload_refund_proof_by_session(token: str, refund_file: UploadFile = File(...), db: Session = Depends(get_db)): - session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == token)) - if not session: - raise HTTPException(status_code=404, detail="二维码无效或已失效") - if _refund_session_expired(session): - raise HTTPException(status_code=410, detail="二维码已过期,请让运营重新生成") - if session.used_at: - raise HTTPException(status_code=409, detail="该返款任务已经确认完成") - target, payable = _refund_session_target(session, db) - if not target or not payable: - raise HTTPException(status_code=410, detail="该返款任务已完成或不可操作") - data, content_type, filename = await _read_refund_file(refund_file) - # A re-upload replaces the pending proof for this target, so the operator - # always confirms the image currently visible on the phone. - for image in list(target.screenshots): - if image.stage == "refund": - db.delete(image) - if session.target_kind == "ask_batch": - db.add(AskAssignmentBatchScreenshot( - batch_id=target.id, stage="refund", - **screenshot_fields(data, filename, content_type, "ask-batches"), - )) - elif session.target_kind == "ask_assignment": - db.add(AskAssignmentScreenshot( - assignment_id=target.id, stage="refund", - **screenshot_fields(data, filename, content_type, "ask-assignments"), - )) - else: - db.add(AnswerScreenshot( - submission_id=target.id, stage="refund", - **screenshot_fields(data, filename, content_type, "answers"), - )) - session.uploaded_at = datetime.utcnow() - db.commit() - db.refresh(session) - return {"success": True, "data": {"uploaded": True, "filename": filename, "uploaded_at": session.uploaded_at.isoformat()}} - - def _snapshot_refund(db, record, kind, user, payer_account="", alipay_trade_no=""): identity = _refund_recipient_data(record, kind) task = record.assignment.task if kind == "ask_batch" else record.task if kind == "ask_assignment" else record diff --git a/backend/app/models.py b/backend/app/models.py index 87610ec..8adecb0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -477,27 +477,3 @@ class AskAssignmentReview(Base): created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) assignment: Mapped[AskAssignment] = relationship(back_populates="review_decisions") - - -class RefundUploadSession(Base): - """Short-lived, login-free upload ticket for an operator refund proof. - - The token is deliberately bound to one concrete board record rather than - accepting a task id from the phone. This keeps a scanned code from being - reused to attach a screenshot to a different task. - """ - - __tablename__ = "refund_upload_sessions" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - token: Mapped[str] = mapped_column(String(96), unique=True, index=True) - target_kind: Mapped[str] = mapped_column(String(32), index=True) - target_id: Mapped[int] = mapped_column(Integer, index=True) - # Keep this as an indexed audit value without a database FK. Existing - # deployments may use a different integer width for admin_users.id, and - # MySQL rejects creating the new table when FK column types differ. - created_by_id: Mapped[int] = mapped_column(Integer, index=True) - expires_at: Mapped[datetime] = mapped_column(DateTime, index=True) - uploaded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 7ab794f..73ce355 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1346,7 +1346,7 @@ def test_approved_answer_appears_as_pending_refund_with_detail(monkeypatch): ) assert user_detail.status_code == 200 assert user_detail.json()["data"]["status"] == "已完成" - assert len(user_detail.json()["data"]["refund_screenshots"]) == 0 + assert user_detail.json()["data"]["refund_completed"] is True board_after_refund = task_board_items(client, page_size=100) completed_payout = next( item diff --git a/backend/tests/test_llm_ocr.py b/backend/tests/test_llm_ocr.py index ab9fb0c..4928985 100644 --- a/backend/tests/test_llm_ocr.py +++ b/backend/tests/test_llm_ocr.py @@ -6,6 +6,29 @@ from PIL import Image from app import llm +def test_generated_qa_is_normalized_to_conversational_text(): + content = '{"items":[{"question":" 洗完会不会很干?? ","answers":["回答:我用着还行。”", "感觉不会,挺清爽的!"]}]}' + + result = llm._parse_content(content, expected_answers=2) + + assert result[0]["question"] == "洗完会不会很干" + assert result[0]["answers"] == ["我用着还行", "感觉不会,挺清爽的"] + + +def test_generation_prompts_request_varied_buyer_language_without_question_marks(): + qa_prompt = llm._prompt(llm.QAGenerateRequest(product_name="洗面奶")) + task_prompt = llm._questions_prompt({"product_name": "洗面奶"}, 5) + task_prompt_terms = ( + "像买家随手输入", + "末尾不要加问号", + "不要每句都用", + ) + + assert all(term in qa_prompt for term in task_prompt_terms) + assert all(term in task_prompt for term in task_prompt_terms) + assert "不要写成客服说明" in qa_prompt + + def test_multi_image_ocr_retries_per_image_and_deduplicates(monkeypatch): attempts = Counter() diff --git a/frontend/src/AnswerUserPortal.jsx b/frontend/src/AnswerUserPortal.jsx index aeb7a01..9f231a6 100644 --- a/frontend/src/AnswerUserPortal.jsx +++ b/frontend/src/AnswerUserPortal.jsx @@ -94,7 +94,6 @@ return <>
✓ 任务已派发 · function FinalStage({ latest, reload, busy, reopen, startNew }) { const paid = latest.status === '已完成' - const refundProofs = latest.refund_screenshots || [] const passed = ['已通过', '已完成'].includes(latest.status) const rejected = latest.status === '未通过' const cancelled = latest.status === '已撤销' @@ -120,7 +119,6 @@ function FinalStage({ latest, reload, busy, reopen, startNew }) {
{passed ? : cancelled ? '!' : }

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

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

{passed &&
{paid ? '返款已完成' : '审核已完成,等待打款'}{paid ? '款项已由运营确认发放,请留意收款账户。' : '款项将按运营安排发放,请留意收款账户。'}
} - {paid && refundProofs.length > 0 &&

返款凭证

运营已上传
{refundProofs.map((image, index) =>
{`返款凭证${index
返款支付截图
)}
}
提交编号:{latest.submission_no}
{latest.completion_screenshots?.length > 0 && } @@ -130,7 +128,7 @@ function FinalStage({ latest, reload, busy, reopen, startNew }) { function HistoryPage({ records, close, onOpen, openingId }) { const canContinue = status => ['待筛选', '识别失败', '待运营派发', '已派发', '待用户完成', '未通过'].includes(status) - const actionText = record => record.status === '已完成' ? '查看返款凭证' : canContinue(record.status) ? '继续此任务' : '查看任务详情' + const actionText = record => canContinue(record.status) ? '继续此任务' : '查看任务详情' return

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

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

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

{record.invited_screenshots.slice(0, 4).map(image => { const src = image.url || image.data_url; return 被邀请回答截图 })}
)}
} @@ -168,7 +166,6 @@ export default function AnswerUserPortalV2({ api, linkToken = '' }) { 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, - refund_screenshots: record.refund_screenshots?.length ? record.refund_screenshots : previous.refund_screenshots, } : record })) setOptimisticStatuses(current => { @@ -232,7 +229,6 @@ export default function AnswerUserPortalV2({ api, linkToken = '' }) { invited_screenshots: submitted.invited_screenshots?.length ? submitted.invited_screenshots : record.invited_screenshots, completion_screenshots: submitted.completion_screenshots || [], payment_screenshots: submitted.payment_screenshots || [], - refund_screenshots: submitted.refund_screenshots || [], } : record)) } const visibleLatest = newTaskMode ? null : latest diff --git a/frontend/src/AskUserPortal.jsx b/frontend/src/AskUserPortal.jsx index f1fa5cd..9dbe02a 100644 --- a/frontend/src/AskUserPortal.jsx +++ b/frontend/src/AskUserPortal.jsx @@ -683,29 +683,14 @@ function AuditStatus({ assignment, reload, busy, onBackHome }) { const paid = assignment.status === "已完成"; const passed = ["已通过", "已完成"].includes(assignment.status); const review = assignment.latest_review; - const refundProofs = assignment.refund_screenshots || []; return (
- {paid && refundProofs.length > 0 ? ( -
- {refundProofs.map((image, index) => ( -
- {`返款凭证${index -
返款凭证
-
- ))} -
- ) : ( - - {passed ? "✓" : assignment.status === "未通过" ? "!" : "⌁"} - - )} + + {passed ? "✓" : assignment.status === "未通过" ? "!" : "⌁"} + {assignment.status}

{pending diff --git a/frontend/src/RefundUploadPage.jsx b/frontend/src/RefundUploadPage.jsx deleted file mode 100644 index 49570b8..0000000 --- a/frontend/src/RefundUploadPage.jsx +++ /dev/null @@ -1,108 +0,0 @@ -import React, { useEffect, useRef, useState } from "react"; - -export default function RefundUploadPage({ api }) { - const token = window.location.pathname.split("/").filter(Boolean).pop() || ""; - const [session, setSession] = useState(null); - const [file, setFile] = useState(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [done, setDone] = useState(false); - const cameraInputRef = useRef(null); - const fileInputRef = useRef(null); - - useEffect(() => { - if (!token) return; - api(`/api/refund-upload-sessions/${encodeURIComponent(token)}`) - .then((result) => { - setSession(result.data); - setDone(Boolean(result.data.uploaded)); - }) - .catch((err) => setError(err.message)); - }, [api, token]); - - async function upload(event) { - event.preventDefault(); - if (!file) { - setError("请选择返款支付截图"); - return; - } - setBusy(true); - setError(""); - try { - const body = new FormData(); - body.append("refund_file", file); - await api(`/api/refund-upload-sessions/${encodeURIComponent(token)}/upload`, { - method: "POST", - body, - }); - setDone(true); - setSession((current) => (current ? { ...current, uploaded: true } : current)); - } catch (err) { - setError(err.message); - } finally { - setBusy(false); - } - } - - function chooseFile(event) { - setError(""); - setFile(event.target.files?.[0] || null); - // Allow choosing the same file again after replacing/removing it. - event.target.value = ""; - } - - return ( -
-
-
【问大家】
-

上传返款支付截图

-

无需登录,仅用于本次返款凭证上传

- {error ?
{error}
: null} - {session && !done ? ( -
-
- 请选择返款支付截图 -
- - -
- - - {file ? file.name : "支持 JPG、PNG、WEBP,最大 10MB"} -
- -
- ) : done ? ( -
-
✓
-

上传成功

-

请回到运营端点击“确定返款完成”。

-
- ) : ( -
正在验证二维码…
- )} -
-
- ); -} diff --git a/frontend/src/TaskBoardPage.jsx b/frontend/src/TaskBoardPage.jsx index c502846..f9e5f08 100644 --- a/frontend/src/TaskBoardPage.jsx +++ b/frontend/src/TaskBoardPage.jsx @@ -87,7 +87,6 @@ export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, a ...(task.completion_screenshots || []), ]; const paymentScreenshots = task.payment_screenshots || []; - const refundScreenshots = task.refund_screenshots || []; useEffect(() => { if (!refundOpen) return undefined; let cancelled = false; @@ -226,7 +225,6 @@ export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, a 关联问题与回答 认领用户 状态 - 返款凭证

{task.selected_questions.length === 0 ? (
暂无问题
@@ -264,27 +262,6 @@ export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, a ))} - - {refundScreenshots.length ? ( - - ) : ( - "未上传" - )} - )} @@ -500,7 +477,7 @@ export default function TaskBoardPage({ api, user }) { window.alert("当前没有可导出的任务"); return; } - const headers = ["用户ID", "任务ID", "商品/店铺信息", "类型", "Q&A数", "完成", "状态", "创建人", "返款凭证", "创建时间"]; + const headers = ["用户ID", "任务ID", "商品/店铺信息", "类型", "Q&A数", "完成", "状态", "创建人", "创建时间"]; const csvCell = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`; const lines = [headers, ...rows.map((task) => [ task.user_id, @@ -511,7 +488,6 @@ export default function TaskBoardPage({ api, user }) { task.completed_count, task.status_label, task.creator_name, - task.refund_screenshot_count ? "已上传" : "--", formatTime(task.created_at), ])].map((line) => line.map(csvCell).join(",")); const blob = new Blob([`\uFEFF${lines.join("\r\n")}`], { type: "text/csv;charset=utf-8" }); @@ -655,9 +631,6 @@ export default function TaskBoardPage({ api, user }) { task.completion_screenshots || result.data.completion_screenshots || [], payment_screenshots: task.payment_screenshots || result.data.payment_screenshots || [], - refund_screenshots: - result.data.refund_screenshots || task.refund_screenshots || [], - refund_screenshot_count: result.data.refund_screenshots?.length || task.refund_screenshot_count || 0, }; detailCache.current.set(detailCacheKey(task), updatedDetail); setDetail(updatedDetail); @@ -749,7 +722,6 @@ export default function TaskBoardPage({ api, user }) { 完成 状态 创建人 - 返款凭证 操作 {loading ? ( @@ -770,16 +742,6 @@ export default function TaskBoardPage({ api, user }) { toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} /> - {children.length > 0 && ( - - )} {task.user_id} {!isAskTask(task) && task.phone && task.phone !== task.user_id && {maskPhone(task.phone)}} @@ -793,11 +755,23 @@ export default function TaskBoardPage({ api, user }) { {task.completed_count} {task.status_label} {task.creator_name}{formatTime(task.created_at)} - {task.refund_screenshot_count ? "已上传" : "--"} - + {isAskTask(task) ? ( + + ) : ( + + )} {task.can_cancel && ( ))} diff --git a/frontend/src/admin.css b/frontend/src/admin.css index 6076b86..456fec5 100644 --- a/frontend/src/admin.css +++ b/frontend/src/admin.css @@ -2551,7 +2551,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.75fr minmax(150px, 1.2fr); + 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; @@ -2589,26 +2589,6 @@ .board-answer-user small { margin-left: 0; } -.board-expand-button { - width: 20px; - height: 20px; - padding: 0; - border: 0; - border-radius: 5px; - background: #edf3ff; - color: #2864e8; - font-size: 14px; - line-height: 18px; - cursor: pointer; - transform: rotate(-90deg); - transition: transform 160ms ease, background 160ms ease; -} -.board-expand-button:hover { - background: #dce8ff; -} -.board-expand-button.open { - transform: rotate(0deg); -} .board-child-row { min-height: 58px; background: #fbfcff; @@ -2631,11 +2611,6 @@ .board-child-row .board-actions button { color: #2864e8; } -.board-refund-proof { - color: #12965b; - font-size: 11px; - white-space: nowrap; -} .board-select-cell { display: grid; place-items: center; @@ -2889,7 +2864,7 @@ .task-detail-table .head, .task-detail-table .row { display: grid; - grid-template-columns: 45px 2fr 1fr 0.7fr 0.9fr; + grid-template-columns: 45px 2fr 1fr 0.7fr; gap: 10px; align-items: center; padding: 11px; @@ -2901,7 +2876,7 @@ } .task-detail-table-body { display: grid; - grid-template-columns: 45px 2fr 1fr 0.7fr 0.9fr; + grid-template-columns: 45px 2fr 1fr 0.7fr; } .task-detail-cell { display: grid; @@ -2943,73 +2918,6 @@ text-align: center; color: #8a9cb1; } -.task-detail-refund-proof button { - display: grid; - gap: 4px; - padding: 0; - background: transparent; - color: #12965b; - font-size: 10px; -} -.task-detail-refund-proof img { - width: 48px; - height: 48px; - object-fit: cover; - border-radius: 6px; -} -.task-detail-refund-proof-merged { - display: flex; - align-items: center; - justify-content: center; - min-width: 0; - padding: 11px; - border-left: 1px solid #edf1f6; - color: #8094ac; - font-size: 10px; - text-align: center; -} -.ask-refund-proof-card { - margin-top: 12px; - padding: 14px; - border: 1px solid #bfe8d1; - border-radius: 12px; - background: #f4fff8; -} -.ask-refund-proof-card > header { - display: flex; - justify-content: space-between; - margin-bottom: 10px; - color: #168451; - font-size: 12px; -} -.ask-refund-proof-card > div, -.refund-proof-user > div { - display: flex; - gap: 9px; - flex-wrap: wrap; -} -.ask-refund-proof-card figure, -.refund-proof-user figure { - margin: 0; - overflow: hidden; - border: 1px solid #d3e8da; - border-radius: 8px; - background: #fff; -} -.ask-refund-proof-card img, -.refund-proof-user img { - display: block; - width: 96px; - height: 96px; - object-fit: cover; -} -.ask-refund-proof-card figcaption, -.refund-proof-user figcaption { - padding: 5px; - color: #5d816b; - font-size: 10px; - text-align: center; -} .task-detail-evidence { display: grid; grid-template-columns: 1.5fr 1fr; @@ -3141,171 +3049,12 @@ font-size: 12px; line-height: 1.75; } -.refund-proof-upload { - display: grid; - grid-template-columns: 54px 1fr; - gap: 7px; - align-items: center; - margin-top: 16px; - padding: 12px; - border: 1px dashed #b8cbe2; - border-radius: 10px; - outline: none; - background: #fbfdff; - color: #294767; - text-align: left; -} -.refund-proof-upload:focus { - border-color: #1764e8; - box-shadow: 0 0 0 3px rgba(23, 100, 232, .1); -} -.refund-proof-upload.has-image { - border-style: solid; - border-color: #72cda6; - background: #f4fcf8; -} -.refund-proof-upload > img, -.refund-paste-icon { - grid-row: 1 / span 4; - width: 54px; - height: 54px; - border-radius: 8px; -} -.refund-proof-upload > img { - object-fit: cover; - border: 1px solid #dce7f5; -} -.refund-paste-icon { - display: grid; - place-items: center; - background: #eaf2ff; - color: #1764e8; - font-size: 13px; - font-weight: 800; -} -.refund-proof-upload > span:not(.refund-paste-icon) { - font-size: 13px; - font-weight: 700; -} -.refund-proof-upload strong { - color: #1764e8; - font-size: 13px; -} -.refund-proof-upload small { - overflow: hidden; - color: #8195ad; - text-overflow: ellipsis; - white-space: nowrap; -} -.refund-proof-upload em { - color: #dc4f5c; - font-size: 12px; - font-style: normal; -} -.refund-proof-remove { - justify-self: start; - padding: 3px 7px; - border: 1px solid #f0b7c0; - border-radius: 6px; - background: #fff5f6; - color: #d23850; - font-size: 11px; - cursor: pointer; -} -.refund-proof-remove:hover { - border-color: #d23850; - background: #ffecef; -} -.refund-proof-fallback { - display: inline-flex; - justify-content: flex-start; - margin-top: 9px; - color: #1764e8; - font-size: 12px; - cursor: pointer; -} -.refund-proof-fallback input { - position: absolute; - width: 1px; - height: 1px; - opacity: 0; - pointer-events: none; -} -.refund-proof-fallback span:hover { text-decoration: underline; } -.refund-scan-upload { - display: grid; - grid-template-columns: 1fr 118px; - gap: 14px; - align-items: center; - margin-top: 16px; - padding: 13px; - border: 1px solid #c8dcf6; - border-radius: 11px; - background: #f6faff; - text-align: left; -} -.refund-scan-upload > div { display: grid; gap: 5px; } -.refund-scan-upload strong { color: #193b65; font-size: 14px; } -.refund-scan-upload small { color: #7187a3; line-height: 1.55; } -.refund-scan-url { overflow-wrap: anywhere; color: #5b78a0 !important; font-size: 10px; } -.refund-scan-upload b { color: #078b59; font-size: 12px; } -.refund-scan-upload em { color: #dc4f5c; font-size: 12px; font-style: normal; } -.refund-scan-upload img { - width: 108px; - height: 108px; - justify-self: center; - padding: 5px; - border: 1px solid #dce7f5; - border-radius: 8px; - background: #fff; - image-rendering: pixelated; -} -.refund-scan-upload i { color: #8195ad; font-size: 12px; text-align: center; } -.refund-upload-page { - min-height: 100vh; - display: grid; - place-items: center; - padding: 24px 16px; - background: #f2f7fd; -} -.refund-upload-card { - width: min(440px, 100%); - padding: 30px 24px; - border: 1px solid #dbe7f5; - border-radius: 18px; - background: #fff; - box-shadow: 0 18px 50px rgba(27, 65, 110, .12); - text-align: center; -} -.refund-upload-brand { color: #1764e8; font-weight: 800; letter-spacing: .06em; } -.refund-upload-card h1 { margin: 18px 0 8px; color: #142d4d; font-size: 23px; } -.refund-upload-tip { margin: 0 0 18px; color: #778da6; font-size: 13px; } -.refund-upload-error { margin-bottom: 14px; padding: 10px; border-radius: 8px; background: #fff0f1; color: #d83f52; font-size: 13px; } -.refund-upload-picker { display: grid; gap: 12px; padding: 22px 15px; border: 1px dashed #a9c5e8; border-radius: 12px; color: #1c5fc7; } -.refund-upload-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } -.refund-upload-option { display: grid; gap: 5px; padding: 13px 8px; border: 1px solid #c8dcf6; border-radius: 9px; background: #f6faff; color: #1764e8; cursor: pointer; } -.refund-upload-option strong { font-size: 14px; } -.refund-upload-option small { color: #7187a3; font-size: 12px; } -.refund-upload-option:hover { border-color: #1764e8; background: #eef5ff; } -.refund-upload-hidden-input { display: none; } -.refund-upload-picker small { color: #7b91aa; font-size: 12px; } -.refund-upload-card form > button { width: 100%; margin-top: 16px; padding: 12px; border: 0; border-radius: 9px; background: #1764e8; color: #fff; font-weight: 700; } -.refund-upload-card button:disabled { cursor: wait; opacity: .55; } -.refund-upload-success > div { display: grid; place-items: center; width: 56px; height: 56px; margin: 12px auto; border-radius: 50%; background: #e8f8ef; color: #078b59; font-size: 28px; font-weight: 800; } -.refund-upload-success h2 { margin: 10px 0; color: #142d4d; font-size: 20px; } -.refund-upload-success p, .refund-upload-loading { color: #7187a3; font-size: 13px; } .refund-confirm-modal > div { display: grid; grid-template-columns: 1fr 1.35fr; gap: 10px; margin-top: 22px; } -.refund-confirm-modal > .refund-proof-upload { - grid-template-columns: 54px 1fr; - gap: 7px; - align-items: center; - margin-top: 16px; -} .refund-confirm-modal button { padding: 11px; border-radius: 9px; @@ -3347,8 +3096,6 @@ padding: 16px; } .refund-confirm-modal { max-height: calc(100vh - 28px); overflow-y: auto; padding: 20px 16px; } - .refund-scan-upload { grid-template-columns: 1fr 96px; gap: 8px; } - .refund-scan-upload img { width: 88px; height: 88px; } .task-detail-meta { grid-template-columns: 1fr 1fr; } @@ -6181,11 +5928,6 @@ body img { font-size: 21px; } } -.ask-audit-card>span.refund-proof-badge{width:auto;min-width:60px;height:38px;padding:0 14px;border-radius:12px;font-size:13px;font-weight:700} -.ask-audit-card>div.ask-audit-refund-proof{display:flex;align-items:center;justify-content:center;gap:8px;width:auto;min-height:60px;margin:0;padding:0;border:0;background:transparent;text-align:center} -.ask-audit-refund-proof figure{margin:0;text-align:center} -.ask-audit-refund-proof img{display:block;width:78px;height:78px;object-fit:cover;border:1px solid #67ca98;border-radius:12px;background:#fff} -.ask-audit-refund-proof figcaption{margin-top:4px;color:#168451;font-size:10px} /* Product token/QR access shown on the public ask-task confirmation page. */ .ask-product-access { display: grid; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 5fd3a08..217ce1c 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -6,6 +6,7 @@ import { Database, BarChart3, ChevronDown, + Download, FileText, LogOut, MessageCircle, @@ -24,7 +25,6 @@ import TaskBoardPage, { clearTaskBoardCache } from "./TaskBoardPage.jsx"; import ReviewCenterPage from "./ReviewCenterPage.jsx"; import CreateAskTasksPage from "./CreateAskTasksPage.jsx"; import AskUserPortal from "./AskUserPortal.jsx"; -import RefundUploadPage from "./RefundUploadPage.jsx"; import RefundManagementPage from "./RefundManagementPage.jsx"; import RefundRecordsPage from "./RefundRecordsPage.jsx"; import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js"; @@ -1752,6 +1752,14 @@ function QALibraryEditModal({ record, onClose, onSaved }) { function AssistantPageFinal() { const cached = readPageCache("assistant", {}); + const cachedItems = (cached.items || []).map((item) => ({ + ...item, + answers: String(item.source || "").includes("AI") + ? (item.answers || []).map((answer) => + String(answer || "").replace(/[,。!?、;:,.!?;:…~~·"'“”‘’()()【】\[\]《》<>]+$/u, "").trimEnd(), + ) + : item.answers, + })); const [form, setForm] = useState(cached.form || { product_name: "", category: "", @@ -1766,7 +1774,7 @@ function AssistantPageFinal() { answer_requirement: "", seed_style: false, }); - const [items, setItems] = useState(cached.items || []); + const [items, setItems] = useState(cachedItems); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); @@ -1828,6 +1836,48 @@ function AssistantPageFinal() { setImportOpen(false); clearReferenceFile(); } + function exportGeneratedQa() { + if (!items.length) return; + const answerCount = Math.max( + 1, + ...items.map((item) => (Array.isArray(item.answers) ? item.answers.length : 0)), + ); + const headers = [ + "序号", + "问题", + ...Array.from({ length: answerCount }, (_, index) => `回答${index + 1}`), + "来源", + "状态", + ]; + const rows = items.map((item, index) => [ + index + 1, + item.question || "", + ...Array.from({ length: answerCount }, (_, answerIndex) => + Array.isArray(item.answers) ? item.answers[answerIndex] || "" : "", + ), + item.source || "", + item.status || (item.accepted ? "已采纳" : "待采纳"), + ]); + const csvCell = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`; + const content = [headers, ...rows] + .map((row) => row.map(csvCell).join(",")) + .join("\r\n"); + const blob = new Blob([`\uFEFF${content}`], { + type: "text/csv;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + const productName = (form.product_name || "生成结果").replace( + /[\\/:*?"<>|]/g, + "_", + ); + link.href = url; + link.download = `问答助手_${productName}_${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } async function generate() { const intentError = analyzeProductIntent([ form.product_name, @@ -2030,6 +2080,14 @@ function AssistantPageFinal() {
生成条件配置 * 商品信息至少填写一项 +

商品信息

@@ -2217,6 +2275,15 @@ function AssistantPageFinal() { > 批量导入问题 + {items.length > 0 && ( + + )} -
{items.length === 0 && ( @@ -4701,8 +4760,6 @@ function TextDecoder() { return null; } function App() { - if (window.location.pathname.startsWith("/refund-upload/")) - return ; if (window.location.pathname === "/ask" || window.location.pathname.startsWith("/ask/")) return ; if (window.location.pathname.startsWith("/answer/"))