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

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.benefits or '未提供'}
商品卖点:{payload.selling_points or '未提供'}
语言风格:{payload.language_style}
目标人群:{payload.target_group}
使用场景:{payload.usage_scene}
购买渠道:{payload.purchase_channel}
语言风格:{payload.language_style or '客观自然'}
目标人群:{payload.target_group or '普通消费者'}
使用场景:{payload.usage_scene or '日常使用'}
购买渠道:{payload.purchase_channel or '电商平台'}
回答要求:{payload.answer_requirement or '直接回应问题,避免夸大功效'}
回答语气:{style}
真实问答参考:{payload.reference_qa or '未提供'}
@ -65,7 +65,7 @@ def _parse_content(content: str, expected_answers: int) -> list[dict]:
return items
def generate_qa(payload: QAGenerateRequest) -> list[dict]:
def _generate_qa_batch(payload: QAGenerateRequest) -> list[dict]:
if not settings.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"
@ -88,7 +88,33 @@ def generate_qa(payload: QAGenerateRequest) -> list[dict]:
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."""
if not settings.llm_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('benefits') or '未提供'}
商品卖点:{payload.get('selling_points') or '未提供'}
语言风格:{payload.get('language_style')}
目标人群:{payload.get('target_group')}
使用场景:{payload.get('usage_scene')}
购买渠道:{payload.get('purchase_channel')}
语言风格:{payload.get('language_style') or '客观自然'}
目标人群:{payload.get('target_group') or '普通消费者'}
使用场景:{payload.get('usage_scene') or '日常使用'}
购买渠道:{payload.get('purchase_channel') or '电商平台'}
真实问答参考:{reference_qa or '未提供'}
只返回严格 JSON,不要 Markdown 或解释,格式:{{"questions":["问题1","问题2"]}}。问题应像真实买家提问,尽量不超过30字,不要虚构具体参数或功效。"""
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
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:
"""Generate one concise answer. Falls back to a safe draft during the simulated-OCR MVP."""
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.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 .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:
required = [
("language_style", "语言风格"),
("target_group", "目标人群"),
("question_count", "提问生成条数"),
("usage_scene", "使用场景"),
("purchase_channel", "购买渠道"),
]
if include_answers:
required.insert(3, ("answers_per_question", "单题回答条数"))
# 语言风格、目标人群、场景等生成偏好均可留空,仅限制生成条数必填。
required = [("question_count", "提问生成条数")]
missing = [label for key, label in required if not str(payload.get(key, "")).strip()]
if missing:
return f"请填写{'、'.join(missing)}"
@ -103,13 +96,6 @@ def _generation_options_error(payload: dict, include_answers: bool = True) -> st
return "提问生成条数必须是大于 0 的整数"
if question_count < 1:
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
@ -171,6 +157,21 @@ def seed_users(db: Session):
@app.on_event("startup")
def startup():
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())
try:
seed_users(db)
@ -353,6 +354,20 @@ def _dingtalk_login(code: str, db: Session, response: Response) -> AdminUser:
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:
if task_no == "latest":
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,
"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,
"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,
} 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:
questions = sorted(task.questions, key=lambda value: value.sequence_no)
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)
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 {
"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": "提问任务",
"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),
"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),
"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,
"delete_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}",
"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 []),
"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,
}
@ -739,7 +839,8 @@ def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)):
status_code=409,
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="待选择")
db.add(assignment); db.flush()
for question in assigned:
@ -856,17 +957,19 @@ async def submit_public_ask_task(
@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(
select(AskAssignmentBatch)
select(AskAssignmentBatch).join(AskAssignmentBatch.assignment).join(AskAssignment.task)
.where(AskAssignmentBatch.status == "pending")
.where(_ask_task_owner_clause(user))
.order_by(AskAssignmentBatch.updated_at.desc())
).unique().all()
legacy = db.scalars(
select(AskAssignment).where(
select(AskAssignment).join(AskAssignment.task).where(
AskAssignment.status == "待审核",
~AskAssignment.batches.any(),
AskAssignment.screenshots.any(),
_ask_task_owner_clause(user),
).order_by(AskAssignment.updated_at.desc())
).unique().all()
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")
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."""
answer_count = db.scalar(
select(func.count(AnswerSubmission.id)).where(AnswerSubmission.status == "待审核")
) or 0
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
legacy_ask_count = db.scalar(
select(func.count(AskAssignment.id)).where(
select(func.count(AskAssignment.id)).join(AskAssignment.task).where(
AskAssignment.status == "待审核",
~AskAssignment.batches.any(),
AskAssignment.screenshots.any(),
_ask_task_owner_clause(user),
)
) or 0
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}")
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)
if batch:
_require_ask_task_access(batch.assignment.task, user)
return {"success": True, "data": _ask_batch_data(batch)}
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="提问审核任务不存在")
return {"success": True, "data": _ask_assignment_data(assignment)}
@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)
if batch:
_require_ask_task_access(batch.assignment.task, user)
if batch.status != "pending":
raise HTTPException(status_code=409, detail="review already completed")
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)}
@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")
def reject_answer_task_draft(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
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")
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_name = str(payload.get("product_name", "")).strip()
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="提问生成条数必须是正整数")
if count < 1:
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")
try:
task = db.get(AskTask, int(existing_task_id)) if existing_task_id else None
except (TypeError, ValueError):
task = None
if task:
_require_ask_task_access(task, user)
if task.status != "草稿":
raise HTTPException(status_code=409, detail="已发布任务不能重新生成")
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.usage_scene = str(payload.get("usage_scene", ""))
task.purchase_channel = str(payload.get("purchase_channel", ""))
task.claim_questions_per_user = claim_limit
for question in list(task.questions):
db.delete(question)
db.flush()
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()
questions = payload.get("questions") if isinstance(payload.get("questions"), list) else []
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}")
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))
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" in payload:
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")
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)
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 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)
@ -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")
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)
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 not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问")
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")
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)
if not task: raise HTTPException(status_code=404, detail="提问任务不存在")
_require_ask_task_access(task, user)
if task.status != "已发布": raise HTTPException(status_code=409, detail="当前任务不能撤销")
task.status = "已撤销"; db.commit(); db.refresh(task)
return {"success": True, "data": _ask_task_data(task)}
@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)
if not task:
raise HTTPException(status_code=404, detail="提问任务不存在")
_require_ask_task_access(task, user)
if task.status != "已发布":
raise HTTPException(status_code=409, detail="当前任务不能完成归档")
task.status = "已完成"
@ -1217,7 +1371,7 @@ def publish_answer_task(submission_id: int, _: AdminUser = Depends(current_user)
@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):
db.commit()
task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"]
@ -1231,58 +1385,28 @@ def admin_task_board(_: AdminUser = Depends(current_user), db: Session = Depends
.where(AnswerSubmission.status.in_(task_statuses))
.order_by(AnswerSubmission.updated_at.desc())
).unique().all()
ask_tasks = db.scalars(select(AskTask).where(AskTask.status.in_(["已发布", "已完成", "已撤销"])).order_by(AskTask.updated_at.desc())).unique().all()
approved_ask_batches = db.scalars(
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()
ask_statement = select(AskTask).options(selectinload(AskTask.owner)).where(AskTask.status.in_(["已发布", "已完成", "已撤销"]), _ask_task_owner_clause(user)).order_by(AskTask.updated_at.desc())
ask_tasks = db.scalars(ask_statement).unique().all()
data = (
[_task_board_data(record, include_images=False) for record in records]
+ [_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)
return {"success": True, "data": data}
@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)
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="提问任务不存在")
return {"success": True, "data": _ask_task_data(task, include_questions=True)}
@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)
if not task:
raise HTTPException(status_code=404, detail="提问任务不存在")
_require_ask_task_access(task, user)
# AskTask's assignment relationship is intentionally not orphan-cascaded
# because assignments may be created by users. Remove those dependent
# 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}")
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)
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="待返款提问记录不存在")
return {"success": True, "data": _ask_review_board_data(batch, include_images=True)}
@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)
if not batch:
if not batch or not _can_access_ask_task(batch.assignment.task, user):
raise HTTPException(status_code=404, detail="提问提交记录不存在")
db.delete(batch)
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}")
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)
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="待返款提问记录不存在")
return {"success": True, "data": _ask_review_board_data(assignment, include_images=True)}
@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)
if not assignment:
if not assignment or not _can_access_ask_task(assignment.task, user):
raise HTTPException(status_code=404, detail="提问分配记录不存在")
db.delete(assignment)
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")
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)
if not batch:
if not batch or not _can_access_ask_task(batch.assignment.task, user):
raise HTTPException(status_code=404, detail="待返款提问记录不存在")
if batch.status == "paid":
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")
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)
if not assignment:
if not assignment or not _can_access_ask_task(assignment.task, user):
raise HTTPException(status_code=404, detail="待返款提问记录不存在")
if assignment.status == "已完成":
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)
login_logs: Mapped[list["AdminLoginLog"]] = relationship(back_populates="user")
ask_tasks: Mapped[list["AskTask"]] = relationship(back_populates="owner")
class AdminLoginLog(Base):
@ -106,6 +107,8 @@ class AnswerRecognizedQuestion(Base):
sequence_no: Mapped[int] = mapped_column(Integer)
product_title: Mapped[str] = mapped_column(String(255))
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)
answer_text: Mapped[str] = mapped_column(Text, 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)
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_name: Mapped[str] = mapped_column(String(255))
category: Mapped[str] = mapped_column(String(100), default="")
@ -143,12 +147,14 @@ class AskTask(Base):
target_group: 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="电商平台")
claim_questions_per_user: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
status: Mapped[str] = mapped_column(String(32), default="草稿", 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)
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")
owner: Mapped[AdminUser | None] = relationship(back_populates="ask_tasks")
class AskQuestion(Base):

View File

@ -35,7 +35,7 @@ class QAGenerateRequest(BaseModel):
target_group: str = Field(default="女性", max_length=100)
usage_scene: 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)
answer_requirement: str = Field(default="", max_length=1000)
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),
CONSTRAINT fk_admin_login_logs_user FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE SET NULL
) 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)
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 }) {
@ -139,6 +139,19 @@ export default function AnswerUserPortalV2({ api }) {
return previous ? {
...previous,
...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,
completion_screenshots: record.completion_screenshots?.length ? record.completion_screenshots : previous.completion_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 (historyOpen) setError('') }, [historyOpen])
async function loadSubmissionImages(record) {
if (!record?.id || imageLoadIds.current.has(record.id)) return
imageLoadIds.current.add(record.id)
if (!record?.id) return
const loadKey = `${record.id}:${record.status}`
if (imageLoadIds.current.has(loadKey)) return
imageLoadIds.current.add(loadKey)
try {
const result = await api(`/api/answer/me/submissions/${record.id}?token=${encodeURIComponent(session.token)}`)
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 latest = latestRecord && optimisticStatuses[latestRecord.id] ? { ...latestRecord, status: optimisticStatuses[latestRecord.id] } : latestRecord
@ -186,7 +201,7 @@ export default function AnswerUserPortalV2({ api }) {
useEffect(() => {
if (!visibleLatest || historyOpen || newTaskMode) return
loadSubmissionImages(visibleLatest)
}, [visibleLatest?.id, historyOpen, newTaskMode])
}, [visibleLatest?.id, visibleLatest?.status, historyOpen, newTaskMode])
useEffect(() => {
if (!historyOpen) return
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}/>
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,
product_image: question.product_image || previousQuestions.get(question.id)?.product_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 <>
<div className="create-page-heading">
<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="draft-reject-button" disabled={!!busy || locked || identifying} onClick={() => setRejecting(record)}>打回</button>
</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>
<div className="answer-draft-content">
<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 })}/>
</div>
<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>
})}
</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 { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
import { readPageCache, writePageCache } from "./pageCache.js";
const blankForm = {
product_link: "",
@ -13,6 +14,7 @@ const blankForm = {
usage_scene: "",
purchase_channel: "",
question_count: "",
claim_questions_per_user: "",
};
async function readReferenceFile(file, requestApi) {
@ -27,15 +29,42 @@ const blankForm = {
}
export default function CreateAskTasksPage({ api }) {
const [form, setForm] = useState(blankForm);
const [task, setTask] = useState(null);
const [questions, setQuestions] = useState([]);
const cached = readPageCache("create-ask-task", {});
const [form, setForm] = useState({ ...blankForm, ...(cached.form || {}) });
const [task, setTask] = useState(cached.task || null);
const [questions, setQuestions] = useState(cached.questions || []);
const [editing, setEditing] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
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) =>
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() {
const intentError = analyzeProductIntent([
@ -56,7 +85,8 @@ export default function CreateAskTasksPage({ api }) {
setBusy(true);
setError("");
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", {
method: "POST",
body: JSON.stringify({
@ -185,6 +215,7 @@ export default function CreateAskTasksPage({ api }) {
setForm(blankForm);
setQuestions([]);
setTask(null);
clearReferenceFile();
}}
>
继续创建新任务
@ -269,11 +300,35 @@ export default function CreateAskTasksPage({ api }) {
</label>
<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(""); }} />
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown、XLSX,生成提问时会参考其中的问题角度"}</small>
{referenceFile && <button type="button" className="reference-qa-clear" onClick={() => setReferenceFile(null)}>清除已选文件</button>}
<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>{referenceFileName ? `已选择:${referenceFileName}` : "支持 TXT、CSV、JSON、Markdown、XLSX,生成提问时会参考其中的问题角度"}</small>
{referenceFileName && <button type="button" className="reference-qa-clear" onClick={clearReferenceFile}>清除已选文件</button>}
</label>
<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">
<label>
语言风格
@ -302,15 +357,6 @@ export default function CreateAskTasksPage({ api }) {
</select>
</label>
</div>
<label>
提问生成条数
<input
type="number"
min="1"
value={form.question_count}
onChange={(event) => update("question_count", event.target.value)}
/>
</label>
<div className="two-col">
<label>
使用场景

View File

@ -22,6 +22,24 @@ const StatusBadge = ({ status }) => (
<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 }) {
const [reason, setReason] = useState(REASONS[0]);
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 [reasonText, setReasonText] = useState("");
const [busy, setBusy] = useState(false);
@ -190,6 +208,7 @@ function AskReviewDetail({ record, close, onChanged, api }) {
close();
await onChanged();
window.dispatchEvent(new Event("review-count-changed"));
if (approved) onApproved();
} catch (error) {
window.alert(error.message);
} finally {
@ -328,7 +347,7 @@ function AskReviewDetail({ record, close, onChanged, api }) {
);
}
function AskReviewQueue({ api }) {
function AskReviewQueue({ api, onApproved }) {
const [records, setRecords] = useState([]);
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true);
@ -409,6 +428,7 @@ function AskReviewQueue({ api }) {
close={() => setDetail(null)}
onChanged={load}
api={api}
onApproved={onApproved}
/>
)}
</>
@ -424,6 +444,7 @@ export default function ReviewCenterPage({ api }) {
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [refundPrompt, setRefundPrompt] = useState(false);
const loadVersion = useRef(0);
async function openAnswerDetail(record) {
setDetailLoading(true);
@ -490,6 +511,7 @@ export default function ReviewCenterPage({ api }) {
await load();
window.dispatchEvent(new Event("review-count-changed"));
setStatusFilter("待审核");
if (approved) setRefundPrompt(true);
} catch (err) {
window.alert(err.message);
} finally {
@ -546,7 +568,7 @@ export default function ReviewCenterPage({ api }) {
</div>
{error && <div className="form-error">{error}</div>}
{queue === "ask" ? (
<AskReviewQueue api={api} />
<AskReviewQueue api={api} onApproved={() => setRefundPrompt(true)} />
) : (
<>
<div className="review-v2-status-filter">
@ -619,6 +641,15 @@ export default function ReviewCenterPage({ api }) {
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 }) {
const [tasks, setTasks] = useState(() => taskBoardCache || []);
const [type, setType] = useState("all");
const [status, setStatus] = useState("all");
const [status, setStatus] = useState(() => window.__taskBoardInitialStatus || "all");
const [keyword, setKeyword] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [selectedIds, setSelectedIds] = useState([]);
const [expandedAskTasks, setExpandedAskTasks] = useState(() => new Set());
const [detail, setDetail] = useState(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(() => taskBoardCache === null);
@ -278,8 +279,11 @@ export default function TaskBoardPage({ api }) {
}
useEffect(() => {
load(taskBoardCache === null);
window.__taskBoardInitialStatus = "";
}, []);
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
// immediately updates the table (without relying on a stale memoized list).
const rows = tasks.filter((task) => {
@ -295,8 +299,13 @@ export default function TaskBoardPage({ api }) {
(taskType === "ask" || taskTypeLabel === "提问任务")) ||
(type === "answer" &&
(taskType === "answer" || taskTypeLabel === "回答任务"));
const statusMatch = status === "all" || taskStatus === status;
const text = `${task.user_id || ""} ${task.task_id || ""} ${task.product_name || ""}`.toLowerCase();
const children = childRows(task);
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 fromMatch = !dateFrom || (createdDate && createdDate >= dateFrom);
const toMatch = !dateTo || (createdDate && createdDate <= dateTo);
@ -321,6 +330,15 @@ export default function TaskBoardPage({ api }) {
setType("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() {
if (!rows.length) {
window.alert("当前没有可导出的任务");
@ -457,6 +475,9 @@ export default function TaskBoardPage({ api }) {
taskBoardCache = 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 (
<>
@ -542,68 +563,66 @@ export default function TaskBoardPage({ api }) {
暂无符合条件的任务
</div>
) : (
rows.map((task) => (
<div className="board-table-row" key={rowKey(task)}>
<span className="board-select-cell">
<input type="checkbox" checked={selectedIds.includes(rowKey(task))} onChange={() => toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} />
</span>
<span>
<code>{task.user_id}</code>
</span>
<span>
<b>{task.task_id}</b>
</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.pending_review_count} / {task.completed_count}
</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">
<button onClick={() => openDetail(task)}>详情</button>
{task.can_cancel && (
<button
className="cancel"
disabled={busy === task.id}
onClick={() => cancel(task)}
>
{busy === task.id ? "撤销中" : "撤销"}
</button>
)}
{task.can_complete && task.task_type === "ask" && (
<button
className="complete"
disabled={busy === task.id}
onClick={() => setCompleteConfirm(task)}
>
完成任务
</button>
)}
<button
className="delete"
onClick={() => setBatchConfirm({ tasks: [task] })}
>
删除
</button>
</span>
</div>
))
rows.map((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">
<input type="checkbox" checked={selectedIds.includes(key)} onChange={() => toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} />
</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>
</span>
<span><b>{task.task_id}</b></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.pending_review_count} / {task.completed_count}</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">
<button onClick={() => openDetail(task)}>详情</button>
{task.can_cancel && (
<button className="cancel" disabled={busy === task.id} onClick={() => cancel(task)}>
{busy === task.id ? "撤销中" : "撤销"}
</button>
)}
{task.can_complete && task.task_type === "ask" && (
<button className="complete" disabled={busy === task.id} onClick={() => setCompleteConfirm(task)}>完成任务</button>
)}
<button className="delete" onClick={() => setBatchConfirm({ tasks: [task] })}>删除</button>
</span>
</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>
{detail && (

View File

@ -1589,6 +1589,67 @@
.source-image-wrap small {
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 {
flex: 0 0 38px;
height: 38px;
@ -1830,6 +1891,51 @@
.board-table-row > span {
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 {
display: grid;
place-items: center;
@ -2780,6 +2886,68 @@
justify-content: space-between;
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 {
margin: 0 0 4px;
font-size: 16px;
@ -3287,6 +3455,67 @@
background: rgba(9, 20, 38, 0.58);
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 {
width: min(900px, 100%);
max-height: calc(100vh - 36px);

View File

@ -1,8 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
Bell,
ChevronDown,
ClipboardList,
Database,
FileText,
@ -24,6 +22,7 @@ import ReviewCenterPage from "./ReviewCenterPage.jsx";
import CreateAskTasksPage from "./CreateAskTasksPage.jsx";
import AskUserPortal from "./AskUserPortal.jsx";
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
import { readPageCache, writePageCache } from "./pageCache.js";
// 兼容公共回答链接末尾有无斜杠,避免 /answer 被识别为运营后台路由。
if (window.location.pathname === "/answer")
@ -333,17 +332,13 @@ function Topbar({ user, onLogout }) {
<header className="topbar">
<div />
<div className="top-actions">
<Bell size={19} className="bell" />
<span className="divider" />
<div className="user-menu">
<div className="avatar">{user.display_name.slice(0, 1)}</div>
<div>
<strong>
{user.display_name}({user.role_label})
</strong>
<span>{"\u7f8e\u5986\u6570\u636e\u7ec4"}</span>
</div>
<ChevronDown size={15} />
<button
className="logout-btn"
onClick={onLogout}
@ -416,7 +411,7 @@ function AssistantPage() {
body: JSON.stringify({
...form,
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,
}),
});
@ -566,11 +561,11 @@ function AssistantPage() {
</div>
<div className="two-col">
<label>
提问生成条数
提问生成条数 *
<input
type="number"
min="1"
max="20"
required
value={form.question_count}
onChange={(event) =>
update("question_count", event.target.value)
@ -1000,7 +995,7 @@ function AssistantPageV2() {
body: JSON.stringify({
...form,
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);
@ -1127,11 +1122,11 @@ function AssistantPageV2() {
</div>
<div className="two-col">
<label>
提问生成条数
提问生成条数 *
<input
type="number"
min="1"
max="20"
required
value={form.question_count}
onChange={(event) =>
update("question_count", event.target.value)
@ -1482,7 +1477,8 @@ function QALibraryEditModal({ record, onClose, onSaved }) {
}
function AssistantPageFinal() {
const [form, setForm] = useState({
const cached = readPageCache("assistant", {});
const [form, setForm] = useState(cached.form || {
product_name: "",
category: "",
benefits: "",
@ -1496,7 +1492,7 @@ function AssistantPageFinal() {
answer_requirement: "",
seed_style: false,
});
const [items, setItems] = useState([]);
const [items, setItems] = useState(cached.items || []);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
@ -1504,9 +1500,34 @@ function AssistantPageFinal() {
const [adoptedDialog, setAdoptedDialog] = useState(null);
const [manualOpen, setManualOpen] = useState(false);
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) =>
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() {
const intentError = analyzeProductIntent([
form.product_name,
@ -1528,12 +1549,13 @@ function AssistantPageFinal() {
setLoading(true);
try {
const reference_qa = referenceFile ? await readReferenceFile(referenceFile, api) : referenceQa;
setReferenceQa(reference_qa);
const result = await api("/api/admin/qa/generate", {
method: "POST",
body: JSON.stringify({
...form,
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,
}),
});
@ -1668,9 +1690,9 @@ function AssistantPageFinal() {
</label>
<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)} />
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown、XLSX,模型将参考其中的问答语气与角度"}</small>
{referenceFile && <button type="button" className="reference-qa-clear" onClick={() => setReferenceFile(null)}>清除已选文件</button>}
<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>{referenceFileName ? `已选择:${referenceFileName}` : "支持 TXT、CSV、JSON、Markdown、XLSX,模型将参考其中的问答语气与角度"}</small>
{referenceFileName && <button type="button" className="reference-qa-clear" onClick={clearReferenceFile}>清除已选文件</button>}
</label>
<div className="rule" />
<h4>生成偏好与数量</h4>
@ -1705,11 +1727,11 @@ function AssistantPageFinal() {
</div>
<div className="two-col">
<label>
提问生成条数
提问生成条数 *
<input
type="number"
min="1"
max="20"
required
value={form.question_count}
onChange={(event) =>
update("question_count", event.target.value)
@ -3740,7 +3762,10 @@ function Dashboard({ user, onLogout }) {
}
useEffect(() => {
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") {
setActive("create");
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) {
const required = [
["language_style", "语言风格"],
["target_group", "目标人群"],
["question_count", "提问生成条数"],
["usage_scene", "使用场景"],
["purchase_channel", "购买渠道"],
];
if (includeAnswers) required.splice(3, 0, ["answers_per_question", "单题回答条数"]);
// 生成偏好均为选填,只有生成条数需要填写并进行数值校验。
const required = [["question_count", "提问生成条数"]];
const missing = required
.filter(([key]) => !String(form[key] ?? "").trim())
.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) {
return "提问生成条数必须是大于 0 的整数";
}
if (includeAnswers && (!Number.isInteger(Number(form.answers_per_question)) || Number(form.answers_per_question) < 1)) {
return "单题回答条数必须是大于 0 的整数";
if (String(form.claim_questions_per_user ?? "").trim()) {
const claimCount = Number(form.claim_questions_per_user);
if (!Number.isInteger(claimCount) || claimCount < 2 || claimCount > 5) {
return "每个用户可认领的问题数量必须在 2-5 条之间";
}
}
return "";
}

View File

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