返款凭证上传修复,新增回答任务专属链接
This commit is contained in:
parent
3f1da0e159
commit
66aca010f1
@ -15,7 +15,7 @@ import io
|
||||
import zipfile
|
||||
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 sqlalchemy import func, inspect, or_, select, text
|
||||
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_name": user.dingtalk_dept_name,
|
||||
"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_title": "VARCHAR(255) NULL",
|
||||
"is_dept_manager": "BOOLEAN NOT NULL DEFAULT 0",
|
||||
"answer_public_token": "VARCHAR(96) NULL",
|
||||
}
|
||||
for column_name, column_type in admin_user_migrations.items():
|
||||
if column_name not in admin_user_columns:
|
||||
@ -259,6 +261,9 @@ def startup():
|
||||
if "owner_id" not in answer_submission_columns:
|
||||
with engine.begin() as connection:
|
||||
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")}
|
||||
if "operator_image_screenshot_id" not in question_columns:
|
||||
with engine.begin() as connection:
|
||||
@ -280,6 +285,31 @@ def startup():
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE UNIQUE INDEX ix_ask_tasks_public_token ON ask_tasks (public_token)"))
|
||||
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:
|
||||
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:
|
||||
return True
|
||||
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:
|
||||
return False
|
||||
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":
|
||||
return True
|
||||
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(
|
||||
or_(
|
||||
AdminUser.id == user.id,
|
||||
@ -776,6 +811,28 @@ def _new_ask_public_token(db: Session) -> str:
|
||||
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:
|
||||
statement = select(AskTask).where(
|
||||
# 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,
|
||||
"user_id": record.user.public_id,
|
||||
"phone": record.user.phone,
|
||||
"shop_name": record.shop_name or "",
|
||||
"status": record.status,
|
||||
"created_at": record.created_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,
|
||||
"user_id": record.user.public_id,
|
||||
"phone": record.user.phone,
|
||||
"shop_name": record.shop_name or "",
|
||||
"status": record.status,
|
||||
"created_at": record.created_at.isoformat(),
|
||||
"updated_at": record.updated_at.isoformat(),
|
||||
@ -1007,10 +1066,11 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d
|
||||
**summary,
|
||||
"board_key": f"answer-{record.id}", "record_kind": "answer_submission",
|
||||
"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,
|
||||
"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,
|
||||
"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,
|
||||
@ -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")
|
||||
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")
|
||||
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)}
|
||||
|
||||
|
||||
@ -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)):
|
||||
"""Return lightweight sidebar counts without loading review rows or images."""
|
||||
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
|
||||
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))
|
||||
@ -1547,18 +1635,24 @@ def ask_completion_decision(assignment_id: int, payload: dict, user: AdminUser =
|
||||
|
||||
|
||||
@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)
|
||||
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]}
|
||||
|
||||
|
||||
@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)
|
||||
record = db.get(AnswerSubmission, submission_id)
|
||||
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="任务不存在")
|
||||
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(
|
||||
background_tasks: BackgroundTasks,
|
||||
token: str = Form(...),
|
||||
owner_token: str = Form(default=""),
|
||||
files: list[UploadFile] = File(...),
|
||||
db: Session = Depends(get_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
|
||||
db.rollback()
|
||||
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 图片")
|
||||
uploaded.append((upload.filename or "screenshot", upload.content_type, await upload.read()))
|
||||
# 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()
|
||||
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))
|
||||
@ -1672,15 +1768,15 @@ async def answer_create_submission(
|
||||
|
||||
|
||||
@app.get("/api/admin/answer-task-drafts")
|
||||
def admin_answer_task_drafts(_: 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()
|
||||
def admin_answer_task_drafts(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||
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]}
|
||||
|
||||
|
||||
@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)
|
||||
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="回答任务不存在")
|
||||
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(
|
||||
question_id: int,
|
||||
files: list[UploadFile] = File(...),
|
||||
_: AdminUser = Depends(current_user),
|
||||
user: AdminUser = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Attach up to three optional operation-specified images to one question."""
|
||||
question = db.get(AnswerRecognizedQuestion, question_id)
|
||||
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="回答问题不存在或已经发布")
|
||||
if not files or len(files) > 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))
|
||||
question = db.get(AnswerRecognizedQuestion, question_id_value)
|
||||
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="回答问题不存在或已经发布")
|
||||
old_ids = [value for value in (question.operator_image_screenshot_ids or "").split(",") if value.strip().isdigit()]
|
||||
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")
|
||||
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)
|
||||
if not record:
|
||||
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="当前任务不能打回")
|
||||
@ -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}")
|
||||
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)
|
||||
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"):
|
||||
product_title, question_text = question.product_title, question.question_text
|
||||
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()
|
||||
answer_text = generate_answer_for_question(product_title, question_text)
|
||||
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="问题不存在")
|
||||
if selected is not None:
|
||||
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")
|
||||
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)
|
||||
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 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:
|
||||
record.owner_id = user.id
|
||||
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")
|
||||
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)
|
||||
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
|
||||
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张截图")
|
||||
@ -2268,6 +2375,8 @@ async def answer_completion(submission_id: int, token: str = Form(...), files: l
|
||||
record = db.get(AnswerSubmission, submission_id)
|
||||
if not record or record.user_id != user.id or record.status != record_status:
|
||||
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 == "未通过":
|
||||
for old_screenshot in list(record.screenshots):
|
||||
if old_screenshot.stage in {"completion", "payment"}:
|
||||
|
||||
@ -23,6 +23,10 @@ class AdminUser(Base):
|
||||
dingtalk_dept_path: Mapped[str | None] = mapped_column(String(1000), 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)
|
||||
# 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)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
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)
|
||||
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)
|
||||
# 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)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
@ -7,12 +7,14 @@ CREATE TABLE IF NOT EXISTS admin_users (
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(64) NOT NULL,
|
||||
role_code VARCHAR(32) NOT NULL DEFAULT 'operator',
|
||||
answer_public_token VARCHAR(96) NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
last_login_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
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_status (is_active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
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('')
|
||||
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() {
|
||||
if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码')
|
||||
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) }
|
||||
}
|
||||
async function submit(event) {
|
||||
@ -34,13 +34,13 @@ function Login({ api, onLogin }) {
|
||||
if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码')
|
||||
if (!/^\d{6}$/.test(code)) return setError('请输入6位验证码')
|
||||
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) }
|
||||
}
|
||||
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 previews = useMemo(() => files.map(file => ({ file, url: URL.createObjectURL(file) })), [files])
|
||||
useEffect(() => () => previews.forEach(item => URL.revokeObjectURL(item.url)), [previews])
|
||||
@ -48,6 +48,7 @@ function UploadStage({ api, session, onUploaded, setError }) {
|
||||
async function submit() {
|
||||
if (!files.length) return setError('请至少上传一张被邀请回答截图')
|
||||
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) }
|
||||
}
|
||||
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></>
|
||||
}
|
||||
|
||||
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 [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)
|
||||
@ -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 complete(event) {
|
||||
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) }
|
||||
}
|
||||
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>
|
||||
}
|
||||
|
||||
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 [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())
|
||||
@ -134,7 +135,8 @@ export default function AnswerUserPortalV2({ api }) {
|
||||
const version = ++loadVersion.current
|
||||
if (!silent) setBusy(true)
|
||||
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
|
||||
setRecords(current => result.data.map(record => {
|
||||
const previous = current.find(item => item.id === record.id)
|
||||
@ -182,7 +184,8 @@ export default function AnswerUserPortalV2({ api }) {
|
||||
if (imageLoadIds.current.has(loadKey)) return
|
||||
imageLoadIds.current.add(loadKey)
|
||||
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))
|
||||
} catch (err) { imageLoadIds.current.delete(loadKey); setError(err.message) }
|
||||
}
|
||||
@ -210,7 +213,7 @@ export default function AnswerUserPortalV2({ api }) {
|
||||
if (!historyOpen) return
|
||||
records.slice(0, 20).forEach(record => loadSubmissionImages(record))
|
||||
}, [historyOpen, records.length])
|
||||
if (!session) return <Login api={api} onLogin={setSession}/>
|
||||
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>
|
||||
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} 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>
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,34 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export default function CreateAnswerTasksPageV2({ api }) {
|
||||
export default function CreateAnswerTasksPageV2({ api, user }) {
|
||||
const [records, setRecords] = useState([])
|
||||
const [expanded, setExpanded] = useState({})
|
||||
const [busy, setBusy] = useState('')
|
||||
const [detailBusy, setDetailBusy] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
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() {
|
||||
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 => {
|
||||
const next = { ...current }
|
||||
result.data.forEach((record, index) => {
|
||||
@ -104,6 +133,12 @@ export default function CreateAnswerTasksPageV2({ api }) {
|
||||
}
|
||||
|
||||
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)) {
|
||||
setError(`请先在用户 ${record.user_id} 下选择至少一个问题`)
|
||||
setExpanded(current => ({ ...current, [record.id]: true }))
|
||||
@ -112,7 +147,10 @@ export default function CreateAnswerTasksPageV2({ api }) {
|
||||
setBusy(record.id)
|
||||
setError('')
|
||||
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()
|
||||
} catch (err) {
|
||||
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>
|
||||
<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>}
|
||||
<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>
|
||||
@ -179,6 +218,7 @@ export default function CreateAnswerTasksPageV2({ api }) {
|
||||
<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>
|
||||
<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>}
|
||||
<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>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function RefundUploadPage({ api }) {
|
||||
const token = window.location.pathname.split("/").filter(Boolean).pop() || "";
|
||||
@ -7,6 +7,8 @@ export default function RefundUploadPage({ api }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
const cameraInputRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
@ -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 (
|
||||
<main className="refund-upload-page">
|
||||
<section className="refund-upload-card">
|
||||
@ -51,16 +60,35 @@ export default function RefundUploadPage({ api }) {
|
||||
{error ? <div className="refund-upload-error">{error}</div> : null}
|
||||
{session && !done ? (
|
||||
<form onSubmit={upload}>
|
||||
<label className="refund-upload-picker">
|
||||
<span>选择或拍摄支付截图</span>
|
||||
<div className="refund-upload-picker">
|
||||
<span>请选择返款支付截图</span>
|
||||
<div className="refund-upload-options">
|
||||
<button type="button" className="refund-upload-option" onClick={() => cameraInputRef.current?.click()}>
|
||||
<strong>拍摄上传</strong>
|
||||
<small>调用手机摄像头</small>
|
||||
</button>
|
||||
<button type="button" className="refund-upload-option" onClick={() => fileInputRef.current?.click()}>
|
||||
<strong>相册/文件选择</strong>
|
||||
<small>选择本地图片</small>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
className="refund-upload-hidden-input"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
capture="environment"
|
||||
onChange={(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>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" disabled={busy || !file}>
|
||||
{busy ? "上传中…" : "上传返款凭证"}
|
||||
</button>
|
||||
|
||||
@ -161,8 +161,8 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={`task-detail-meta${isAskTask ? " ask-task-meta" : ""}`}>
|
||||
{isAskTask && <span>
|
||||
<div className={`task-detail-meta${isAskTask ? " ask-task-meta" : task.shop_name ? " shop-task-meta" : ""}`}>
|
||||
{task.shop_name && <span>
|
||||
店铺名称<b>{task.shop_name || "--"}</b>
|
||||
</span>}
|
||||
<span>
|
||||
@ -701,7 +701,7 @@ export default function TaskBoardPage({ api }) {
|
||||
<span className="board-select-cell">
|
||||
<input type="checkbox" checked={selectedIds.includes(key)} onChange={() => toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} />
|
||||
</span>
|
||||
<span className="board-user-cell">
|
||||
<span className={`board-user-cell${!isAskTask(task) ? " board-answer-user" : ""}`}>
|
||||
{children.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
@ -713,11 +713,12 @@ export default function TaskBoardPage({ api }) {
|
||||
</button>
|
||||
)}
|
||||
<code>{task.user_id}</code>
|
||||
{!isAskTask(task) && <small>{maskPhone(task.phone)}</small>}
|
||||
</span>
|
||||
<span><b>{task.task_id}</b></span>
|
||||
<span>
|
||||
<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><em className={`board-type ${task.task_type}`}>{task.task_type_label}</em></span>
|
||||
<span>{task.qa_count}</span>
|
||||
|
||||
@ -1588,6 +1588,48 @@
|
||||
color: #b96b08;
|
||||
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 {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
@ -1642,6 +1684,7 @@
|
||||
}
|
||||
.draft-group-header {
|
||||
gap: 8px !important;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.draft-expand,
|
||||
.draft-fold {
|
||||
@ -1667,6 +1710,39 @@
|
||||
.draft-user-meta em {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -1809,6 +1885,11 @@
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.draft-shop-field {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
}
|
||||
.draft-group-header {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
@ -1822,6 +1903,12 @@
|
||||
.source-image-wrap {
|
||||
margin-left: 0;
|
||||
}
|
||||
.answer-link-bar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.answer-link-bar button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 公共回答用户中心 V2 */
|
||||
@ -2030,10 +2117,18 @@
|
||||
}
|
||||
.board-user-cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.board-user-cell small {
|
||||
flex-basis: 100%;
|
||||
margin-left: 26px;
|
||||
}
|
||||
.board-answer-user small {
|
||||
margin-left: 0;
|
||||
}
|
||||
.board-expand-button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@ -2300,6 +2395,9 @@
|
||||
.task-detail-meta.ask-task-meta {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@ -2628,8 +2726,13 @@
|
||||
.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 { display: grid; gap: 12px; padding: 22px 15px; border: 1px dashed #a9c5e8; border-radius: 12px; color: #1c5fc7; }
|
||||
.refund-upload-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.refund-upload-option { display: grid; gap: 5px; padding: 13px 8px; border: 1px solid #c8dcf6; border-radius: 9px; background: #f6faff; color: #1764e8; cursor: pointer; }
|
||||
.refund-upload-option strong { font-size: 14px; }
|
||||
.refund-upload-option small { color: #7187a3; font-size: 12px; }
|
||||
.refund-upload-option:hover { border-color: #1764e8; background: #eef5ff; }
|
||||
.refund-upload-hidden-input { display: none; }
|
||||
.refund-upload-picker small { color: #7b91aa; font-size: 12px; }
|
||||
.refund-upload-card form > button { width: 100%; margin-top: 16px; padding: 12px; border: 0; border-radius: 9px; background: #1764e8; color: #fff; font-weight: 700; }
|
||||
.refund-upload-card button:disabled { cursor: wait; opacity: .55; }
|
||||
@ -2691,6 +2794,9 @@
|
||||
.task-detail-meta.ask-task-meta {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.task-detail-meta.shop-task-meta {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.task-detail-share {
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@ -2400,7 +2400,9 @@ function AdminPage({ type }) {
|
||||
}
|
||||
|
||||
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. */
|
||||
useEffect(() => {
|
||||
const button = document.createElement("button");
|
||||
@ -3914,7 +3916,7 @@ function Dashboard({ user, onLogout }) {
|
||||
createMode === "ask" ? (
|
||||
<CreateAskTasksPage api={api} librarySelection={librarySelection} onLibrarySelectionConsumed={() => setLibrarySelection([])} />
|
||||
) : (
|
||||
<CreateAnswerTasksPageV2 api={api} />
|
||||
<CreateAnswerTasksPageV2 api={api} user={user} />
|
||||
)
|
||||
) : active === "board" ? (
|
||||
<TaskBoardPage api={api} />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user