删除遗留上传凭证字段,加入生成问答导出

This commit is contained in:
db_huang 2026-11-03 11:55:37 +08:00
parent a18f75f735
commit 9a4dd3ed0f
11 changed files with 171 additions and 672 deletions

View File

@ -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]:

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -94,7 +94,6 @@ return <><section className="answer-v2-hero green"><small>✓ 任务已派发 ·
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 }) {
<section className={`answer-v2-card status-card ${passed ? 'passed' : cancelled ? 'rejected' : ''}`}>
<div className="status-title"><span>{passed ? <Check size={25}/> : cancelled ? '!' : <Clock3 size={24}/>}</span><div><h1>{paid ? '已返款' : passed ? '审核已完成' : cancelled ? '任务已撤销' : '完成凭证审核中'} <em>{STATUS_TEXT[latest.status]}</em></h1><p>{paid ? '运营已完成本次任务返款,请留意您的收款账户。' : passed ? '运营已审核通过您提交的任务凭证,当前进入打款流程,请耐心等待打款。' : cancelled ? '运营已撤销本次任务,您无需继续提交。' : '您提交的回答完成截图正在审核,请耐心等待运营确认。'}</p></div></div>
{passed && <div className={`answer-v2-payment-wait ${paid ? 'paid' : ''}`}><strong>{paid ? '返款已完成' : '审核已完成,等待打款'}</strong><span>{paid ? '款项已由运营确认发放,请留意收款账户。' : '款项将按运营安排发放,请留意收款账户。'}</span></div>}
{paid && refundProofs.length > 0 && <section className="answer-v2-card material-card refund-proof-user"><header><h2>返款凭证</h2><span>运营已上传</span></header><div>{refundProofs.map((image, index) => <figure key={image.id || image.data_url}><img src={image.data_url || image.url} alt={`返款凭证${index + 1}`} /><figcaption>返款支付截图</figcaption></figure>)}</div></section>}
<footer><small>提交编号:{latest.submission_no}</small><button onClick={() => reload()} disabled={busy}><RefreshCw size={14} className={busy ? 'spin' : ''}/>{busy ? '刷新中…' : '刷新状态'}</button></footer>
</section>
{latest.completion_screenshots?.length > 0 && <MaterialCard latest={latest} completion/>}
@ -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 <section className="answer-v2-history"><header><button onClick={close}>← 返回任务</button><h2>任务状态 / 上传历史({records.length})</h2></header>{records.length === 0 ? <div className="answer-v2-card">暂无历史记录</div> : records.map(record => <article className="answer-v2-card" key={record.id}><header><strong>{record.submission_no}</strong><span>{STATUS_TEXT[record.status] || record.status}</span></header><p>{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张</p><div>{record.invited_screenshots.slice(0, 4).map(image => { const src = image.url || image.data_url; return <img key={image.id || src} src={src} alt="被邀请回答截图"/> })}</div><button className="answer-v2-history-continue" onClick={() => onOpen(record)} disabled={openingId === record.id}>{record.status === '已完成' ? <ReceiptText size={14}/> : <Eye size={14}/>} {openingId === record.id ? '正在读取…' : actionText(record)}</button></article>)}</section>
}
@ -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

View File

@ -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 (
<div className="ask-audit-body">
<section
className={`ask-audit-card ${passed ? "passed" : assignment.status === "未通过" ? "rejected" : ""}`}
>
{paid && refundProofs.length > 0 ? (
<div className="ask-audit-refund-proof">
{refundProofs.map((image, index) => (
<figure key={image.id || image.data_url}>
<img
src={image.data_url || image.url}
alt={`返款凭证${index + 1}`}
/>
<figcaption>返款凭证</figcaption>
</figure>
))}
</div>
) : (
<span>
{passed ? "✓" : assignment.status === "未通过" ? "!" : "⌁"}
</span>
)}
<span>
{passed ? "✓" : assignment.status === "未通过" ? "!" : "⌁"}
</span>
<em>{assignment.status}</em>
<h1>
{pending

View File

@ -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 (
<main className="refund-upload-page">
<section className="refund-upload-card">
<div className="refund-upload-brand">【问大家】</div>
<h1>上传返款支付截图</h1>
<p className="refund-upload-tip">无需登录,仅用于本次返款凭证上传</p>
{error ? <div className="refund-upload-error">{error}</div> : null}
{session && !done ? (
<form onSubmit={upload}>
<div className="refund-upload-picker">
<span>请选择返款支付截图</span>
<div className="refund-upload-options">
<button type="button" className="refund-upload-option" onClick={() => cameraInputRef.current?.click()}>
<strong>拍摄上传</strong>
<small>调用手机摄像头</small>
</button>
<button type="button" className="refund-upload-option" onClick={() => fileInputRef.current?.click()}>
<strong>相册/文件选择</strong>
<small>选择本地图片</small>
</button>
</div>
<input
ref={cameraInputRef}
className="refund-upload-hidden-input"
type="file"
accept="image/jpeg,image/png,image/webp"
capture="environment"
onChange={chooseFile}
/>
<input
ref={fileInputRef}
className="refund-upload-hidden-input"
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={chooseFile}
/>
<small>{file ? file.name : "支持 JPG、PNG、WEBP,最大 10MB"}</small>
</div>
<button type="submit" disabled={busy || !file}>
{busy ? "上传中…" : "上传返款凭证"}
</button>
</form>
) : done ? (
<div className="refund-upload-success">
<div>✓</div>
<h2>上传成功</h2>
<p>请回到运营端点击“确定返款完成”。</p>
</div>
) : (
<div className="refund-upload-loading">正在验证二维码…</div>
)}
</section>
</main>
);
}

View File

@ -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
<span>关联问题与回答</span>
<span>认领用户</span>
<span>状态</span>
<span>返款凭证</span>
</div>
{task.selected_questions.length === 0 ? (
<div className="task-detail-empty">暂无问题</div>
@ -264,27 +262,6 @@ export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, a
</span>
</React.Fragment>
))}
<span
className="task-detail-refund-proof task-detail-refund-proof-merged"
style={{
gridColumn: 5,
gridRow: `1 / span ${task.selected_questions.length}`,
}}
>
{refundScreenshots.length ? (
<button type="button">
<img
src={refundScreenshots[0].data_url || refundScreenshots[0].url}
alt="返款凭证"
loading="lazy"
decoding="async"
/>
<small>已上传</small>
</button>
) : (
"未上传"
)}
</span>
</div>
)}
</div>
@ -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 }) {
<span>完成</span>
<span>状态</span>
<span>创建人</span>
<span>返款凭证</span>
<span>操作</span>
</div>
{loading ? (
@ -770,16 +742,6 @@ export default function TaskBoardPage({ api, user }) {
<input type="checkbox" checked={selectedIds.includes(key)} onChange={() => toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} />
</span>
<span className={`board-user-cell${!isAskTask(task) ? " board-answer-user" : ""}`}>
{children.length > 0 && (
<button
type="button"
className={`board-expand-button${expanded ? " open" : ""}`}
onClick={() => toggleAskTask(task)}
aria-label={expanded ? "收起认领任务" : "展开认领任务"}
>
▾
</button>
)}
<code>{task.user_id}</code>
{!isAskTask(task) && task.phone && task.phone !== task.user_id && <small>{maskPhone(task.phone)}</small>}
</span>
@ -793,11 +755,23 @@ export default function TaskBoardPage({ api, user }) {
<span>{task.completed_count}</span>
<span><em className={`board-status ${task.status_label}`}>{task.status_label}</em></span>
<span>{task.creator_name}<small>{formatTime(task.created_at)}</small></span>
<span className="board-refund-proof">{task.refund_screenshot_count ? "已上传" : "--"}</span>
<span className="board-actions">
<button disabled={Boolean(detailLoading)} onClick={() => openDetail(task)}>
{detailLoading === detailCacheKey(task) ? "打开中…" : "详情"}
</button>
{isAskTask(task) ? (
<button
type="button"
className="expand"
disabled={children.length === 0}
onClick={() => toggleAskTask(task)}
aria-expanded={expanded}
title={children.length === 0 ? "暂无认领明细" : undefined}
>
{expanded ? "收起" : "展开"}
</button>
) : (
<button disabled={Boolean(detailLoading)} onClick={() => openDetail(task)}>
{detailLoading === detailCacheKey(task) ? "打开中…" : "详情"}
</button>
)}
{task.can_cancel && (
<button className="cancel" disabled={busy === task.id} onClick={() => cancel(task)}>
{busy === task.id ? "撤销中" : "撤销"}
@ -823,7 +797,6 @@ export default function TaskBoardPage({ api, user }) {
<span>{child.completed_count}</span>
<span><em className={`board-status ${child.status_label}`}>{child.status_label}</em></span>
<span>{child.creator_name}<small>{formatTime(child.created_at)}</small></span>
<span className="board-refund-proof">{child.refund_proof_uploaded ? "已上传" : child.refund_endpoint ? "待上传" : "--"}</span>
<span className="board-actions"><button disabled={Boolean(detailLoading)} onClick={() => openDetail(child)}>{detailLoading === detailCacheKey(child) ? "打开中…" : "详情"}</button></span>
</div>
))}

View File

@ -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;

View File

@ -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() {
<div className="panel-title">
<Settings size={18} />
生成条件配置 <small>* 商品信息至少填写一项</small>
<button
type="button"
className="ask-clear-all"
onClick={clearAllAssistant}
disabled={loading || saving}
>
一键清空
</button>
</div>
<div className="rule" />
<h4>商品信息</h4>
@ -2217,6 +2275,15 @@ function AssistantPageFinal() {
>
<FileText size={14} /> 批量导入问题
</button>
{items.length > 0 && (
<button
type="button"
className="ghost-button"
onClick={exportGeneratedQa}
>
<Download size={14} /> 导出问答
</button>
)}
<button
className="success-button"
onClick={acceptAll}
@ -2231,14 +2298,6 @@ function AssistantPageFinal() {
>
↻ 重新生成
</button>
<button
type="button"
className="ask-clear-all"
onClick={clearAllAssistant}
disabled={loading || saving}
>
一键清空
</button>
</div>
</div>
{items.length === 0 && (
@ -4701,8 +4760,6 @@ function TextDecoder() {
return null;
}
function App() {
if (window.location.pathname.startsWith("/refund-upload/"))
return <RefundUploadPage api={api} />;
if (window.location.pathname === "/ask" || window.location.pathname.startsWith("/ask/"))
return <AskUserPortal api={api} />;
if (window.location.pathname.startsWith("/answer/"))