优化bug,返款扫码上传,新加填写店铺名,上传商品二维码
This commit is contained in:
parent
0b79c6812c
commit
3f1da0e159
@ -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)}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
BIN
backend/data/prepush_check.db
Normal file
BIN
backend/data/prepush_check.db
Normal file
Binary file not shown.
BIN
backend/data/prepush_check3.db
Normal file
BIN
backend/data/prepush_check3.db
Normal file
Binary file not shown.
BIN
backend/data/prepush_check4.db
Normal file
BIN
backend/data/prepush_check4.db
Normal file
Binary file not shown.
BIN
backend/data/prepush_check5.db
Normal file
BIN
backend/data/prepush_check5.db
Normal file
Binary file not shown.
BIN
backend/data/prepush_targeted2.db
Normal file
BIN
backend/data/prepush_targeted2.db
Normal file
Binary file not shown.
BIN
backend/data/test-completed-count-focused.db
Normal file
BIN
backend/data/test-completed-count-focused.db
Normal file
Binary file not shown.
BIN
backend/data/test-completed-count.db
Normal file
BIN
backend/data/test-completed-count.db
Normal file
Binary file not shown.
BIN
backend/data/test-local-qr.db
Normal file
BIN
backend/data/test-local-qr.db
Normal file
Binary file not shown.
BIN
backend/data/test-publish-status-2.db
Normal file
BIN
backend/data/test-publish-status-2.db
Normal file
Binary file not shown.
BIN
backend/data/test-publish-status.db
Normal file
BIN
backend/data/test-publish-status.db
Normal file
Binary file not shown.
BIN
backend/data/test-qr-final.db
Normal file
BIN
backend/data/test-qr-final.db
Normal file
Binary file not shown.
BIN
backend/data/test-qr-fresh-2.db
Normal file
BIN
backend/data/test-qr-fresh-2.db
Normal file
Binary file not shown.
BIN
backend/data/test-qr-fresh-3.db
Normal file
BIN
backend/data/test-qr-fresh-3.db
Normal file
Binary file not shown.
BIN
backend/data/test-qr-fresh-4.db
Normal file
BIN
backend/data/test-qr-fresh-4.db
Normal file
Binary file not shown.
BIN
backend/data/test-qr-fresh-5.db
Normal file
BIN
backend/data/test-qr-fresh-5.db
Normal file
Binary file not shown.
BIN
backend/data/test-qr-fresh.db
Normal file
BIN
backend/data/test-qr-fresh.db
Normal file
Binary file not shown.
BIN
backend/data/test_ask_features.db
Normal file
BIN
backend/data/test_ask_features.db
Normal file
Binary file not shown.
@ -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 拼接用户链接。
|
||||||
|
|||||||
@ -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.
@ -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>
|
||||||
|
|||||||
@ -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>;
|
||||||
|
}
|
||||||
|
|||||||
80
frontend/src/RefundUploadPage.jsx
Normal file
80
frontend/src/RefundUploadPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 && (
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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
128
frontend/src/qrCode.js
Normal 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)))}`;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user