优化bug,返款扫码上传,新加填写店铺名,上传商品二维码

This commit is contained in:
kx_guo 2026-09-10 10:03:37 +08:00
parent 0b79c6812c
commit 3f1da0e159
29 changed files with 1697 additions and 184 deletions

View File

@ -23,7 +23,7 @@ from sqlalchemy.orm import Session, noload, selectinload
from .config import settings from .config import settings
from .database import Base, SessionLocal, engine, get_db from .database import Base, SessionLocal, engine, get_db
from .llm import extract_questions_from_images, generate_answer_for_question, generate_questions, generate_qa from .llm import extract_questions_from_images, generate_answer_for_question, generate_questions, generate_qa
from .models import AdminLoginLog, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QALibraryEntry from .models import AdminLoginLog, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QALibraryEntry, RefundUploadSession
from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QAGenerateRequest, RegisterRequest from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QAGenerateRequest, RegisterRequest
from .security import create_session_token, decode_session_token, hash_password, verify_password from .security import create_session_token, decode_session_token, hash_password, verify_password
@ -85,18 +85,51 @@ def _product_intent_error(values: list[object]) -> str | None:
return None return None
def _product_token_error(value: object) -> str | None: def _product_token_error(value: object, qr_image: object = None) -> str | None:
"""Validate only that a Taobao product token was provided. """Require at least one way for the user to open the product.
Taobao's command is intentionally treated as opaque text. It may contain Taobao's command is intentionally treated as opaque text. It may contain
characters that are not valid in a URL, so no HTTP/HTTPS parsing should be characters that are not valid in a URL, so no HTTP/HTTPS parsing should be
performed here. performed here.
""" """
if not str(value or "").strip(): if not str(value or "").strip() and not qr_image:
return "商品淘口令为必填项" return "请填写商品淘口令或上传商品二维码"
return None return None
def _decode_product_qr_image(value: object, filename: object = "") -> tuple[bytes | None, str | None, str | None]:
"""Decode the small data URL sent by the create-task form."""
if value is None:
return None, None, None
raw = str(value).strip()
if not raw:
return None, None, None
match = re.fullmatch(r"data:(image/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=\s]+)", raw, flags=re.IGNORECASE)
if not match:
raise HTTPException(status_code=400, detail="商品二维码必须是 JPG、PNG 或 WEBP 图片")
try:
image_data = base64.b64decode(re.sub(r"\s+", "", match.group(2)), validate=True)
except (ValueError, base64.binascii.Error):
raise HTTPException(status_code=400, detail="商品二维码图片数据无效")
if not image_data:
raise HTTPException(status_code=400, detail="商品二维码图片不能为空")
if len(image_data) > 5 * 1024 * 1024:
raise HTTPException(status_code=400, detail="商品二维码图片不能超过 5MB")
image_name = str(filename or "商品二维码").strip()[:255] or "商品二维码"
return image_data, match.group(1).lower(), image_name
def _product_qr_data(task: AskTask) -> dict | None:
if not task.product_qr_image_data:
return None
content_type = task.product_qr_image_content_type or "image/png"
return {
"name": task.product_qr_image_name or "商品二维码",
"content_type": content_type,
"data_url": f"data:{content_type};base64,{base64.b64encode(task.product_qr_image_data).decode()}",
}
def _generation_options_error(payload: dict, include_answers: bool = True) -> str | None: def _generation_options_error(payload: dict, include_answers: bool = True) -> str | None:
# 语言风格、目标人群、场景等生成偏好均可留空,仅限制生成条数必填。 # 语言风格、目标人群、场景等生成偏好均可留空,仅限制生成条数必填。
required = [("question_count", "提问生成条数")] required = [("question_count", "提问生成条数")]
@ -180,12 +213,33 @@ def startup():
if "claim_questions_per_user" not in columns: if "claim_questions_per_user" not in columns:
with engine.begin() as connection: with engine.begin() as connection:
connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN claim_questions_per_user INTEGER NOT NULL DEFAULT 5")) connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN claim_questions_per_user INTEGER NOT NULL DEFAULT 5"))
if "shop_name" not in columns:
with engine.begin() as connection:
connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN shop_name VARCHAR(255) NOT NULL DEFAULT ''"))
if "owner_id" not in columns: if "owner_id" not in columns:
with engine.begin() as connection: with engine.begin() as connection:
connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN owner_id INTEGER NULL")) connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN owner_id INTEGER NULL"))
if "public_token" not in columns: if "public_token" not in columns:
with engine.begin() as connection: with engine.begin() as connection:
connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN public_token VARCHAR(64) NULL")) connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN public_token VARCHAR(64) NULL"))
if "product_qr_image_data" not in columns:
# SQLite accepts LONGBLOB as a type name and stores it with BLOB
# affinity; MySQL needs LONGBLOB so a normal QR image is not capped at
# the 64KB BLOB limit.
qr_blob_type = "LONGBLOB" if engine.dialect.name == "mysql" else "BLOB"
with engine.begin() as connection:
connection.execute(text(f"ALTER TABLE ask_tasks ADD COLUMN product_qr_image_data {qr_blob_type} NULL"))
task_columns = {column["name"] for column in inspect(engine).get_columns("ask_tasks")}
if "product_qr_image_content_type" not in task_columns:
with engine.begin() as connection:
connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN product_qr_image_content_type VARCHAR(64) NULL"))
if "product_qr_image_name" not in task_columns:
with engine.begin() as connection:
connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN product_qr_image_name VARCHAR(255) NULL"))
question_table_columns = {column["name"] for column in inspect(engine).get_columns("ask_questions")}
if "source" not in question_table_columns:
with engine.begin() as connection:
connection.execute(text("ALTER TABLE ask_questions ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'AI生成'"))
admin_user_columns = {column["name"] for column in inspect(engine).get_columns("admin_users")} admin_user_columns = {column["name"] for column in inspect(engine).get_columns("admin_users")}
admin_user_migrations = { admin_user_migrations = {
"dingtalk_user_id": "VARCHAR(128) NULL", "dingtalk_user_id": "VARCHAR(128) NULL",
@ -583,6 +637,28 @@ def _require_ask_task_access(task: AskTask | None, user: AdminUser) -> AskTask:
return task return task
def _dedupe_draft_ask_questions(task: AskTask, db: Session) -> int:
"""Remove duplicate question text from a draft, keeping the first row.
This repairs drafts created before the picker became idempotent. Published
tasks are never changed, and a draft has no user assignments to orphan.
"""
seen: set[str] = set()
duplicates: list[AskQuestion] = []
for question in sorted(task.questions, key=lambda value: (value.sequence_no, value.id)):
key = re.sub(r"\s+", " ", str(question.question_text or "").strip())
if key and key in seen:
duplicates.append(question)
elif key:
seen.add(key)
for question in duplicates:
db.delete(question)
if duplicates:
db.flush()
db.expire(task, ["questions"])
return len(duplicates)
def _ask_task_owner_clause(user: AdminUser): def _ask_task_owner_clause(user: AdminUser):
if user.role_code == "super_admin": if user.role_code == "super_admin":
return True return True
@ -625,6 +701,72 @@ def _answer_owner_clause(user: AdminUser):
return or_(AnswerSubmission.owner_id.is_(None), AnswerSubmission.owner_id.in_(descendant_owners)) return or_(AnswerSubmission.owner_id.is_(None), 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.
if target_kind == "ask_batch":
record = db.get(AskAssignmentBatch, target_id)
return record, record.status == "approved" if record else False
if target_kind == "ask_assignment":
record = db.get(AskAssignment, target_id)
return record, record.status == "已通过" if record else False
if target_kind == "answer_submission":
record = db.get(AnswerSubmission, target_id)
return record, record.status == "已通过" if record else False
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: def _new_ask_public_token(db: Session) -> str:
@ -681,8 +823,10 @@ def _ask_assignment_data(assignment: AskAssignment, include_images: bool = True,
"used_count": len(used_ids), "used_count": len(used_ids),
"remaining_count": max(len(items) - len(used_ids), 0), "remaining_count": max(len(items) - len(used_ids), 0),
"task_id": assignment.task.task_no, "task_id": assignment.task.task_no,
"shop_name": assignment.task.shop_name,
"product_name": assignment.task.product_name, "product_name": assignment.task.product_name,
"product_link": assignment.task.product_link, "product_link": assignment.task.product_link,
"product_qr": _product_qr_data(assignment.task),
"user_id": assignment.user.public_id, "user_id": assignment.user.public_id,
"phone": assignment.user.phone, "phone": assignment.user.phone,
"created_at": assignment.created_at.isoformat(), "created_at": assignment.created_at.isoformat(),
@ -855,7 +999,10 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d
"未通过": "进行中", "未通过": "进行中",
"已撤销": "已撤销", "已撤销": "已撤销",
} }
completed_count = len(selected) if record.status in {"待审核", "已通过", "已完成"} else 0 # "待审核" and "待返款" mean the user has submitted evidence, not that
# the work is finished. The board's 完成 column must only count records
# whose refund workflow has reached 已完成.
completed_count = len(selected) if record.status == "已完成" else 0
return { return {
**summary, **summary,
"board_key": f"answer-{record.id}", "record_kind": "answer_submission", "board_key": f"answer-{record.id}", "record_kind": "answer_submission",
@ -931,7 +1078,9 @@ def _ask_assignment_child_data(assignment: AskAssignment) -> dict:
"claimed_count": question_count, "claimed_count": question_count,
"submitted_count": len(used_ids), "submitted_count": len(used_ids),
"pending_review_count": 1 if status_label == "待审核" else 0, "pending_review_count": 1 if status_label == "待审核" else 0,
"completed_count": len(used_ids) if used_ids else (question_count if refund_completed else 0), # Used questions have been submitted, but remain incomplete until the
# operator confirms the refund (the child status becomes 已完成).
"completed_count": question_count if refund_completed else 0,
"total_count": question_count, "total_count": question_count,
"creator_name": task.owner.display_name if task.owner else "运营后台", "creator_name": task.owner.display_name if task.owner else "运营后台",
"created_at": assignment.created_at.isoformat(), "created_at": assignment.created_at.isoformat(),
@ -962,9 +1111,12 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict:
reverse=True, reverse=True,
) )
assigned_count = len(task.assignments) assigned_count = len(task.assignments)
completed_count = sum(len(assignment.used_questions) for assignment in task.assignments) submitted_count = sum(len(assignment.used_questions) for assignment in task.assignments)
# Keep the parent status based on whether all questions have been claimed;
# the 完成 column is deliberately stricter and only counts refunded rows.
completed_count = sum(child["completed_count"] for child in claimed_tasks)
pending_count = sum(child["pending_review_count"] for child in claimed_tasks) pending_count = sum(child["pending_review_count"] for child in claimed_tasks)
fully_claimed = bool(selected_questions) and completed_count >= len(selected_questions) fully_claimed = bool(selected_questions) and submitted_count >= len(selected_questions)
if task.status == "已撤销": if task.status == "已撤销":
status_label = "已撤销" status_label = "已撤销"
elif task.status == "已完成" or fully_claimed: elif task.status == "已完成" or fully_claimed:
@ -975,10 +1127,20 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict:
"id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task", "id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task",
"task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务", "task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务",
"public_token": task.public_token, "public_token": task.public_token,
"product_name": task.product_name, "product_link": task.product_link, "status": task.status, "shop_name": task.shop_name,
"product_name": task.product_name, "product_link": task.product_link,
# Keep the persisted task state alongside the display label. The
# create-task page uses this value to switch to the publish-success
# view immediately after the publish request completes. The label is
# intentionally derived for the board (a fully claimed task may show
# as completed while its persisted workflow state remains published).
"status": task.status,
# The board list is intentionally lightweight; the QR is included in
# task detail/public assignment responses where it is actually used.
"product_qr": _product_qr_data(task) if include_questions else None,
"claim_questions_per_user": task.claim_questions_per_user or 5, "claim_questions_per_user": task.claim_questions_per_user or 5,
"status_label": status_label, "status_label": status_label,
"qa_count": len(selected_questions), "claimed_count": assigned_count, "submitted_count": completed_count, "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), "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), "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(), "creator_name": task.owner.display_name if task.owner else "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(),
@ -989,7 +1151,7 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict:
# the ambiguous placeholder "--". Claimed child rows still expose # the ambiguous placeholder "--". Claimed child rows still expose
# the actual answer user's public ID. # the actual answer user's public ID.
"user_id": task.owner.display_name if task.owner else "运营后台", "phone": "", "questions": [], "user_id": task.owner.display_name if task.owner else "运营后台", "phone": "", "questions": [],
"selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name} for q in selected_questions] if include_questions else []), "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name, "source": q.source or "AI生成"} for q in selected_questions] if include_questions else []),
"assigned_users": [child["assigned_users"][0] for child in claimed_tasks if child.get("assigned_users")], "assigned_users": [child["assigned_users"][0] for child in claimed_tasks if child.get("assigned_users")],
"claimed_tasks": claimed_tasks, "claimed_tasks": claimed_tasks,
"invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None, "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None,
@ -1022,8 +1184,10 @@ def _ask_review_board_data(record: AskAssignmentBatch | AskAssignment, include_i
"assignment_id": record.id, "assignment_id": record.id,
"status": "已通过", "status": "已通过",
"task_id": assignment.task.task_no, "task_id": assignment.task.task_no,
"shop_name": assignment.task.shop_name,
"product_name": assignment.task.product_name, "product_name": assignment.task.product_name,
"product_link": assignment.task.product_link, "product_link": assignment.task.product_link,
"product_qr": _product_qr_data(assignment.task),
"user_id": assignment.user.public_id, "user_id": assignment.user.public_id,
"phone": assignment.user.phone, "phone": assignment.user.phone,
"created_at": record.created_at.isoformat(), "created_at": record.created_at.isoformat(),
@ -1048,12 +1212,13 @@ def _ask_review_board_data(record: AskAssignmentBatch | AskAssignment, include_i
"task_type": "ask", "task_type": "ask",
"task_type_label": "提问任务", "task_type_label": "提问任务",
"status_label": "已完成" if refund_completed else "待返款", "status_label": "已完成" if refund_completed else "待返款",
"shop_name": assignment.task.shop_name,
"product_name": assignment.task.product_name, "product_name": assignment.task.product_name,
"qa_count": selected_count, "qa_count": selected_count,
"claimed_count": selected_count, "claimed_count": selected_count,
"submitted_count": selected_count, "submitted_count": selected_count,
"pending_review_count": 0, "pending_review_count": 0,
"completed_count": selected_count, "completed_count": selected_count if refund_completed else 0,
"total_count": selected_count, "total_count": selected_count,
"creator_name": "运营后台", "creator_name": "运营后台",
"can_cancel": False, "can_cancel": False,
@ -1579,9 +1744,17 @@ def reject_answer_task_draft(submission_id: int, _: AdminUser = Depends(current_
@app.post("/api/admin/ask-tasks/generate") @app.post("/api/admin/ask-tasks/generate")
def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
shop_name = str(payload.get("shop_name", "")).strip()
product_link = str(payload.get("product_link", "")).strip() product_link = str(payload.get("product_link", "")).strip()
product_qr_data, product_qr_content_type, product_qr_name = _decode_product_qr_image(
payload.get("product_qr_image"), payload.get("product_qr_image_name")
)
product_name = str(payload.get("product_name", "")).strip() product_name = str(payload.get("product_name", "")).strip()
product_token_error = _product_token_error(product_link) if not shop_name:
raise HTTPException(status_code=400, detail="店铺名称为必填项")
if len(shop_name) > 255:
raise HTTPException(status_code=400, detail="店铺名称不能超过255个字符")
product_token_error = _product_token_error(product_link, product_qr_data)
if product_token_error: if product_token_error:
raise HTTPException(status_code=400, detail=product_token_error) raise HTTPException(status_code=400, detail=product_token_error)
intent_error = _product_intent_error([product_name, payload.get("category"), payload.get("benefits"), payload.get("selling_points")]) intent_error = _product_intent_error([product_name, payload.get("category"), payload.get("benefits"), payload.get("selling_points")])
@ -1621,8 +1794,13 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db
db.rollback() db.rollback()
questions = payload.get("questions") if isinstance(payload.get("questions"), list) else [] questions = payload.get("questions") if isinstance(payload.get("questions"), list) else []
reference_text = str(payload.get("reference_qa", ""))[:20000] reference_text = str(payload.get("reference_qa", ""))[:20000]
generation_payload = {
key: value
for key, value in payload.items()
if key not in {"shop_name", "product_qr_image", "product_qr_image_name"}
}
if not questions: if not questions:
questions = generate_questions(payload, count, reference_text) questions = generate_questions(generation_payload, count, reference_text)
# 自定义问题优先,不足的数量用候选问题补齐,保证返回数量与运营填写值一致。 # 自定义问题优先,不足的数量用候选问题补齐,保证返回数量与运营填写值一致。
generated: list[str] = [] generated: list[str] = []
for value in questions: for value in questions:
@ -1632,13 +1810,18 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db
if len(generated) == count: if len(generated) == count:
break break
if len(generated) < count: if len(generated) < count:
generated.extend(generate_questions(payload, count - len(generated), reference_text)) generated.extend(generate_questions(generation_payload, count - len(generated), reference_text))
if existing_id: if existing_id:
task = db.get(AskTask, existing_id) task = db.get(AskTask, existing_id)
_require_ask_task_access(task, user) _require_ask_task_access(task, user)
if task.status != "草稿": if task.status != "草稿":
raise HTTPException(status_code=409, detail="已发布任务不能重新生成") raise HTTPException(status_code=409, detail="已发布任务不能重新生成")
_dedupe_draft_ask_questions(task, db)
task.shop_name = shop_name
task.product_link = product_link task.product_link = product_link
task.product_qr_image_data = product_qr_data
task.product_qr_image_content_type = product_qr_content_type
task.product_qr_image_name = product_qr_name
task.product_name = product_name or "待补充商品" task.product_name = product_name or "待补充商品"
task.category = str(payload.get("category", "")) task.category = str(payload.get("category", ""))
task.benefits = str(payload.get("benefits", "")) task.benefits = str(payload.get("benefits", ""))
@ -1648,13 +1831,12 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db
task.usage_scene = str(payload.get("usage_scene", "")) task.usage_scene = str(payload.get("usage_scene", ""))
task.purchase_channel = str(payload.get("purchase_channel", "")) task.purchase_channel = str(payload.get("purchase_channel", ""))
task.claim_questions_per_user = claim_limit task.claim_questions_per_user = claim_limit
for question in list(task.questions):
db.delete(question)
db.flush()
else: else:
task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, product_link=product_link, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), claim_questions_per_user=claim_limit, status="草稿") task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, shop_name=shop_name, product_link=product_link, product_qr_image_data=product_qr_data, product_qr_image_content_type=product_qr_content_type, product_qr_image_name=product_qr_name, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), claim_questions_per_user=claim_limit, status="草稿")
db.add(task); db.flush() db.add(task); db.flush()
for index, question in enumerate(generated): db.add(AskQuestion(task_id=task.id, sequence_no=index + 1, question_text=question, selected=True)) next_sequence = max((question.sequence_no for question in task.questions), default=0) + 1
for index, question in enumerate(generated):
db.add(AskQuestion(task_id=task.id, sequence_no=next_sequence + index, question_text=question, source="AI生成", selected=True))
db.commit(); db.refresh(task) db.commit(); db.refresh(task)
return {"success": True, "data": _ask_task_data(task)} return {"success": True, "data": _ask_task_data(task)}
@ -1670,19 +1852,44 @@ def update_ask_question(task_id: int, question_id: int, payload: dict, user: Adm
question.question_text = value question.question_text = value
if "selected" in payload: question.selected = bool(payload["selected"]) if "selected" in payload: question.selected = bool(payload["selected"])
db.commit(); db.refresh(question) db.commit(); db.refresh(question)
return {"success": True, "data": {"id": question.id, "question": question.question_text, "selected": question.selected}} return {"success": True, "data": {"id": question.id, "question": question.question_text, "selected": question.selected, "source": question.source or "AI生成"}}
@app.post("/api/admin/ask-tasks/{task_id}/questions") @app.post("/api/admin/ask-tasks/{task_id}/questions")
def add_ask_question(task_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def add_ask_question(task_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id) # Lock the draft row while checking/inserting. This closes the small
# race window where two picker requests arrive at the same time; MySQL
# serializes them, while SQLite simply ignores FOR UPDATE in local tests.
task = db.scalar(select(AskTask).where(AskTask.id == task_id).with_for_update())
text = str(payload.get("question", "")).strip() text = str(payload.get("question", "")).strip()
_require_ask_task_access(task, user) _require_ask_task_access(task, user)
if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能添加提问") if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能添加提问")
if not text: raise HTTPException(status_code=400, detail="提问内容不能为空") if not text: raise HTTPException(status_code=400, detail="提问内容不能为空")
question = AskQuestion(task_id=task.id, sequence_no=len(task.questions) + 1, question_text=text, selected=True) # The library picker may retry the same request (for example after a
# mobile navigation/re-render). Treat identical text within a draft as
# idempotent instead of creating a second row.
duplicate = db.scalar(select(AskQuestion).where(AskQuestion.task_id == task.id, AskQuestion.question_text == text))
if duplicate:
return {"success": True, "data": {"id": duplicate.id, "sequence_no": duplicate.sequence_no, "question": duplicate.question_text, "selected": duplicate.selected, "source": duplicate.source or "AI生成"}}
next_sequence = max((item.sequence_no for item in task.questions), default=0) + 1
source = str(payload.get("source", "自主添加")).strip() or "自主添加"
if source not in {"自主添加", "问答库", "AI生成"}:
source = "自主添加"
question = AskQuestion(task_id=task.id, sequence_no=next_sequence, question_text=text, source=source, selected=True)
db.add(question); db.commit(); db.refresh(question) db.add(question); db.commit(); db.refresh(question)
return {"success": True, "data": {"id": question.id, "sequence_no": question.sequence_no, "question": question.question_text, "selected": question.selected}} return {"success": True, "data": {"id": question.id, "sequence_no": question.sequence_no, "question": question.question_text, "selected": question.selected, "source": question.source}}
@app.delete("/api/admin/ask-tasks/{task_id}/questions/{question_id}")
def delete_ask_question(task_id: int, question_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
question = db.scalar(select(AskQuestion).where(AskQuestion.id == question_id, AskQuestion.task_id == task_id))
if not question or not _can_access_ask_task(question.task, user):
raise HTTPException(status_code=404, detail="提问不存在")
if question.task.status != "草稿":
raise HTTPException(status_code=409, detail="已发布任务不能删除提问")
db.delete(question)
db.commit()
return {"success": True, "data": {"id": question_id}}
@app.post("/api/admin/ask-tasks/{task_id}/publish") @app.post("/api/admin/ask-tasks/{task_id}/publish")
@ -1690,7 +1897,8 @@ def publish_ask_task(task_id: int, user: AdminUser = Depends(current_user), db:
task = db.get(AskTask, task_id) task = db.get(AskTask, task_id)
_require_ask_task_access(task, user) _require_ask_task_access(task, user)
if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布") if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布")
product_token_error = _product_token_error(task.product_link) _dedupe_draft_ask_questions(task, db)
product_token_error = _product_token_error(task.product_link, task.product_qr_image_data)
if product_token_error: if product_token_error:
raise HTTPException(status_code=400, detail=f"无法发布任务:{product_token_error}") raise HTTPException(status_code=400, detail=f"无法发布任务:{product_token_error}")
if not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问") if not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问")
@ -1842,8 +2050,90 @@ def admin_task_board_delete_ask_assignment(assignment_id: int, user: AdminUser =
return {"success": True, "data": {"id": assignment_id}} return {"success": True, "data": {"id": assignment_id}}
@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, 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", filename=filename, content_type=content_type, image_data=data))
elif session.target_kind == "ask_assignment":
db.add(AskAssignmentScreenshot(assignment_id=target.id, stage="refund", filename=filename, content_type=content_type, image_data=data))
else:
db.add(AnswerScreenshot(submission_id=target.id, stage="refund", filename=filename, content_type=content_type, image_data=data))
session.uploaded_at = datetime.utcnow()
db.commit()
db.refresh(session)
return {"success": True, "data": {"uploaded": True, "filename": filename, "uploaded_at": session.uploaded_at.isoformat()}}
@app.post("/api/admin/task-board/ask-batches/{batch_id}/confirm-refund") @app.post("/api/admin/task-board/ask-batches/{batch_id}/confirm-refund")
async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile = File(...), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
batch = db.get(AskAssignmentBatch, batch_id) batch = db.get(AskAssignmentBatch, batch_id)
if not batch or not _can_access_ask_task(batch.assignment.task, user): if not batch or not _can_access_ask_task(batch.assignment.task, user):
raise HTTPException(status_code=404, detail="待返款提问记录不存在") raise HTTPException(status_code=404, detail="待返款提问记录不存在")
@ -1851,12 +2141,19 @@ async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile = File
raise HTTPException(status_code=409, detail="该任务已经完成返款") raise HTTPException(status_code=409, detail="该任务已经完成返款")
if batch.status != "approved": if batch.status != "approved":
raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
if refund_file.content_type not in {"image/jpeg", "image/png", "image/webp"}: if refund_file is not None:
raise HTTPException(status_code=400, detail="返款凭证仅支持 JPG、PNG、WEBP 图片") refund_data, content_type, filename = await _read_refund_file(refund_file)
refund_data = await refund_file.read() for image in list(batch.screenshots):
if not refund_data: if image.stage == "refund":
raise HTTPException(status_code=400, detail="请上传返款凭证") db.delete(image)
db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="refund", filename=refund_file.filename or "refund", content_type=refund_file.content_type, image_data=refund_data)) db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="refund", filename=filename, content_type=content_type, image_data=refund_data))
elif not any(image.stage == "refund" for image in batch.screenshots):
raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传")
if upload_token:
session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == upload_token, RefundUploadSession.target_kind == "ask_batch", RefundUploadSession.target_id == batch.id))
if not session or _refund_session_expired(session) or session.used_at:
raise HTTPException(status_code=409, detail="扫码上传会话无效或已过期,请重新生成")
session.used_at = datetime.utcnow()
batch.status = "paid" batch.status = "paid"
batch.updated_at = datetime.utcnow() batch.updated_at = datetime.utcnow()
db.commit(); db.refresh(batch) db.commit(); db.refresh(batch)
@ -1864,7 +2161,7 @@ async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile = File
@app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund") @app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund")
async def confirm_ask_assignment_refund(assignment_id: int, refund_file: UploadFile = File(...), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): async def confirm_ask_assignment_refund(assignment_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
assignment = db.get(AskAssignment, assignment_id) assignment = db.get(AskAssignment, assignment_id)
if not assignment or not _can_access_ask_task(assignment.task, user): if not assignment or not _can_access_ask_task(assignment.task, user):
raise HTTPException(status_code=404, detail="待返款提问记录不存在") raise HTTPException(status_code=404, detail="待返款提问记录不存在")
@ -1872,12 +2169,19 @@ async def confirm_ask_assignment_refund(assignment_id: int, refund_file: UploadF
raise HTTPException(status_code=409, detail="该任务已经完成返款") raise HTTPException(status_code=409, detail="该任务已经完成返款")
if assignment.status != "已通过": if assignment.status != "已通过":
raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
if refund_file.content_type not in {"image/jpeg", "image/png", "image/webp"}: if refund_file is not None:
raise HTTPException(status_code=400, detail="返款凭证仅支持 JPG、PNG、WEBP 图片") refund_data, content_type, filename = await _read_refund_file(refund_file)
refund_data = await refund_file.read() for image in list(assignment.screenshots):
if not refund_data: if image.stage == "refund":
raise HTTPException(status_code=400, detail="请上传返款凭证") db.delete(image)
db.add(AskAssignmentScreenshot(assignment_id=assignment.id, stage="refund", filename=refund_file.filename or "refund", content_type=refund_file.content_type, image_data=refund_data)) db.add(AskAssignmentScreenshot(assignment_id=assignment.id, stage="refund", filename=filename, content_type=content_type, image_data=refund_data))
elif not any(image.stage == "refund" for image in assignment.screenshots):
raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传")
if upload_token:
session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == upload_token, RefundUploadSession.target_kind == "ask_assignment", RefundUploadSession.target_id == assignment.id))
if not session or _refund_session_expired(session) or session.used_at:
raise HTTPException(status_code=409, detail="扫码上传会话无效或已过期,请重新生成")
session.used_at = datetime.utcnow()
assignment.status = "已完成" assignment.status = "已完成"
assignment.updated_at = datetime.utcnow() assignment.updated_at = datetime.utcnow()
db.commit(); db.refresh(assignment) db.commit(); db.refresh(assignment)
@ -1903,7 +2207,7 @@ def admin_task_board_delete_submission(submission_id: int, user: AdminUser = Dep
@app.post("/api/admin/task-board/{submission_id}/confirm-refund") @app.post("/api/admin/task-board/{submission_id}/confirm-refund")
async def confirm_answer_refund(submission_id: int, refund_file: UploadFile = File(...), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): async def confirm_answer_refund(submission_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
record = db.get(AnswerSubmission, submission_id) record = db.get(AnswerSubmission, submission_id)
if not record or not _can_access_answer_submission(record, user): if not record or not _can_access_answer_submission(record, user):
raise HTTPException(status_code=404, detail="待返款回答记录不存在") raise HTTPException(status_code=404, detail="待返款回答记录不存在")
@ -1911,12 +2215,19 @@ async def confirm_answer_refund(submission_id: int, refund_file: UploadFile = Fi
raise HTTPException(status_code=409, detail="该任务已经完成返款") raise HTTPException(status_code=409, detail="该任务已经完成返款")
if record.status != "已通过": if record.status != "已通过":
raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
if refund_file.content_type not in {"image/jpeg", "image/png", "image/webp"}: if refund_file is not None:
raise HTTPException(status_code=400, detail="返款凭证仅支持 JPG、PNG、WEBP 图片") refund_data, content_type, filename = await _read_refund_file(refund_file)
refund_data = await refund_file.read() for image in list(record.screenshots):
if not refund_data: if image.stage == "refund":
raise HTTPException(status_code=400, detail="请上传返款凭证") db.delete(image)
db.add(AnswerScreenshot(submission_id=record.id, stage="refund", filename=refund_file.filename or "refund", content_type=refund_file.content_type, image_data=refund_data)) db.add(AnswerScreenshot(submission_id=record.id, stage="refund", filename=filename, content_type=content_type, image_data=refund_data))
elif not any(image.stage == "refund" for image in record.screenshots):
raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传")
if upload_token:
session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == upload_token, RefundUploadSession.target_kind == "answer_submission", RefundUploadSession.target_id == record.id))
if not session or _refund_session_expired(session) or session.used_at:
raise HTTPException(status_code=409, detail="扫码上传会话无效或已过期,请重新生成")
session.used_at = datetime.utcnow()
record.status = "已完成" record.status = "已完成"
db.commit(); db.refresh(record) db.commit(); db.refresh(record)
return {"success": True, "data": _task_board_data(record, include_images=True)} return {"success": True, "data": _task_board_data(record, include_images=True)}

View File

@ -1,6 +1,7 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint
from sqlalchemy.dialects.mysql import MEDIUMBLOB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base from .database import Base
@ -150,6 +151,15 @@ class AskTask(Base):
public_token: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True) public_token: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True)
owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True) owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True)
product_link: Mapped[str] = mapped_column(String(1000)) product_link: Mapped[str] = mapped_column(String(1000))
# QR images are capped at 5 MB by the API. MEDIUMBLOB keeps fresh MySQL
# installs above the 64 KB BLOB limit while the generic type remains
# compatible with SQLite development databases.
product_qr_image_data: Mapped[bytes | None] = mapped_column(
LargeBinary().with_variant(MEDIUMBLOB, "mysql"), nullable=True
)
product_qr_image_content_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
product_qr_image_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
shop_name: Mapped[str] = mapped_column(String(255), default="")
product_name: Mapped[str] = mapped_column(String(255)) product_name: Mapped[str] = mapped_column(String(255))
category: Mapped[str] = mapped_column(String(100), default="") category: Mapped[str] = mapped_column(String(100), default="")
benefits: Mapped[str] = mapped_column(Text, default="") benefits: Mapped[str] = mapped_column(Text, default="")
@ -175,6 +185,9 @@ class AskQuestion(Base):
task_id: Mapped[int] = mapped_column(ForeignKey("ask_tasks.id"), index=True) task_id: Mapped[int] = mapped_column(ForeignKey("ask_tasks.id"), index=True)
sequence_no: Mapped[int] = mapped_column(Integer) sequence_no: Mapped[int] = mapped_column(Integer)
question_text: Mapped[str] = mapped_column(Text) question_text: Mapped[str] = mapped_column(Text)
# Keep the origin so the draft UI can distinguish AI-generated questions
# from questions added manually or imported from the Q&A library.
source: Mapped[str] = mapped_column(String(32), default="AI生成", nullable=False)
selected: Mapped[bool] = mapped_column(Boolean, default=True) selected: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
@ -315,3 +328,27 @@ class AskAssignmentReview(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
assignment: Mapped[AskAssignment] = relationship(back_populates="review_decisions") 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)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -33,5 +33,5 @@ CREATE TABLE IF NOT EXISTS admin_login_logs (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 提问任务归属运营账号;历史数据允许为空,超级管理员仍可查看全部任务。 -- 提问任务归属运营账号;历史数据允许为空,超级管理员仍可查看全部任务。
-- 新环境建表时请将 owner_id 一并加入 ask_tasks;已有环境由应用启动时自动补列。 -- 新环境建表时请将 owner_id、shop_name 一并加入 ask_tasks;已有环境由应用启动时自动补列。
-- public_token 是对外分享链接标识,不应使用 task_no 拼接用户链接。 -- public_token 是对外分享链接标识,不应使用 task_no 拼接用户链接。

View File

@ -1,10 +1,18 @@
import base64
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from uuid import uuid4 from uuid import uuid4
import app.main as main_module
from app.main import app from app.main import app
from app.config import settings from app.config import settings
def fake_ask_questions(payload, count, reference_text):
assert "shop_name" not in payload
return [f"本地测试问题{i + 1}?" for i in range(count)]
def test_admin_login_me_and_logout(): def test_admin_login_me_and_logout():
with TestClient(app) as client: with TestClient(app) as client:
login = client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"}) login = client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"})
@ -162,15 +170,24 @@ def test_answer_rejection_reason_syncs_to_public_user():
settings.llm_api_key = previous_key settings.llm_api_key = previous_key
def test_ask_task_publish_appears_on_task_board(): def test_ask_task_publish_appears_on_task_board(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client: with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
missing_link = client.post("/api/admin/ask-tasks/generate", json={"product_name": "测试商品"}) missing_link = client.post("/api/admin/ask-tasks/generate", json={"product_name": "测试商品"})
assert missing_link.status_code == 400 assert missing_link.status_code == 400
generated = client.post("/api/admin/ask-tasks/generate", json={"product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3}) missing_product_access = client.post(
"/api/admin/ask-tasks/generate",
json={"shop_name": "测试店铺", "product_name": "测试商品", "question_count": 3},
)
assert missing_product_access.status_code == 400
missing_shop = client.post("/api/admin/ask-tasks/generate", json={"product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3})
assert missing_shop.status_code == 400
generated = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "测试店铺", "product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3})
assert generated.status_code == 200 assert generated.status_code == 200
task = generated.json()["data"] task = generated.json()["data"]
assert task["public_token"] is None assert task["public_token"] is None
assert task["shop_name"] == "测试店铺"
published = client.post(f"/api/admin/ask-tasks/{task['id']}/publish") published = client.post(f"/api/admin/ask-tasks/{task['id']}/publish")
assert published.status_code == 200 assert published.status_code == 200
public_token = published.json()["data"]["public_token"] public_token = published.json()["data"]["public_token"]
@ -183,14 +200,20 @@ def test_ask_task_publish_appears_on_task_board():
assert created["task_type_label"] == "提问任务" assert created["task_type_label"] == "提问任务"
assert created["qa_count"] == 3 assert created["qa_count"] == 3
assert created["public_token"] == public_token assert created["public_token"] == public_token
assert created["shop_name"] == "测试店铺"
detail = client.get(created["detail_endpoint"])
assert detail.status_code == 200
assert detail.json()["data"]["shop_name"] == "测试店铺"
def test_ask_task_generation_honors_question_count(): def test_ask_task_generation_honors_question_count(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client: with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
generated = client.post( generated = client.post(
"/api/admin/ask-tasks/generate", "/api/admin/ask-tasks/generate",
json={ json={
"shop_name": "数量测试店铺",
"product_link": "https://item.example.com/count-test", "product_link": "https://item.example.com/count-test",
"product_name": "数量测试商品", "product_name": "数量测试商品",
"question_count": 25, "question_count": 25,
@ -202,12 +225,99 @@ def test_ask_task_generation_honors_question_count():
assert len({item["question"] for item in questions}) == 25 assert len({item["question"] for item in questions}) == 25
def test_ask_task_accepts_qr_image_without_token_and_excludes_it_from_ai(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
qr_data_url = "data:image/png;base64," + base64.b64encode(b"local-qr-image").decode()
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
generated = client.post(
"/api/admin/ask-tasks/generate",
json={
"shop_name": "二维码测试店铺",
"product_link": "",
"product_qr_image": qr_data_url,
"product_qr_image_name": "商品二维码.png",
"product_name": "二维码测试商品",
"question_count": 2,
},
)
assert generated.status_code == 200
task = generated.json()["data"]
assert task["product_link"] == ""
assert task["product_qr"]["content_type"] == "image/png"
assert task["product_qr"]["name"] == "商品二维码.png"
assert task["product_qr"]["data_url"] == qr_data_url
published = client.post(f"/api/admin/ask-tasks/{task['id']}/publish")
assert published.status_code == 200
assert published.json()["data"]["status"] == "已发布"
board = client.get("/api/admin/task-board").json()["data"]
created = next(item for item in board if item["task_id"] == task["task_id"])
detail = client.get(created["detail_endpoint"])
assert detail.status_code == 200
assert detail.json()["data"]["product_qr"]["data_url"] == qr_data_url
public_token = published.json()["data"]["public_token"]
assert public_token
def test_ask_task_regeneration_appends_to_existing_questions(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
first = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "追加测试店铺", "product_link": "https://item.example.com/append", "product_name": "追加测试商品", "question_count": 2})
assert first.status_code == 200
task = first.json()["data"]
assert len(task["selected_questions"]) == 2
second = client.post("/api/admin/ask-tasks/generate", json={"task_id": task["id"], "shop_name": "追加测试店铺", "product_link": "https://item.example.com/append", "product_name": "追加测试商品", "question_count": 2})
assert second.status_code == 200
questions = second.json()["data"]["selected_questions"]
assert len(questions) == 4
assert [item["sequence_no"] for item in questions] == [1, 2, 3, 4]
def test_ask_task_added_question_is_idempotent_and_deletable(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
created = client.post(
"/api/admin/ask-tasks/generate",
json={
"shop_name": f"去重测试店铺-{uuid4().hex[:8]}",
"product_link": "TOKEN-IDEMPOTENT",
"product_name": "去重测试商品",
"question_count": 1,
},
)
assert created.status_code == 200
task_id = created.json()["data"]["id"]
first = client.post(f"/api/admin/ask-tasks/{task_id}/questions", json={"question": "问答库重复问题?", "source": "问答库"})
second = client.post(f"/api/admin/ask-tasks/{task_id}/questions", json={"question": "问答库重复问题?", "source": "问答库"})
assert first.status_code == 200 and second.status_code == 200
assert first.json()["data"]["id"] == second.json()["data"]["id"]
assert second.json()["data"]["source"] == "问答库"
question_id = first.json()["data"]["id"]
removed = client.delete(f"/api/admin/ask-tasks/{task_id}/questions/{question_id}")
assert removed.status_code == 200
refreshed = client.post(
"/api/admin/ask-tasks/generate",
json={
"task_id": task_id,
"shop_name": "去重测试店铺",
"product_link": "TOKEN-IDEMPOTENT",
"product_name": "去重测试商品",
"question_count": 1,
},
)
assert refreshed.status_code == 200
assert all(item["question"] != "问答库重复问题?" for item in refreshed.json()["data"]["selected_questions"])
def test_public_ask_user_gets_stable_unique_five_question_assignment(): def test_public_ask_user_gets_stable_unique_five_question_assignment():
with TestClient(app) as client: with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
generated = client.post( generated = client.post(
"/api/admin/ask-tasks/generate", "/api/admin/ask-tasks/generate",
json={ json={
"shop_name": "公共提问测试店铺",
"product_link": "https://item.example.com/public-ask", "product_link": "https://item.example.com/public-ask",
"product_name": "公共提问测试商品", "product_name": "公共提问测试商品",
"question_count": 8, "question_count": 8,
@ -275,6 +385,7 @@ def test_public_ask_user_gets_stable_unique_five_question_assignment():
assert payout["status_label"] == "待返款" assert payout["status_label"] == "待返款"
assert payout["task_type"] == "ask" assert payout["task_type"] == "ask"
assert payout["qa_count"] == 1 assert payout["qa_count"] == 1
assert payout["completed_count"] == 0
payout_detail = client.get(payout["detail_endpoint"]) payout_detail = client.get(payout["detail_endpoint"])
assert payout_detail.status_code == 200 assert payout_detail.status_code == 200
assert len(payout_detail.json()["data"]["question_screenshots"]) == 1 assert len(payout_detail.json()["data"]["question_screenshots"]) == 1
@ -291,6 +402,7 @@ def test_public_ask_user_gets_stable_unique_five_question_assignment():
and item["id"] == review["assignment_id"] and item["id"] == review["assignment_id"]
) )
assert completed_payout["status_label"] == "已完成" assert completed_payout["status_label"] == "已完成"
assert completed_payout["completed_count"] == 1
audit_history = client.get("/api/admin/ask-completion-reviews").json()["data"] audit_history = client.get("/api/admin/ask-completion-reviews").json()["data"]
assert not any(item["assignment_id"] == review["assignment_id"] for item in audit_history) assert not any(item["assignment_id"] == review["assignment_id"] for item in audit_history)
refreshed = client.get(url) refreshed = client.get(url)
@ -366,6 +478,7 @@ def test_public_ask_questions_are_unique_across_users_and_eventually_exhausted()
generated = client.post( generated = client.post(
"/api/admin/ask-tasks/generate", "/api/admin/ask-tasks/generate",
json={ json={
"shop_name": "唯一分配测试店铺",
"product_link": "https://item.example.com/unique-allocation", "product_link": "https://item.example.com/unique-allocation",
"product_name": "跨用户唯一分配测试商品", "product_name": "跨用户唯一分配测试商品",
"question_count": 10, "question_count": 10,

Binary file not shown.

View File

@ -418,17 +418,27 @@ function ConfirmStage({
<h2>{assignment.product_name}</h2> <h2>{assignment.product_name}</h2>
</div> </div>
</div> </div>
{(assignment.product_link || assignment.product_qr) && (
<div className="ask-product-access">
{assignment.product_link && (
<footer> <footer>
<code title={assignment.product_link}>{assignment.product_link || "未提供商品淘口令"}</code> <code title={assignment.product_link}>{assignment.product_link}</code>
<button <button
onClick={() => onClick={() => navigator.clipboard?.writeText(assignment.product_link)}
navigator.clipboard?.writeText(assignment.product_link)
}
> >
<Copy size={13} /> <Copy size={13} />
复制淘口令 复制淘口令
</button> </button>
</footer> </footer>
)}
{assignment.product_qr?.data_url && (
<figure className="ask-product-qr-card">
<img src={assignment.product_qr.data_url} alt="商品二维码" />
<figcaption>商品二维码</figcaption>
</figure>
)}
</div>
)}
</section> </section>
<section className="ask-selected-card"> <section className="ask-selected-card">
<header> <header>

View File

@ -1,10 +1,13 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { Check, Link2, Plus, Sparkles } from "lucide-react"; import { Check, Link2, Plus, Sparkles, X } from "lucide-react";
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js"; import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
import { readPageCache, writePageCache } from "./pageCache.js"; import { clearPageCache, readPageCache, writePageCache } from "./pageCache.js";
const blankForm = { const blankForm = {
shop_name: "",
product_link: "", product_link: "",
product_qr_image: "",
product_qr_image_name: "",
product_name: "", product_name: "",
category: "", category: "",
benefits: "", benefits: "",
@ -17,6 +20,26 @@ const blankForm = {
claim_questions_per_user: "", claim_questions_per_user: "",
}; };
const ADDED_QUESTION_SOURCES = new Set(["自主添加", "问答库"]);
function questionKey(value) {
return String(value || "").trim().replace(/\s+/g, " ");
}
function dedupeQuestions(items = []) {
const seen = new Set();
return items.filter((item) => {
const key = questionKey(item?.question);
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function isAddedQuestion(item) {
return ADDED_QUESTION_SOURCES.has(item?.source) || String(item?.id || "").startsWith("new-");
}
async function readReferenceFile(file, requestApi) { async function readReferenceFile(file, requestApi) {
if (!file) return ""; if (!file) return "";
if (/\.xlsx$/i.test(file.name)) { if (/\.xlsx$/i.test(file.name)) {
@ -28,21 +51,49 @@ const blankForm = {
return (await file.text()).slice(0, 20000); return (await file.text()).slice(0, 20000);
} }
export default function CreateAskTasksPage({ api }) { export default function CreateAskTasksPage({ api, librarySelection = [], onLibrarySelectionConsumed }) {
const cached = readPageCache("create-ask-task", {}); const cached = readPageCache("create-ask-task", {});
const [form, setForm] = useState({ ...blankForm, ...(cached.form || {}) }); const [form, setForm] = useState({ ...blankForm, ...(cached.form || {}) });
const [task, setTask] = useState(cached.task || null); // Only a task explicitly marked as a draft can be resumed. Older cached
const [questions, setQuestions] = useState(cached.questions || []); // payloads did not include `status`, so treating them as editable could
// resend a published task id and leave the operator stuck on this page.
const resumableCachedTask = cached.task?.status === "草稿" ? cached.task : null;
const [task, setTask] = useState(resumableCachedTask);
const [questions, setQuestions] = useState(() =>
resumableCachedTask ? dedupeQuestions(cached.questions || []) : [],
);
const [editing, setEditing] = useState(null); const [editing, setEditing] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [referenceFile, setReferenceFile] = useState(null); const [referenceFile, setReferenceFile] = useState(null);
const referenceInputRef = useRef(null); const referenceInputRef = useRef(null);
const productQrInputRef = useRef(null);
const [referenceFileName, setReferenceFileName] = useState(cached.referenceFileName || ""); const [referenceFileName, setReferenceFileName] = useState(cached.referenceFileName || "");
const [referenceQa, setReferenceQa] = useState(cached.referenceQa || ""); const [referenceQa, setReferenceQa] = useState(cached.referenceQa || "");
const [addDialog, setAddDialog] = useState(null);
const processingLibrarySelection = useRef(false);
const consumedLibrarySelectionKey = useRef("");
useEffect(() => { useEffect(() => {
writePageCache("create-ask-task", { form, task, questions, referenceQa, referenceFileName }); writePageCache("create-ask-task", { form, task, questions, referenceQa, referenceFileName });
}, [form, task, questions, referenceQa, referenceFileName]); }, [form, task, questions, referenceQa, referenceFileName]);
useEffect(() => {
if (!librarySelection.length) {
consumedLibrarySelectionKey.current = "";
return;
}
const selectionKey = librarySelection
.map((item) => `${item.id}:${questionKey(item.question)}`)
.join("|");
if (consumedLibrarySelectionKey.current === selectionKey || processingLibrarySelection.current) return;
// Mark before the async requests start. This also makes the effect safe
// under React StrictMode, which intentionally re-runs effects in dev.
consumedLibrarySelectionKey.current = selectionKey;
processingLibrarySelection.current = true;
void addLibraryQuestions(librarySelection).finally(() => {
processingLibrarySelection.current = false;
onLibrarySelectionConsumed?.();
});
}, [librarySelection, onLibrarySelectionConsumed]);
const update = (key, value) => const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value })); setForm((current) => ({ ...current, [key]: value }));
async function selectReferenceFile(file) { async function selectReferenceFile(file) {
@ -66,7 +117,50 @@ export default function CreateAskTasksPage({ api }) {
if (referenceInputRef.current) referenceInputRef.current.value = ""; if (referenceInputRef.current) referenceInputRef.current.value = "";
} }
function selectProductQr(file) {
setError("");
if (!file) return;
const allowed = new Set(["image/jpeg", "image/png", "image/webp"]);
if (!allowed.has(file.type)) {
setError("商品二维码仅支持 JPG、PNG 或 WEBP 图片");
if (productQrInputRef.current) productQrInputRef.current.value = "";
return;
}
if (file.size > 5 * 1024 * 1024) {
setError("商品二维码图片不能超过 5MB");
if (productQrInputRef.current) productQrInputRef.current.value = "";
return;
}
const reader = new FileReader();
reader.onload = () => {
setForm((current) => ({
...current,
product_qr_image: String(reader.result || ""),
product_qr_image_name: file.name,
}));
};
reader.onerror = () => setError("商品二维码图片读取失败,请重试");
reader.readAsDataURL(file);
}
function clearProductQr() {
setForm((current) => ({
...current,
product_qr_image: "",
product_qr_image_name: "",
}));
if (productQrInputRef.current) productQrInputRef.current.value = "";
}
async function generate() { async function generate() {
if (!form.shop_name.trim()) {
setError("请填写店铺名称");
return;
}
if (!form.product_link.trim() && !form.product_qr_image) {
setError("请填写商品淘口令或上传商品二维码");
return;
}
const intentError = analyzeProductIntent([ const intentError = analyzeProductIntent([
form.product_name, form.product_name,
form.category, form.category,
@ -85,26 +179,46 @@ export default function CreateAskTasksPage({ api }) {
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
// A stale browser cache may contain a task that was already published
// before the page received the publish response. Never send that id to
// the draft-generation endpoint; start a fresh draft instead.
const draftTaskId = task?.status === "草稿" ? task.id : null;
if (task && !draftTaskId) {
setTask(null);
setQuestions([]);
}
const reference_qa = referenceFile ? await readReferenceFile(referenceFile, api) : referenceQa; const reference_qa = referenceFile ? await readReferenceFile(referenceFile, api) : referenceQa;
setReferenceQa(reference_qa); setReferenceQa(reference_qa);
const pendingQuestions = dedupeQuestions(questions.filter((item) => String(item.id).startsWith("new-")).map((item) => ({ question: item.question, source: item.source || "自主添加" })).filter((item) => item.question));
const result = await api("/api/admin/ask-tasks/generate", { const result = await api("/api/admin/ask-tasks/generate", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
...form, ...form,
question_count: Number(form.question_count), question_count: Number(form.question_count),
...(task ? { task_id: task.id } : {}), ...(draftTaskId ? { task_id: draftTaskId } : {}),
reference_qa, reference_qa,
}), }),
}); });
setTask(result.data); setTask(result.data);
setQuestions( const generatedQuestions = result.data.selected_questions.map((item) => ({ ...item, selected: true }));
result.data.selected_questions.map((item) => ({ if (!draftTaskId && pendingQuestions.length) {
...item, const added = [];
selected: true, for (const item of pendingQuestions) {
})), added.push(await api(`/api/admin/ask-tasks/${result.data.id}/questions`, { method: "POST", body: JSON.stringify({ question: item.question, source: item.source }) }));
); }
generatedQuestions.push(...added.map((item, index) => ({ ...item.data, source: pendingQuestions[index].source })));
}
const priorSourceByQuestion = new Map(questions.map((item) => [item.question, item.source]));
setQuestions(dedupeQuestions(generatedQuestions.map((item) => ({ ...item, source: item.source || priorSourceByQuestion.get(item.question) || "AI生成" }))));
} catch (err) { } catch (err) {
if (String(err.message || "").includes("已发布任务不能重新生成")) {
clearPageCache("create-ask-task");
setTask(null);
setQuestions([]);
setError("这条任务已经发布,已为你清空旧任务;请再次点击“生成提问”创建新的测试任务。");
} else {
setError(err.message || "生成失败,请检查参考文件和大模型配置后重试"); setError(err.message || "生成失败,请检查参考文件和大模型配置后重试");
}
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -139,20 +253,93 @@ export default function CreateAskTasksPage({ api }) {
body: JSON.stringify({ selected }), body: JSON.stringify({ selected }),
}); });
} }
async function addQuestion() { function addQuestion() {
const value = window.prompt("请输入提问内容"); setAddDialog("choose");
if (!value?.trim()) return; }
if (!task)
return setQuestions((current) => [ async function removeQuestion(index) {
...current, const question = questions[index];
{ id: `new-${Date.now()}`, question: value.trim(), selected: true }, if (!question || !isAddedQuestion(question)) return;
]); setBusy(true);
setError("");
try { try {
const result = await api(`/api/admin/ask-tasks/${task.id}/questions`, { if (task && !String(question.id).startsWith("new-")) {
method: "POST", await api(`/api/admin/ask-tasks/${task.id}/questions/${question.id}`, { method: "DELETE" });
body: JSON.stringify({ question: value.trim() }), }
setQuestions((current) => current.filter((_, itemIndex) => itemIndex !== index));
if (editing === index) setEditing(null);
} catch (err) {
setError(err.message || "移除提问失败");
} finally {
setBusy(false);
}
}
async function clearAddedQuestions() {
const addedQuestions = questions.filter(isAddedQuestion);
if (!addedQuestions.length) return;
setBusy(true);
setError("");
try {
if (task) {
for (const question of addedQuestions) {
if (!String(question.id).startsWith("new-")) {
await api(`/api/admin/ask-tasks/${task.id}/questions/${question.id}`, { method: "DELETE" });
}
}
}
setQuestions((current) => current.filter((question) => !isAddedQuestion(question)));
setEditing(null);
} catch (err) {
setError(err.message || "清除新增提问失败");
} finally {
setBusy(false);
}
}
async function addManualQuestion(value) {
const text = value.trim();
if (!text) return;
if (!task) {
setQuestions((current) => dedupeQuestions([...current, { id: `new-${Date.now()}`, question: text, selected: true, source: "自主添加" }]));
setAddDialog(null);
return;
}
try {
const result = await api(`/api/admin/ask-tasks/${task.id}/questions`, { method: "POST", body: JSON.stringify({ question: text, source: "自主添加" }) });
setQuestions((current) => dedupeQuestions([...current, { ...result.data, source: result.data.source || "自主添加" }]));
setAddDialog(null);
} catch (err) {
setError(err.message);
}
}
function openLibrary() {
setAddDialog(null);
window.dispatchEvent(new CustomEvent("open-qa-library-picker"));
}
async function addLibraryQuestions(items = []) {
const existingTexts = new Set(questions.map((item) => questionKey(item.question)));
const seenSelection = new Set();
const selected = items.filter((item) => {
const text = questionKey(item.question);
if (!text || existingTexts.has(text) || seenSelection.has(text)) return false;
seenSelection.add(text);
return true;
}); });
setQuestions((current) => [...current, result.data]); if (!selected.length) return;
try {
if (!task) {
setQuestions((current) => dedupeQuestions([...current, ...selected.map((item, index) => ({ id: `new-library-${item.id}-${Date.now()}-${index}`, question: item.question, selected: true, source: "问答库" }))]));
} else {
const added = [];
for (const item of selected) {
added.push(await api(`/api/admin/ask-tasks/${task.id}/questions`, {
method: "POST",
body: JSON.stringify({ question: item.question, source: "问答库" }),
}));
}
setQuestions((current) => dedupeQuestions([...current, ...added.map((result) => ({ ...result.data, source: result.data.source || "问答库" }))]));
}
setAddDialog(null);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} }
@ -165,7 +352,11 @@ export default function CreateAskTasksPage({ api }) {
const result = await api(`/api/admin/ask-tasks/${task.id}/publish`, { const result = await api(`/api/admin/ask-tasks/${task.id}/publish`, {
method: "POST", method: "POST",
}); });
setTask(result.data); // A successful response from this endpoint is authoritative even when
// an older backend build omits the raw workflow state from its payload.
// Keep the state explicit so the success/link view renders on the first
// click instead of waiting for a second request to return 409.
setTask({ ...result.data, status: result.data.status || "已发布" });
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@ -173,7 +364,7 @@ export default function CreateAskTasksPage({ api }) {
} }
} }
if (task?.status === "已发布") if (task?.status === "已发布" || task?.status_label === "已发布")
return ( return (
<> <>
<div className="create-page-heading"> <div className="create-page-heading">
@ -190,6 +381,10 @@ export default function CreateAskTasksPage({ api }) {
<p> <p>
任务ID:<strong>{task.task_id}</strong> 任务ID:<strong>{task.task_id}</strong>
</p> </p>
<div className="ask-created-summary">
<span>店铺名称<b>{task.shop_name || form.shop_name || "--"}</b></span>
<span>商品名称<b>{task.product_name}</b></span>
</div>
<div className="ask-link-box"> <div className="ask-link-box">
<span> <span>
<Link2 size={14} /> <Link2 size={14} />
@ -212,10 +407,12 @@ export default function CreateAskTasksPage({ api }) {
<button <button
className="ghost-button" className="ghost-button"
onClick={() => { onClick={() => {
clearPageCache("create-ask-task");
setForm(blankForm); setForm(blankForm);
setQuestions([]); setQuestions([]);
setTask(null); setTask(null);
clearReferenceFile(); clearReferenceFile();
if (productQrInputRef.current) productQrInputRef.current.value = "";
}} }}
> >
继续创建新任务 继续创建新任务
@ -253,17 +450,52 @@ export default function CreateAskTasksPage({ api }) {
<section className="ask-config"> <section className="ask-config">
<div className="panel-title"> <div className="panel-title">
<Sparkles size={17} /> <Sparkles size={17} />
生成条件配置 <small>* 商品淘口令为必填项</small> 生成条件配置 <small>* 店铺名称必填,淘口令和二维码至少填写一种</small>
</div> </div>
<div className="ask-product-token-field">
<label> <label>
商品淘口令 * 商品淘口令/二维码
<input <input
value={form.product_link} value={form.product_link}
onChange={(event) => update("product_link", event.target.value)} onChange={(event) => update("product_link", event.target.value)}
placeholder="请填写淘宝商品淘口令" placeholder="请输入商品淘口令(可与二维码同时填写)"
/> />
</label> </label>
<input
ref={productQrInputRef}
hidden
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(event) => selectProductQr(event.target.files?.[0] || null)}
/>
{form.product_qr_image ? (
<div className="ask-product-qr-preview">
<img src={form.product_qr_image} alt="商品二维码预览" />
<div>
<strong>{form.product_qr_image_name || "商品二维码"}</strong>
<small>已上传商品二维码,可与淘口令同时使用</small>
<button type="button" className="ghost-button" onClick={clearProductQr}>移除二维码</button>
</div>
</div>
) : (
<button type="button" className="ask-product-qr-upload" onClick={() => productQrInputRef.current?.click()}>
<span>+ 上传商品二维码</span>
<small>支持 JPG、PNG、WEBP,单张不超过 5MB</small>
</button>
)}
<small className="ask-product-token-hint">淘口令和二维码至少填写一种,也可以两者都填写</small>
</div>
<h4>商品信息</h4> <h4>商品信息</h4>
<div className="two-col">
<label>
店铺名称 *
<input
value={form.shop_name}
onChange={(event) => update("shop_name", event.target.value)}
placeholder="请输入店铺名称"
maxLength={255}
/>
</label>
<label> <label>
商品名称 商品名称
<input <input
@ -272,6 +504,7 @@ export default function CreateAskTasksPage({ api }) {
placeholder="请输入商品名称" placeholder="请输入商品名称"
/> />
</label> </label>
</div>
<div className="two-col"> <div className="two-col">
<label> <label>
商品品类 商品品类
@ -317,7 +550,7 @@ export default function CreateAskTasksPage({ api }) {
/> />
</label> </label>
<label> <label>
每个用户可认领的问题数量(选填) 每个用户可认领的问题数量(填写后仅允许 2~5 条)
<input <input
type="number" type="number"
min="2" min="2"
@ -326,7 +559,6 @@ export default function CreateAskTasksPage({ api }) {
onChange={(event) => update("claim_questions_per_user", event.target.value)} onChange={(event) => update("claim_questions_per_user", event.target.value)}
placeholder="不填写默认每人 5 条" placeholder="不填写默认每人 5 条"
/> />
<small>填写后仅允许 2-5 条</small>
</label> </label>
</div> </div>
<div className="two-col"> <div className="two-col">
@ -397,6 +629,7 @@ export default function CreateAskTasksPage({ api }) {
</section> </section>
<section className="ask-results"> <section className="ask-results">
<div className="ask-toolbar"> <div className="ask-toolbar">
<div className="ask-toolbar-selection">
<label> <label>
<input <input
type="checkbox" type="checkbox"
@ -416,23 +649,32 @@ export default function CreateAskTasksPage({ api }) {
全选 ({questions.filter((item) => item.selected).length}/ 全选 ({questions.filter((item) => item.selected).length}/
{questions.length}) {questions.length})
</label> </label>
<button className="ghost-button" onClick={addQuestion}> {questions.some(isAddedQuestion) && (
<button type="button" className="ask-clear-added" disabled={busy} onClick={clearAddedQuestions}>
一键清除新增({questions.filter(isAddedQuestion).length})
</button>
)}
</div>
<div className="ask-toolbar-actions">
<button type="button" className="ghost-button" onClick={addQuestion}>
<Plus size={14} /> <Plus size={14} />
添加提问 添加提问
</button> </button>
<button className="ghost-button" disabled={busy} onClick={generate}> <button type="button" className="ghost-button" disabled={busy} onClick={generate}>
↻ 重新生成 ↻ 重新生成
</button> </button>
<button <button
type="button"
className="primary-button" className="primary-button"
disabled={ disabled={
!questions.some((item) => item.selected) || !task || busy !questions.some((item) => item.selected) || !task || busy
} }
onClick={publish} onClick={publish}
> >
发布任务 {busy ? "发布中…" : "发布任务"}
</button> </button>
</div> </div>
</div>
{questions.length === 0 ? ( {questions.length === 0 ? (
<div className="ask-empty"> <div className="ask-empty">
点击左侧「生成提问」,AI 生成的提问将展示在这里,可逐条勾选。 点击左侧「生成提问」,AI 生成的提问将展示在这里,可逐条勾选。
@ -464,20 +706,44 @@ export default function CreateAskTasksPage({ api }) {
<strong>{question.question}</strong> <strong>{question.question}</strong>
)} )}
<button <button
type="button"
onClick={() => onClick={() =>
setEditing(editing === index ? null : index) setEditing(editing === index ? null : index)
} }
> >
{editing === index ? "完成" : "编辑"} {editing === index ? "完成" : "编辑"}
</button> </button>
{isAddedQuestion(question) && (
<button type="button" className="ask-question-remove" disabled={busy} onClick={() => removeQuestion(index)}>
移除
</button>
)}
</header> </header>
<small>来源:AI生成 · 可编辑后发布</small> <small>来源:{question.source || "AI生成"} · 可编辑后发布</small>
</div> </div>
</article> </article>
)) ))
)} )}
</section> </section>
</div> </div>
{addDialog === "choose" && (
<div className="manual-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setAddDialog(null)}>
<section className="manual-modal ask-add-dialog" role="dialog" aria-modal="true" aria-labelledby="ask-add-title">
<div className="manual-modal-header"><h3 id="ask-add-title">添加提问</h3><button type="button" onClick={() => setAddDialog(null)}><X size={18} /></button></div>
<p>选择添加方式,新增提问不会覆盖已生成的问题。</p>
<div className="ask-add-options">
<button type="button" onClick={() => setAddDialog("manual")}>自主添加<span>手动输入问题</span></button>
<button type="button" onClick={openLibrary}>问答库添加<span>进入问答库搜索并勾选</span></button>
</div>
</section>
</div>
)}
{addDialog === "manual" && <ManualAskQuestionModal onClose={() => setAddDialog(null)} onAdd={addManualQuestion} />}
</> </>
); );
} }
function ManualAskQuestionModal({ onClose, onAdd }) {
const [value, setValue] = useState("");
return <div className="manual-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && onClose()}><form className="manual-modal ask-manual-dialog" onSubmit={(event) => { event.preventDefault(); onAdd(value); }}><div className="manual-modal-header"><h3>自主添加提问</h3><button type="button" onClick={onClose}><X size={18} /></button></div><label>问题内容<textarea autoFocus value={value} onChange={(event) => setValue(event.target.value)} placeholder="请输入要加入任务的问题" maxLength={500} /></label><div className="manual-modal-actions"><button type="button" onClick={onClose}>取消</button><button type="submit" className="primary-button" disabled={!value.trim()}>添加问题</button></div></form></div>;
}

View File

@ -0,0 +1,80 @@
import React, { useEffect, 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);
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);
}
}
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}>
<label className="refund-upload-picker">
<span>选择或拍摄支付截图</span>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
capture="environment"
onChange={(event) => setFile(event.target.files?.[0] || null)}
/>
<small>{file ? file.name : "支持 JPG、PNG、WEBP,最大 10MB"}</small>
</label>
<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

@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
import { qrDataUrl } from "./qrCode.js";
const STATUS_FILTERS = [ const STATUS_FILTERS = [
["all", "全部任务"], ["all", "全部任务"],
@ -33,10 +34,13 @@ const formatTime = (value) => {
const maskPhone = (phone = "") => const maskPhone = (phone = "") =>
phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2"); phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
function TaskDetail({ task, close, onConfirmRefund }) { function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPollRefundUpload }) {
const [refundOpen, setRefundOpen] = useState(false); const [refundOpen, setRefundOpen] = useState(false);
const [refundBusy, setRefundBusy] = useState(false); const [refundBusy, setRefundBusy] = useState(false);
const [refundFile, setRefundFile] = useState(null); const [refundFile, setRefundFile] = useState(null);
const [refundSession, setRefundSession] = useState(null);
const [refundQr, setRefundQr] = useState("");
const [refundSessionError, setRefundSessionError] = useState("");
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const isAskTask = const isAskTask =
task.task_type === "ask" || task.task_type_label === "提问任务"; task.task_type === "ask" || task.task_type_label === "提问任务";
@ -49,11 +53,42 @@ function TaskDetail({ task, close, onConfirmRefund }) {
]; ];
const paymentScreenshots = task.payment_screenshots || []; const paymentScreenshots = task.payment_screenshots || [];
const refundScreenshots = task.refund_screenshots || []; const refundScreenshots = task.refund_screenshots || [];
useEffect(() => {
if (!refundOpen || refundSession || !task.refund_endpoint) return undefined;
let cancelled = false;
setRefundSessionError("");
onCreateRefundUpload(task).then((session) => {
if (cancelled) return;
setRefundSession(session);
try {
setRefundQr(qrDataUrl(`${window.location.origin}${session.upload_url}`));
} catch (error) {
setRefundSessionError(error.message || "二维码生成失败,请改用下方直接上传");
}
}).catch((error) => !cancelled && setRefundSessionError(error.message));
return () => { cancelled = true; };
}, [refundOpen, refundSession, task.id, onCreateRefundUpload]);
useEffect(() => {
if (!refundOpen || !refundSession || refundSession.uploaded) return undefined;
let cancelled = false;
const poll = async () => {
try {
const current = await onPollRefundUpload(refundSession.token);
if (!cancelled && current.uploaded) setRefundSession((value) => ({ ...value, ...current }));
} catch (error) {
if (!cancelled) setRefundSessionError(error.message);
}
};
const timer = window.setInterval(poll, 2500);
return () => { cancelled = true; window.clearInterval(timer); };
}, [refundOpen, refundSession?.token, refundSession?.uploaded]);
async function confirmRefund() { async function confirmRefund() {
setRefundBusy(true); setRefundBusy(true);
try { try {
await onConfirmRefund(task, refundFile); await onConfirmRefund(task, refundFile, refundSession?.uploaded ? refundSession.token : null);
setRefundOpen(false); setRefundOpen(false);
setRefundSession(null);
setRefundFile(null);
} catch (error) { } catch (error) {
window.alert(error.message); window.alert(error.message);
} finally { } finally {
@ -126,7 +161,10 @@ function TaskDetail({ task, close, onConfirmRefund }) {
</button> </button>
</div> </div>
)} )}
<div className="task-detail-meta"> <div className={`task-detail-meta${isAskTask ? " ask-task-meta" : ""}`}>
{isAskTask && <span>
店铺名称<b>{task.shop_name || "--"}</b>
</span>}
<span> <span>
商品名称<b>{task.product_name}</b> 商品名称<b>{task.product_name}</b>
</span> </span>
@ -250,7 +288,12 @@ function TaskDetail({ task, close, onConfirmRefund }) {
<button <button
type="button" type="button"
className="confirm-refund" className="confirm-refund"
onClick={() => setRefundOpen(true)} onClick={() => {
setRefundSession(null);
setRefundFile(null);
setRefundQr("");
setRefundOpen(true);
}}
> >
确认返款 确认返款
</button> </button>
@ -265,13 +308,22 @@ function TaskDetail({ task, close, onConfirmRefund }) {
} }
> >
<section className="refund-confirm-modal"> <section className="refund-confirm-modal">
<span>✓</span> <span>扫码</span>
<h2>确认已经完成返款?</h2> <h2>确认已经完成返款?</h2>
<p> <p>
请确认已按用户上传的收款码完成付款。确认后,该任务将归入“已完成”,此操作不可撤销。 请确认已按用户上传的收款码完成付款。确认后,该任务将归入“已完成”,此操作不可撤销。
</p> </p>
<div className="refund-scan-upload">
<div>
<strong>扫码上传返款凭证</strong>
<small>二维码有效期 10 分钟</small>
{refundSessionError && <em>{refundSessionError}</em>}
{refundSession?.uploaded && <b>✓ 手机端已上传,运营端可确认返款</b>}
</div>
{refundQr ? <img src={refundQr} alt="扫码上传返款凭证" /> : <i>正在生成二维码…</i>}
</div>
<label className="refund-proof-upload"> <label className="refund-proof-upload">
<span>上传返款凭证(必传)</span> <span>电脑直接上传(可选)</span>
<input <input
type="file" type="file"
accept="image/jpeg,image/png,image/webp" accept="image/jpeg,image/png,image/webp"
@ -282,7 +334,7 @@ function TaskDetail({ task, close, onConfirmRefund }) {
<div> <div>
<button <button
type="button" type="button"
disabled={refundBusy || !refundFile} disabled={refundBusy}
onClick={() => setRefundOpen(false)} onClick={() => setRefundOpen(false)}
> >
取消 取消
@ -290,7 +342,7 @@ function TaskDetail({ task, close, onConfirmRefund }) {
<button <button
type="button" type="button"
className="confirm" className="confirm"
disabled={refundBusy} disabled={refundBusy || (!refundFile && !refundSession?.uploaded)}
onClick={confirmRefund} onClick={confirmRefund}
> >
{refundBusy ? "确认中…" : "确定返款完成"} {refundBusy ? "确认中…" : "确定返款完成"}
@ -361,7 +413,7 @@ export default function TaskBoardPage({ api }) {
return (STATUS_ALIASES[childRawStatus] || childRawStatus) === status; return (STATUS_ALIASES[childRawStatus] || childRawStatus) === status;
}); });
const statusMatch = status === "all" || taskStatus === status || childStatusMatch; const statusMatch = status === "all" || taskStatus === status || childStatusMatch;
const text = `${task.user_id || ""} ${task.task_id || ""} ${task.product_name || ""} ${children.map((child) => `${child.user_id || ""} ${child.phone || ""}`).join(" ")}`.toLowerCase(); const text = `${task.user_id || ""} ${task.task_id || ""} ${task.shop_name || ""} ${task.product_name || ""} ${children.map((child) => `${child.user_id || ""} ${child.phone || ""}`).join(" ")}`.toLowerCase();
const createdDate = String(task.created_at || task.updated_at || "").slice(0, 10); const createdDate = String(task.created_at || task.updated_at || "").slice(0, 10);
const fromMatch = !dateFrom || (createdDate && createdDate >= dateFrom); const fromMatch = !dateFrom || (createdDate && createdDate >= dateFrom);
const toMatch = !dateTo || (createdDate && createdDate <= dateTo); const toMatch = !dateTo || (createdDate && createdDate <= dateTo);
@ -400,12 +452,12 @@ export default function TaskBoardPage({ api }) {
window.alert("当前没有可导出的任务"); window.alert("当前没有可导出的任务");
return; return;
} }
const headers = ["用户ID", "任务ID", "商品信息", "类型", "Q&A数", "完成", "状态", "创建人", "返款凭证", "创建时间"]; const headers = ["用户ID", "任务ID", "店铺/商品信息", "类型", "Q&A数", "完成", "状态", "创建人", "返款凭证", "创建时间"];
const csvCell = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`; const csvCell = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`;
const lines = [headers, ...rows.map((task) => [ const lines = [headers, ...rows.map((task) => [
task.user_id, task.user_id,
task.task_id, task.task_id,
task.product_name, [task.shop_name, task.product_name].filter(Boolean).join(" / "),
task.task_type_label, task.task_type_label,
task.qa_count, task.qa_count,
task.completed_count, task.completed_count,
@ -510,10 +562,22 @@ export default function TaskBoardPage({ api }) {
setBusy(""); setBusy("");
} }
} }
async function confirmRefund(task, refundFile) { async function createRefundUpload(task) {
if (!refundFile) throw new Error("请先上传返款凭证"); const result = await api("/api/admin/refund-upload-sessions", {
method: "POST",
body: JSON.stringify({ target_kind: task.record_kind, target_id: task.id }),
});
return result.data;
}
async function pollRefundUpload(token) {
const result = await api(`/api/refund-upload-sessions/${encodeURIComponent(token)}`);
return result.data;
}
async function confirmRefund(task, refundFile, uploadToken) {
if (!refundFile && !uploadToken) throw new Error("请先上传返款凭证,或用手机扫码上传");
const body = new FormData(); const body = new FormData();
body.append("refund_file", refundFile); if (refundFile) body.append("refund_file", refundFile);
if (uploadToken) body.append("upload_token", uploadToken);
const result = await api(task.refund_endpoint, { method: "POST", body }); const result = await api(task.refund_endpoint, { method: "POST", body });
const updatedDetail = { const updatedDetail = {
...task, ...task,
@ -582,7 +646,7 @@ export default function TaskBoardPage({ api }) {
<input <input
value={keyword} value={keyword}
onChange={(event) => setKeyword(event.target.value)} onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索用户ID或任务ID…" placeholder="搜索用户ID、任务ID、店铺或商品…"
/> />
</div> </div>
<div className="board-tools"> <div className="board-tools">
@ -611,7 +675,7 @@ export default function TaskBoardPage({ api }) {
</span> </span>
<span>用户ID</span> <span>用户ID</span>
<span>任务ID</span> <span>任务ID</span>
<span>商品信息</span> <span>店铺/商品信息</span>
<span>类型</span> <span>类型</span>
<span>Q&A数</span> <span>Q&A数</span>
<span>完成</span> <span>完成</span>
@ -651,7 +715,10 @@ export default function TaskBoardPage({ api }) {
<code>{task.user_id}</code> <code>{task.user_id}</code>
</span> </span>
<span><b>{task.task_id}</b></span> <span><b>{task.task_id}</b></span>
<span><strong>{task.product_name}</strong><small>{maskPhone(task.phone)}</small></span> <span>
<strong>{isAskTask(task) ? task.shop_name || "--" : task.product_name}</strong>
<small>{isAskTask(task) ? task.product_name : maskPhone(task.phone)}</small>
</span>
<span><em className={`board-type ${task.task_type}`}>{task.task_type_label}</em></span> <span><em className={`board-type ${task.task_type}`}>{task.task_type_label}</em></span>
<span>{task.qa_count}</span> <span>{task.qa_count}</span>
<span>{task.completed_count}</span> <span>{task.completed_count}</span>
@ -696,6 +763,8 @@ export default function TaskBoardPage({ api }) {
task={detail} task={detail}
close={() => setDetail(null)} close={() => setDetail(null)}
onConfirmRefund={confirmRefund} onConfirmRefund={confirmRefund}
onCreateRefundUpload={createRefundUpload}
onPollRefundUpload={pollRefundUpload}
/> />
)} )}
{batchConfirm && ( {batchConfirm && (

View File

@ -363,6 +363,137 @@
gap: 10px; gap: 10px;
margin-top: 22px; margin-top: 22px;
} }
.ask-add-dialog {
width: min(430px, calc(100vw - 32px));
}
.ask-add-dialog > p {
margin: 12px 0 18px;
color: #7187a1;
font-size: 12px;
}
.ask-add-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.ask-add-options button {
display: grid;
gap: 7px;
padding: 18px 14px;
border: 1px solid #d6e2f0;
border-radius: 11px;
background: #f8fbff;
color: #1c3f68;
text-align: left;
font-weight: 700;
}
.ask-add-options button:hover {
border-color: #2864e8;
background: #f1f6ff;
}
.ask-add-options span {
color: #8195ad;
font-size: 11px;
font-weight: 400;
}
.ask-manual-dialog {
width: min(500px, calc(100vw - 32px));
}
.ask-library-search {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
margin-top: 16px;
padding: 8px 10px;
border: 1px solid #ccd9e8;
border-radius: 9px;
color: #7890aa;
}
.ask-library-search input {
min-width: 0;
padding: 3px 0;
border: 0;
}
.ask-library-search input:focus {
box-shadow: none;
}
.ask-library-search button {
padding: 7px 11px;
border-radius: 7px;
background: #2864e8;
color: #fff;
font-size: 12px;
}
.ask-library-toolbar,
.qa-library-select-all {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
color: #7187a1;
font-size: 12px;
}
.ask-library-toolbar span {
margin-left: auto;
}
.ask-library-results {
display: grid;
gap: 8px;
max-height: min(430px, 52vh);
margin-top: 10px;
overflow: auto;
}
.ask-library-item {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 11px;
border: 1px solid #dce6f1;
border-radius: 9px;
background: #fbfdff;
cursor: pointer;
}
.ask-library-item:hover {
border-color: #9dbff5;
background: #f4f8ff;
}
.ask-library-item > span {
display: grid;
gap: 5px;
min-width: 0;
}
.ask-library-item b {
color: #23415f;
font-size: 12px;
line-height: 1.5;
}
.ask-library-item small {
overflow: hidden;
color: #8598ae;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ask-library-empty {
padding: 35px 12px;
color: #91a2b7;
text-align: center;
font-size: 12px;
}
.qa-library-card.selectable > header > div:first-child {
display: flex;
align-items: flex-start;
gap: 9px;
}
.qa-library-card.selectable > header > div:first-child > input {
margin-top: 3px;
}
@media (max-width: 600px) {
.ask-add-options {
grid-template-columns: 1fr;
}
}
.answer-page { .answer-page {
min-height: 100vh; min-height: 100vh;
background: #f4f7fb; background: #f4f7fb;
@ -2166,6 +2297,9 @@
border-radius: 12px; border-radius: 12px;
background: #f8fafc; background: #f8fafc;
} }
.task-detail-meta.ask-task-meta {
grid-template-columns: 1.25fr 1.5fr 0.9fr 0.9fr 1.1fr;
}
.task-detail-meta span { .task-detail-meta span {
display: grid; display: grid;
gap: 6px; gap: 6px;
@ -2445,6 +2579,63 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.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: 8px; padding: 22px 15px; border: 1px dashed #a9c5e8; border-radius: 12px; color: #1c5fc7; cursor: pointer; }
.refund-upload-picker input { width: 100%; }
.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 { .refund-confirm-modal > div {
display: grid; display: grid;
grid-template-columns: 1fr 1.35fr; grid-template-columns: 1fr 1.35fr;
@ -2491,9 +2682,15 @@
.task-detail-modal { .task-detail-modal {
padding: 16px; 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 { .task-detail-meta {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.task-detail-meta.ask-task-meta {
grid-template-columns: 1fr 1fr;
}
.task-detail-share { .task-detail-share {
align-items: stretch; align-items: stretch;
flex-wrap: wrap; flex-wrap: wrap;
@ -4033,13 +4230,20 @@
} }
.qa-library-toolbar { .qa-library-toolbar {
display: flex; display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
margin-bottom: 16px; margin-bottom: 16px;
padding: 14px; padding: 14px;
} }
.qa-library-toolbar > div { .qa-library-search-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.qa-library-search-input {
display: flex; display: flex;
align-items: center; align-items: center;
flex: 1; flex: 1;
@ -4050,15 +4254,31 @@
background: #fff; background: #fff;
color: #7890aa; color: #7890aa;
} }
.qa-library-toolbar input { .qa-library-selection-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
}
.qa-library-select-all {
display: inline-flex;
align-items: center;
gap: 6px;
color: #456486;
font-size: 12px;
white-space: nowrap;
}
.qa-library-search-input input {
width: 100%; width: 100%;
padding: 11px 0; padding: 11px 0;
border: 0; border: 0;
outline: 0; outline: 0;
} }
.qa-library-toolbar > span { .qa-library-selection-actions > span {
color: #7187a1; color: #7187a1;
font-size: 12px; font-size: 12px;
white-space: nowrap;
} }
.qa-library-list { .qa-library-list {
display: grid; display: grid;
@ -4205,7 +4425,12 @@
font-size: 11px; font-size: 11px;
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.qa-library-toolbar, .qa-library-toolbar {
grid-template-columns: 1fr;
}
.qa-library-selection-actions {
justify-content: flex-start;
}
.qa-library-card > header { .qa-library-card > header {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
@ -4445,25 +4670,108 @@
color: #23415f; color: #23415f;
font-size: 12px; font-size: 12px;
} }
.ask-product-token-field {
display: grid;
gap: 8px;
}
.ask-product-token-hint {
color: #8296ad;
font-size: 11px;
}
.ask-product-qr-upload {
display: grid;
gap: 4px;
padding: 11px 12px;
border: 1px dashed #9bbbe8;
border-radius: 8px;
background: #f8fbff;
color: #1764e8;
text-align: left;
cursor: pointer;
}
.ask-product-qr-upload small {
color: #8296ad;
font-size: 10px;
}
.ask-product-qr-preview {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border: 1px solid #c7d6e8;
border-radius: 8px;
background: #f8fbff;
}
.ask-product-qr-preview img {
width: 64px;
height: 64px;
flex: 0 0 64px;
object-fit: contain;
border-radius: 5px;
background: #fff;
}
.ask-product-qr-preview div {
display: grid;
gap: 3px;
min-width: 0;
}
.ask-product-qr-preview strong {
overflow: hidden;
color: #23415f;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ask-product-qr-preview small {
color: #8296ad;
font-size: 10px;
}
.ask-product-qr-preview button {
justify-self: start;
padding: 3px 7px;
font-size: 10px;
}
.ask-results { .ask-results {
min-width: 0; min-width: 0;
} }
.ask-toolbar { .ask-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 8px; gap: 8px;
padding: 12px; padding: 12px;
border-radius: 9px; border-radius: 9px;
background: #f8fafc; background: #f8fafc;
} }
.ask-toolbar-selection,
.ask-toolbar-actions {
display: flex;
align-items: center;
gap: 6px;
}
.ask-toolbar-selection {
flex: 1;
min-width: 0;
}
.ask-toolbar label { .ask-toolbar label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
flex: 1;
color: #405d7d; color: #405d7d;
font-size: 12px; font-size: 12px;
} }
.ask-clear-added,
.ask-question-remove {
border: 1px solid #ffc2c8 !important;
border-radius: 7px;
background: #fff5f6 !important;
color: #d9364f !important;
cursor: pointer;
}
.ask-clear-added:hover,
.ask-question-remove:hover { background: #ffe8eb !important; }
.ask-clear-added:disabled,
.ask-question-remove:disabled { opacity: .6; cursor: wait; }
.ask-toolbar button { .ask-toolbar button {
display: flex; display: flex;
align-items: center; align-items: center;
@ -4531,12 +4839,28 @@
color: #1764e8; color: #1764e8;
font-size: 11px; font-size: 11px;
} }
.ask-results article header .ask-question-remove {
padding: 3px 7px;
font-size: 11px;
}
.ask-results article small { .ask-results article small {
display: block; display: block;
margin-top: 7px; margin-top: 7px;
color: #8a9db4; color: #8a9db4;
font-size: 10px; font-size: 10px;
} }
@media (max-width: 700px) {
.ask-toolbar {
align-items: stretch;
flex-direction: column;
}
.ask-toolbar-selection,
.ask-toolbar-actions { flex-wrap: wrap; }
.ask-toolbar-actions > button {
flex: 1;
justify-content: center;
}
}
.ask-created-card { .ask-created-card {
display: grid; display: grid;
justify-items: center; justify-items: center;
@ -4550,6 +4874,36 @@
.ask-created-card p { .ask-created-card p {
color: #6d84a2; color: #6d84a2;
} }
.ask-created-summary {
display: grid;
grid-template-columns: 1fr 1.5fr;
gap: 10px;
width: min(510px, 100%);
padding: 12px 14px;
border: 1px solid #d8e3ee;
border-radius: 10px;
background: #fff;
text-align: left;
}
.ask-created-summary span {
display: grid;
gap: 5px;
min-width: 0;
color: #8194aa;
font-size: 11px;
}
.ask-created-summary b {
overflow: hidden;
color: #23415f;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 600px) {
.ask-created-summary {
grid-template-columns: 1fr;
}
}
.ask-created-icon { .ask-created-icon {
display: grid; display: grid;
place-items: center; place-items: center;
@ -4890,3 +5244,119 @@ body img {
.ask-audit-refund-proof figure{margin:0;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 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} .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;
gap: 9px;
margin-top: 13px;
}
.ask-product-access > footer {
display: flex;
align-items: center;
gap: 7px;
margin-top: 0;
padding: 8px;
border: 1px solid #e2e8f0;
border-radius: 9px;
background: #f8fafc;
}
.ask-product-access > footer code {
min-width: 0;
flex: 1;
overflow: hidden;
color: #7590ad;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ask-product-access > footer button {
display: flex;
gap: 3px;
background: transparent;
color: #1764e8;
font-size: 10px;
}
.ask-product-qr-card {
display: flex;
align-items: center;
gap: 9px;
width: fit-content;
margin: 0;
padding: 7px 9px;
border: 1px solid #d9e4ef;
border-radius: 9px;
background: #f8fafc;
}
.ask-product-qr-card img {
width: 86px;
height: 86px;
object-fit: contain;
border-radius: 5px;
background: #fff;
}
.ask-product-qr-card figcaption {
color: #54708f;
font-size: 10px;
}
.ask-product-card > .ask-product-access {
display: grid;
align-items: stretch;
width: 100%;
min-width: 0;
}
.ask-product-card > .ask-product-access > * {
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
@media(max-width:700px){
.ask-confirm-body,
.ask-product-card,
.ask-selected-card,
.ask-upload-card {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.ask-confirm-body {
overflow-x: hidden;
}
.ask-product-card > div:not(.ask-product-access) {
width: 100%;
min-width: 0;
justify-content: center;
text-align: center;
}
.ask-product-card > div:not(.ask-product-access) > div {
min-width: 0;
}
.ask-product-access > footer { flex-wrap: wrap; }
.ask-product-access > footer {
width: 100%;
box-sizing: border-box;
}
.ask-product-access > footer code { flex-basis: 100%; }
.ask-product-qr-card {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
.ask-selected-card > header {
flex-wrap: wrap;
gap: 6px;
}
.ask-selected-card article,
.ask-selected-card article span {
min-width: 0;
}
.ask-selected-card article span {
overflow-wrap: anywhere;
}
.ask-upload-card > header > div {
min-width: 0;
}
.ask-upload-card > header strong,
.ask-upload-card > header small {
overflow-wrap: anywhere;
}
}

View File

@ -21,6 +21,7 @@ import TaskBoardPage from "./TaskBoardPage.jsx";
import ReviewCenterPage from "./ReviewCenterPage.jsx"; import ReviewCenterPage from "./ReviewCenterPage.jsx";
import CreateAskTasksPage from "./CreateAskTasksPage.jsx"; import CreateAskTasksPage from "./CreateAskTasksPage.jsx";
import AskUserPortal from "./AskUserPortal.jsx"; import AskUserPortal from "./AskUserPortal.jsx";
import RefundUploadPage from "./RefundUploadPage.jsx";
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js"; import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
import { readPageCache, writePageCache } from "./pageCache.js"; import { readPageCache, writePageCache } from "./pageCache.js";
@ -1344,7 +1345,7 @@ function AssistantPageV2() {
); );
} }
function QALibraryPage() { function QALibraryPage({ selectionMode = false }) {
const [records, setRecords] = useState([]); const [records, setRecords] = useState([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -1352,6 +1353,7 @@ function QALibraryPage() {
const [editing, setEditing] = useState(null); const [editing, setEditing] = useState(null);
const [deleting, setDeleting] = useState(null); const [deleting, setDeleting] = useState(null);
const [deleteBusy, setDeleteBusy] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false);
const [selectedIds, setSelectedIds] = useState([]);
async function load(keyword = search) { async function load(keyword = search) {
setLoading(true); setLoading(true);
try { try {
@ -1389,11 +1391,12 @@ function QALibraryPage() {
return ( return (
<> <>
<PageHeading <PageHeading
title="问答库" title={selectionMode ? "从问答库添加提问" : "问答库"}
description="集中沉淀运营已采纳的问答内容,供后续选题和回答参考。" description={selectionMode ? "搜索并勾选问题,添加后将返回当前提问任务。" : "集中沉淀运营已采纳的问答内容,供后续选题和回答参考。"}
/> />
<section className="qa-library-toolbar panel"> <section className={`qa-library-toolbar panel${selectionMode ? " selection-mode" : ""}`}>
<div> <div className="qa-library-search-row">
<div className="qa-library-search-input">
<Search size={17} /> <Search size={17} />
<input <input
value={search} value={search}
@ -1402,10 +1405,16 @@ function QALibraryPage() {
placeholder="搜索商品、品类、问题或回答" placeholder="搜索商品、品类、问题或回答"
/> />
</div> </div>
<button className="primary-button" onClick={() => load()}> <button type="button" className="primary-button" onClick={() => load()}>
搜索 搜索
</button> </button>
</div>
<div className="qa-library-selection-actions">
{selectionMode && <label className="qa-library-select-all"><input type="checkbox" checked={records.length > 0 && records.every((item) => selectedIds.includes(item.id))} onChange={(event) => setSelectedIds(event.target.checked ? records.map((item) => item.id) : [])} /> 全选当前搜索结果</label>}
<span>共 {records.length} 条问答</span> <span>共 {records.length} 条问答</span>
{selectionMode && <button type="button" className="ghost-button" onClick={() => window.dispatchEvent(new CustomEvent("switch-create-ask"))}>返回创建任务</button>}
{selectionMode && <button type="button" className="primary-button" disabled={!selectedIds.length} onClick={() => window.dispatchEvent(new CustomEvent("qa-library-selected", { detail: records.filter((item) => selectedIds.includes(item.id)) }))}>添加已选并返回({selectedIds.length})</button>}
</div>
</section> </section>
{error && <div className="form-error">{error}</div>} {error && <div className="form-error">{error}</div>}
<section className="qa-library-list"> <section className="qa-library-list">
@ -1417,9 +1426,10 @@ function QALibraryPage() {
</div> </div>
) : ( ) : (
records.map((record) => ( records.map((record) => (
<article className="qa-library-card panel" key={record.id}> <article className={`qa-library-card panel${selectionMode ? " selectable" : ""}`} key={record.id}>
<header> <header>
<div> <div>
{selectionMode && <input type="checkbox" checked={selectedIds.includes(record.id)} onChange={() => setSelectedIds((current) => current.includes(record.id) ? current.filter((id) => id !== record.id) : [...current, record.id])} aria-label={`选择问题 ${record.question}`} />}
<strong>{record.product_name || "未填写商品名称"}</strong> <strong>{record.product_name || "未填写商品名称"}</strong>
{record.category && <span>{record.category}</span>} {record.category && <span>{record.category}</span>}
</div> </div>
@ -3822,6 +3832,7 @@ function Dashboard({ user, onLogout }) {
const [active, setActive] = useState("assistant"); const [active, setActive] = useState("assistant");
const [createMode, setCreateMode] = useState("answer"); const [createMode, setCreateMode] = useState("answer");
const [reviewBadge, setReviewBadge] = useState(0); const [reviewBadge, setReviewBadge] = useState(0);
const [librarySelection, setLibrarySelection] = useState([]);
async function loadReviewBadge() { async function loadReviewBadge() {
try { try {
const result = await api("/api/admin/review-pending-count"); const result = await api("/api/admin/review-pending-count");
@ -3843,10 +3854,24 @@ function Dashboard({ user, onLogout }) {
if (event.type === "switch-create-ask") { if (event.type === "switch-create-ask") {
setActive("create"); setActive("create");
setCreateMode("ask"); setCreateMode("ask");
window.__qaLibrarySelectionMode = false;
}
if (event.type === "open-qa-library-picker") {
setActive("library");
setCreateMode("ask");
window.__qaLibrarySelectionMode = true;
}
if (event.type === "qa-library-selected") {
setLibrarySelection(event.detail || []);
window.__qaLibrarySelectionMode = false;
setActive("create");
setCreateMode("ask");
} }
}; };
document.addEventListener("click", handler); document.addEventListener("click", handler);
window.addEventListener("open-task-board", handler); window.addEventListener("open-task-board", handler);
window.addEventListener("open-qa-library-picker", handler);
window.addEventListener("qa-library-selected", handler);
window.addEventListener("switch-create-answer", handler); window.addEventListener("switch-create-answer", handler);
window.addEventListener("switch-create-ask", handler); window.addEventListener("switch-create-ask", handler);
window.addEventListener("review-count-changed", loadReviewBadge); window.addEventListener("review-count-changed", loadReviewBadge);
@ -3855,6 +3880,8 @@ function Dashboard({ user, onLogout }) {
return () => { return () => {
document.removeEventListener("click", handler); document.removeEventListener("click", handler);
window.removeEventListener("open-task-board", handler); window.removeEventListener("open-task-board", handler);
window.removeEventListener("open-qa-library-picker", handler);
window.removeEventListener("qa-library-selected", handler);
window.removeEventListener("switch-create-answer", handler); window.removeEventListener("switch-create-answer", handler);
window.removeEventListener("switch-create-ask", handler); window.removeEventListener("switch-create-ask", handler);
window.removeEventListener("review-count-changed", loadReviewBadge); window.removeEventListener("review-count-changed", loadReviewBadge);
@ -3885,7 +3912,7 @@ function Dashboard({ user, onLogout }) {
> >
{active === "create" ? ( {active === "create" ? (
createMode === "ask" ? ( createMode === "ask" ? (
<CreateAskTasksPage api={api} /> <CreateAskTasksPage api={api} librarySelection={librarySelection} onLibrarySelectionConsumed={() => setLibrarySelection([])} />
) : ( ) : (
<CreateAnswerTasksPageV2 api={api} /> <CreateAnswerTasksPageV2 api={api} />
) )
@ -3894,7 +3921,7 @@ function Dashboard({ user, onLogout }) {
) : active === "review" ? ( ) : active === "review" ? (
<ReviewCenterPage api={api} /> <ReviewCenterPage api={api} />
) : active === "library" ? ( ) : active === "library" ? (
<QALibraryPage /> <QALibraryPage selectionMode={Boolean(window.__qaLibrarySelectionMode)} />
) : isAdminPage ? ( ) : isAdminPage ? (
<AdminPage type={active} /> <AdminPage type={active} />
) : ( ) : (
@ -3946,6 +3973,8 @@ function TextDecoder() {
return null; return null;
} }
function App() { function App() {
if (window.location.pathname.startsWith("/refund-upload/"))
return <RefundUploadPage api={api} />;
if (window.location.pathname === "/ask" || window.location.pathname.startsWith("/ask/")) if (window.location.pathname === "/ask" || window.location.pathname.startsWith("/ask/"))
return <AskUserPortal api={api} />; return <AskUserPortal api={api} />;
if (window.location.pathname.startsWith("/answer/")) if (window.location.pathname.startsWith("/answer/"))

128
frontend/src/qrCode.js Normal file
View File

@ -0,0 +1,128 @@
// Tiny self-contained QR encoder for the short-lived refund upload URL.
// It intentionally supports QR version 4-L (up to 78 UTF-8 bytes), which is
// enough for the local upload route and keeps the admin bundle dependency-free.
const EXP = new Array(512);
const LOG = new Array(256);
let value = 1;
for (let i = 0; i < 255; i += 1) {
EXP[i] = value;
LOG[value] = i;
value <<= 1;
if (value & 0x100) value ^= 0x11d;
}
for (let i = 255; i < 512; i += 1) EXP[i] = EXP[i - 255];
function multiply(a, b) {
return a && b ? EXP[LOG[a] + LOG[b]] : 0;
}
function reedSolomon(data, count) {
let generator = [1];
for (let i = 0; i < count; i += 1) {
const next = new Array(generator.length + 1).fill(0);
generator.forEach((coefficient, index) => {
next[index] ^= coefficient;
next[index + 1] ^= multiply(coefficient, EXP[i]);
});
generator = next;
}
const result = new Array(count).fill(0);
data.forEach((byte) => {
const factor = byte ^ result[0];
result.shift();
result.push(0);
for (let i = 0; i < count; i += 1) result[i] ^= multiply(generator[i + 1], factor);
});
return result;
}
function bchTypeInfo(data) {
let value = data << 10;
while (value && value.toString(2).length >= 11) {
value ^= 0x537 << (value.toString(2).length - 11);
}
return ((data << 10) | value) ^ 0x5412;
}
function setFinder(matrix, row, col) {
for (let r = -1; r <= 7; r += 1) {
for (let c = -1; c <= 7; c += 1) {
if (row + r < 0 || row + r >= matrix.length || col + c < 0 || col + c >= matrix.length) continue;
matrix[row + r][col + c] = r >= 0 && r <= 6 && c >= 0 && c <= 6 && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4));
}
}
}
function makeMatrix(text) {
const bytes = Array.from(new TextEncoder().encode(text));
if (bytes.length > 78) throw new Error("二维码内容过长");
const bits = [];
const push = (value, length) => { for (let i = length - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1); };
push(0x4, 4);
push(bytes.length, 8);
bytes.forEach((byte) => push(byte, 8));
for (let i = 0; i < 4 && bits.length < 640; i += 1) bits.push(0);
while (bits.length % 8) bits.push(0);
const data = [];
for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((sum, bit) => (sum << 1) | bit, 0));
// Version 4-L has 100 total codewords: one block of 80 data bytes and
// 20 Reed-Solomon correction bytes.
while (data.length < 80) data.push(data.length % 2 ? 0x11 : 0xec);
const codewords = data.concat(reedSolomon(data, 20));
const size = 33;
const matrix = Array.from({ length: size }, () => Array(size).fill(null));
setFinder(matrix, 0, 0); setFinder(matrix, size - 7, 0); setFinder(matrix, 0, size - 7);
for (let i = 8; i < size - 8; i += 1) {
if (matrix[i][6] === null) matrix[i][6] = i % 2 === 0;
if (matrix[6][i] === null) matrix[6][i] = i % 2 === 0;
}
// Version 4 has one alignment pattern at (26, 26).
for (let r = -2; r <= 2; r += 1) for (let c = -2; c <= 2; c += 1) {
if (matrix[26 + r][26 + c] === null) matrix[26 + r][26 + c] = Math.max(Math.abs(r), Math.abs(c)) !== 1;
}
let bitIndex = 0;
const writeFormat = (format) => {
for (let i = 0; i < 15; i += 1) {
const dark = ((format >>> i) & 1) === 1;
if (i < 6) matrix[i][8] = dark;
else if (i < 8) matrix[i + 1][8] = dark;
else matrix[size - 15 + i][8] = dark;
if (i < 8) matrix[8][size - i - 1] = dark;
else if (i < 9) matrix[8][15 - i] = dark;
else matrix[8][15 - i - 1] = dark;
}
matrix[size - 8][8] = true;
};
// Reserve these cells before data placement. Otherwise the data walk
// would overwrite them and the resulting code could not be scanned.
writeFormat(0);
let upward = true;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right -= 1;
for (let offset = 0; offset < size; offset += 1) {
const row = upward ? size - 1 - offset : offset;
for (const col of [right, right - 1]) {
if (matrix[row][col] !== null) continue;
const byte = codewords[Math.floor(bitIndex / 8)] || 0;
const raw = ((byte >>> (7 - (bitIndex % 8))) & 1) === 1;
matrix[row][col] = raw !== ((row + col) % 2 === 0); // mask 0
bitIndex += 1;
}
}
upward = !upward;
}
writeFormat(bchTypeInfo(8)); // error correction L, mask 0
return matrix;
}
export function qrDataUrl(text, moduleSize = 5) {
const matrix = makeMatrix(text);
const quiet = 4;
const dimension = (matrix.length + quiet * 2) * moduleSize;
const paths = [];
matrix.forEach((row, r) => row.forEach((dark, c) => {
if (dark) paths.push(`M${(c + quiet) * moduleSize} ${(r + quiet) * moduleSize}h${moduleSize}v${moduleSize}h-${moduleSize}z`);
}));
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${dimension} ${dimension}" width="${dimension}" height="${dimension}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${paths.join(" ")}" fill="#0b1a35"/></svg>`;
return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`;
}