返款凭证上传修复,新增回答任务专属链接

This commit is contained in:
kx_guo 2026-09-10 14:23:31 +08:00
parent 3f1da0e159
commit 66aca010f1
9 changed files with 353 additions and 53 deletions

View File

@ -15,7 +15,7 @@ import io
import zipfile import zipfile
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status from fastapi import BackgroundTasks, Body, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import func, inspect, or_, select, text from sqlalchemy import func, inspect, or_, select, text
from sqlalchemy.orm import Session, noload, selectinload from sqlalchemy.orm import Session, noload, selectinload
@ -192,6 +192,7 @@ def public_user(user: AdminUser) -> dict:
"dingtalk_dept_id": user.dingtalk_dept_id, "dingtalk_dept_id": user.dingtalk_dept_id,
"dingtalk_dept_name": user.dingtalk_dept_name, "dingtalk_dept_name": user.dingtalk_dept_name,
"is_dept_manager": user.is_dept_manager, "is_dept_manager": user.is_dept_manager,
"answer_public_token": user.answer_public_token,
} }
@ -250,6 +251,7 @@ def startup():
"dingtalk_dept_path": "VARCHAR(1000) NULL", "dingtalk_dept_path": "VARCHAR(1000) NULL",
"dingtalk_title": "VARCHAR(255) NULL", "dingtalk_title": "VARCHAR(255) NULL",
"is_dept_manager": "BOOLEAN NOT NULL DEFAULT 0", "is_dept_manager": "BOOLEAN NOT NULL DEFAULT 0",
"answer_public_token": "VARCHAR(96) NULL",
} }
for column_name, column_type in admin_user_migrations.items(): for column_name, column_type in admin_user_migrations.items():
if column_name not in admin_user_columns: if column_name not in admin_user_columns:
@ -259,6 +261,9 @@ def startup():
if "owner_id" not in answer_submission_columns: if "owner_id" not in answer_submission_columns:
with engine.begin() as connection: with engine.begin() as connection:
connection.execute(text("ALTER TABLE answer_submissions ADD COLUMN owner_id INTEGER NULL")) connection.execute(text("ALTER TABLE answer_submissions ADD COLUMN owner_id INTEGER NULL"))
if "shop_name" not in answer_submission_columns:
with engine.begin() as connection:
connection.execute(text("ALTER TABLE answer_submissions ADD COLUMN shop_name VARCHAR(255) NOT NULL DEFAULT ''"))
question_columns = {column["name"] for column in inspect(engine).get_columns("answer_recognized_questions")} question_columns = {column["name"] for column in inspect(engine).get_columns("answer_recognized_questions")}
if "operator_image_screenshot_id" not in question_columns: if "operator_image_screenshot_id" not in question_columns:
with engine.begin() as connection: with engine.begin() as connection:
@ -280,6 +285,31 @@ def startup():
with engine.begin() as connection: with engine.begin() as connection:
connection.execute(text("CREATE UNIQUE INDEX ix_ask_tasks_public_token ON ask_tasks (public_token)")) connection.execute(text("CREATE UNIQUE INDEX ix_ask_tasks_public_token ON ask_tasks (public_token)"))
seed_users(db) seed_users(db)
# Every operator keeps one stable public answer-task URL. Generate
# tokens after seeding so the default accounts receive one as well.
answer_users = db.scalars(
select(AdminUser).where(
AdminUser.answer_public_token.is_(None),
AdminUser.role_code != "super_admin",
)
).all()
for operator in answer_users:
operator.answer_public_token = _new_answer_public_token(db)
if answer_users:
db.commit()
admin_user_indexes = inspect(engine).get_indexes("admin_users")
admin_user_unique_constraints = inspect(engine).get_unique_constraints("admin_users")
answer_token_index_exists = any(
index.get("name") == "ix_admin_users_answer_public_token"
or index.get("column_names") == ["answer_public_token"]
for index in admin_user_indexes
) or any(
constraint.get("column_names") == ["answer_public_token"]
for constraint in admin_user_unique_constraints
)
if not answer_token_index_exists:
with engine.begin() as connection:
connection.execute(text("CREATE UNIQUE INDEX ix_admin_users_answer_public_token ON admin_users (answer_public_token)"))
finally: finally:
db.close() db.close()
@ -680,7 +710,10 @@ def _can_access_answer_submission(record: AnswerSubmission | None, user: AdminUs
if user.role_code == "super_admin" or record.owner_id == user.id: if user.role_code == "super_admin" or record.owner_id == user.id:
return True return True
if record.owner_id is None: if record.owner_id is None:
return user.role_code == "dept_manager" # Records created before fixed operator links have no owner. Keep
# those legacy rows operable during migration; every new submission
# made through an operator link is assigned an owner before insert.
return True
if user.role_code != "dept_manager" or not user.dingtalk_dept_id or not record.owner: if user.role_code != "dept_manager" or not user.dingtalk_dept_id or not record.owner:
return False return False
return f",{user.dingtalk_dept_id}," in (record.owner.dingtalk_dept_path or "") return f",{user.dingtalk_dept_id}," in (record.owner.dingtalk_dept_path or "")
@ -690,7 +723,9 @@ def _answer_owner_clause(user: AdminUser):
if user.role_code == "super_admin": if user.role_code == "super_admin":
return True return True
if user.role_code != "dept_manager" or not user.dingtalk_dept_id: if user.role_code != "dept_manager" or not user.dingtalk_dept_id:
return AnswerSubmission.owner_id == user.id # Include legacy unassigned submissions while isolating all rows that
# carry a fixed-link owner.
return or_(AnswerSubmission.owner_id == user.id, AnswerSubmission.owner_id.is_(None))
descendant_owners = select(AdminUser.id).where( descendant_owners = select(AdminUser.id).where(
or_( or_(
AdminUser.id == user.id, AdminUser.id == user.id,
@ -776,6 +811,28 @@ def _new_ask_public_token(db: Session) -> str:
return token return token
def _new_answer_public_token(db: Session) -> str:
while True:
token = secrets.token_urlsafe(32)
if not db.scalar(select(AdminUser.id).where(AdminUser.answer_public_token == token)):
return token
def _answer_link_owner(token: str, db: Session) -> AdminUser:
normalized = str(token or "").strip()
if not normalized:
raise HTTPException(status_code=400, detail="回答任务专属链接无效")
owner = db.scalar(
select(AdminUser).where(
AdminUser.answer_public_token == normalized,
AdminUser.is_active.is_(True),
)
)
if not owner:
raise HTTPException(status_code=404, detail="回答任务专属链接无效或已停用")
return owner
def _published_ask_task(public_token: str, db: Session, *, lock: bool = False) -> AskTask: def _published_ask_task(public_token: str, db: Session, *, lock: bool = False) -> AskTask:
statement = select(AskTask).where( statement = select(AskTask).where(
# New links use a random public token. Keep the task number as a # New links use a random public token. Keep the task number as a
@ -943,6 +1000,7 @@ def _submission_data(record: AnswerSubmission, include_images: bool = True, incl
"submission_no": record.submission_no, "submission_no": record.submission_no,
"user_id": record.user.public_id, "user_id": record.user.public_id,
"phone": record.user.phone, "phone": record.user.phone,
"shop_name": record.shop_name or "",
"status": record.status, "status": record.status,
"created_at": record.created_at.isoformat(), "created_at": record.created_at.isoformat(),
"updated_at": record.updated_at.isoformat(), "updated_at": record.updated_at.isoformat(),
@ -978,6 +1036,7 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d
"submission_no": record.submission_no, "submission_no": record.submission_no,
"user_id": record.user.public_id, "user_id": record.user.public_id,
"phone": record.user.phone, "phone": record.user.phone,
"shop_name": record.shop_name or "",
"status": record.status, "status": record.status,
"created_at": record.created_at.isoformat(), "created_at": record.created_at.isoformat(),
"updated_at": record.updated_at.isoformat(), "updated_at": record.updated_at.isoformat(),
@ -1007,10 +1066,11 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d
**summary, **summary,
"board_key": f"answer-{record.id}", "record_kind": "answer_submission", "board_key": f"answer-{record.id}", "record_kind": "answer_submission",
"task_id": record.submission_no, "task_type": "answer", "task_type_label": "回答任务", "task_id": record.submission_no, "task_type": "answer", "task_type_label": "回答任务",
"status_label": status_labels.get(record.status, record.status), "product_name": " / ".join(product_titles) or "待识别商品", "status_label": status_labels.get(record.status, record.status), "shop_name": record.shop_name or "", "product_name": " / ".join(product_titles) or "待识别商品",
"creator_name": record.owner.display_name if record.owner else "运营后台",
"qa_count": len(selected), "claimed_count": 1, "submitted_count": 1 if record.status in {"待审核", "已通过", "已完成"} else 0, "qa_count": len(selected), "claimed_count": 1, "submitted_count": 1 if record.status in {"待审核", "已通过", "已完成"} else 0,
"pending_review_count": 1 if record.status == "待审核" else 0, "completed_count": completed_count, "total_count": len(selected), "pending_review_count": 1 if record.status == "待审核" else 0, "completed_count": completed_count, "total_count": len(selected),
"creator_name": "运营后台", "can_cancel": record.status in {"待用户完成", "待审核", "未通过"}, "can_cancel": record.status in {"待用户完成", "待审核", "未通过"},
"can_delete": True, "can_delete": True,
"delete_endpoint": f"/api/admin/task-board/{record.id}", "delete_endpoint": f"/api/admin/task-board/{record.id}",
"refund_endpoint": f"/api/admin/task-board/{record.id}/confirm-refund" if record.status == "已通过" else None, "refund_endpoint": f"/api/admin/task-board/{record.id}/confirm-refund" if record.status == "已通过" else None,
@ -1259,13 +1319,38 @@ def _ask_review_board_data(record: AskAssignmentBatch | AskAssignment, include_i
} }
@app.get("/api/admin/auth/answer-link")
def admin_answer_link(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
"""Return the current operator's stable public answer-task URL token."""
stored_user = db.get(AdminUser, user.id)
if not stored_user:
raise HTTPException(status_code=401, detail="登录状态已失效,请重新登录")
if not stored_user.answer_public_token:
stored_user.answer_public_token = _new_answer_public_token(db)
db.commit()
db.refresh(stored_user)
return {"success": True, "data": {"token": stored_user.answer_public_token, "path": f"/answer/{stored_user.answer_public_token}"}}
@app.post("/api/answer/auth/phone-login") @app.post("/api/answer/auth/phone-login")
def answer_phone_login(payload: dict, db: Session = Depends(get_db)): def answer_phone_login(payload: dict, db: Session = Depends(get_db)):
return {"success": True, "data": _public_phone_session(_phone_login(payload, db))} # Validate the link before issuing a user session. The token is carried
# by the client on subsequent requests so a phone number can safely use
# more than one operator's link without changing the user account model.
owner_token = str(payload.get("owner_token") or payload.get("operator_token") or "").strip()
if owner_token:
_answer_link_owner(owner_token, db)
session = _public_phone_session(_phone_login(payload, db))
if owner_token:
session["owner_token"] = owner_token
return {"success": True, "data": session}
@app.post("/api/answer/auth/send-code") @app.post("/api/answer/auth/send-code")
def answer_send_code(payload: dict): def answer_send_code(payload: dict, db: Session = Depends(get_db)):
owner_token = str(payload.get("owner_token") or payload.get("operator_token") or "").strip()
if owner_token:
_answer_link_owner(owner_token, db)
return {"success": True, "data": _send_phone_code(payload)} return {"success": True, "data": _send_phone_code(payload)}
@ -1470,7 +1555,10 @@ def ask_completion_reviews(user: AdminUser = Depends(current_user), db: Session
def review_pending_count(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def review_pending_count(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
"""Return lightweight sidebar counts without loading review rows or images.""" """Return lightweight sidebar counts without loading review rows or images."""
answer_count = db.scalar( answer_count = db.scalar(
select(func.count(AnswerSubmission.id)).where(AnswerSubmission.status == "待审核") select(func.count(AnswerSubmission.id)).where(
AnswerSubmission.status == "待审核",
_answer_owner_clause(user),
)
) or 0 ) or 0
ask_batch_count = db.scalar( ask_batch_count = db.scalar(
select(func.count(AskAssignmentBatch.id)).join(AskAssignmentBatch.assignment).join(AskAssignment.task).where(AskAssignmentBatch.status == "pending", _ask_task_owner_clause(user)) select(func.count(AskAssignmentBatch.id)).join(AskAssignmentBatch.assignment).join(AskAssignment.task).where(AskAssignmentBatch.status == "pending", _ask_task_owner_clause(user))
@ -1547,18 +1635,24 @@ def ask_completion_decision(assignment_id: int, payload: dict, user: AdminUser =
@app.get("/api/answer/me/submissions") @app.get("/api/answer/me/submissions")
def answer_my_submissions(token: str, include_images: bool = False, db: Session = Depends(get_db)): def answer_my_submissions(token: str, owner_token: str = "", include_images: bool = False, db: Session = Depends(get_db)):
user = _answer_user_from_token(token, db) user = _answer_user_from_token(token, db)
records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.user_id == user.id).order_by(AnswerSubmission.created_at.desc())).unique().all() statement = select(AnswerSubmission).where(AnswerSubmission.user_id == user.id)
if owner_token.strip():
owner = _answer_link_owner(owner_token, db)
statement = statement.where(AnswerSubmission.owner_id == owner.id)
records = db.scalars(statement.order_by(AnswerSubmission.created_at.desc())).unique().all()
return {"success": True, "data": [_submission_data(record, include_images=include_images) for record in records]} return {"success": True, "data": [_submission_data(record, include_images=include_images) for record in records]}
@app.get("/api/answer/me/submissions/{submission_id}") @app.get("/api/answer/me/submissions/{submission_id}")
def answer_submission_detail(submission_id: int, token: str, db: Session = Depends(get_db)): def answer_submission_detail(submission_id: int, token: str, owner_token: str = "", db: Session = Depends(get_db)):
user = _answer_user_from_token(token, db) user = _answer_user_from_token(token, db)
record = db.get(AnswerSubmission, submission_id) record = db.get(AnswerSubmission, submission_id)
if not record or record.user_id != user.id: if not record or record.user_id != user.id:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
if owner_token.strip() and record.owner_id != _answer_link_owner(owner_token, db).id:
raise HTTPException(status_code=404, detail="任务不存在")
return {"success": True, "data": _submission_data(record, include_images=True, include_refund_images=True)} return {"success": True, "data": _submission_data(record, include_images=True, include_refund_images=True)}
@ -1646,10 +1740,12 @@ def _process_answer_submission(submission_id: int):
async def answer_create_submission( async def answer_create_submission(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
token: str = Form(...), token: str = Form(...),
owner_token: str = Form(default=""),
files: list[UploadFile] = File(...), files: list[UploadFile] = File(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
user = _answer_user_from_token(token, db) user = _answer_user_from_token(token, db)
owner = _answer_link_owner(owner_token, db) if owner_token.strip() else None
user_id = user.id user_id = user.id
db.rollback() db.rollback()
if not files or len(files) > 10: if not files or len(files) > 10:
@ -1661,7 +1757,7 @@ async def answer_create_submission(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片")
uploaded.append((upload.filename or "screenshot", upload.content_type, await upload.read())) uploaded.append((upload.filename or "screenshot", upload.content_type, await upload.read()))
# Persist only after all asynchronous file reads have completed. # Persist only after all asynchronous file reads have completed.
record = AnswerSubmission(submission_no=f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", user_id=user_id, status="待筛选") record = AnswerSubmission(submission_no=f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", user_id=user_id, owner_id=owner.id if owner else None, status="待筛选")
db.add(record); db.flush() db.add(record); db.flush()
for filename, content_type, image_data in uploaded: for filename, content_type, image_data in uploaded:
db.add(AnswerScreenshot(submission_id=record.id, stage="invited", filename=filename, content_type=content_type, image_data=image_data)) db.add(AnswerScreenshot(submission_id=record.id, stage="invited", filename=filename, content_type=content_type, image_data=image_data))
@ -1672,15 +1768,15 @@ async def answer_create_submission(
@app.get("/api/admin/answer-task-drafts") @app.get("/api/admin/answer-task-drafts")
def admin_answer_task_drafts(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def admin_answer_task_drafts(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.status.in_(["待筛选", "待运营派发", "已派发", "待用户完成"])).order_by(AnswerSubmission.created_at.desc())).unique().all() records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.status.in_(["待筛选", "待运营派发", "已派发", "待用户完成"])).where(_answer_owner_clause(user)).order_by(AnswerSubmission.created_at.desc())).unique().all()
return {"success": True, "data": [_submission_data(record, include_images=False) for record in records]} return {"success": True, "data": [_submission_data(record, include_images=False) for record in records]}
@app.get("/api/admin/answer-task-drafts/{submission_id}") @app.get("/api/admin/answer-task-drafts/{submission_id}")
def admin_answer_task_draft_detail(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def admin_answer_task_draft_detail(submission_id: int, 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 record.status not in {"待筛选", "待运营派发", "已派发", "待用户完成"}: if not record or record.status not in {"待筛选", "待运营派发", "已派发", "待用户完成"} or not _can_access_answer_submission(record, user):
raise HTTPException(status_code=404, detail="回答任务不存在") raise HTTPException(status_code=404, detail="回答任务不存在")
return {"success": True, "data": _submission_data(record, include_images=True)} return {"success": True, "data": _submission_data(record, include_images=True)}
@ -1689,13 +1785,13 @@ def admin_answer_task_draft_detail(submission_id: int, _: AdminUser = Depends(cu
async def upload_answer_operator_images( async def upload_answer_operator_images(
question_id: int, question_id: int,
files: list[UploadFile] = File(...), files: list[UploadFile] = File(...),
_: AdminUser = Depends(current_user), user: AdminUser = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Attach up to three optional operation-specified images to one question.""" """Attach up to three optional operation-specified images to one question."""
question = db.get(AnswerRecognizedQuestion, question_id) question = db.get(AnswerRecognizedQuestion, question_id)
record = db.get(AnswerSubmission, question.submission_id) if question else None record = db.get(AnswerSubmission, question.submission_id) if question else None
if not question or not record or record.status not in {"待筛选", "待运营派发", "已派发"}: if not question or not record or record.status not in {"待筛选", "待运营派发", "已派发"} or not _can_access_answer_submission(record, user):
raise HTTPException(status_code=404, detail="回答问题不存在或已经发布") raise HTTPException(status_code=404, detail="回答问题不存在或已经发布")
if not files or len(files) > 3: if not files or len(files) > 3:
raise HTTPException(status_code=400, detail="每道问题最多上传3张运营指定图片") raise HTTPException(status_code=400, detail="每道问题最多上传3张运营指定图片")
@ -1712,7 +1808,7 @@ async def upload_answer_operator_images(
uploaded.append((file.filename or "operator-image", file.content_type, image_data)) uploaded.append((file.filename or "operator-image", file.content_type, image_data))
question = db.get(AnswerRecognizedQuestion, question_id_value) question = db.get(AnswerRecognizedQuestion, question_id_value)
record = db.get(AnswerSubmission, record_id) record = db.get(AnswerSubmission, record_id)
if not question or not record or record.status not in {"待筛选", "待运营派发", "已派发"}: if not question or not record or record.status not in {"待筛选", "待运营派发", "已派发"} or not _can_access_answer_submission(record, user):
raise HTTPException(status_code=404, detail="回答问题不存在或已经发布") raise HTTPException(status_code=404, detail="回答问题不存在或已经发布")
old_ids = [value for value in (question.operator_image_screenshot_ids or "").split(",") if value.strip().isdigit()] old_ids = [value for value in (question.operator_image_screenshot_ids or "").split(",") if value.strip().isdigit()]
for old_id in old_ids: for old_id in old_ids:
@ -1731,9 +1827,9 @@ async def upload_answer_operator_images(
@app.post("/api/admin/answer-task-drafts/{submission_id}/reject") @app.post("/api/admin/answer-task-drafts/{submission_id}/reject")
def reject_answer_task_draft(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def reject_answer_task_draft(submission_id: int, 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: if not record or not _can_access_answer_submission(record, user):
raise HTTPException(status_code=404, detail="回答任务不存在") raise HTTPException(status_code=404, detail="回答任务不存在")
if record.status not in {"待筛选", "待运营派发"}: if record.status not in {"待筛选", "待运营派发"}:
raise HTTPException(status_code=409, detail="当前任务不能打回") raise HTTPException(status_code=409, detail="当前任务不能打回")
@ -1931,9 +2027,11 @@ def complete_ask_task(task_id: int, user: AdminUser = Depends(current_user), db:
@app.patch("/api/admin/answer-task-drafts/questions/{question_id}") @app.patch("/api/admin/answer-task-drafts/questions/{question_id}")
def update_answer_question(question_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def update_answer_question(question_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
question = db.get(AnswerRecognizedQuestion, question_id) question = db.get(AnswerRecognizedQuestion, question_id)
if not question: raise HTTPException(status_code=404, detail="问题不存在") record = db.get(AnswerSubmission, question.submission_id) if question else None
if not question or not record or not _can_access_answer_submission(record, user):
raise HTTPException(status_code=404, detail="问题不存在")
if payload.get("regenerate"): if payload.get("regenerate"):
product_title, question_text = question.product_title, question.question_text product_title, question_text = question.product_title, question.question_text
selected = bool(payload["selected"]) if "selected" in payload else None selected = bool(payload["selected"]) if "selected" in payload else None
@ -1941,7 +2039,8 @@ def update_answer_question(question_id: int, payload: dict, _: AdminUser = Depen
db.rollback() db.rollback()
answer_text = generate_answer_for_question(product_title, question_text) answer_text = generate_answer_for_question(product_title, question_text)
question = db.get(AnswerRecognizedQuestion, question_id) question = db.get(AnswerRecognizedQuestion, question_id)
if not question: record = db.get(AnswerSubmission, question.submission_id) if question else None
if not question or not record or not _can_access_answer_submission(record, user):
raise HTTPException(status_code=404, detail="问题不存在") raise HTTPException(status_code=404, detail="问题不存在")
if selected is not None: if selected is not None:
question.selected = selected question.selected = selected
@ -1954,11 +2053,17 @@ def update_answer_question(question_id: int, payload: dict, _: AdminUser = Depen
@app.post("/api/admin/answer-task-drafts/{submission_id}/publish") @app.post("/api/admin/answer-task-drafts/{submission_id}/publish")
def publish_answer_task(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def publish_answer_task(submission_id: int, payload: dict = Body(default={}), 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: raise HTTPException(status_code=404, detail="提交记录不存在") if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="提交记录不存在")
if record.status not in {"待筛选", "待运营派发", "已派发"}: raise HTTPException(status_code=409, detail="当前任务已经发布或不能发布") if record.status not in {"待筛选", "待运营派发", "已派发"}: raise HTTPException(status_code=409, detail="当前任务已经发布或不能发布")
if not any(q.selected for q in record.questions): raise HTTPException(status_code=400, detail="请至少选择一个问题") if not any(q.selected for q in record.questions): raise HTTPException(status_code=400, detail="请至少选择一个问题")
shop_name = str(payload.get("shop_name", "")).strip()
if not shop_name:
raise HTTPException(status_code=400, detail="派发前必须填写店铺名称")
if len(shop_name) > 255:
raise HTTPException(status_code=400, detail="店铺名称不能超过255个字符")
record.shop_name = shop_name
if record.owner_id is None: if record.owner_id is None:
record.owner_id = user.id record.owner_id = user.id
record.status = "待用户完成"; db.commit() record.status = "待用户完成"; db.commit()
@ -2250,9 +2355,11 @@ def admin_task_board_cancel(submission_id: int, user: AdminUser = Depends(curren
@app.post("/api/answer/me/submissions/{submission_id}/completion") @app.post("/api/answer/me/submissions/{submission_id}/completion")
async def answer_completion(submission_id: int, token: str = Form(...), files: list[UploadFile] = File(...), payment_files: list[UploadFile] = File(default=[]), db: Session = Depends(get_db)): async def answer_completion(submission_id: int, token: str = Form(...), owner_token: str = Form(default=""), files: list[UploadFile] = File(...), payment_files: list[UploadFile] = File(default=[]), db: Session = Depends(get_db)):
user = _answer_user_from_token(token, db); record = db.get(AnswerSubmission, submission_id) user = _answer_user_from_token(token, db); record = db.get(AnswerSubmission, submission_id)
if not record or record.user_id != user.id: raise HTTPException(status_code=404, detail="任务不存在") if not record or record.user_id != user.id: raise HTTPException(status_code=404, detail="任务不存在")
if owner_token.strip() and record.owner_id != _answer_link_owner(owner_token, db).id:
raise HTTPException(status_code=404, detail="任务不存在")
record_status = record.status record_status = record.status
if record_status not in ("待用户完成", "未通过"): raise HTTPException(status_code=400, detail="当前任务不能提交完成截图") if record_status not in ("待用户完成", "未通过"): raise HTTPException(status_code=400, detail="当前任务不能提交完成截图")
if not files or len(files) > 10: raise HTTPException(status_code=400, detail="最多上传10张截图") if not files or len(files) > 10: raise HTTPException(status_code=400, detail="最多上传10张截图")
@ -2268,6 +2375,8 @@ async def answer_completion(submission_id: int, token: str = Form(...), files: l
record = db.get(AnswerSubmission, submission_id) record = db.get(AnswerSubmission, submission_id)
if not record or record.user_id != user.id or record.status != record_status: if not record or record.user_id != user.id or record.status != record_status:
raise HTTPException(status_code=409, detail="任务状态已发生变化,请刷新后重试") raise HTTPException(status_code=409, detail="任务状态已发生变化,请刷新后重试")
if owner_token.strip() and record.owner_id != _answer_link_owner(owner_token, db).id:
raise HTTPException(status_code=404, detail="任务不存在")
if record_status == "未通过": if record_status == "未通过":
for old_screenshot in list(record.screenshots): for old_screenshot in list(record.screenshots):
if old_screenshot.stage in {"completion", "payment"}: if old_screenshot.stage in {"completion", "payment"}:

View File

@ -23,6 +23,10 @@ class AdminUser(Base):
dingtalk_dept_path: Mapped[str | None] = mapped_column(String(1000), nullable=True) dingtalk_dept_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
dingtalk_title: Mapped[str | None] = mapped_column(String(255), nullable=True) dingtalk_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
is_dept_manager: Mapped[bool] = mapped_column(Boolean, default=False, index=True) is_dept_manager: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
# Stable public entry point for answer-task submissions created by this
# operator. The token is intentionally separate from the admin session
# cookie and is safe to embed in a long-lived URL.
answer_public_token: Mapped[str | None] = mapped_column(String(96), unique=True, index=True, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
@ -83,6 +87,11 @@ class AnswerSubmission(Base):
submission_no: Mapped[str] = mapped_column(String(40), unique=True, index=True) submission_no: Mapped[str] = mapped_column(String(40), unique=True, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("answer_users.id"), index=True) user_id: Mapped[int] = mapped_column(ForeignKey("answer_users.id"), index=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)
# Store name is entered by the operator when this answer task is
# dispatched. It is kept on the submission because one operator may
# handle multiple stores and the same user can submit through different
# operator links.
shop_name: Mapped[str] = mapped_column(String(255), default="")
# 用户上传受邀截图后立即进入运营筛选队列。 # 用户上传受邀截图后立即进入运营筛选队列。
status: Mapped[str] = mapped_column(String(32), default="待筛选", index=True) status: Mapped[str] = mapped_column(String(32), default="待筛选", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)

View File

@ -7,12 +7,14 @@ CREATE TABLE IF NOT EXISTS admin_users (
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(64) NOT NULL, display_name VARCHAR(64) NOT NULL,
role_code VARCHAR(32) NOT NULL DEFAULT 'operator', role_code VARCHAR(32) NOT NULL DEFAULT 'operator',
answer_public_token VARCHAR(96) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1, is_active TINYINT(1) NOT NULL DEFAULT 1,
last_login_at DATETIME NULL, last_login_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_admin_users_username (username), UNIQUE KEY uk_admin_users_username (username),
UNIQUE KEY uk_admin_users_answer_public_token (answer_public_token),
KEY idx_admin_users_role (role_code), KEY idx_admin_users_role (role_code),
KEY idx_admin_users_status (is_active) KEY idx_admin_users_status (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -20,13 +20,13 @@ function Progress({ status }) {
return <div className="answer-v2-progress">{labels.map((label, index) => { const step = index + 1; const done = step < active || (active === 4 && ['已通过', '已完成'].includes(status)); return <div key={label} className={step === active ? 'active' : done ? 'done' : ''}><b>{done ? <Check size={15}/> : step}</b><span>{label}</span></div> })}</div> return <div className="answer-v2-progress">{labels.map((label, index) => { const step = index + 1; const done = step < active || (active === 4 && ['已通过', '已完成'].includes(status)); return <div key={label} className={step === active ? 'active' : done ? 'done' : ''}><b>{done ? <Check size={15}/> : step}</b><span>{label}</span></div> })}</div>
} }
function Login({ api, onLogin }) { function Login({ api, onLogin, linkToken }) {
const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); const [busy, setBusy] = useState(false); const [sending, setSending] = useState(false); const [cooldown, setCooldown] = useState(0); const [error, setError] = useState(''); const [devCode, setDevCode] = useState('') const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); const [busy, setBusy] = useState(false); const [sending, setSending] = useState(false); const [cooldown, setCooldown] = useState(0); const [error, setError] = useState(''); const [devCode, setDevCode] = useState('')
useEffect(() => { if (!cooldown) return undefined; const timer = window.setInterval(() => setCooldown(value => Math.max(0, value - 1)), 1000); return () => window.clearInterval(timer) }, [cooldown]) useEffect(() => { if (!cooldown) return undefined; const timer = window.setInterval(() => setCooldown(value => Math.max(0, value - 1)), 1000); return () => window.clearInterval(timer) }, [cooldown])
async function sendCode() { async function sendCode() {
if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码') if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码')
setSending(true); setError('') setSending(true); setError('')
try { const result = await api('/api/answer/auth/send-code', { method: 'POST', body: JSON.stringify({ phone }) }); setCooldown(result.data.retry_after || 60); setDevCode(result.data.dev_code || '') } try { const result = await api('/api/answer/auth/send-code', { method: 'POST', body: JSON.stringify({ phone, owner_token: linkToken || undefined }) }); setCooldown(result.data.retry_after || 60); setDevCode(result.data.dev_code || '') }
catch (err) { setError(err.message) } finally { setSending(false) } catch (err) { setError(err.message) } finally { setSending(false) }
} }
async function submit(event) { async function submit(event) {
@ -34,13 +34,13 @@ function Login({ api, onLogin }) {
if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码') if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码')
if (!/^\d{6}$/.test(code)) return setError('请输入6位验证码') if (!/^\d{6}$/.test(code)) return setError('请输入6位验证码')
setBusy(true) setBusy(true)
try { const result = await api('/api/answer/auth/phone-login', { method: 'POST', body: JSON.stringify({ phone, code }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) } try { const result = await api('/api/answer/auth/phone-login', { method: 'POST', body: JSON.stringify({ phone, code, owner_token: linkToken || undefined }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) }
catch (err) { setError(err.message) } finally { setBusy(false) } catch (err) { setError(err.message) } finally { setBusy(false) }
} }
return <main className="answer-v2-shell"><section className="answer-v2-app login-only"><header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div></header><form className="answer-v2-login" onSubmit={submit}><span><MessageCircle size={28}/></span><h1>手机号登录</h1><p>使用短信验证码安全登录</p><label>手机号码<input value={phone} onChange={event => setPhone(event.target.value.replace(/\D/g, '').slice(0, 11))} inputMode="numeric" placeholder="请输入 11 位手机号码" autoFocus/></label><label>短信验证码<div className="answer-v2-code-row"><input value={code} onChange={event => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} inputMode="numeric" placeholder="请输入6位验证码"/><button type="button" onClick={sendCode} disabled={sending || cooldown > 0}>{cooldown ? `${cooldown}s后重发` : sending ? '发送中…' : '获取验证码'}</button></div></label>{devCode && <small className="answer-v2-dev-code">开发环境验证码:{devCode}</small>}{error && <div className="answer-v2-error">{error}</div>}<button disabled={busy}>{busy ? '登录中…' : '登录并进入任务中心'}</button></form></section></main> return <main className="answer-v2-shell"><section className="answer-v2-app login-only"><header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div></header><form className="answer-v2-login" onSubmit={submit}><span><MessageCircle size={28}/></span><h1>手机号登录</h1><p>使用短信验证码安全登录</p><label>手机号码<input value={phone} onChange={event => setPhone(event.target.value.replace(/\D/g, '').slice(0, 11))} inputMode="numeric" placeholder="请输入 11 位手机号码" autoFocus/></label><label>短信验证码<div className="answer-v2-code-row"><input value={code} onChange={event => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} inputMode="numeric" placeholder="请输入6位验证码"/><button type="button" onClick={sendCode} disabled={sending || cooldown > 0}>{cooldown ? `${cooldown}s后重发` : sending ? '发送中…' : '获取验证码'}</button></div></label>{devCode && <small className="answer-v2-dev-code">开发环境验证码:{devCode}</small>}{error && <div className="answer-v2-error">{error}</div>}<button disabled={busy}>{busy ? '登录中…' : '登录并进入任务中心'}</button></form></section></main>
} }
function UploadStage({ api, session, onUploaded, setError }) { function UploadStage({ api, session, onUploaded, setError, linkToken }) {
const [files, setFiles] = useState([]); const [busy, setBusy] = useState(false); const inputRef = useRef(null) const [files, setFiles] = useState([]); const [busy, setBusy] = useState(false); const inputRef = useRef(null)
const previews = useMemo(() => files.map(file => ({ file, url: URL.createObjectURL(file) })), [files]) const previews = useMemo(() => files.map(file => ({ file, url: URL.createObjectURL(file) })), [files])
useEffect(() => () => previews.forEach(item => URL.revokeObjectURL(item.url)), [previews]) useEffect(() => () => previews.forEach(item => URL.revokeObjectURL(item.url)), [previews])
@ -48,6 +48,7 @@ function UploadStage({ api, session, onUploaded, setError }) {
async function submit() { async function submit() {
if (!files.length) return setError('请至少上传一张被邀请回答截图') if (!files.length) return setError('请至少上传一张被邀请回答截图')
setBusy(true); setError(''); const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file)) setBusy(true); setError(''); const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file))
if (linkToken) body.append('owner_token', linkToken)
try { await api('/api/answer/me/submissions', { method: 'POST', body }); await onUploaded() } catch (err) { setError(err.message) } finally { setBusy(false) } try { await api('/api/answer/me/submissions', { method: 'POST', body }); await onUploaded() } catch (err) { setError(err.message) } finally { setBusy(false) }
} }
return <><section className="answer-v2-hero purple"><small>✨ 任务认领与核验</small><h1>上传被邀请回答截图</h1><p>提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。</p></section><section className="answer-v2-card upload-card"><header><div><h2>上传被邀请回答截图 <em>截图 · 必传</em></h2><p>用于确认您的账号可回答指定问题(支持上传多张)。</p></div><b>{files.length}/10</b></header><div className="answer-v2-upload-grid">{previews.map(({ file, url }) => <figure key={fileKey(file)}><img src={url} alt={file.name}/><figcaption>{file.name}</figcaption><button onClick={() => setFiles(current => current.filter(row => fileKey(row) !== fileKey(file)))}>删除</button></figure>)}{files.length < 10 && <button type="button" className="answer-v2-add" onClick={() => inputRef.current?.click()}><ImagePlus size={28}/><strong>+ 上传被邀截图</strong><small>支持 JPG、PNG、WEBP</small></button>}</div><input ref={inputRef} hidden type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => addFiles(event.target.files)}/><p className="answer-v2-photo-note">▧ 请确保凭证清晰、信息完整、无遮挡</p></section><button className="answer-v2-primary wide" disabled={busy || !files.length} onClick={submit}>{busy ? '正在提交…' : '提交截图并进入审核'}</button></> return <><section className="answer-v2-hero purple"><small>✨ 任务认领与核验</small><h1>上传被邀请回答截图</h1><p>提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。</p></section><section className="answer-v2-card upload-card"><header><div><h2>上传被邀请回答截图 <em>截图 · 必传</em></h2><p>用于确认您的账号可回答指定问题(支持上传多张)。</p></div><b>{files.length}/10</b></header><div className="answer-v2-upload-grid">{previews.map(({ file, url }) => <figure key={fileKey(file)}><img src={url} alt={file.name}/><figcaption>{file.name}</figcaption><button onClick={() => setFiles(current => current.filter(row => fileKey(row) !== fileKey(file)))}>删除</button></figure>)}{files.length < 10 && <button type="button" className="answer-v2-add" onClick={() => inputRef.current?.click()}><ImagePlus size={28}/><strong>+ 上传被邀截图</strong><small>支持 JPG、PNG、WEBP</small></button>}</div><input ref={inputRef} hidden type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => addFiles(event.target.files)}/><p className="answer-v2-photo-note">▧ 请确保凭证清晰、信息完整、无遮挡</p></section><button className="answer-v2-primary wide" disabled={busy || !files.length} onClick={submit}>{busy ? '正在提交…' : '提交截图并进入审核'}</button></>
@ -62,7 +63,7 @@ function ReviewStage({ latest, reload, busy, startNew }) {
return <><section className="answer-v2-card status-card"><div className="status-title"><span><Clock3 size={24}/></span><div><h1>已进入任务派发审核 <em>截图审核中</em></h1><p>截图已提交,任务派发审核界面已创建。运营正在核验截图并整理可回答问题,识别完成后会自动显示在后台。</p></div></div><div className="status-meta"><span>提交时间<b>{formatTime(latest.created_at)}</b></span><span>当前状态<b>等待运营审核</b></span></div><footer><small>页面每 10 秒自动检查最新审核结果</small><button onClick={() => reload()} disabled={busy}><RefreshCw size={14} className={busy ? 'spin' : ''}/>{busy ? '刷新中…' : '刷新状态'}</button></footer></section><MaterialCard latest={latest}/><section className="answer-v2-wait"><ShieldCheck size={17}/><div><strong>等待运营确认并派发问题</strong><p>截图已进入审核队列,无需停留等待识别完成;问题整理好后会自动开放任务。</p></div></section><section className="answer-v2-new-task answer-v2-new-task-light"><strong>还可以继续提交新的任务</strong><p>当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。</p><button onClick={startNew}>+ 开始新的任务</button></section></> return <><section className="answer-v2-card status-card"><div className="status-title"><span><Clock3 size={24}/></span><div><h1>已进入任务派发审核 <em>截图审核中</em></h1><p>截图已提交,任务派发审核界面已创建。运营正在核验截图并整理可回答问题,识别完成后会自动显示在后台。</p></div></div><div className="status-meta"><span>提交时间<b>{formatTime(latest.created_at)}</b></span><span>当前状态<b>等待运营审核</b></span></div><footer><small>页面每 10 秒自动检查最新审核结果</small><button onClick={() => reload()} disabled={busy}><RefreshCw size={14} className={busy ? 'spin' : ''}/>{busy ? '刷新中…' : '刷新状态'}</button></footer></section><MaterialCard latest={latest}/><section className="answer-v2-wait"><ShieldCheck size={17}/><div><strong>等待运营确认并派发问题</strong><p>截图已进入审核队列,无需停留等待识别完成;问题整理好后会自动开放任务。</p></div></section><section className="answer-v2-new-task answer-v2-new-task-light"><strong>还可以继续提交新的任务</strong><p>当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。</p><button onClick={startNew}>+ 开始新的任务</button></section></>
} }
function AssignmentStage({ api, session, latest, reload, setError }) { function AssignmentStage({ api, session, latest, reload, setError, linkToken }) {
const selected = latest.questions.filter(question => question.selected); const progressKey = `answer_progress_${latest.id}` const selected = latest.questions.filter(question => question.selected); const progressKey = `answer_progress_${latest.id}`
const [done, setDone] = useState(() => { try { return JSON.parse(localStorage.getItem(progressKey) || '[]') } catch { return [] } }) const [done, setDone] = useState(() => { try { return JSON.parse(localStorage.getItem(progressKey) || '[]') } catch { return [] } })
const [tab, setTab] = useState('list'); const [files, setFiles] = useState([]); const [payFiles, setPayFiles] = useState([]); const [busy, setBusy] = useState(false) const [tab, setTab] = useState('list'); const [files, setFiles] = useState([]); const [payFiles, setPayFiles] = useState([]); const [busy, setBusy] = useState(false)
@ -77,7 +78,7 @@ function AssignmentStage({ api, session, latest, reload, setError }) {
async function copy(question) { try { await navigator.clipboard.writeText(question.answer || ''); if (!done.includes(question.id)) saveDone([...done, question.id]) } catch { setError('复制失败,请长按参考回答手动复制') } } async function copy(question) { try { await navigator.clipboard.writeText(question.answer || ''); if (!done.includes(question.id)) saveDone([...done, question.id]) } catch { setError('复制失败,请长按参考回答手动复制') } }
async function complete(event) { async function complete(event) {
event.preventDefault(); setError(''); if (!files.length) return setError('请至少上传一张完成回答截图'); if (!payFiles.length) return setError('请上传收款二维码后再提交') event.preventDefault(); setError(''); if (!files.length) return setError('请至少上传一张完成回答截图'); if (!payFiles.length) return setError('请上传收款二维码后再提交')
const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file)); payFiles.forEach(file => body.append('payment_files', file)); setBusy(true) const body = new FormData(); body.append('token', session.token); if (linkToken) body.append('owner_token', linkToken); files.forEach(file => body.append('files', file)); payFiles.forEach(file => body.append('payment_files', file)); setBusy(true)
try { const result = await api(`/api/answer/me/submissions/${latest.id}/completion`, { method: 'POST', body }); await reload(result.data) } catch (err) { setError(err.message) } finally { setBusy(false) } try { const result = await api(`/api/answer/me/submissions/${latest.id}/completion`, { method: 'POST', body }); await reload(result.data) } catch (err) { setError(err.message) } finally { setBusy(false) }
} }
return <><section className="answer-v2-hero green"><small>✓ 任务已派发 · 完成任务阶段</small><h1>完成问答并上传任务凭证</h1><p>请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。</p></section><div className="answer-v2-inner-tabs"><button className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>▣ 待回答清单 <small>{completed}/{selected.length}</small></button><button className={tab === 'upload' ? 'active' : ''} onClick={() => setTab('upload')}>✣ 任务上传(截图与收款码)</button></div>{tab === 'list' ? <section className="answer-v2-card assignment-list"><header><div><h2>待回答问题清单</h2><p>点击复制参考回答即可前往淘宝粘贴提交</p></div><span>回答进度 <b>{completed}/{selected.length}</b><i><u style={{width:`${selected.length ? completed / selected.length * 100 : 0}%`}}/></i></span></header><div className="answer-v2-list-tip">完成所有问题后,进入“任务上传” <button onClick={() => saveDone(selected.map(q => q.id))}>一键全部标记</button></div>{selected.map((question, index) => <article key={question.id} className={done.includes(question.id) ? 'complete' : ''}><header><b>{String(index + 1).padStart(2, '0')}</b><h3>{question.question}</h3><button aria-label="标记完成" onClick={() => mark(question.id)}>{done.includes(question.id) ? <Check size={15}/> : ''}</button></header><small>#{question.product_title || '商品问题'}</small>{question.operator_images?.length > 0 && <div className="answer-question-operator-image"><span>运营指定图片(请在回答本题时添加)</span><div>{question.operator_images.map((image, imageIndex) => <img key={image.id || image.data_url || image} src={image.data_url || image.url || image} alt={`运营指定回答图片${imageIndex + 1}`}/>)}</div></div>}<div><strong>▱ 参考回答:</strong><p>{question.answer}</p></div><footer><span>{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}</span><button onClick={() => copy(question)}><Copy size={15}/>复制回答</button></footer></article>)}<button className="answer-v2-primary wide" disabled={!selected.length} onClick={() => setTab('upload')}>完成回答,进入任务上传</button></section> : <form className="answer-v2-card completion-form" onSubmit={complete}><h2>上传任务完成凭证</h2><p>请上传已发布回答的截图;收款二维码为必传材料,用于后续核验。</p><label>完成回答截图(必传)<input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => setFiles(Array.from(event.target.files).slice(0, 10))}/><small>已选择 {files.length} 张</small></label><label>收款二维码(必传)<input type="file" accept="image/jpeg,image/png,image/webp" onChange={event => setPayFiles(Array.from(event.target.files).slice(0, 1))}/><small>已选择 {payFiles.length} 张</small></label><button className="answer-v2-primary wide" disabled={busy || !files.length}>{busy ? '提交中…' : '提交任务凭证'}</button></form>}</> return <><section className="answer-v2-hero green"><small>✓ 任务已派发 · 完成任务阶段</small><h1>完成问答并上传任务凭证</h1><p>请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。</p></section><div className="answer-v2-inner-tabs"><button className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>▣ 待回答清单 <small>{completed}/{selected.length}</small></button><button className={tab === 'upload' ? 'active' : ''} onClick={() => setTab('upload')}>✣ 任务上传(截图与收款码)</button></div>{tab === 'list' ? <section className="answer-v2-card assignment-list"><header><div><h2>待回答问题清单</h2><p>点击复制参考回答即可前往淘宝粘贴提交</p></div><span>回答进度 <b>{completed}/{selected.length}</b><i><u style={{width:`${selected.length ? completed / selected.length * 100 : 0}%`}}/></i></span></header><div className="answer-v2-list-tip">完成所有问题后,进入“任务上传” <button onClick={() => saveDone(selected.map(q => q.id))}>一键全部标记</button></div>{selected.map((question, index) => <article key={question.id} className={done.includes(question.id) ? 'complete' : ''}><header><b>{String(index + 1).padStart(2, '0')}</b><h3>{question.question}</h3><button aria-label="标记完成" onClick={() => mark(question.id)}>{done.includes(question.id) ? <Check size={15}/> : ''}</button></header><small>#{question.product_title || '商品问题'}</small>{question.operator_images?.length > 0 && <div className="answer-question-operator-image"><span>运营指定图片(请在回答本题时添加)</span><div>{question.operator_images.map((image, imageIndex) => <img key={image.id || image.data_url || image} src={image.data_url || image.url || image} alt={`运营指定回答图片${imageIndex + 1}`}/>)}</div></div>}<div><strong>▱ 参考回答:</strong><p>{question.answer}</p></div><footer><span>{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}</span><button onClick={() => copy(question)}><Copy size={15}/>复制回答</button></footer></article>)}<button className="answer-v2-primary wide" disabled={!selected.length} onClick={() => setTab('upload')}>完成回答,进入任务上传</button></section> : <form className="answer-v2-card completion-form" onSubmit={complete}><h2>上传任务完成凭证</h2><p>请上传已发布回答的截图;收款二维码为必传材料,用于后续核验。</p><label>完成回答截图(必传)<input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => setFiles(Array.from(event.target.files).slice(0, 10))}/><small>已选择 {files.length} 张</small></label><label>收款二维码(必传)<input type="file" accept="image/jpeg,image/png,image/webp" onChange={event => setPayFiles(Array.from(event.target.files).slice(0, 1))}/><small>已选择 {payFiles.length} 张</small></label><button className="answer-v2-primary wide" disabled={busy || !files.length}>{busy ? '提交中…' : '提交任务凭证'}</button></form>}</>
@ -124,7 +125,7 @@ function HistoryPage({ records, close, onContinue }) {
return <section className="answer-v2-history"><header><button onClick={close}>← 返回任务</button><h2>任务状态 / 上传历史({records.length})</h2></header>{records.length === 0 ? <div className="answer-v2-card">暂无历史记录</div> : records.map(record => <article className="answer-v2-card" key={record.id}><header><strong>{record.submission_no}</strong><span>{STATUS_TEXT[record.status] || record.status}</span></header><p>{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张</p><div>{record.invited_screenshots.slice(0, 4).map(image => { const src = image.url || image.data_url; return <img key={image.id || src} src={src}/> })}</div>{canContinue(record.status) && <button className="answer-v2-history-continue" onClick={() => onContinue(record)}>↗ 继续此任务</button>}</article>)}</section> return <section className="answer-v2-history"><header><button onClick={close}>← 返回任务</button><h2>任务状态 / 上传历史({records.length})</h2></header>{records.length === 0 ? <div className="answer-v2-card">暂无历史记录</div> : records.map(record => <article className="answer-v2-card" key={record.id}><header><strong>{record.submission_no}</strong><span>{STATUS_TEXT[record.status] || record.status}</span></header><p>{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张</p><div>{record.invited_screenshots.slice(0, 4).map(image => { const src = image.url || image.data_url; return <img key={image.id || src} src={src}/> })}</div>{canContinue(record.status) && <button className="answer-v2-history-continue" onClick={() => onContinue(record)}>↗ 继续此任务</button>}</article>)}</section>
} }
export default function AnswerUserPortalV2({ api }) { export default function AnswerUserPortalV2({ api, linkToken = '' }) {
const [session, setSession] = useState(() => { try { return JSON.parse(localStorage.getItem(SESSION_KEY) || 'null') } catch { return null } }) const [session, setSession] = useState(() => { try { return JSON.parse(localStorage.getItem(SESSION_KEY) || 'null') } catch { return null } })
const [records, setRecords] = useState([]); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const [loaded, setLoaded] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [retrying, setRetrying] = useState(false); const [newTaskMode, setNewTaskMode] = useState(false); const [activeRecordId, setActiveRecordId] = useState(null); const [optimisticStatuses, setOptimisticStatuses] = useState({}) const [records, setRecords] = useState([]); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const [loaded, setLoaded] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [retrying, setRetrying] = useState(false); const [newTaskMode, setNewTaskMode] = useState(false); const [activeRecordId, setActiveRecordId] = useState(null); const [optimisticStatuses, setOptimisticStatuses] = useState({})
const imageLoadIds = useRef(new Set()) const imageLoadIds = useRef(new Set())
@ -134,7 +135,8 @@ export default function AnswerUserPortalV2({ api }) {
const version = ++loadVersion.current const version = ++loadVersion.current
if (!silent) setBusy(true) if (!silent) setBusy(true)
try { try {
const result = await api(`/api/answer/me/submissions?token=${encodeURIComponent(session.token)}&include_images=false`) const ownerQuery = linkToken ? `&owner_token=${encodeURIComponent(linkToken)}` : ''
const result = await api(`/api/answer/me/submissions?token=${encodeURIComponent(session.token)}&include_images=false${ownerQuery}`)
if (version !== loadVersion.current) return if (version !== loadVersion.current) return
setRecords(current => result.data.map(record => { setRecords(current => result.data.map(record => {
const previous = current.find(item => item.id === record.id) const previous = current.find(item => item.id === record.id)
@ -182,7 +184,8 @@ export default function AnswerUserPortalV2({ api }) {
if (imageLoadIds.current.has(loadKey)) return if (imageLoadIds.current.has(loadKey)) return
imageLoadIds.current.add(loadKey) imageLoadIds.current.add(loadKey)
try { try {
const result = await api(`/api/answer/me/submissions/${record.id}?token=${encodeURIComponent(session.token)}`) const ownerQuery = linkToken ? `&owner_token=${encodeURIComponent(linkToken)}` : ''
const result = await api(`/api/answer/me/submissions/${record.id}?token=${encodeURIComponent(session.token)}${ownerQuery}`)
setRecords(current => current.map(item => item.id === record.id ? { ...item, ...result.data, _imagesLoaded: true } : item)) setRecords(current => current.map(item => item.id === record.id ? { ...item, ...result.data, _imagesLoaded: true } : item))
} catch (err) { imageLoadIds.current.delete(loadKey); setError(err.message) } } catch (err) { imageLoadIds.current.delete(loadKey); setError(err.message) }
} }
@ -210,7 +213,7 @@ export default function AnswerUserPortalV2({ api }) {
if (!historyOpen) return if (!historyOpen) return
records.slice(0, 20).forEach(record => loadSubmissionImages(record)) records.slice(0, 20).forEach(record => loadSubmissionImages(record))
}, [historyOpen, records.length]) }, [historyOpen, records.length])
if (!session) return <Login api={api} onLogin={setSession}/> if (!session) return <Login api={api} onLogin={setSession} linkToken={linkToken}/>
return <main className="answer-v2-shell"><section className="answer-v2-app"><Top session={session} latest={visibleLatest} logout={logout}/><div className="answer-v2-tabs"><button className={!historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(false)}><ClipboardCheck size={15}/>任务认领</button><button className={historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(true)}><History size={15}/>任务状态 <small>{visibleLatest ? STATUS_TEXT[visibleLatest.status] || visibleLatest.status : '待上传'}</small></button></div>{!historyOpen && <Progress status={visibleLatest?.status}/>}<div className="answer-v2-body">{error && <div className="answer-v2-error">{error}</div>}{!loaded ? <div className="answer-v2-card answer-v2-loading">正在读取任务状态…</div> : historyOpen ? <HistoryPage records={records} close={() => setHistoryOpen(false)} onContinue={record => { setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false) }}/> : !visibleLatest ? <UploadStage api={api} session={session} onUploaded={async () => { setNewTaskMode(false); setActiveRecordId(null); await load() }} setError={setError}/> : ['待筛选', '待运营派发', '已派发'].includes(visibleLatest.status) ? <ReviewStage latest={visibleLatest} reload={load} busy={busy} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/> : visibleLatest.status === '待用户完成' || (visibleLatest.status === '未通过' && retrying) ? <AssignmentStage api={api} session={session} latest={visibleLatest} reload={async submitted => { if (submitted) { applySubmittedRecord(submitted); setRetrying(false); load(true); return } await load(); setRetrying(false) }} setError={setError}/> : <FinalStage latest={visibleLatest} reload={load} busy={busy} reopen={() => setRetrying(true)} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/>}</div><footer className="answer-v2-footer"><button onClick={() => setHistoryOpen(true)}>上传历史({records.length})</button>{visibleLatest && <button className="primary" onClick={() => load()}><RefreshCw size={14}/>刷新状态</button>}</footer></section></main> return <main className="answer-v2-shell"><section className="answer-v2-app"><Top session={session} latest={visibleLatest} logout={logout}/><div className="answer-v2-tabs"><button className={!historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(false)}><ClipboardCheck size={15}/>任务认领</button><button className={historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(true)}><History size={15}/>任务状态 <small>{visibleLatest ? STATUS_TEXT[visibleLatest.status] || visibleLatest.status : '待上传'}</small></button></div>{!historyOpen && <Progress status={visibleLatest?.status}/>}<div className="answer-v2-body">{error && <div className="answer-v2-error">{error}</div>}{!loaded ? <div className="answer-v2-card answer-v2-loading">正在读取任务状态…</div> : historyOpen ? <HistoryPage records={records} close={() => setHistoryOpen(false)} onContinue={record => { setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false) }}/> : !visibleLatest ? <UploadStage api={api} session={session} linkToken={linkToken} onUploaded={async () => { setNewTaskMode(false); setActiveRecordId(null); await load() }} setError={setError}/> : ['待筛选', '待运营派发', '已派发'].includes(visibleLatest.status) ? <ReviewStage latest={visibleLatest} reload={load} busy={busy} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/> : visibleLatest.status === '待用户完成' || (visibleLatest.status === '未通过' && retrying) ? <AssignmentStage api={api} session={session} latest={visibleLatest} linkToken={linkToken} reload={async submitted => { if (submitted) { applySubmittedRecord(submitted); setRetrying(false); load(true); return } await load(); setRetrying(false) }} setError={setError}/> : <FinalStage latest={visibleLatest} reload={load} busy={busy} reopen={() => setRetrying(true)} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/>}</div><footer className="answer-v2-footer"><button onClick={() => setHistoryOpen(true)}>上传历史({records.length})</button>{visibleLatest && <button className="primary" onClick={() => load()}><RefreshCw size={14}/>刷新状态</button>}</footer></section></main>
} }

View File

@ -1,12 +1,34 @@
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
export default function CreateAnswerTasksPageV2({ api }) { export default function CreateAnswerTasksPageV2({ api, user }) {
const [records, setRecords] = useState([]) const [records, setRecords] = useState([])
const [expanded, setExpanded] = useState({}) const [expanded, setExpanded] = useState({})
const [busy, setBusy] = useState('') const [busy, setBusy] = useState('')
const [detailBusy, setDetailBusy] = useState('') const [detailBusy, setDetailBusy] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [rejecting, setRejecting] = useState(null) const [rejecting, setRejecting] = useState(null)
const [shopNames, setShopNames] = useState({})
const [answerLink, setAnswerLink] = useState('')
const [linkCopied, setLinkCopied] = useState(false)
useEffect(() => {
let active = true
api('/api/admin/auth/answer-link').then(result => {
if (active && result.data?.path) setAnswerLink(new URL(result.data.path, window.location.origin).toString())
}).catch(() => {})
return () => { active = false }
}, [api, user?.id])
async function copyAnswerLink() {
if (!answerLink) return
try {
await navigator.clipboard.writeText(answerLink)
setLinkCopied(true)
window.setTimeout(() => setLinkCopied(false), 1600)
} catch {
setError('复制失败,请手动复制专属链接')
}
}
async function load() { async function load() {
try { try {
@ -25,6 +47,13 @@ export default function CreateAnswerTasksPageV2({ api }) {
})), })),
} }
})) }))
setShopNames(current => {
const next = { ...current }
result.data.forEach(record => {
if (next[record.id] === undefined) next[record.id] = record.shop_name || ''
})
return next
})
setExpanded(current => { setExpanded(current => {
const next = { ...current } const next = { ...current }
result.data.forEach((record, index) => { result.data.forEach((record, index) => {
@ -104,6 +133,12 @@ export default function CreateAnswerTasksPageV2({ api }) {
} }
async function publish(record) { async function publish(record) {
const shopName = String(shopNames[record.id] ?? record.shop_name ?? '').trim()
if (!shopName) {
setError(`请先为用户 ${record.user_id} 填写店铺名`)
setExpanded(current => ({ ...current, [record.id]: true }))
return
}
if (!record.questions.some(question => question.selected)) { if (!record.questions.some(question => question.selected)) {
setError(`请先在用户 ${record.user_id} 下选择至少一个问题`) setError(`请先在用户 ${record.user_id} 下选择至少一个问题`)
setExpanded(current => ({ ...current, [record.id]: true })) setExpanded(current => ({ ...current, [record.id]: true }))
@ -112,7 +147,10 @@ export default function CreateAnswerTasksPageV2({ api }) {
setBusy(record.id) setBusy(record.id)
setError('') setError('')
try { try {
await api(`/api/admin/answer-task-drafts/${record.id}/publish`, { method: 'POST' }) await api(`/api/admin/answer-task-drafts/${record.id}/publish`, {
method: 'POST',
body: JSON.stringify({ shop_name: shopName }),
})
await load() await load()
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -161,6 +199,7 @@ export default function CreateAnswerTasksPageV2({ api }) {
<div className="create-mode-tabs"><button onClick={() => window.dispatchEvent(new CustomEvent('switch-create-ask'))}>创建提问任务</button><button className="active">创建回答任务</button></div> <div className="create-mode-tabs"><button onClick={() => window.dispatchEvent(new CustomEvent('switch-create-ask'))}>创建提问任务</button><button className="active">创建回答任务</button></div>
</div> </div>
<div className="create-guide"><b>ⓘ 回答截图审核</b><span>用户提交截图后会立即进入此审核队列。按「用户ID」分组核对识别的问题,勾选需要派发的问题,回答可重新生成或直接编辑,确认后生成任务。</span></div> <div className="create-guide"><b>ⓘ 回答截图审核</b><span>用户提交截图后会立即进入此审核队列。按「用户ID」分组核对识别的问题,勾选需要派发的问题,回答可重新生成或直接编辑,确认后生成任务。</span></div>
{answerLink && <div className="answer-link-bar"><div><strong>我的回答任务专属链接</strong><span>长期固定,用户通过此链接提交的截图只会进入您的审核队列</span></div><input readOnly value={answerLink} onFocus={event => event.target.select()} /><button type="button" onClick={copyAnswerLink}>{linkCopied ? '已复制' : '复制链接'}</button></div>}
{error && <div className="form-error create-task-error">{error}</div>} {error && <div className="form-error create-task-error">{error}</div>}
<div className="create-select-all"> <div className="create-select-all">
<label><input className="task-checkbox" type="checkbox" checked={editableQuestions.length > 0 && selectedCount === editableQuestions.length} disabled={!editableQuestions.length} onChange={event => toggleMany(editableQuestions, event.target.checked)}/><span>全选所有问题</span></label> <label><input className="task-checkbox" type="checkbox" checked={editableQuestions.length > 0 && selectedCount === editableQuestions.length} disabled={!editableQuestions.length} onChange={event => toggleMany(editableQuestions, event.target.checked)}/><span>全选所有问题</span></label>
@ -179,6 +218,7 @@ export default function CreateAnswerTasksPageV2({ api }) {
<button className="draft-expand" disabled={identifying || detailBusy === record.id} onClick={() => toggleOpen(record)}>{open ? '⌄' : '›'}</button> <button className="draft-expand" disabled={identifying || detailBusy === record.id} onClick={() => toggleOpen(record)}>{open ? '⌄' : '›'}</button>
<label className="draft-group-select"><input className="task-checkbox" type="checkbox" checked={allSelected} disabled={locked || identifying} onChange={event => toggleMany(record.questions.map(question => ({ ...question, locked })), event.target.checked)}/></label> <label className="draft-group-select"><input className="task-checkbox" type="checkbox" checked={allSelected} disabled={locked || identifying} onChange={event => toggleMany(record.questions.map(question => ({ ...question, locked })), event.target.checked)}/></label>
<div className="draft-user-meta"><strong>{record.user_id}</strong><span>用户_{record.user_id.slice(1)}</span><code>{record.submission_no}</code><em>已选 {selected}/{record.questions.length} 题</em></div> <div className="draft-user-meta"><strong>{record.user_id}</strong><span>用户_{record.user_id.slice(1)}</span><code>{record.submission_no}</code><em>已选 {selected}/{record.questions.length} 题</em></div>
<label className="draft-shop-field"><span>店铺名<span className="required-mark">*</span></span><input value={shopNames[record.id] ?? record.shop_name ?? ''} disabled={locked || identifying} onChange={event => setShopNames(current => ({ ...current, [record.id]: event.target.value }))} placeholder="请输入店铺名" maxLength={255}/></label>
{identifying && <span className="draft-identifying" role="status">正在识别,请等待…</span>} {identifying && <span className="draft-identifying" role="status">正在识别,请等待…</span>}
<button className="draft-fold" disabled={identifying || detailBusy === record.id} onClick={() => toggleOpen(record)}>{detailBusy === record.id ? '正在加载图片…' : open ? '点击折叠' : '点击展开'}</button> <button className="draft-fold" disabled={identifying || detailBusy === record.id} onClick={() => toggleOpen(record)}>{detailBusy === record.id ? '正在加载图片…' : open ? '点击折叠' : '点击展开'}</button>
<button className="primary-button" disabled={!!busy || locked || identifying} onClick={() => publish(record)}>{locked ? '已派发' : identifying ? '识别中…' : '生成任务'}</button> <button className="primary-button" disabled={!!busy || locked || identifying} onClick={() => publish(record)}>{locked ? '已派发' : identifying ? '识别中…' : '生成任务'}</button>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
export default function RefundUploadPage({ api }) { export default function RefundUploadPage({ api }) {
const token = window.location.pathname.split("/").filter(Boolean).pop() || ""; const token = window.location.pathname.split("/").filter(Boolean).pop() || "";
@ -7,6 +7,8 @@ export default function RefundUploadPage({ api }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const cameraInputRef = useRef(null);
const fileInputRef = useRef(null);
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
@ -42,6 +44,13 @@ export default function RefundUploadPage({ api }) {
} }
} }
function chooseFile(event) {
setError("");
setFile(event.target.files?.[0] || null);
// Allow choosing the same file again after replacing/removing it.
event.target.value = "";
}
return ( return (
<main className="refund-upload-page"> <main className="refund-upload-page">
<section className="refund-upload-card"> <section className="refund-upload-card">
@ -51,16 +60,35 @@ export default function RefundUploadPage({ api }) {
{error ? <div className="refund-upload-error">{error}</div> : null} {error ? <div className="refund-upload-error">{error}</div> : null}
{session && !done ? ( {session && !done ? (
<form onSubmit={upload}> <form onSubmit={upload}>
<label className="refund-upload-picker"> <div className="refund-upload-picker">
<span>选择或拍摄支付截图</span> <span>请选择返款支付截图</span>
<div className="refund-upload-options">
<button type="button" className="refund-upload-option" onClick={() => cameraInputRef.current?.click()}>
<strong>拍摄上传</strong>
<small>调用手机摄像头</small>
</button>
<button type="button" className="refund-upload-option" onClick={() => fileInputRef.current?.click()}>
<strong>相册/文件选择</strong>
<small>选择本地图片</small>
</button>
</div>
<input <input
ref={cameraInputRef}
className="refund-upload-hidden-input"
type="file" type="file"
accept="image/jpeg,image/png,image/webp" accept="image/jpeg,image/png,image/webp"
capture="environment" capture="environment"
onChange={(event) => setFile(event.target.files?.[0] || null)} onChange={chooseFile}
/>
<input
ref={fileInputRef}
className="refund-upload-hidden-input"
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={chooseFile}
/> />
<small>{file ? file.name : "支持 JPG、PNG、WEBP,最大 10MB"}</small> <small>{file ? file.name : "支持 JPG、PNG、WEBP,最大 10MB"}</small>
</label> </div>
<button type="submit" disabled={busy || !file}> <button type="submit" disabled={busy || !file}>
{busy ? "上传中…" : "上传返款凭证"} {busy ? "上传中…" : "上传返款凭证"}
</button> </button>

View File

@ -161,8 +161,8 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
</button> </button>
</div> </div>
)} )}
<div className={`task-detail-meta${isAskTask ? " ask-task-meta" : ""}`}> <div className={`task-detail-meta${isAskTask ? " ask-task-meta" : task.shop_name ? " shop-task-meta" : ""}`}>
{isAskTask && <span> {task.shop_name && <span>
店铺名称<b>{task.shop_name || "--"}</b> 店铺名称<b>{task.shop_name || "--"}</b>
</span>} </span>}
<span> <span>
@ -701,7 +701,7 @@ export default function TaskBoardPage({ api }) {
<span className="board-select-cell"> <span className="board-select-cell">
<input type="checkbox" checked={selectedIds.includes(key)} onChange={() => toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} /> <input type="checkbox" checked={selectedIds.includes(key)} onChange={() => toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} />
</span> </span>
<span className="board-user-cell"> <span className={`board-user-cell${!isAskTask(task) ? " board-answer-user" : ""}`}>
{children.length > 0 && ( {children.length > 0 && (
<button <button
type="button" type="button"
@ -713,11 +713,12 @@ export default function TaskBoardPage({ api }) {
</button> </button>
)} )}
<code>{task.user_id}</code> <code>{task.user_id}</code>
{!isAskTask(task) && <small>{maskPhone(task.phone)}</small>}
</span> </span>
<span><b>{task.task_id}</b></span> <span><b>{task.task_id}</b></span>
<span> <span>
<strong>{isAskTask(task) ? task.shop_name || "--" : task.product_name}</strong> <strong>{isAskTask(task) ? task.shop_name || "--" : task.product_name}</strong>
<small>{isAskTask(task) ? task.product_name : maskPhone(task.phone)}</small> <small>{isAskTask(task) ? task.product_name : task.shop_name || "--"}</small>
</span> </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>

View File

@ -1588,6 +1588,48 @@
color: #b96b08; color: #b96b08;
font-size: 12px; font-size: 12px;
} }
.answer-link-bar {
display: grid;
grid-template-columns: minmax(220px, 0.8fr) minmax(260px, 1.5fr) auto;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 12px 16px;
border: 1px solid #cfe0f7;
border-radius: 12px;
background: #f7fbff;
}
.answer-link-bar > div {
display: grid;
gap: 3px;
}
.answer-link-bar strong {
color: #173258;
font-size: 13px;
}
.answer-link-bar span {
color: #7187a1;
font-size: 11px;
}
.answer-link-bar input {
min-width: 0;
height: 36px;
padding: 0 11px;
border: 1px solid #c8d8ec;
border-radius: 8px;
background: #fff;
color: #31557e;
font-size: 12px;
}
.answer-link-bar button {
height: 36px;
padding: 0 15px;
border: 1px solid #2864e8;
border-radius: 8px;
background: #2864e8;
color: #fff;
white-space: nowrap;
}
.create-task-error { .create-task-error {
margin-bottom: 14px; margin-bottom: 14px;
} }
@ -1642,6 +1684,7 @@
} }
.draft-group-header { .draft-group-header {
gap: 8px !important; gap: 8px !important;
flex-wrap: wrap;
} }
.draft-expand, .draft-expand,
.draft-fold { .draft-fold {
@ -1667,6 +1710,39 @@
.draft-user-meta em { .draft-user-meta em {
font-style: normal; font-style: normal;
} }
.draft-shop-field {
display: grid !important;
grid-template-columns: auto minmax(150px, 220px);
align-items: center;
gap: 6px;
margin-left: auto;
color: #617895;
font-size: 11px;
white-space: nowrap;
}
.draft-shop-field input {
width: 100%;
min-width: 0;
padding: 7px 9px;
border: 1px solid #cddceb;
border-radius: 7px;
background: #fff;
color: #193c69;
font-size: 12px;
outline: none;
}
.draft-shop-field input:focus {
border-color: #2864e8;
box-shadow: 0 0 0 2px rgba(40, 100, 232, .12);
}
.draft-shop-field input:disabled {
background: #f1f5f9;
color: #7890aa;
}
.required-mark {
margin-left: 2px;
color: #e25061;
}
.draft-identifying { .draft-identifying {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -1809,6 +1885,11 @@
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.draft-shop-field {
width: 100%;
margin-left: 0;
grid-template-columns: 52px minmax(0, 1fr);
}
.draft-group-header { .draft-group-header {
align-items: flex-start !important; align-items: flex-start !important;
} }
@ -1822,6 +1903,12 @@
.source-image-wrap { .source-image-wrap {
margin-left: 0; margin-left: 0;
} }
.answer-link-bar {
grid-template-columns: 1fr;
}
.answer-link-bar button {
width: 100%;
}
} }
/* 公共回答用户中心 V2 */ /* 公共回答用户中心 V2 */
@ -2030,10 +2117,18 @@
} }
.board-user-cell { .board-user-cell {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
} }
.board-user-cell small {
flex-basis: 100%;
margin-left: 26px;
}
.board-answer-user small {
margin-left: 0;
}
.board-expand-button { .board-expand-button {
width: 20px; width: 20px;
height: 20px; height: 20px;
@ -2300,6 +2395,9 @@
.task-detail-meta.ask-task-meta { .task-detail-meta.ask-task-meta {
grid-template-columns: 1.25fr 1.5fr 0.9fr 0.9fr 1.1fr; grid-template-columns: 1.25fr 1.5fr 0.9fr 0.9fr 1.1fr;
} }
.task-detail-meta.shop-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;
@ -2628,8 +2726,13 @@
.refund-upload-card h1 { margin: 18px 0 8px; color: #142d4d; font-size: 23px; } .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-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-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 { display: grid; gap: 12px; padding: 22px 15px; border: 1px dashed #a9c5e8; border-radius: 12px; color: #1c5fc7; }
.refund-upload-picker input { width: 100%; } .refund-upload-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.refund-upload-option { display: grid; gap: 5px; padding: 13px 8px; border: 1px solid #c8dcf6; border-radius: 9px; background: #f6faff; color: #1764e8; cursor: pointer; }
.refund-upload-option strong { font-size: 14px; }
.refund-upload-option small { color: #7187a3; font-size: 12px; }
.refund-upload-option:hover { border-color: #1764e8; background: #eef5ff; }
.refund-upload-hidden-input { display: none; }
.refund-upload-picker small { color: #7b91aa; font-size: 12px; } .refund-upload-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 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-card button:disabled { cursor: wait; opacity: .55; }
@ -2691,6 +2794,9 @@
.task-detail-meta.ask-task-meta { .task-detail-meta.ask-task-meta {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.task-detail-meta.shop-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;

View File

@ -2400,7 +2400,9 @@ function AdminPage({ type }) {
} }
function AnswerTaskPage() { function AnswerTaskPage() {
return <AnswerUserPortalV2 api={api} />; const segments = window.location.pathname.split('/').filter(Boolean)
const linkToken = segments[0] === 'answer' ? (segments[1] || '') : ''
return <AnswerUserPortalV2 api={api} linkToken={linkToken} />;
/* Legacy task-specific flow retained temporarily for migration. */ /* Legacy task-specific flow retained temporarily for migration. */
useEffect(() => { useEffect(() => {
const button = document.createElement("button"); const button = document.createElement("button");
@ -3914,7 +3916,7 @@ function Dashboard({ user, onLogout }) {
createMode === "ask" ? ( createMode === "ask" ? (
<CreateAskTasksPage api={api} librarySelection={librarySelection} onLibrarySelectionConsumed={() => setLibrarySelection([])} /> <CreateAskTasksPage api={api} librarySelection={librarySelection} onLibrarySelectionConsumed={() => setLibrarySelection([])} />
) : ( ) : (
<CreateAnswerTasksPageV2 api={api} /> <CreateAnswerTasksPageV2 api={api} user={user} />
) )
) : active === "board" ? ( ) : active === "board" ? (
<TaskBoardPage api={api} /> <TaskBoardPage api={api} />