删除遗留上传凭证字段,加入生成问答导出
This commit is contained in:
parent
a18f75f735
commit
9a4dd3ed0f
@ -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]:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
<em>{assignment.status}</em>
|
||||
<h1>
|
||||
{pending
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
{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>
|
||||
))}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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/"))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user