适配手机端,优化组件,完善权限隔离

This commit is contained in:
kx_guo 2026-09-05 15:17:53 +08:00
parent cf83de291c
commit 20ca5384fc
15 changed files with 821 additions and 228 deletions

View File

@ -21,10 +21,10 @@ def _prompt(payload: QAGenerateRequest) -> str:
商品品类:{payload.category or '未提供'} 商品品类:{payload.category or '未提供'}
商品功效:{payload.benefits or '未提供'} 商品功效:{payload.benefits or '未提供'}
商品卖点:{payload.selling_points or '未提供'} 商品卖点:{payload.selling_points or '未提供'}
语言风格:{payload.language_style} 语言风格:{payload.language_style or '客观自然'}
目标人群:{payload.target_group} 目标人群:{payload.target_group or '普通消费者'}
使用场景:{payload.usage_scene} 使用场景:{payload.usage_scene or '日常使用'}
购买渠道:{payload.purchase_channel} 购买渠道:{payload.purchase_channel or '电商平台'}
回答要求:{payload.answer_requirement or '直接回应问题,避免夸大功效'} 回答要求:{payload.answer_requirement or '直接回应问题,避免夸大功效'}
回答语气:{style} 回答语气:{style}
真实问答参考:{payload.reference_qa or '未提供'} 真实问答参考:{payload.reference_qa or '未提供'}
@ -65,7 +65,7 @@ def _parse_content(content: str, expected_answers: int) -> list[dict]:
return items return items
def generate_qa(payload: QAGenerateRequest) -> list[dict]: def _generate_qa_batch(payload: QAGenerateRequest) -> list[dict]:
if not settings.llm_api_key: if not settings.llm_api_key:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置大模型 API Key,请先设置 LLM_API_KEY") raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置大模型 API Key,请先设置 LLM_API_KEY")
url = settings.llm_base_url.rstrip("/") + "/chat/completions" url = settings.llm_base_url.rstrip("/") + "/chat/completions"
@ -88,7 +88,33 @@ def generate_qa(payload: QAGenerateRequest) -> list[dict]:
return _parse_content(content, payload.answers_per_question) return _parse_content(content, payload.answers_per_question)
def generate_questions(payload: dict, count: int, reference_qa: str = "") -> list[str]: def generate_qa(payload: QAGenerateRequest) -> list[dict]:
"""Generate the requested amount in small batches so large requests are not truncated."""
target = payload.question_count
items: list[dict] = []
seen: set[str] = set()
attempts = 0
max_attempts = max((target + 19) // 20 + 2, 3)
while len(items) < target and attempts < max_attempts:
batch_size = min(20, target - len(items))
batch_payload = payload.model_copy(update={"question_count": batch_size})
for item in _generate_qa_batch(batch_payload):
key = item["question"].strip()
if key in seen:
continue
seen.add(key)
items.append(item)
if len(items) >= target:
break
attempts += 1
if len(items) < target:
logger.warning("LLM returned %s/%s unique QA items", len(items), target)
for index, item in enumerate(items[:target], start=1):
item["id"] = f"Q{index:03d}"
return items[:target]
def _generate_questions_batch(payload: dict, count: int, reference_qa: str = "") -> list[str]:
"""Generate only consumer questions for the ask-task workflow.""" """Generate only consumer questions for the ask-task workflow."""
if not settings.llm_api_key: if not settings.llm_api_key:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置大模型 API Key,无法生成真实提问") raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置大模型 API Key,无法生成真实提问")
@ -97,10 +123,10 @@ def generate_questions(payload: dict, count: int, reference_qa: str = "") -> lis
商品品类:{payload.get('category') or '未提供'} 商品品类:{payload.get('category') or '未提供'}
商品功效:{payload.get('benefits') or '未提供'} 商品功效:{payload.get('benefits') or '未提供'}
商品卖点:{payload.get('selling_points') or '未提供'} 商品卖点:{payload.get('selling_points') or '未提供'}
语言风格:{payload.get('language_style')} 语言风格:{payload.get('language_style') or '客观自然'}
目标人群:{payload.get('target_group')} 目标人群:{payload.get('target_group') or '普通消费者'}
使用场景:{payload.get('usage_scene')} 使用场景:{payload.get('usage_scene') or '日常使用'}
购买渠道:{payload.get('purchase_channel')} 购买渠道:{payload.get('purchase_channel') or '电商平台'}
真实问答参考:{reference_qa or '未提供'} 真实问答参考:{reference_qa or '未提供'}
只返回严格 JSON,不要 Markdown 或解释,格式:{{"questions":["问题1","问题2"]}}。问题应像真实买家提问,尽量不超过30字,不要虚构具体参数或功效。""" 只返回严格 JSON,不要 Markdown 或解释,格式:{{"questions":["问题1","问题2"]}}。问题应像真实买家提问,尽量不超过30字,不要虚构具体参数或功效。"""
url = settings.llm_base_url.rstrip("/") + "/chat/completions" url = settings.llm_base_url.rstrip("/") + "/chat/completions"
@ -131,6 +157,27 @@ def generate_questions(payload: dict, count: int, reference_qa: str = "") -> lis
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型提问生成失败,请稍后重试") from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型提问生成失败,请稍后重试") from exc
def generate_questions(payload: dict, count: int, reference_qa: str = "") -> list[str]:
"""Generate questions in batches and merge them until the requested count is reached."""
target = int(count)
output: list[str] = []
seen: set[str] = set()
attempts = 0
max_attempts = max((target + 19) // 20 + 2, 3)
while len(output) < target and attempts < max_attempts:
batch = _generate_questions_batch(payload, min(20, target - len(output)), reference_qa)
for question in batch:
if question not in seen:
seen.add(question)
output.append(question)
if len(output) >= target:
break
attempts += 1
if len(output) < target:
logger.warning("LLM returned %s/%s unique questions", len(output), target)
return output[:target]
def generate_answer_for_question(product_title: str, question: str) -> str: def generate_answer_for_question(product_title: str, question: str) -> str:
"""Generate one concise answer. Falls back to a safe draft during the simulated-OCR MVP.""" """Generate one concise answer. Falls back to a safe draft during the simulated-OCR MVP."""
if not settings.llm_api_key: if not settings.llm_api_key:

View File

@ -16,7 +16,7 @@ import xml.etree.ElementTree as ET
from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status from fastapi import BackgroundTasks, 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, or_, select from sqlalchemy import func, inspect, or_, select, text
from sqlalchemy.orm import Session, noload, selectinload from sqlalchemy.orm import Session, noload, selectinload
from .config import settings from .config import settings
@ -85,15 +85,8 @@ def _product_intent_error(values: list[object]) -> str | None:
def _generation_options_error(payload: dict, include_answers: bool = True) -> str | None: def _generation_options_error(payload: dict, include_answers: bool = True) -> str | None:
required = [ # 语言风格、目标人群、场景等生成偏好均可留空,仅限制生成条数必填。
("language_style", "语言风格"), required = [("question_count", "提问生成条数")]
("target_group", "目标人群"),
("question_count", "提问生成条数"),
("usage_scene", "使用场景"),
("purchase_channel", "购买渠道"),
]
if include_answers:
required.insert(3, ("answers_per_question", "单题回答条数"))
missing = [label for key, label in required if not str(payload.get(key, "")).strip()] missing = [label for key, label in required if not str(payload.get(key, "")).strip()]
if missing: if missing:
return f"请填写{'、'.join(missing)}" return f"请填写{'、'.join(missing)}"
@ -103,13 +96,6 @@ def _generation_options_error(payload: dict, include_answers: bool = True) -> st
return "提问生成条数必须是大于 0 的整数" return "提问生成条数必须是大于 0 的整数"
if question_count < 1: if question_count < 1:
return "提问生成条数必须是大于 0 的整数" return "提问生成条数必须是大于 0 的整数"
if include_answers:
try:
answers_count = int(payload.get("answers_per_question"))
except (TypeError, ValueError):
return "单题回答条数必须是大于 0 的整数"
if answers_count < 1:
return "单题回答条数必须是大于 0 的整数"
return None return None
@ -171,6 +157,21 @@ def seed_users(db: Session):
@app.on_event("startup") @app.on_event("startup")
def startup(): def startup():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Add fields introduced after the initial SQLite database was created.
columns = {column["name"] for column in inspect(engine).get_columns("ask_tasks")}
if "claim_questions_per_user" not in columns:
with engine.begin() as connection:
connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN claim_questions_per_user INTEGER NOT NULL DEFAULT 5"))
if "owner_id" not in columns:
with engine.begin() as connection:
connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN owner_id INTEGER NULL"))
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:
connection.execute(text("ALTER TABLE answer_recognized_questions ADD COLUMN operator_image_screenshot_id INTEGER NULL"))
if "operator_image_screenshot_ids" not in question_columns:
with engine.begin() as connection:
connection.execute(text("ALTER TABLE answer_recognized_questions ADD COLUMN operator_image_screenshot_ids TEXT NULL"))
db = next(get_db()) db = next(get_db())
try: try:
seed_users(db) seed_users(db)
@ -353,6 +354,20 @@ def _dingtalk_login(code: str, db: Session, response: Response) -> AdminUser:
return user return user
def _can_access_ask_task(task: AskTask, user: AdminUser) -> bool:
return user.role_code == "super_admin" or task.owner_id == user.id
def _require_ask_task_access(task: AskTask | None, user: AdminUser) -> AskTask:
if not task or not _can_access_ask_task(task, user):
raise HTTPException(status_code=404, detail="提问任务不存在")
return task
def _ask_task_owner_clause(user: AdminUser):
return True if user.role_code == "super_admin" else AskTask.owner_id == user.id
def _published_ask_task(task_no: str, db: Session, *, lock: bool = False) -> AskTask: def _published_ask_task(task_no: str, db: Session, *, lock: bool = False) -> AskTask:
if task_no == "latest": if task_no == "latest":
statement = select(AskTask).where(AskTask.status == "已发布").order_by(AskTask.created_at.desc()) statement = select(AskTask).where(AskTask.status == "已发布").order_by(AskTask.created_at.desc())
@ -521,6 +536,10 @@ def _submission_data(record: AnswerSubmission, include_images: bool = True) -> d
"id": q.id, "sequence_no": q.sequence_no, "product_title": q.product_title, "id": q.id, "sequence_no": q.sequence_no, "product_title": q.product_title,
"product_image": _image_url(images[q.product_image_screenshot_id]) if include_images and q.product_image_screenshot_id in images else None, "product_image": _image_url(images[q.product_image_screenshot_id]) if include_images and q.product_image_screenshot_id in images else None,
"source_image": _image_url(images[q.source_screenshot_id]) if include_images and q.source_screenshot_id in images else None, "source_image": _image_url(images[q.source_screenshot_id]) if include_images and q.source_screenshot_id in images else None,
"operator_images": [
_image_url(images[int(image_id)]) for image_id in ((q.operator_image_screenshot_ids or str(q.operator_image_screenshot_id or "")).split(","))
if image_id.strip().isdigit() and int(image_id) in images
] if include_images else [],
"question": q.question_text, "answer": q.answer_text, "answer_status": q.answer_status, "selected": q.selected, "question": q.question_text, "answer": q.answer_text, "answer_status": q.answer_status, "selected": q.selected,
} for q in sorted(record.questions, key=lambda value: value.sequence_no)], } for q in sorted(record.questions, key=lambda value: value.sequence_no)],
} }
@ -570,25 +589,106 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d
} }
def _ask_assignment_child_data(assignment: AskAssignment) -> dict:
"""Return a compact user-claim row nested under its system ask task."""
task = assignment.task
selected_ids = {
item.question_id for item in assignment.items if item.selected
}
used_ids = {item.question_id for item in assignment.used_questions}
latest_batch = max(assignment.batches, key=lambda value: value.updated_at or value.created_at, default=None)
batch_selected_ids = set()
if latest_batch and latest_batch.selected_question_ids:
batch_selected_ids = {
int(value) for value in latest_batch.selected_question_ids.split(",") if value.strip().isdigit()
}
question_count = len(batch_selected_ids or selected_ids or used_ids)
refund_completed = bool(latest_batch and latest_batch.status == "paid") or assignment.status == "已完成"
refund_pending = bool(latest_batch and latest_batch.status == "approved") or assignment.status == "已通过"
if refund_completed:
status_label = "已完成"
elif refund_pending:
status_label = "待返款"
elif assignment.status == "待审核" or (latest_batch and latest_batch.status == "pending"):
status_label = "待审核"
elif assignment.status == "已撤销":
status_label = "已撤销"
else:
status_label = "进行中"
detail_endpoint = None
refund_endpoint = None
if latest_batch and latest_batch.status in {"approved", "paid"}:
detail_endpoint = f"/api/admin/task-board/ask-batches/{latest_batch.id}"
if not refund_completed:
refund_endpoint = f"/api/admin/task-board/ask-batches/{latest_batch.id}/confirm-refund"
elif assignment.status in {"已通过", "已完成"} and not assignment.batches:
detail_endpoint = f"/api/admin/task-board/ask-assignments/{assignment.id}"
if not refund_completed:
refund_endpoint = f"/api/admin/task-board/ask-assignments/{assignment.id}/confirm-refund"
return {
"id": assignment.id,
"board_key": f"ask-assignment-{assignment.id}",
"record_kind": "ask_assignment",
"is_child": True,
"task_id": task.task_no,
"task_type": "ask",
"task_type_label": "提问任务",
"product_name": task.product_name,
"product_link": task.product_link,
"status": assignment.status,
"status_label": status_label,
"user_id": assignment.user.public_id,
"phone": assignment.user.phone,
"qa_count": question_count,
"claimed_count": question_count,
"submitted_count": len(used_ids),
"pending_review_count": 1 if status_label == "待审核" else 0,
"completed_count": len(used_ids) if used_ids else (question_count if refund_completed else 0),
"total_count": question_count,
"creator_name": task.owner.display_name if task.owner else "运营后台",
"created_at": assignment.created_at.isoformat(),
"updated_at": assignment.updated_at.isoformat(),
"can_cancel": False,
"can_complete": False,
"can_delete": False,
"detail_endpoint": detail_endpoint,
"refund_endpoint": refund_endpoint,
"assigned_users": [{"user_id": assignment.user.public_id, "phone": assignment.user.phone, "status": status_label}],
"selected_questions": [],
"question_screenshots": [],
"completion_screenshots": [],
"payment_screenshots": [],
"latest_review": None,
}
def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict:
questions = sorted(task.questions, key=lambda value: value.sequence_no) questions = sorted(task.questions, key=lambda value: value.sequence_no)
selected_questions = [q for q in questions if q.selected] selected_questions = [q for q in questions if q.selected]
claimed_tasks = sorted(
(_ask_assignment_child_data(assignment) for assignment in task.assignments),
key=lambda value: value.get("updated_at") or value.get("created_at") or "",
reverse=True,
)
assigned_count = len(task.assignments) assigned_count = len(task.assignments)
completed_count = sum(len(assignment.used_questions) for assignment in task.assignments) completed_count = sum(len(assignment.used_questions) for assignment in task.assignments)
pending_count = sum(1 for assignment in task.assignments if assignment.status in {"待审核", "未通过"}) pending_count = sum(child["pending_review_count"] for child in claimed_tasks)
return { return {
"id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task", "id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task",
"task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务", "task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务",
"product_name": task.product_name, "product_link": task.product_link, "status": task.status, "product_name": task.product_name, "product_link": task.product_link, "status": task.status,
"claim_questions_per_user": task.claim_questions_per_user or 5,
"status_label": {"已发布": "进行中", "已撤销": "已撤销", "已完成": "已完成"}.get(task.status, task.status), "status_label": {"已发布": "进行中", "已撤销": "已撤销", "已完成": "已完成"}.get(task.status, task.status),
"qa_count": len(selected_questions), "claimed_count": assigned_count, "submitted_count": completed_count, "qa_count": len(selected_questions), "claimed_count": assigned_count, "submitted_count": completed_count,
"pending_review_count": pending_count, "completed_count": completed_count, "total_count": len(selected_questions), "pending_review_count": pending_count, "completed_count": completed_count, "total_count": len(selected_questions),
"creator_name": "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(), "creator_name": task.owner.display_name if task.owner else "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(),
"can_cancel": task.status == "已发布", "can_complete": task.status == "已发布", "can_delete": True, "can_cancel": task.status == "已发布", "can_complete": task.status == "已发布", "can_delete": True,
"delete_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}", "delete_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}",
"user_id": "--", "phone": "", "questions": [], "user_id": "--", "phone": "", "questions": [],
"selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name} for q in selected_questions] if include_questions else []), "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name} for q in selected_questions] if include_questions else []),
"assigned_users": [], "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None, "assigned_users": [child["assigned_users"][0] for child in claimed_tasks if child.get("assigned_users")],
"claimed_tasks": claimed_tasks,
"invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None,
"detail_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}" if not include_questions else None, "detail_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}" if not include_questions else None,
} }
@ -739,7 +839,8 @@ def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)):
status_code=409, status_code=409,
detail="本次任务已被领取完,请关注下一个任务!", detail="本次任务已被领取完,请关注下一个任务!",
) )
assigned = random.sample(available, min(5, len(available))) claim_limit = task.claim_questions_per_user or 5
assigned = random.sample(available, min(claim_limit, len(available)))
assignment = AskAssignment(task_id=task.id, user_id=user.id, status="待选择") assignment = AskAssignment(task_id=task.id, user_id=user.id, status="待选择")
db.add(assignment); db.flush() db.add(assignment); db.flush()
for question in assigned: for question in assigned:
@ -856,17 +957,19 @@ async def submit_public_ask_task(
@app.get("/api/admin/ask-completion-reviews") @app.get("/api/admin/ask-completion-reviews")
def ask_completion_reviews(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def ask_completion_reviews(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
batches = db.scalars( batches = db.scalars(
select(AskAssignmentBatch) select(AskAssignmentBatch).join(AskAssignmentBatch.assignment).join(AskAssignment.task)
.where(AskAssignmentBatch.status == "pending") .where(AskAssignmentBatch.status == "pending")
.where(_ask_task_owner_clause(user))
.order_by(AskAssignmentBatch.updated_at.desc()) .order_by(AskAssignmentBatch.updated_at.desc())
).unique().all() ).unique().all()
legacy = db.scalars( legacy = db.scalars(
select(AskAssignment).where( select(AskAssignment).join(AskAssignment.task).where(
AskAssignment.status == "待审核", AskAssignment.status == "待审核",
~AskAssignment.batches.any(), ~AskAssignment.batches.any(),
AskAssignment.screenshots.any(), AskAssignment.screenshots.any(),
_ask_task_owner_clause(user),
).order_by(AskAssignment.updated_at.desc()) ).order_by(AskAssignment.updated_at.desc())
).unique().all() ).unique().all()
data = [_ask_batch_data(batch, include_images=False) for batch in batches] data = [_ask_batch_data(batch, include_images=False) for batch in batches]
@ -875,19 +978,20 @@ def ask_completion_reviews(_: AdminUser = Depends(current_user), db: Session = D
@app.get("/api/admin/review-pending-count") @app.get("/api/admin/review-pending-count")
def review_pending_count(_: 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 == "待审核")
) or 0 ) or 0
ask_batch_count = db.scalar( ask_batch_count = db.scalar(
select(func.count(AskAssignmentBatch.id)).where(AskAssignmentBatch.status == "pending") select(func.count(AskAssignmentBatch.id)).join(AskAssignmentBatch.assignment).join(AskAssignment.task).where(AskAssignmentBatch.status == "pending", _ask_task_owner_clause(user))
) or 0 ) or 0
legacy_ask_count = db.scalar( legacy_ask_count = db.scalar(
select(func.count(AskAssignment.id)).where( select(func.count(AskAssignment.id)).join(AskAssignment.task).where(
AskAssignment.status == "待审核", AskAssignment.status == "待审核",
~AskAssignment.batches.any(), ~AskAssignment.batches.any(),
AskAssignment.screenshots.any(), AskAssignment.screenshots.any(),
_ask_task_owner_clause(user),
) )
) or 0 ) or 0
ask_count = ask_batch_count + legacy_ask_count ask_count = ask_batch_count + legacy_ask_count
@ -902,20 +1006,22 @@ def review_pending_count(_: AdminUser = Depends(current_user), db: Session = Dep
@app.get("/api/admin/ask-completion-reviews/{assignment_id}") @app.get("/api/admin/ask-completion-reviews/{assignment_id}")
def ask_completion_review_detail(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def ask_completion_review_detail(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
batch = db.get(AskAssignmentBatch, assignment_id) batch = db.get(AskAssignmentBatch, assignment_id)
if batch: if batch:
_require_ask_task_access(batch.assignment.task, user)
return {"success": True, "data": _ask_batch_data(batch)} return {"success": True, "data": _ask_batch_data(batch)}
assignment = db.get(AskAssignment, assignment_id) assignment = db.get(AskAssignment, assignment_id)
if not assignment: if not assignment or not _can_access_ask_task(assignment.task, user):
raise HTTPException(status_code=404, detail="提问审核任务不存在") raise HTTPException(status_code=404, detail="提问审核任务不存在")
return {"success": True, "data": _ask_assignment_data(assignment)} return {"success": True, "data": _ask_assignment_data(assignment)}
@app.post("/api/admin/ask-completion-reviews/{assignment_id}/decision") @app.post("/api/admin/ask-completion-reviews/{assignment_id}/decision")
def ask_completion_decision(assignment_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def ask_completion_decision(assignment_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
batch = db.get(AskAssignmentBatch, assignment_id) batch = db.get(AskAssignmentBatch, assignment_id)
if batch: if batch:
_require_ask_task_access(batch.assignment.task, user)
if batch.status != "pending": if batch.status != "pending":
raise HTTPException(status_code=409, detail="review already completed") raise HTTPException(status_code=409, detail="review already completed")
approved = bool(payload.get("approved")) approved = bool(payload.get("approved"))
@ -1064,6 +1170,43 @@ def admin_answer_task_draft_detail(submission_id: int, _: AdminUser = Depends(cu
return {"success": True, "data": _submission_data(record, include_images=True)} return {"success": True, "data": _submission_data(record, include_images=True)}
@app.post("/api/admin/answer-task-drafts/questions/{question_id}/operator-image")
async def upload_answer_operator_images(
question_id: int,
files: list[UploadFile] = File(...),
_: 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 {"待筛选", "待运营派发", "已派发"}:
raise HTTPException(status_code=404, detail="回答问题不存在或已经发布")
if not files or len(files) > 3:
raise HTTPException(status_code=400, detail="每道问题最多上传3张运营指定图片")
old_ids = [value for value in (question.operator_image_screenshot_ids or "").split(",") if value.strip().isdigit()]
for old_id in old_ids:
old = db.get(AnswerScreenshot, int(old_id))
if old:
db.delete(old)
new_ids = []
for file in files:
if file.content_type not in {"image/jpeg", "image/png", "image/webp"}:
db.rollback()
raise HTTPException(status_code=400, detail="运营指定图片仅支持 JPG、PNG、WEBP")
image_data = await file.read()
if not image_data:
db.rollback()
raise HTTPException(status_code=400, detail="不能上传空图片")
image = AnswerScreenshot(submission_id=record.id, stage="operator", filename=file.filename or "operator-image", content_type=file.content_type, image_data=image_data)
db.add(image); db.flush(); new_ids.append(str(image.id))
question.operator_image_screenshot_ids = ",".join(new_ids)
question.operator_image_screenshot_id = int(new_ids[0]) if new_ids else None
db.commit()
db.refresh(record)
return {"success": True, "data": _submission_data(record, include_images=True)}
@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, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
record = db.get(AnswerSubmission, submission_id) record = db.get(AnswerSubmission, submission_id)
@ -1077,7 +1220,7 @@ def reject_answer_task_draft(submission_id: int, _: AdminUser = Depends(current_
@app.post("/api/admin/ask-tasks/generate") @app.post("/api/admin/ask-tasks/generate")
def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
product_link = str(payload.get("product_link", "")).strip() product_link = str(payload.get("product_link", "")).strip()
product_name = str(payload.get("product_name", "")).strip() product_name = str(payload.get("product_name", "")).strip()
if not product_link: raise HTTPException(status_code=400, detail="商品链接为必填项") if not product_link: raise HTTPException(status_code=400, detail="商品链接为必填项")
@ -1093,12 +1236,23 @@ def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: S
raise HTTPException(status_code=400, detail="提问生成条数必须是正整数") raise HTTPException(status_code=400, detail="提问生成条数必须是正整数")
if count < 1: if count < 1:
raise HTTPException(status_code=400, detail="提问生成条数必须大于 0") raise HTTPException(status_code=400, detail="提问生成条数必须大于 0")
raw_claim_limit = payload.get("claim_questions_per_user", "")
if str(raw_claim_limit).strip():
try:
claim_limit = int(raw_claim_limit)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="每个用户可认领的问题数量必须是 2-5 的整数")
if claim_limit < 2 or claim_limit > 5:
raise HTTPException(status_code=400, detail="每个用户可认领的问题数量必须在 2-5 条之间")
else:
claim_limit = 5
existing_task_id = payload.get("task_id") existing_task_id = payload.get("task_id")
try: try:
task = db.get(AskTask, int(existing_task_id)) if existing_task_id else None task = db.get(AskTask, int(existing_task_id)) if existing_task_id else None
except (TypeError, ValueError): except (TypeError, ValueError):
task = None task = None
if task: if task:
_require_ask_task_access(task, user)
if task.status != "草稿": if task.status != "草稿":
raise HTTPException(status_code=409, detail="已发布任务不能重新生成") raise HTTPException(status_code=409, detail="已发布任务不能重新生成")
task.product_link = product_link task.product_link = product_link
@ -1110,11 +1264,12 @@ def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: S
task.target_group = str(payload.get("target_group", "")) task.target_group = str(payload.get("target_group", ""))
task.usage_scene = str(payload.get("usage_scene", "")) task.usage_scene = str(payload.get("usage_scene", ""))
task.purchase_channel = str(payload.get("purchase_channel", "")) task.purchase_channel = str(payload.get("purchase_channel", ""))
task.claim_questions_per_user = claim_limit
for question in list(task.questions): for question in list(task.questions):
db.delete(question) db.delete(question)
db.flush() db.flush()
else: else:
task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", product_link=product_link, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), status="草稿") task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, product_link=product_link, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), claim_questions_per_user=claim_limit, status="草稿")
db.add(task); db.flush() db.add(task); db.flush()
questions = payload.get("questions") if isinstance(payload.get("questions"), list) else [] questions = payload.get("questions") if isinstance(payload.get("questions"), list) else []
reference_text = str(payload.get("reference_qa", ""))[:20000] reference_text = str(payload.get("reference_qa", ""))[:20000]
@ -1136,9 +1291,9 @@ def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: S
@app.patch("/api/admin/ask-tasks/{task_id}/questions/{question_id}") @app.patch("/api/admin/ask-tasks/{task_id}/questions/{question_id}")
def update_ask_question(task_id: int, question_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def update_ask_question(task_id: int, question_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
question = db.scalar(select(AskQuestion).where(AskQuestion.id == question_id, AskQuestion.task_id == task_id)) question = db.scalar(select(AskQuestion).where(AskQuestion.id == question_id, AskQuestion.task_id == task_id))
if not question: raise HTTPException(status_code=404, detail="提问不存在") if not question or not _can_access_ask_task(question.task, user): raise HTTPException(status_code=404, detail="提问不存在")
if question.task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能修改") if question.task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能修改")
if "question" in payload: if "question" in payload:
value = str(payload.get("question", "")).strip() value = str(payload.get("question", "")).strip()
@ -1150,10 +1305,10 @@ def update_ask_question(task_id: int, question_id: int, payload: dict, _: AdminU
@app.post("/api/admin/ask-tasks/{task_id}/questions") @app.post("/api/admin/ask-tasks/{task_id}/questions")
def add_ask_question(task_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def add_ask_question(task_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id) task = db.get(AskTask, task_id)
text = str(payload.get("question", "")).strip() text = str(payload.get("question", "")).strip()
if not task: raise HTTPException(status_code=404, detail="提问任务不存在") _require_ask_task_access(task, user)
if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能添加提问") if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能添加提问")
if not text: raise HTTPException(status_code=400, detail="提问内容不能为空") if not text: raise HTTPException(status_code=400, detail="提问内容不能为空")
question = AskQuestion(task_id=task.id, sequence_no=len(task.questions) + 1, question_text=text, selected=True) question = AskQuestion(task_id=task.id, sequence_no=len(task.questions) + 1, question_text=text, selected=True)
@ -1162,9 +1317,9 @@ def add_ask_question(task_id: int, payload: dict, _: AdminUser = Depends(current
@app.post("/api/admin/ask-tasks/{task_id}/publish") @app.post("/api/admin/ask-tasks/{task_id}/publish")
def publish_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def publish_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id) task = db.get(AskTask, task_id)
if not task: raise HTTPException(status_code=404, detail="提问任务不存在") _require_ask_task_access(task, user)
if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布") if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布")
if not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问") if not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问")
task.status = "已发布"; db.commit(); db.refresh(task) task.status = "已发布"; db.commit(); db.refresh(task)
@ -1172,19 +1327,18 @@ def publish_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Ses
@app.post("/api/admin/ask-tasks/{task_id}/cancel") @app.post("/api/admin/ask-tasks/{task_id}/cancel")
def cancel_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def cancel_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id) task = db.get(AskTask, task_id)
if not task: raise HTTPException(status_code=404, detail="提问任务不存在") _require_ask_task_access(task, user)
if task.status != "已发布": raise HTTPException(status_code=409, detail="当前任务不能撤销") if task.status != "已发布": raise HTTPException(status_code=409, detail="当前任务不能撤销")
task.status = "已撤销"; db.commit(); db.refresh(task) task.status = "已撤销"; db.commit(); db.refresh(task)
return {"success": True, "data": _ask_task_data(task)} return {"success": True, "data": _ask_task_data(task)}
@app.post("/api/admin/ask-tasks/{task_id}/complete") @app.post("/api/admin/ask-tasks/{task_id}/complete")
def complete_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def complete_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id) task = db.get(AskTask, task_id)
if not task: _require_ask_task_access(task, user)
raise HTTPException(status_code=404, detail="提问任务不存在")
if task.status != "已发布": if task.status != "已发布":
raise HTTPException(status_code=409, detail="当前任务不能完成归档") raise HTTPException(status_code=409, detail="当前任务不能完成归档")
task.status = "已完成" task.status = "已完成"
@ -1217,7 +1371,7 @@ def publish_answer_task(submission_id: int, _: AdminUser = Depends(current_user)
@app.get("/api/admin/task-board") @app.get("/api/admin/task-board")
def admin_task_board(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def admin_task_board(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
if _release_expired_ask_assignments(db): if _release_expired_ask_assignments(db):
db.commit() db.commit()
task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"] task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"]
@ -1231,58 +1385,28 @@ def admin_task_board(_: AdminUser = Depends(current_user), db: Session = Depends
.where(AnswerSubmission.status.in_(task_statuses)) .where(AnswerSubmission.status.in_(task_statuses))
.order_by(AnswerSubmission.updated_at.desc()) .order_by(AnswerSubmission.updated_at.desc())
).unique().all() ).unique().all()
ask_tasks = db.scalars(select(AskTask).where(AskTask.status.in_(["已发布", "已完成", "已撤销"])).order_by(AskTask.updated_at.desc())).unique().all() ask_statement = select(AskTask).options(selectinload(AskTask.owner)).where(AskTask.status.in_(["已发布", "已完成", "已撤销"]), _ask_task_owner_clause(user)).order_by(AskTask.updated_at.desc())
approved_ask_batches = db.scalars( ask_tasks = db.scalars(ask_statement).unique().all()
select(AskAssignmentBatch)
.options(
selectinload(AskAssignmentBatch.assignment).selectinload(AskAssignment.task),
selectinload(AskAssignmentBatch.assignment).selectinload(AskAssignment.user),
noload(AskAssignmentBatch.screenshots),
noload(AskAssignmentBatch.review_decisions),
)
.where(AskAssignmentBatch.status.in_(["approved", "paid"]))
.order_by(AskAssignmentBatch.updated_at.desc())
).unique().all()
approved_legacy_ask_assignments = db.scalars(
select(AskAssignment)
.options(
selectinload(AskAssignment.task),
selectinload(AskAssignment.user),
selectinload(AskAssignment.items).selectinload(AskAssignmentQuestion.question),
selectinload(AskAssignment.used_questions),
noload(AskAssignment.screenshots),
noload(AskAssignment.review_decisions),
)
.where(
AskAssignment.status.in_(["已通过", "已完成"]),
~AskAssignment.batches.any(),
AskAssignment.screenshots.any(),
)
.order_by(AskAssignment.updated_at.desc())
).unique().all()
data = ( data = (
[_task_board_data(record, include_images=False) for record in records] [_task_board_data(record, include_images=False) for record in records]
+ [_ask_task_data(task, include_questions=False) for task in ask_tasks] + [_ask_task_data(task, include_questions=False) for task in ask_tasks]
+ [_ask_review_board_data(batch, include_images=False) for batch in approved_ask_batches]
+ [_ask_review_board_data(assignment, include_images=False) for assignment in approved_legacy_ask_assignments]
) )
data.sort(key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True) data.sort(key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True)
return {"success": True, "data": data} return {"success": True, "data": data}
@app.get("/api/admin/task-board/ask-tasks/{task_id}") @app.get("/api/admin/task-board/ask-tasks/{task_id}")
def admin_task_board_ask_task_detail(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def admin_task_board_ask_task_detail(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id) task = db.get(AskTask, task_id)
if not task or task.status not in {"已发布", "已完成", "已撤销"}: if not task or task.status not in {"已发布", "已完成", "已撤销"} or not _can_access_ask_task(task, user):
raise HTTPException(status_code=404, detail="提问任务不存在") raise HTTPException(status_code=404, detail="提问任务不存在")
return {"success": True, "data": _ask_task_data(task, include_questions=True)} return {"success": True, "data": _ask_task_data(task, include_questions=True)}
@app.delete("/api/admin/task-board/ask-tasks/{task_id}") @app.delete("/api/admin/task-board/ask-tasks/{task_id}")
def admin_task_board_delete_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def admin_task_board_delete_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id) task = db.get(AskTask, task_id)
if not task: _require_ask_task_access(task, user)
raise HTTPException(status_code=404, detail="提问任务不存在")
# AskTask's assignment relationship is intentionally not orphan-cascaded # AskTask's assignment relationship is intentionally not orphan-cascaded
# because assignments may be created by users. Remove those dependent # because assignments may be created by users. Remove those dependent
# records explicitly before deleting the task to keep every status # records explicitly before deleting the task to keep every status
@ -1295,17 +1419,17 @@ def admin_task_board_delete_ask_task(task_id: int, _: AdminUser = Depends(curren
@app.get("/api/admin/task-board/ask-batches/{batch_id}") @app.get("/api/admin/task-board/ask-batches/{batch_id}")
def admin_task_board_ask_batch_detail(batch_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def admin_task_board_ask_batch_detail(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
batch = db.get(AskAssignmentBatch, batch_id) batch = db.get(AskAssignmentBatch, batch_id)
if not batch or batch.status not in {"approved", "paid"}: if not batch or batch.status not in {"approved", "paid"} or not _can_access_ask_task(batch.assignment.task, user):
raise HTTPException(status_code=404, detail="待返款提问记录不存在") raise HTTPException(status_code=404, detail="待返款提问记录不存在")
return {"success": True, "data": _ask_review_board_data(batch, include_images=True)} return {"success": True, "data": _ask_review_board_data(batch, include_images=True)}
@app.delete("/api/admin/task-board/ask-batches/{batch_id}") @app.delete("/api/admin/task-board/ask-batches/{batch_id}")
def admin_task_board_delete_ask_batch(batch_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def admin_task_board_delete_ask_batch(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
batch = db.get(AskAssignmentBatch, batch_id) batch = db.get(AskAssignmentBatch, batch_id)
if not batch: if not batch or not _can_access_ask_task(batch.assignment.task, user):
raise HTTPException(status_code=404, detail="提问提交记录不存在") raise HTTPException(status_code=404, detail="提问提交记录不存在")
db.delete(batch) db.delete(batch)
db.commit() db.commit()
@ -1313,17 +1437,17 @@ def admin_task_board_delete_ask_batch(batch_id: int, _: AdminUser = Depends(curr
@app.get("/api/admin/task-board/ask-assignments/{assignment_id}") @app.get("/api/admin/task-board/ask-assignments/{assignment_id}")
def admin_task_board_ask_assignment_detail(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def admin_task_board_ask_assignment_detail(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
assignment = db.get(AskAssignment, assignment_id) assignment = db.get(AskAssignment, assignment_id)
if not assignment or assignment.status not in {"已通过", "已完成"}: if not assignment or assignment.status not in {"已通过", "已完成"} or not _can_access_ask_task(assignment.task, user):
raise HTTPException(status_code=404, detail="待返款提问记录不存在") raise HTTPException(status_code=404, detail="待返款提问记录不存在")
return {"success": True, "data": _ask_review_board_data(assignment, include_images=True)} return {"success": True, "data": _ask_review_board_data(assignment, include_images=True)}
@app.delete("/api/admin/task-board/ask-assignments/{assignment_id}") @app.delete("/api/admin/task-board/ask-assignments/{assignment_id}")
def admin_task_board_delete_ask_assignment(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def admin_task_board_delete_ask_assignment(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
assignment = db.get(AskAssignment, assignment_id) assignment = db.get(AskAssignment, assignment_id)
if not assignment: if not assignment or not _can_access_ask_task(assignment.task, user):
raise HTTPException(status_code=404, detail="提问分配记录不存在") raise HTTPException(status_code=404, detail="提问分配记录不存在")
db.delete(assignment) db.delete(assignment)
db.commit() db.commit()
@ -1331,9 +1455,9 @@ def admin_task_board_delete_ask_assignment(assignment_id: int, _: AdminUser = De
@app.post("/api/admin/task-board/ask-batches/{batch_id}/confirm-refund") @app.post("/api/admin/task-board/ask-batches/{batch_id}/confirm-refund")
def confirm_ask_batch_refund(batch_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def confirm_ask_batch_refund(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
batch = db.get(AskAssignmentBatch, batch_id) batch = db.get(AskAssignmentBatch, batch_id)
if not batch: if not batch or not _can_access_ask_task(batch.assignment.task, user):
raise HTTPException(status_code=404, detail="待返款提问记录不存在") raise HTTPException(status_code=404, detail="待返款提问记录不存在")
if batch.status == "paid": if batch.status == "paid":
raise HTTPException(status_code=409, detail="该任务已经完成返款") raise HTTPException(status_code=409, detail="该任务已经完成返款")
@ -1346,9 +1470,9 @@ def confirm_ask_batch_refund(batch_id: int, _: AdminUser = Depends(current_user)
@app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund") @app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund")
def confirm_ask_assignment_refund(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def confirm_ask_assignment_refund(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
assignment = db.get(AskAssignment, assignment_id) assignment = db.get(AskAssignment, assignment_id)
if not assignment: if not assignment or not _can_access_ask_task(assignment.task, user):
raise HTTPException(status_code=404, detail="待返款提问记录不存在") raise HTTPException(status_code=404, detail="待返款提问记录不存在")
if assignment.status == "已完成": if assignment.status == "已完成":
raise HTTPException(status_code=409, detail="该任务已经完成返款") raise HTTPException(status_code=409, detail="该任务已经完成返款")

View File

@ -20,6 +20,7 @@ class AdminUser(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
login_logs: Mapped[list["AdminLoginLog"]] = relationship(back_populates="user") login_logs: Mapped[list["AdminLoginLog"]] = relationship(back_populates="user")
ask_tasks: Mapped[list["AskTask"]] = relationship(back_populates="owner")
class AdminLoginLog(Base): class AdminLoginLog(Base):
@ -106,6 +107,8 @@ class AnswerRecognizedQuestion(Base):
sequence_no: Mapped[int] = mapped_column(Integer) sequence_no: Mapped[int] = mapped_column(Integer)
product_title: Mapped[str] = mapped_column(String(255)) product_title: Mapped[str] = mapped_column(String(255))
product_image_screenshot_id: Mapped[int | None] = mapped_column(ForeignKey("answer_screenshots.id"), nullable=True) product_image_screenshot_id: Mapped[int | None] = mapped_column(ForeignKey("answer_screenshots.id"), nullable=True)
operator_image_screenshot_id: Mapped[int | None] = mapped_column(ForeignKey("answer_screenshots.id"), nullable=True)
operator_image_screenshot_ids: Mapped[str | None] = mapped_column(Text, nullable=True)
question_text: Mapped[str] = mapped_column(Text) question_text: Mapped[str] = mapped_column(Text)
answer_text: Mapped[str] = mapped_column(Text, default="") answer_text: Mapped[str] = mapped_column(Text, default="")
answer_status: Mapped[str] = mapped_column(String(32), default="已生成") answer_status: Mapped[str] = mapped_column(String(32), default="已生成")
@ -134,6 +137,7 @@ class AskTask(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
task_no: Mapped[str] = mapped_column(String(40), unique=True, index=True) task_no: Mapped[str] = mapped_column(String(40), unique=True, index=True)
owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True)
product_link: Mapped[str] = mapped_column(String(1000)) product_link: Mapped[str] = mapped_column(String(1000))
product_name: Mapped[str] = mapped_column(String(255)) product_name: Mapped[str] = mapped_column(String(255))
category: Mapped[str] = mapped_column(String(100), default="") category: Mapped[str] = mapped_column(String(100), default="")
@ -143,12 +147,14 @@ class AskTask(Base):
target_group: Mapped[str] = mapped_column(String(100), default="女性") target_group: Mapped[str] = mapped_column(String(100), default="女性")
usage_scene: Mapped[str] = mapped_column(String(100), default="居家") usage_scene: Mapped[str] = mapped_column(String(100), default="居家")
purchase_channel: Mapped[str] = mapped_column(String(100), default="电商平台") purchase_channel: Mapped[str] = mapped_column(String(100), default="电商平台")
claim_questions_per_user: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
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)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
questions: Mapped[list["AskQuestion"]] = relationship(back_populates="task", cascade="all, delete-orphan", lazy="selectin") questions: Mapped[list["AskQuestion"]] = relationship(back_populates="task", cascade="all, delete-orphan", lazy="selectin")
assignments: Mapped[list["AskAssignment"]] = relationship(back_populates="task", cascade="all, delete-orphan") assignments: Mapped[list["AskAssignment"]] = relationship(back_populates="task", cascade="all, delete-orphan")
owner: Mapped[AdminUser | None] = relationship(back_populates="ask_tasks")
class AskQuestion(Base): class AskQuestion(Base):

View File

@ -35,7 +35,7 @@ class QAGenerateRequest(BaseModel):
target_group: str = Field(default="女性", max_length=100) target_group: str = Field(default="女性", max_length=100)
usage_scene: str = Field(default="居家", max_length=100) usage_scene: str = Field(default="居家", max_length=100)
purchase_channel: str = Field(default="电商平台", max_length=100) purchase_channel: str = Field(default="电商平台", max_length=100)
question_count: int = Field(default=5, ge=1, le=20) question_count: int = Field(default=5, ge=1)
answers_per_question: int = Field(default=2, ge=1, le=50) answers_per_question: int = Field(default=2, ge=1, le=50)
answer_requirement: str = Field(default="", max_length=1000) answer_requirement: str = Field(default="", max_length=1000)
reference_qa: str = Field(default="", max_length=20000) reference_qa: str = Field(default="", max_length=20000)

View File

@ -31,3 +31,6 @@ CREATE TABLE IF NOT EXISTS admin_login_logs (
KEY idx_admin_login_logs_created (created_at), KEY idx_admin_login_logs_created (created_at),
CONSTRAINT fk_admin_login_logs_user FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE SET NULL CONSTRAINT fk_admin_login_logs_user FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 提问任务归属运营账号;历史数据允许为空,超级管理员仍可查看全部任务。
-- 新环境建表时请将 owner_id 一并加入 ask_tasks;已有环境由应用启动时自动补列。

View File

@ -80,7 +80,7 @@ function AssignmentStage({ api, session, latest, reload, 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); 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><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>}</>
} }
function FinalStage({ latest, reload, busy, reopen, startNew }) { function FinalStage({ latest, reload, busy, reopen, startNew }) {
@ -139,6 +139,19 @@ export default function AnswerUserPortalV2({ api }) {
return previous ? { return previous ? {
...previous, ...previous,
...record, ...record,
// The polling endpoint is intentionally lightweight and returns
// null image fields. Preserve images already loaded from the detail
// endpoint so polling/clicking never makes task instructions vanish.
questions: record.questions.map(question => {
const oldQuestion = previous.questions?.find(item => item.id === question.id)
return {
...oldQuestion,
...question,
product_image: question.product_image || oldQuestion?.product_image || null,
source_image: question.source_image || oldQuestion?.source_image || null,
operator_images: question.operator_images?.length ? question.operator_images : (oldQuestion?.operator_images || []),
}
}),
invited_screenshots: record.invited_screenshots?.length ? record.invited_screenshots : previous.invited_screenshots, invited_screenshots: record.invited_screenshots?.length ? record.invited_screenshots : previous.invited_screenshots,
completion_screenshots: record.completion_screenshots?.length ? record.completion_screenshots : previous.completion_screenshots, completion_screenshots: record.completion_screenshots?.length ? record.completion_screenshots : previous.completion_screenshots,
payment_screenshots: record.payment_screenshots?.length ? record.payment_screenshots : previous.payment_screenshots, payment_screenshots: record.payment_screenshots?.length ? record.payment_screenshots : previous.payment_screenshots,
@ -161,12 +174,14 @@ export default function AnswerUserPortalV2({ api }) {
useEffect(() => { if (!session) return undefined; load(); const timer = window.setInterval(() => load(true), 10000); return () => window.clearInterval(timer) }, [session?.token]) useEffect(() => { if (!session) return undefined; load(); const timer = window.setInterval(() => load(true), 10000); return () => window.clearInterval(timer) }, [session?.token])
useEffect(() => { if (historyOpen) setError('') }, [historyOpen]) useEffect(() => { if (historyOpen) setError('') }, [historyOpen])
async function loadSubmissionImages(record) { async function loadSubmissionImages(record) {
if (!record?.id || imageLoadIds.current.has(record.id)) return if (!record?.id) return
imageLoadIds.current.add(record.id) const loadKey = `${record.id}:${record.status}`
if (imageLoadIds.current.has(loadKey)) return
imageLoadIds.current.add(loadKey)
try { try {
const result = await api(`/api/answer/me/submissions/${record.id}?token=${encodeURIComponent(session.token)}`) const result = await api(`/api/answer/me/submissions/${record.id}?token=${encodeURIComponent(session.token)}`)
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(record.id); setError(err.message) } } catch (err) { imageLoadIds.current.delete(loadKey); setError(err.message) }
} }
const latestRecord = records.find(record => record.id === activeRecordId) || records[0] const latestRecord = records.find(record => record.id === activeRecordId) || records[0]
const latest = latestRecord && optimisticStatuses[latestRecord.id] ? { ...latestRecord, status: optimisticStatuses[latestRecord.id] } : latestRecord const latest = latestRecord && optimisticStatuses[latestRecord.id] ? { ...latestRecord, status: optimisticStatuses[latestRecord.id] } : latestRecord
@ -186,7 +201,7 @@ export default function AnswerUserPortalV2({ api }) {
useEffect(() => { useEffect(() => {
if (!visibleLatest || historyOpen || newTaskMode) return if (!visibleLatest || historyOpen || newTaskMode) return
loadSubmissionImages(visibleLatest) loadSubmissionImages(visibleLatest)
}, [visibleLatest?.id, historyOpen, newTaskMode]) }, [visibleLatest?.id, visibleLatest?.status, historyOpen, newTaskMode])
useEffect(() => { useEffect(() => {
if (!historyOpen) return if (!historyOpen) return
records.slice(0, 20).forEach(record => loadSubmissionImages(record)) records.slice(0, 20).forEach(record => loadSubmissionImages(record))
@ -194,3 +209,4 @@ export default function AnswerUserPortalV2({ api }) {
if (!session) return <Login api={api} onLogin={setSession}/> 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> 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>
} }

View File

@ -21,6 +21,7 @@ export default function CreateAnswerTasksPageV2({ api }) {
...question, ...question,
product_image: question.product_image || previousQuestions.get(question.id)?.product_image || null, product_image: question.product_image || previousQuestions.get(question.id)?.product_image || null,
source_image: question.source_image || previousQuestions.get(question.id)?.source_image || null, source_image: question.source_image || previousQuestions.get(question.id)?.source_image || null,
operator_images: question.operator_images?.length ? question.operator_images : (previousQuestions.get(question.id)?.operator_images || []),
})), })),
} }
})) }))
@ -135,6 +136,25 @@ export default function CreateAnswerTasksPageV2({ api }) {
} }
} }
async function uploadOperatorImage(question, event) {
const files = Array.from(event.target.files || [])
event.target.value = ''
if (!files.length) return
if (files.length > 3) return setError('每道问题最多上传3张运营指定图片')
const body = new FormData()
files.forEach(file => body.append('files', file))
setBusy(question.id); setError('')
try {
const result = await api(`/api/admin/answer-task-drafts/questions/${question.id}/operator-image`, { method: 'POST', body })
const updated = result.data.questions?.find(item => item.id === question.id)
setQuestionLocal(question.id, { operator_images: updated?.operator_images || [] })
} catch (err) {
setError(err.message)
} finally {
setBusy('')
}
}
return <> return <>
<div className="create-page-heading"> <div className="create-page-heading">
<div><h2>创建任务</h2><p>通过顶部切换创建「提问任务」或「回答任务」。</p></div> <div><h2>创建任务</h2><p>通过顶部切换创建「提问任务」或「回答任务」。</p></div>
@ -164,7 +184,7 @@ export default function CreateAnswerTasksPageV2({ api }) {
<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>
<button className="draft-reject-button" disabled={!!busy || locked || identifying} onClick={() => setRejecting(record)}>打回</button> <button className="draft-reject-button" disabled={!!busy || locked || identifying} onClick={() => setRejecting(record)}>打回</button>
</header> </header>
{open && record.questions.map(question => <section className={`answer-draft-question ${question.selected ? 'selected' : ''}`} key={question.id}> {open && record.questions.map(question => <React.Fragment key={question.id}><section className={`answer-draft-question ${question.selected ? 'selected' : ''}`}>
<label className="question-check"><input className="task-checkbox" type="checkbox" checked={question.selected} disabled={locked} onChange={event => updateQuestion(question, { selected: event.target.checked })}/></label> <label className="question-check"><input className="task-checkbox" type="checkbox" checked={question.selected} disabled={locked} onChange={event => updateQuestion(question, { selected: event.target.checked })}/></label>
<div className="answer-draft-content"> <div className="answer-draft-content">
<div className="draft-question-title"><h3>Q{question.sequence_no}:{question.question}</h3></div> <div className="draft-question-title"><h3>Q{question.sequence_no}:{question.question}</h3></div>
@ -176,7 +196,11 @@ export default function CreateAnswerTasksPageV2({ api }) {
<textarea value={question.answer} disabled={locked} onChange={event => setQuestionLocal(question.id, { answer: event.target.value })} onBlur={event => updateQuestion({ ...question, answer: event.target.value }, { answer: event.target.value })}/> <textarea value={question.answer} disabled={locked} onChange={event => setQuestionLocal(question.id, { answer: event.target.value })} onBlur={event => updateQuestion({ ...question, answer: event.target.value }, { answer: event.target.value })}/>
</div> </div>
<button className="draft-icon-button" title="重新生成回答" disabled={!!busy || locked} onClick={() => updateQuestion(question, { regenerate: true })}>↻</button> <button className="draft-icon-button" title="重新生成回答" disabled={!!busy || locked} onClick={() => updateQuestion(question, { regenerate: true })}>↻</button>
</section>)} </section><div className="operator-image-panel">
<div><strong>运营指定回答图片(选填)</strong><small>用户端会看到这张图片,请用户在回答本题时一并发布。</small></div>
<label className="operator-image-upload"><input type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={event => uploadOperatorImage(question, event)} disabled={!!busy || locked}/>{question.operator_images?.length ? '重新上传指定图片(最多3张)' : '+ 上传指定图片(最多3张)'}</label>
{!!question.operator_images?.length && <div className="operator-image-list">{question.operator_images.map((image, index) => <figure key={image.id || image.data_url}><img src={image.data_url || image.url || image} alt="运营指定图片"/><figcaption>指定图片 {index + 1}</figcaption></figure>)}</div>}
</div></React.Fragment>)}
</article> </article>
})} })}
</div> </div>

View File

@ -1,6 +1,7 @@
import React, { useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { Check, Link2, Plus, Sparkles } from "lucide-react"; import { Check, Link2, Plus, Sparkles } from "lucide-react";
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js"; import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
import { readPageCache, writePageCache } from "./pageCache.js";
const blankForm = { const blankForm = {
product_link: "", product_link: "",
@ -13,6 +14,7 @@ const blankForm = {
usage_scene: "", usage_scene: "",
purchase_channel: "", purchase_channel: "",
question_count: "", question_count: "",
claim_questions_per_user: "",
}; };
async function readReferenceFile(file, requestApi) { async function readReferenceFile(file, requestApi) {
@ -27,15 +29,42 @@ const blankForm = {
} }
export default function CreateAskTasksPage({ api }) { export default function CreateAskTasksPage({ api }) {
const [form, setForm] = useState(blankForm); const cached = readPageCache("create-ask-task", {});
const [task, setTask] = useState(null); const [form, setForm] = useState({ ...blankForm, ...(cached.form || {}) });
const [questions, setQuestions] = useState([]); const [task, setTask] = useState(cached.task || null);
const [questions, setQuestions] = useState(cached.questions || []);
const [editing, setEditing] = useState(null); const [editing, setEditing] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [referenceFile, setReferenceFile] = useState(null); const [referenceFile, setReferenceFile] = useState(null);
const referenceInputRef = useRef(null);
const [referenceFileName, setReferenceFileName] = useState(cached.referenceFileName || "");
const [referenceQa, setReferenceQa] = useState(cached.referenceQa || "");
useEffect(() => {
writePageCache("create-ask-task", { form, task, questions, referenceQa, referenceFileName });
}, [form, task, questions, referenceQa, referenceFileName]);
const update = (key, value) => const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value })); setForm((current) => ({ ...current, [key]: value }));
async function selectReferenceFile(file) {
setReferenceFile(file);
setReferenceFileName(file?.name || "");
setError("");
if (!file) {
setReferenceQa("");
return;
}
try {
setReferenceQa(await readReferenceFile(file, api));
} catch (err) {
setError(err.message || "参考文件读取失败");
}
}
function clearReferenceFile() {
setReferenceFile(null);
setReferenceFileName("");
setReferenceQa("");
if (referenceInputRef.current) referenceInputRef.current.value = "";
}
async function generate() { async function generate() {
const intentError = analyzeProductIntent([ const intentError = analyzeProductIntent([
@ -56,7 +85,8 @@ export default function CreateAskTasksPage({ api }) {
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
const reference_qa = await readReferenceFile(referenceFile, api); const reference_qa = referenceFile ? await readReferenceFile(referenceFile, api) : referenceQa;
setReferenceQa(reference_qa);
const result = await api("/api/admin/ask-tasks/generate", { const result = await api("/api/admin/ask-tasks/generate", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
@ -185,6 +215,7 @@ export default function CreateAskTasksPage({ api }) {
setForm(blankForm); setForm(blankForm);
setQuestions([]); setQuestions([]);
setTask(null); setTask(null);
clearReferenceFile();
}} }}
> >
继续创建新任务 继续创建新任务
@ -269,11 +300,35 @@ export default function CreateAskTasksPage({ api }) {
</label> </label>
<label className="reference-qa-upload"> <label className="reference-qa-upload">
真实问答参考(选填) 真实问答参考(选填)
<input type="file" accept=".txt,.csv,.json,.md,.xlsx,text/plain,text/csv,application/json,text/markdown,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => { setReferenceFile(event.target.files?.[0] || null); setError(""); }} /> <input ref={referenceInputRef} type="file" accept=".txt,.csv,.json,.md,.xlsx,text/plain,text/csv,application/json,text/markdown,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => selectReferenceFile(event.target.files?.[0] || null)} />
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown、XLSX,生成提问时会参考其中的问题角度"}</small> <small>{referenceFileName ? `已选择:${referenceFileName}` : "支持 TXT、CSV、JSON、Markdown、XLSX,生成提问时会参考其中的问题角度"}</small>
{referenceFile && <button type="button" className="reference-qa-clear" onClick={() => setReferenceFile(null)}>清除已选文件</button>} {referenceFileName && <button type="button" className="reference-qa-clear" onClick={clearReferenceFile}>清除已选文件</button>}
</label> </label>
<h4>生成偏好与数量</h4> <h4>生成偏好与数量</h4>
<div className="two-col">
<label>
提问生成条数 *
<input
type="number"
min="1"
required
value={form.question_count}
onChange={(event) => update("question_count", event.target.value)}
/>
</label>
<label>
每个用户可认领的问题数量(选填)
<input
type="number"
min="2"
max="5"
value={form.claim_questions_per_user}
onChange={(event) => update("claim_questions_per_user", event.target.value)}
placeholder="不填写默认每人 5 条"
/>
<small>填写后仅允许 2-5 条</small>
</label>
</div>
<div className="two-col"> <div className="two-col">
<label> <label>
语言风格 语言风格
@ -302,15 +357,6 @@ export default function CreateAskTasksPage({ api }) {
</select> </select>
</label> </label>
</div> </div>
<label>
提问生成条数
<input
type="number"
min="1"
value={form.question_count}
onChange={(event) => update("question_count", event.target.value)}
/>
</label>
<div className="two-col"> <div className="two-col">
<label> <label>
使用场景 使用场景

View File

@ -22,6 +22,24 @@ const StatusBadge = ({ status }) => (
<em className={`review-v2-status ${status}`}>{status}</em> <em className={`review-v2-status ${status}`}>{status}</em>
); );
function RefundPrompt({ onConfirm, onCancel }) {
return (
<div className="review-v2-backdrop refund-prompt-backdrop">
<section className="refund-prompt" role="dialog" aria-modal="true" aria-labelledby="refund-prompt-title">
<div className="refund-prompt-icon">✓</div>
<div className="refund-prompt-content">
<h2 id="refund-prompt-title">审核已通过</h2>
<p>是否前往任务看板的“待返款”模块进行返款?</p>
</div>
<div className="refund-prompt-actions">
<button type="button" className="refund-prompt-cancel" onClick={onCancel}>暂不前往</button>
<button type="button" className="refund-prompt-confirm" onClick={onConfirm}>前往待返款</button>
</div>
</section>
</div>
);
}
function ReviewDetail({ record, close, decide, busy }) { function ReviewDetail({ record, close, decide, busy }) {
const [reason, setReason] = useState(REASONS[0]); const [reason, setReason] = useState(REASONS[0]);
const [customReason, setCustomReason] = useState(""); const [customReason, setCustomReason] = useState("");
@ -168,7 +186,7 @@ function ReviewDetail({ record, close, decide, busy }) {
); );
} }
function AskReviewDetail({ record, close, onChanged, api }) { function AskReviewDetail({ record, close, onChanged, api, onApproved }) {
const [reason, setReason] = useState("截图不清晰"); const [reason, setReason] = useState("截图不清晰");
const [reasonText, setReasonText] = useState(""); const [reasonText, setReasonText] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -190,6 +208,7 @@ function AskReviewDetail({ record, close, onChanged, api }) {
close(); close();
await onChanged(); await onChanged();
window.dispatchEvent(new Event("review-count-changed")); window.dispatchEvent(new Event("review-count-changed"));
if (approved) onApproved();
} catch (error) { } catch (error) {
window.alert(error.message); window.alert(error.message);
} finally { } finally {
@ -328,7 +347,7 @@ function AskReviewDetail({ record, close, onChanged, api }) {
); );
} }
function AskReviewQueue({ api }) { function AskReviewQueue({ api, onApproved }) {
const [records, setRecords] = useState([]); const [records, setRecords] = useState([]);
const [detail, setDetail] = useState(null); const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -409,6 +428,7 @@ function AskReviewQueue({ api }) {
close={() => setDetail(null)} close={() => setDetail(null)}
onChanged={load} onChanged={load}
api={api} api={api}
onApproved={onApproved}
/> />
)} )}
</> </>
@ -424,6 +444,7 @@ export default function ReviewCenterPage({ api }) {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [refundPrompt, setRefundPrompt] = useState(false);
const loadVersion = useRef(0); const loadVersion = useRef(0);
async function openAnswerDetail(record) { async function openAnswerDetail(record) {
setDetailLoading(true); setDetailLoading(true);
@ -490,6 +511,7 @@ export default function ReviewCenterPage({ api }) {
await load(); await load();
window.dispatchEvent(new Event("review-count-changed")); window.dispatchEvent(new Event("review-count-changed"));
setStatusFilter("待审核"); setStatusFilter("待审核");
if (approved) setRefundPrompt(true);
} catch (err) { } catch (err) {
window.alert(err.message); window.alert(err.message);
} finally { } finally {
@ -546,7 +568,7 @@ export default function ReviewCenterPage({ api }) {
</div> </div>
{error && <div className="form-error">{error}</div>} {error && <div className="form-error">{error}</div>}
{queue === "ask" ? ( {queue === "ask" ? (
<AskReviewQueue api={api} /> <AskReviewQueue api={api} onApproved={() => setRefundPrompt(true)} />
) : ( ) : (
<> <>
<div className="review-v2-status-filter"> <div className="review-v2-status-filter">
@ -619,6 +641,15 @@ export default function ReviewCenterPage({ api }) {
busy={busy} busy={busy}
/> />
)} )}
{refundPrompt && (
<RefundPrompt
onCancel={() => setRefundPrompt(false)}
onConfirm={() => {
setRefundPrompt(false);
window.dispatchEvent(new CustomEvent("open-task-board", { detail: { status: "待返款" } }));
}}
/>
)}
</> </>
); );
} }

View File

@ -251,11 +251,12 @@ function TaskDetail({ task, close, onConfirmRefund }) {
export default function TaskBoardPage({ api }) { export default function TaskBoardPage({ api }) {
const [tasks, setTasks] = useState(() => taskBoardCache || []); const [tasks, setTasks] = useState(() => taskBoardCache || []);
const [type, setType] = useState("all"); const [type, setType] = useState("all");
const [status, setStatus] = useState("all"); const [status, setStatus] = useState(() => window.__taskBoardInitialStatus || "all");
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [dateFrom, setDateFrom] = useState(""); const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState(""); const [dateTo, setDateTo] = useState("");
const [selectedIds, setSelectedIds] = useState([]); const [selectedIds, setSelectedIds] = useState([]);
const [expandedAskTasks, setExpandedAskTasks] = useState(() => new Set());
const [detail, setDetail] = useState(null); const [detail, setDetail] = useState(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(() => taskBoardCache === null); const [loading, setLoading] = useState(() => taskBoardCache === null);
@ -278,8 +279,11 @@ export default function TaskBoardPage({ api }) {
} }
useEffect(() => { useEffect(() => {
load(taskBoardCache === null); load(taskBoardCache === null);
window.__taskBoardInitialStatus = "";
}, []); }, []);
const rowKey = (task) => task.board_key || `${task.task_type}-${task.id}`; const rowKey = (task) => task.board_key || `${task.task_type}-${task.id}`;
const isAskTask = (task) => task.task_type === "ask" || task.task_type_label === "提问任务";
const childRows = (task) => (isAskTask(task) ? task.claimed_tasks || [] : []);
// Keep filtering as a direct derivation so changing either filter always // Keep filtering as a direct derivation so changing either filter always
// immediately updates the table (without relying on a stale memoized list). // immediately updates the table (without relying on a stale memoized list).
const rows = tasks.filter((task) => { const rows = tasks.filter((task) => {
@ -295,8 +299,13 @@ export default function TaskBoardPage({ api }) {
(taskType === "ask" || taskTypeLabel === "提问任务")) || (taskType === "ask" || taskTypeLabel === "提问任务")) ||
(type === "answer" && (type === "answer" &&
(taskType === "answer" || taskTypeLabel === "回答任务")); (taskType === "answer" || taskTypeLabel === "回答任务"));
const statusMatch = status === "all" || taskStatus === status; const children = childRows(task);
const text = `${task.user_id || ""} ${task.task_id || ""} ${task.product_name || ""}`.toLowerCase(); const childStatusMatch = children.some((child) => {
const childRawStatus = String(child.status_label || child.status || "").trim();
return (STATUS_ALIASES[childRawStatus] || childRawStatus) === status;
});
const statusMatch = status === "all" || taskStatus === status || childStatusMatch;
const text = `${task.user_id || ""} ${task.task_id || ""} ${task.product_name || ""} ${children.map((child) => `${child.user_id || ""} ${child.phone || ""}`).join(" ")}`.toLowerCase();
const createdDate = String(task.created_at || task.updated_at || "").slice(0, 10); const createdDate = String(task.created_at || task.updated_at || "").slice(0, 10);
const fromMatch = !dateFrom || (createdDate && createdDate >= dateFrom); const fromMatch = !dateFrom || (createdDate && createdDate >= dateFrom);
const toMatch = !dateTo || (createdDate && createdDate <= dateTo); const toMatch = !dateTo || (createdDate && createdDate <= dateTo);
@ -321,6 +330,15 @@ export default function TaskBoardPage({ api }) {
setType("all"); setType("all");
setStatus("all"); setStatus("all");
} }
function toggleAskTask(task) {
const key = rowKey(task);
setExpandedAskTasks((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
function exportRows() { function exportRows() {
if (!rows.length) { if (!rows.length) {
window.alert("当前没有可导出的任务"); window.alert("当前没有可导出的任务");
@ -457,6 +475,9 @@ export default function TaskBoardPage({ api }) {
taskBoardCache = next; taskBoardCache = next;
return next; return next;
}); });
// A claimed ask row is nested under its system task. Refresh the compact
// tree as well so the child immediately leaves “待返款” after payment.
await load(false);
} }
return ( return (
<> <>
@ -542,68 +563,66 @@ export default function TaskBoardPage({ api }) {
暂无符合条件的任务 暂无符合条件的任务
</div> </div>
) : ( ) : (
rows.map((task) => ( rows.map((task) => {
<div className="board-table-row" key={rowKey(task)}> const key = rowKey(task);
const children = childRows(task);
const expanded = expandedAskTasks.has(key);
return (
<React.Fragment key={key}>
<div className="board-table-row">
<span className="board-select-cell"> <span className="board-select-cell">
<input type="checkbox" checked={selectedIds.includes(rowKey(task))} 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> <span className="board-user-cell">
{children.length > 0 && (
<button
type="button"
className={`board-expand-button${expanded ? " open" : ""}`}
onClick={() => toggleAskTask(task)}
aria-label={expanded ? "收起认领任务" : "展开认领任务"}
>
▾
</button>
)}
<code>{task.user_id}</code> <code>{task.user_id}</code>
</span> </span>
<span> <span><b>{task.task_id}</b></span>
<b>{task.task_id}</b> <span><strong>{task.product_name}</strong><small>{maskPhone(task.phone)}</small></span>
</span> <span><em className={`board-type ${task.task_type}`}>{task.task_type_label}</em></span>
<span>
<strong>{task.product_name}</strong>
<small>{maskPhone(task.phone)}</small>
</span>
<span>
<em className={`board-type ${task.task_type}`}>
{task.task_type_label}
</em>
</span>
<span>{task.qa_count}</span> <span>{task.qa_count}</span>
<span> <span>{task.pending_review_count} / {task.completed_count}</span>
{task.pending_review_count} / {task.completed_count} <span><em className={`board-status ${task.status_label}`}>{task.status_label}</em></span>
</span> <span>{task.creator_name}<small>{formatTime(task.created_at)}</small></span>
<span>
<em className={`board-status ${task.status_label}`}>
{task.status_label}
</em>
</span>
<span>
{task.creator_name}
<small>{formatTime(task.created_at)}</small>
</span>
<span className="board-actions"> <span className="board-actions">
<button onClick={() => openDetail(task)}>详情</button> <button onClick={() => openDetail(task)}>详情</button>
{task.can_cancel && ( {task.can_cancel && (
<button <button className="cancel" disabled={busy === task.id} onClick={() => cancel(task)}>
className="cancel"
disabled={busy === task.id}
onClick={() => cancel(task)}
>
{busy === task.id ? "撤销中" : "撤销"} {busy === task.id ? "撤销中" : "撤销"}
</button> </button>
)} )}
{task.can_complete && task.task_type === "ask" && ( {task.can_complete && task.task_type === "ask" && (
<button <button className="complete" disabled={busy === task.id} onClick={() => setCompleteConfirm(task)}>完成任务</button>
className="complete"
disabled={busy === task.id}
onClick={() => setCompleteConfirm(task)}
>
完成任务
</button>
)} )}
<button <button className="delete" onClick={() => setBatchConfirm({ tasks: [task] })}>删除</button>
className="delete"
onClick={() => setBatchConfirm({ tasks: [task] })}
>
删除
</button>
</span> </span>
</div> </div>
)) {expanded && children.map((child) => (
<div className="board-table-row board-child-row" key={rowKey(child)}>
<span className="board-select-cell" />
<span className="board-user-cell board-child-user"><i>↳</i><code>{child.user_id}</code></span>
<span><b>{child.task_id}</b></span>
<span><strong>{child.product_name}</strong><small>{maskPhone(child.phone)}</small></span>
<span><em className="board-type ask">提问任务</em></span>
<span>{child.qa_count}</span>
<span>{child.pending_review_count} / {child.completed_count}</span>
<span><em className={`board-status ${child.status_label}`}>{child.status_label}</em></span>
<span>{child.creator_name}<small>{formatTime(child.created_at)}</small></span>
<span className="board-actions"><button onClick={() => openDetail(child)}>详情</button></span>
</div>
))}
</React.Fragment>
);
})
)} )}
</section> </section>
{detail && ( {detail && (

View File

@ -1589,6 +1589,67 @@
.source-image-wrap small { .source-image-wrap small {
white-space: nowrap; white-space: nowrap;
} }
.operator-image-panel {
display: grid;
gap: 10px;
margin: 0 14px 14px 48px;
padding: 12px 14px;
border: 1px dashed #9bbcf0;
border-radius: 10px;
background: #f6faff;
}
.operator-image-panel > div:first-child {
display: grid;
gap: 4px;
}
.operator-image-panel strong {
color: #193c69;
font-size: 12px;
}
.operator-image-panel small {
color: #7890aa;
font-size: 11px;
}
.operator-image-upload {
width: fit-content;
padding: 7px 11px;
border: 1px solid #b8d0f3;
border-radius: 7px;
background: #fff;
color: #2864e8;
font-size: 11px;
cursor: pointer;
}
.operator-image-upload input {
display: none;
}
.operator-image-list,
.operator-instruction-images {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.operator-image-list figure,
.operator-instruction-images figure {
margin: 0;
}
.operator-image-list img {
display: block;
width: 84px;
height: 64px;
border: 1px solid #d7e3f3;
border-radius: 7px;
object-fit: cover;
}
.operator-image-list figcaption,
.operator-instruction-images figcaption {
max-width: 120px;
overflow: hidden;
color: #7890aa;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.draft-icon-button { .draft-icon-button {
flex: 0 0 38px; flex: 0 0 38px;
height: 38px; height: 38px;
@ -1830,6 +1891,51 @@
.board-table-row > span { .board-table-row > span {
min-width: 0; min-width: 0;
} }
.board-user-cell {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.board-expand-button {
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 5px;
background: #edf3ff;
color: #2864e8;
font-size: 14px;
line-height: 18px;
cursor: pointer;
transform: rotate(-90deg);
transition: transform 160ms ease, background 160ms ease;
}
.board-expand-button:hover {
background: #dce8ff;
}
.board-expand-button.open {
transform: rotate(0deg);
}
.board-child-row {
min-height: 58px;
background: #fbfcff;
color: #55708f;
}
.board-child-row > span:first-child {
visibility: hidden;
}
.board-child-user {
padding-left: 8px;
}
.board-child-user i {
color: #8ca2bc;
font-size: 16px;
font-style: normal;
}
.board-child-row .board-actions button {
color: #2864e8;
}
.board-select-cell { .board-select-cell {
display: grid; display: grid;
place-items: center; place-items: center;
@ -2780,6 +2886,68 @@
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
} }
.operator-instruction-card {
border-color: #a9d9be;
background: #f8fffb;
}
.operator-instruction-card > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.operator-instruction-card h2 {
margin: 0 0 4px;
font-size: 16px;
}
.operator-instruction-card header p {
margin: 0;
color: #7188a4;
font-size: 10px;
}
.operator-instruction-card header span {
padding: 4px 8px;
border-radius: 12px;
background: #dff8e9;
color: #15945d;
font-size: 10px;
white-space: nowrap;
}
.operator-instruction-images {
margin-top: 12px;
}
.operator-instruction-images img {
display: block;
width: 108px;
height: 84px;
border: 1px solid #cfe5d8;
border-radius: 8px;
background: #fff;
object-fit: contain;
}
.answer-question-operator-image {
display: grid;
gap: 6px;
margin: 8px 0;
padding: 8px;
border: 1px solid #b9d8fb;
border-radius: 8px;
background: #f8fbff;
color: #2864e8;
font-size: 10px;
}
.answer-question-operator-image img {
width: 110px;
height: 82px;
border-radius: 6px;
object-fit: contain;
background: #fff;
}
.answer-question-operator-image > div {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.assignment-list h2 { .assignment-list h2 {
margin: 0 0 4px; margin: 0 0 4px;
font-size: 16px; font-size: 16px;
@ -3287,6 +3455,67 @@
background: rgba(9, 20, 38, 0.58); background: rgba(9, 20, 38, 0.58);
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
.refund-prompt-backdrop {
z-index: 70;
}
.refund-prompt {
display: grid;
grid-template-columns: auto 1fr;
gap: 14px;
width: min(430px, calc(100vw - 32px));
padding: 24px;
border: 1px solid #dce7f5;
border-radius: 16px;
background: #fff;
box-shadow: 0 24px 70px rgba(7, 25, 55, 0.28);
}
.refund-prompt-icon {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: #e8f8ef;
color: #15945d;
font-size: 21px;
font-weight: 800;
}
.refund-prompt-content h2 {
margin: 1px 0 6px;
color: #173251;
font-size: 17px;
}
.refund-prompt-content p {
margin: 0;
color: #607998;
font-size: 12px;
line-height: 1.7;
}
.refund-prompt-actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 9px;
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid #edf1f6;
}
.refund-prompt-actions button {
padding: 9px 15px;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
}
.refund-prompt-cancel {
border: 1px solid #d2dfed;
background: #f7faff;
color: #607998;
}
.refund-prompt-confirm {
background: #1764e8;
color: #fff;
box-shadow: 0 5px 12px rgba(23, 100, 232, 0.2);
}
.review-v2-modal { .review-v2-modal {
width: min(900px, 100%); width: min(900px, 100%);
max-height: calc(100vh - 36px); max-height: calc(100vh - 36px);

View File

@ -1,8 +1,6 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { import {
Bell,
ChevronDown,
ClipboardList, ClipboardList,
Database, Database,
FileText, FileText,
@ -24,6 +22,7 @@ import ReviewCenterPage from "./ReviewCenterPage.jsx";
import CreateAskTasksPage from "./CreateAskTasksPage.jsx"; import CreateAskTasksPage from "./CreateAskTasksPage.jsx";
import AskUserPortal from "./AskUserPortal.jsx"; import AskUserPortal from "./AskUserPortal.jsx";
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js"; import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
import { readPageCache, writePageCache } from "./pageCache.js";
// 兼容公共回答链接末尾有无斜杠,避免 /answer 被识别为运营后台路由。 // 兼容公共回答链接末尾有无斜杠,避免 /answer 被识别为运营后台路由。
if (window.location.pathname === "/answer") if (window.location.pathname === "/answer")
@ -333,17 +332,13 @@ function Topbar({ user, onLogout }) {
<header className="topbar"> <header className="topbar">
<div /> <div />
<div className="top-actions"> <div className="top-actions">
<Bell size={19} className="bell" />
<span className="divider" />
<div className="user-menu"> <div className="user-menu">
<div className="avatar">{user.display_name.slice(0, 1)}</div> <div className="avatar">{user.display_name.slice(0, 1)}</div>
<div> <div>
<strong> <strong>
{user.display_name}({user.role_label}) {user.display_name}({user.role_label})
</strong> </strong>
<span>{"\u7f8e\u5986\u6570\u636e\u7ec4"}</span>
</div> </div>
<ChevronDown size={15} />
<button <button
className="logout-btn" className="logout-btn"
onClick={onLogout} onClick={onLogout}
@ -416,7 +411,7 @@ function AssistantPage() {
body: JSON.stringify({ body: JSON.stringify({
...form, ...form,
question_count: Number(form.question_count), question_count: Number(form.question_count),
answers_per_question: Number(form.answers_per_question), answers_per_question: Number(form.answers_per_question) || 2,
reference_qa, reference_qa,
}), }),
}); });
@ -566,11 +561,11 @@ function AssistantPage() {
</div> </div>
<div className="two-col"> <div className="two-col">
<label> <label>
提问生成条数 提问生成条数 *
<input <input
type="number" type="number"
min="1" min="1"
max="20" required
value={form.question_count} value={form.question_count}
onChange={(event) => onChange={(event) =>
update("question_count", event.target.value) update("question_count", event.target.value)
@ -1000,7 +995,7 @@ function AssistantPageV2() {
body: JSON.stringify({ body: JSON.stringify({
...form, ...form,
question_count: Number(form.question_count), question_count: Number(form.question_count),
answers_per_question: Number(form.answers_per_question), answers_per_question: Number(form.answers_per_question) || 2,
}), }),
}); });
setItems(result.data.items); setItems(result.data.items);
@ -1127,11 +1122,11 @@ function AssistantPageV2() {
</div> </div>
<div className="two-col"> <div className="two-col">
<label> <label>
提问生成条数 提问生成条数 *
<input <input
type="number" type="number"
min="1" min="1"
max="20" required
value={form.question_count} value={form.question_count}
onChange={(event) => onChange={(event) =>
update("question_count", event.target.value) update("question_count", event.target.value)
@ -1482,7 +1477,8 @@ function QALibraryEditModal({ record, onClose, onSaved }) {
} }
function AssistantPageFinal() { function AssistantPageFinal() {
const [form, setForm] = useState({ const cached = readPageCache("assistant", {});
const [form, setForm] = useState(cached.form || {
product_name: "", product_name: "",
category: "", category: "",
benefits: "", benefits: "",
@ -1496,7 +1492,7 @@ function AssistantPageFinal() {
answer_requirement: "", answer_requirement: "",
seed_style: false, seed_style: false,
}); });
const [items, setItems] = useState([]); const [items, setItems] = useState(cached.items || []);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -1504,9 +1500,34 @@ function AssistantPageFinal() {
const [adoptedDialog, setAdoptedDialog] = useState(null); const [adoptedDialog, setAdoptedDialog] = useState(null);
const [manualOpen, setManualOpen] = useState(false); const [manualOpen, setManualOpen] = useState(false);
const [referenceFile, setReferenceFile] = useState(null); const [referenceFile, setReferenceFile] = useState(null);
const [referenceQa, setReferenceQa] = useState(""); const [referenceFileName, setReferenceFileName] = useState(cached.referenceFileName || "");
const [referenceQa, setReferenceQa] = useState(cached.referenceQa || "");
const referenceInputRef = useRef(null);
useEffect(() => {
writePageCache("assistant", { form, items, referenceQa, referenceFileName });
}, [form, items, referenceQa, referenceFileName]);
const update = (key, value) => const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value })); setForm((current) => ({ ...current, [key]: value }));
async function selectReferenceFile(file) {
setReferenceFile(file);
setReferenceFileName(file?.name || "");
setError("");
if (!file) {
setReferenceQa("");
return;
}
try {
setReferenceQa(await readReferenceFile(file, api));
} catch (err) {
setError(err.message || "参考文件读取失败");
}
}
function clearReferenceFile() {
setReferenceFile(null);
setReferenceFileName("");
setReferenceQa("");
if (referenceInputRef.current) referenceInputRef.current.value = "";
}
async function generate() { async function generate() {
const intentError = analyzeProductIntent([ const intentError = analyzeProductIntent([
form.product_name, form.product_name,
@ -1528,12 +1549,13 @@ function AssistantPageFinal() {
setLoading(true); setLoading(true);
try { try {
const reference_qa = referenceFile ? await readReferenceFile(referenceFile, api) : referenceQa; const reference_qa = referenceFile ? await readReferenceFile(referenceFile, api) : referenceQa;
setReferenceQa(reference_qa);
const result = await api("/api/admin/qa/generate", { const result = await api("/api/admin/qa/generate", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
...form, ...form,
question_count: Number(form.question_count), question_count: Number(form.question_count),
answers_per_question: Number(form.answers_per_question), answers_per_question: Number(form.answers_per_question) || 2,
reference_qa, reference_qa,
}), }),
}); });
@ -1668,9 +1690,9 @@ function AssistantPageFinal() {
</label> </label>
<label className="reference-qa-upload"> <label className="reference-qa-upload">
真实问答参考(选填) 真实问答参考(选填)
<input type="file" accept=".txt,.csv,.json,.md,.xlsx,text/plain,text/csv,application/json,text/markdown,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => setReferenceFile(event.target.files?.[0] || null)} /> <input ref={referenceInputRef} type="file" accept=".txt,.csv,.json,.md,.xlsx,text/plain,text/csv,application/json,text/markdown,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => selectReferenceFile(event.target.files?.[0] || null)} />
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown、XLSX,模型将参考其中的问答语气与角度"}</small> <small>{referenceFileName ? `已选择:${referenceFileName}` : "支持 TXT、CSV、JSON、Markdown、XLSX,模型将参考其中的问答语气与角度"}</small>
{referenceFile && <button type="button" className="reference-qa-clear" onClick={() => setReferenceFile(null)}>清除已选文件</button>} {referenceFileName && <button type="button" className="reference-qa-clear" onClick={clearReferenceFile}>清除已选文件</button>}
</label> </label>
<div className="rule" /> <div className="rule" />
<h4>生成偏好与数量</h4> <h4>生成偏好与数量</h4>
@ -1705,11 +1727,11 @@ function AssistantPageFinal() {
</div> </div>
<div className="two-col"> <div className="two-col">
<label> <label>
提问生成条数 提问生成条数 *
<input <input
type="number" type="number"
min="1" min="1"
max="20" required
value={form.question_count} value={form.question_count}
onChange={(event) => onChange={(event) =>
update("question_count", event.target.value) update("question_count", event.target.value)
@ -3740,7 +3762,10 @@ function Dashboard({ user, onLogout }) {
} }
useEffect(() => { useEffect(() => {
const handler = (event) => { const handler = (event) => {
if (event.type === "open-task-board") setActive("board"); if (event.type === "open-task-board") {
if (event.detail?.status) window.__taskBoardInitialStatus = event.detail.status;
setActive("board");
}
if (event.type === "switch-create-answer") { if (event.type === "switch-create-answer") {
setActive("create"); setActive("create");
setCreateMode("answer"); setCreateMode("answer");

26
frontend/src/pageCache.js Normal file
View File

@ -0,0 +1,26 @@
const CACHE_PREFIX = "wendajia-page-cache:";
export function readPageCache(key, fallback = null) {
try {
const raw = window.localStorage.getItem(`${CACHE_PREFIX}${key}`);
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
}
export function writePageCache(key, value) {
try {
window.localStorage.setItem(`${CACHE_PREFIX}${key}`, JSON.stringify(value));
} catch {
// 缓存空间不足或浏览器禁用存储时,不影响页面正常使用。
}
}
export function clearPageCache(key) {
try {
window.localStorage.removeItem(`${CACHE_PREFIX}${key}`);
} catch {
// Ignore storage errors.
}
}

View File

@ -27,14 +27,8 @@ export function analyzeProductIntent(values) {
} }
export function validateGenerationOptions(form, includeAnswers = true) { export function validateGenerationOptions(form, includeAnswers = true) {
const required = [ // 生成偏好均为选填,只有生成条数需要填写并进行数值校验。
["language_style", "语言风格"], const required = [["question_count", "提问生成条数"]];
["target_group", "目标人群"],
["question_count", "提问生成条数"],
["usage_scene", "使用场景"],
["purchase_channel", "购买渠道"],
];
if (includeAnswers) required.splice(3, 0, ["answers_per_question", "单题回答条数"]);
const missing = required const missing = required
.filter(([key]) => !String(form[key] ?? "").trim()) .filter(([key]) => !String(form[key] ?? "").trim())
.map(([, label]) => label); .map(([, label]) => label);
@ -42,8 +36,11 @@ export function validateGenerationOptions(form, includeAnswers = true) {
if (!Number.isInteger(Number(form.question_count)) || Number(form.question_count) < 1) { if (!Number.isInteger(Number(form.question_count)) || Number(form.question_count) < 1) {
return "提问生成条数必须是大于 0 的整数"; return "提问生成条数必须是大于 0 的整数";
} }
if (includeAnswers && (!Number.isInteger(Number(form.answers_per_question)) || Number(form.answers_per_question) < 1)) { if (String(form.claim_questions_per_user ?? "").trim()) {
return "单题回答条数必须是大于 0 的整数"; const claimCount = Number(form.claim_questions_per_user);
if (!Number.isInteger(claimCount) || claimCount < 2 || claimCount > 5) {
return "每个用户可认领的问题数量必须在 2-5 条之间";
}
} }
return ""; return "";
} }

View File

@ -220,7 +220,7 @@ button {
font-size: 11px; font-size: 11px;
} }
.reference-qa-upload input[type="file"] { .reference-qa-upload input[type="file"] {
color: #466181; color: transparent;
} }
.reference-qa-upload input[type="file"]::file-selector-button { .reference-qa-upload input[type="file"]::file-selector-button {
margin-right: 10px; margin-right: 10px;