适配手机端,优化组件,完善权限隔离
This commit is contained in:
parent
cf83de291c
commit
20ca5384fc
@ -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:
|
||||
|
||||
@ -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="该任务已经完成返款")
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;已有环境由应用启动时自动补列。
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
使用场景
|
||||
|
||||
@ -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: "待返款" } }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)}>
|
||||
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(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 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><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>{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)}
|
||||
>
|
||||
<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="complete" disabled={busy === task.id} onClick={() => setCompleteConfirm(task)}>完成任务</button>
|
||||
)}
|
||||
<button
|
||||
className="delete"
|
||||
onClick={() => setBatchConfirm({ tasks: [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 && (
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
26
frontend/src/pageCache.js
Normal 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.
|
||||
}
|
||||
}
|
||||
@ -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 "";
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user