修复bug,调整页面布局,优化移动端页面
This commit is contained in:
parent
4fea0a28fc
commit
cf83de291c
@ -88,6 +88,49 @@ def generate_qa(payload: QAGenerateRequest) -> list[dict]:
|
|||||||
return _parse_content(content, payload.answers_per_question)
|
return _parse_content(content, payload.answers_per_question)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_questions(payload: dict, count: int, reference_qa: str = "") -> list[str]:
|
||||||
|
"""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,无法生成真实提问")
|
||||||
|
prompt = f"""你是电商问大家提问策划。请根据商品信息生成 {count} 个真实、具体、互不重复的消费者提问。
|
||||||
|
商品名称:{payload.get('product_name') or '未提供'}
|
||||||
|
商品品类:{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')}
|
||||||
|
真实问答参考:{reference_qa or '未提供'}
|
||||||
|
只返回严格 JSON,不要 Markdown 或解释,格式:{{"questions":["问题1","问题2"]}}。问题应像真实买家提问,尽量不超过30字,不要虚构具体参数或功效。"""
|
||||||
|
url = settings.llm_base_url.rstrip("/") + "/chat/completions"
|
||||||
|
body = {"model": settings.llm_model, "temperature": 0.85, "messages": [{"role": "system", "content": "你只输出合法 JSON。"}, {"role": "user", "content": prompt}]}
|
||||||
|
request = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.llm_api_key}"}, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
content = result["choices"][0]["message"]["content"]
|
||||||
|
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", str(content).strip(), flags=re.I | re.S).strip()
|
||||||
|
parsed = json.loads(cleaned)
|
||||||
|
questions = parsed.get("questions") if isinstance(parsed, dict) else parsed
|
||||||
|
if not isinstance(questions, list):
|
||||||
|
raise ValueError("questions is not a list")
|
||||||
|
output = []
|
||||||
|
for value in questions:
|
||||||
|
text = str(value).strip()
|
||||||
|
if text and text not in output:
|
||||||
|
output.append(text[:200])
|
||||||
|
if not output:
|
||||||
|
raise ValueError("empty questions")
|
||||||
|
return output[:count]
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
logger.error("LLM question generation HTTP error: status=%s url=%s", exc.code, url)
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"大模型服务返回错误(HTTP {exc.code})") from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError, TypeError, ValueError) as exc:
|
||||||
|
logger.exception("LLM question generation failed")
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型提问生成失败,请稍后重试") from exc
|
||||||
|
|
||||||
|
|
||||||
def generate_answer_for_question(product_title: str, question: str) -> str:
|
def generate_answer_for_question(product_title: str, question: str) -> str:
|
||||||
"""Generate one concise answer. Falls back to a safe draft during the simulated-OCR MVP."""
|
"""Generate one concise answer. Falls back to a safe draft during the simulated-OCR MVP."""
|
||||||
if not settings.llm_api_key:
|
if not settings.llm_api_key:
|
||||||
|
|||||||
@ -4,11 +4,15 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status
|
from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@ -17,7 +21,7 @@ from sqlalchemy.orm import Session, noload, selectinload
|
|||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import Base, SessionLocal, engine, get_db
|
from .database import Base, SessionLocal, engine, get_db
|
||||||
from .llm import extract_questions_from_images, generate_answer_for_question, generate_qa
|
from .llm import extract_questions_from_images, generate_answer_for_question, generate_questions, generate_qa
|
||||||
from .models import AdminLoginLog, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QALibraryEntry
|
from .models import AdminLoginLog, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QALibraryEntry
|
||||||
from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QAGenerateRequest, RegisterRequest
|
from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QAGenerateRequest, RegisterRequest
|
||||||
from .security import create_session_token, decode_session_token, hash_password, verify_password
|
from .security import create_session_token, decode_session_token, hash_password, verify_password
|
||||||
@ -32,6 +36,82 @@ app.add_middleware(
|
|||||||
allow_headers=["Content-Type"],
|
allow_headers=["Content-Type"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# A question allocation is temporary until the user submits a completion
|
||||||
|
# batch. Stale, unfinished allocations are released on the next task/status
|
||||||
|
# read so the questions can flow back to the same published task link.
|
||||||
|
ASK_ASSIGNMENT_TIMEOUT = timedelta(hours=5)
|
||||||
|
|
||||||
|
|
||||||
|
def _release_expired_ask_assignments(db: Session, task: AskTask | None = None) -> int:
|
||||||
|
"""Release unsubmitted ask-task allocations older than five hours.
|
||||||
|
|
||||||
|
Submitted batches are intentionally preserved (including rejected batches)
|
||||||
|
because they are part of the review/history flow. Only assignments that
|
||||||
|
have never submitted a batch can be safely returned to the task pool.
|
||||||
|
"""
|
||||||
|
cutoff = datetime.utcnow() - ASK_ASSIGNMENT_TIMEOUT
|
||||||
|
statement = select(AskAssignment).where(
|
||||||
|
AskAssignment.status.in_(["待选择", "进行中"]),
|
||||||
|
# The five-hour window starts when the questions are claimed, not
|
||||||
|
# when the user later changes a selection inside that reservation.
|
||||||
|
AskAssignment.created_at < cutoff,
|
||||||
|
~AskAssignment.batches.any(),
|
||||||
|
)
|
||||||
|
if task is not None:
|
||||||
|
statement = statement.where(AskAssignment.task_id == task.id)
|
||||||
|
expired = db.scalars(statement).unique().all()
|
||||||
|
for assignment in expired:
|
||||||
|
db.delete(assignment)
|
||||||
|
if expired:
|
||||||
|
db.flush()
|
||||||
|
return len(expired)
|
||||||
|
|
||||||
|
|
||||||
|
def _product_intent_error(values: list[object]) -> str | None:
|
||||||
|
"""Reject obvious placeholder input before creating an AI task."""
|
||||||
|
text = " ".join(str(value or "").strip() for value in values if str(value or "").strip())
|
||||||
|
if not text:
|
||||||
|
return "请至少填写一项商品信息"
|
||||||
|
compact = re.sub(r"[\s\W_]+", "", text, flags=re.UNICODE)
|
||||||
|
if not compact:
|
||||||
|
return "商品信息不能只有符号,请填写真实的商品内容"
|
||||||
|
if compact.isdigit():
|
||||||
|
return "商品信息看起来像占位内容,请填写真实的商品名称、品类、功效或卖点"
|
||||||
|
if re.fullmatch(r"(.{1,3})\1+", compact, flags=re.UNICODE):
|
||||||
|
return "商品信息看起来是重复的占位内容,请补充真实的商品信息"
|
||||||
|
if not re.search(r"[\u4e00-\u9fffA-Za-z]", compact):
|
||||||
|
return "请填写包含文字的真实商品信息"
|
||||||
|
return 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", "单题回答条数"))
|
||||||
|
missing = [label for key, label in required if not str(payload.get(key, "")).strip()]
|
||||||
|
if missing:
|
||||||
|
return f"请填写{'、'.join(missing)}"
|
||||||
|
try:
|
||||||
|
question_count = int(payload.get("question_count"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
# 回答用户链接的 MVP 数据暂存于内存:截图只在当前服务进程中保留,后续接入对象存储/持久化表。
|
# 回答用户链接的 MVP 数据暂存于内存:截图只在当前服务进程中保留,后续接入对象存储/持久化表。
|
||||||
ANSWER_TASKS = {
|
ANSWER_TASKS = {
|
||||||
@ -492,18 +572,22 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d
|
|||||||
|
|
||||||
def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict:
|
def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict:
|
||||||
questions = sorted(task.questions, key=lambda value: value.sequence_no)
|
questions = sorted(task.questions, key=lambda value: value.sequence_no)
|
||||||
|
selected_questions = [q for q in questions if q.selected]
|
||||||
|
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 {"待审核", "未通过"})
|
||||||
return {
|
return {
|
||||||
"id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task",
|
"id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task",
|
||||||
"task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务",
|
"task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务",
|
||||||
"product_name": task.product_name, "product_link": task.product_link, "status": task.status,
|
"product_name": task.product_name, "product_link": task.product_link, "status": task.status,
|
||||||
"status_label": {"已发布": "进行中", "已撤销": "已撤销", "已完成": "已完成"}.get(task.status, task.status),
|
"status_label": {"已发布": "进行中", "已撤销": "已撤销", "已完成": "已完成"}.get(task.status, task.status),
|
||||||
"qa_count": len([q for q in questions if q.selected]), "claimed_count": 0, "submitted_count": 0,
|
"qa_count": len(selected_questions), "claimed_count": assigned_count, "submitted_count": completed_count,
|
||||||
"pending_review_count": 0, "completed_count": 0, "total_count": len(questions),
|
"pending_review_count": pending_count, "completed_count": completed_count, "total_count": len(selected_questions),
|
||||||
"creator_name": "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(),
|
"creator_name": "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(),
|
||||||
"can_cancel": task.status == "已发布", "can_delete": True,
|
"can_cancel": task.status == "已发布", "can_complete": task.status == "已发布", "can_delete": True,
|
||||||
"delete_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}",
|
"delete_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}",
|
||||||
"user_id": "--", "phone": "", "questions": [],
|
"user_id": "--", "phone": "", "questions": [],
|
||||||
"selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name} for q in questions if q.selected] if include_questions else []),
|
"selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name} for q in selected_questions] if include_questions else []),
|
||||||
"assigned_users": [], "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None,
|
"assigned_users": [], "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None,
|
||||||
"detail_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}" if not include_questions else None,
|
"detail_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}" if not include_questions else None,
|
||||||
}
|
}
|
||||||
@ -630,11 +714,16 @@ def ask_send_code(payload: dict):
|
|||||||
def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)):
|
def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)):
|
||||||
user = _answer_user_from_token(token, db)
|
user = _answer_user_from_token(token, db)
|
||||||
task = _published_ask_task(task_no, db, lock=True)
|
task = _published_ask_task(task_no, db, lock=True)
|
||||||
|
released = _release_expired_ask_assignments(db, task)
|
||||||
|
if released:
|
||||||
|
db.commit()
|
||||||
|
db.expire_all()
|
||||||
|
task = _published_ask_task(task_no, db, lock=True)
|
||||||
assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
|
assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
|
||||||
if not assignment:
|
if not assignment:
|
||||||
# Allocation is permanent as soon as the task link assigns the
|
# Keep each question reserved while the user is working. Expired
|
||||||
# questions. Exclude every question already allocated to any user,
|
# unfinished reservations have already been removed above, so these
|
||||||
# even if that user has not selected or submitted it yet.
|
# are the currently allocated questions that must stay excluded.
|
||||||
allocated_ids = set(db.scalars(
|
allocated_ids = set(db.scalars(
|
||||||
select(AskAssignmentQuestion.question_id)
|
select(AskAssignmentQuestion.question_id)
|
||||||
.join(AskAssignment, AskAssignmentQuestion.assignment_id == AskAssignment.id)
|
.join(AskAssignment, AskAssignmentQuestion.assignment_id == AskAssignment.id)
|
||||||
@ -672,6 +761,8 @@ def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)):
|
|||||||
@app.get("/api/ask/me/assignments")
|
@app.get("/api/ask/me/assignments")
|
||||||
def public_ask_assignments(token: str, db: Session = Depends(get_db)):
|
def public_ask_assignments(token: str, db: Session = Depends(get_db)):
|
||||||
user = _answer_user_from_token(token, db)
|
user = _answer_user_from_token(token, db)
|
||||||
|
if _release_expired_ask_assignments(db):
|
||||||
|
db.commit()
|
||||||
records = db.scalars(
|
records = db.scalars(
|
||||||
select(AskAssignment)
|
select(AskAssignment)
|
||||||
.where(AskAssignment.user_id == user.id)
|
.where(AskAssignment.user_id == user.id)
|
||||||
@ -691,6 +782,8 @@ def public_ask_assignments(token: str, db: Session = Depends(get_db)):
|
|||||||
def select_public_ask_questions(task_no: str, payload: dict, db: Session = Depends(get_db)):
|
def select_public_ask_questions(task_no: str, payload: dict, db: Session = Depends(get_db)):
|
||||||
user = _answer_user_from_token(str(payload.get("token", "")), db)
|
user = _answer_user_from_token(str(payload.get("token", "")), db)
|
||||||
task = _published_ask_task(task_no, db)
|
task = _published_ask_task(task_no, db)
|
||||||
|
if _release_expired_ask_assignments(db, task):
|
||||||
|
db.commit()
|
||||||
assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
|
assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
|
||||||
if not assignment:
|
if not assignment:
|
||||||
raise HTTPException(status_code=409, detail="请先领取提问任务")
|
raise HTTPException(status_code=409, detail="请先领取提问任务")
|
||||||
@ -726,6 +819,8 @@ async def submit_public_ask_task(
|
|||||||
):
|
):
|
||||||
user = _answer_user_from_token(token, db)
|
user = _answer_user_from_token(token, db)
|
||||||
task = _published_ask_task(task_no, db)
|
task = _published_ask_task(task_no, db)
|
||||||
|
if _release_expired_ask_assignments(db, task):
|
||||||
|
db.commit()
|
||||||
assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
|
assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
|
||||||
if not assignment:
|
if not assignment:
|
||||||
raise HTTPException(status_code=404, detail="提问任务不存在")
|
raise HTTPException(status_code=404, detail="提问任务不存在")
|
||||||
@ -969,52 +1064,62 @@ def admin_answer_task_draft_detail(submission_id: int, _: AdminUser = Depends(cu
|
|||||||
return {"success": True, "data": _submission_data(record, include_images=True)}
|
return {"success": True, "data": _submission_data(record, include_images=True)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/answer-task-drafts/{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)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="回答任务不存在")
|
||||||
|
if record.status not in {"待筛选", "待运营派发"}:
|
||||||
|
raise HTTPException(status_code=409, detail="当前任务不能打回")
|
||||||
|
db.delete(record)
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "data": {"id": submission_id}}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/ask-tasks/generate")
|
@app.post("/api/admin/ask-tasks/generate")
|
||||||
def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||||
product_link = str(payload.get("product_link", "")).strip()
|
product_link = str(payload.get("product_link", "")).strip()
|
||||||
product_name = str(payload.get("product_name", "")).strip()
|
product_name = str(payload.get("product_name", "")).strip()
|
||||||
if not product_link: raise HTTPException(status_code=400, detail="商品链接为必填项")
|
if not product_link: raise HTTPException(status_code=400, detail="商品链接为必填项")
|
||||||
if not product_name and not any(str(payload.get(key, "")).strip() for key in ("category", "benefits", "selling_points")):
|
intent_error = _product_intent_error([product_name, payload.get("category"), payload.get("benefits"), payload.get("selling_points")])
|
||||||
raise HTTPException(status_code=400, detail="请至少填写一项商品信息")
|
if intent_error:
|
||||||
|
raise HTTPException(status_code=400, detail=intent_error)
|
||||||
|
options_error = _generation_options_error(payload, include_answers=False)
|
||||||
|
if options_error:
|
||||||
|
raise HTTPException(status_code=400, detail=options_error)
|
||||||
try:
|
try:
|
||||||
count = int(payload.get("question_count", 5))
|
count = int(payload.get("question_count", 5))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise HTTPException(status_code=400, detail="提问生成条数必须是正整数")
|
raise HTTPException(status_code=400, detail="提问生成条数必须是正整数")
|
||||||
if count < 1:
|
if count < 1:
|
||||||
raise HTTPException(status_code=400, detail="提问生成条数必须大于 0")
|
raise HTTPException(status_code=400, detail="提问生成条数必须大于 0")
|
||||||
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="草稿")
|
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:
|
||||||
|
if task.status != "草稿":
|
||||||
|
raise HTTPException(status_code=409, detail="已发布任务不能重新生成")
|
||||||
|
task.product_link = product_link
|
||||||
|
task.product_name = product_name or "待补充商品"
|
||||||
|
task.category = str(payload.get("category", ""))
|
||||||
|
task.benefits = str(payload.get("benefits", ""))
|
||||||
|
task.selling_points = str(payload.get("selling_points", ""))
|
||||||
|
task.language_style = str(payload.get("language_style", ""))
|
||||||
|
task.target_group = str(payload.get("target_group", ""))
|
||||||
|
task.usage_scene = str(payload.get("usage_scene", ""))
|
||||||
|
task.purchase_channel = str(payload.get("purchase_channel", ""))
|
||||||
|
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="草稿")
|
||||||
db.add(task); db.flush()
|
db.add(task); db.flush()
|
||||||
templates = [
|
|
||||||
"这款商品适合什么人群使用?",
|
|
||||||
"使用起来方便吗?效果怎么样?",
|
|
||||||
"日常使用有什么需要注意的吗?",
|
|
||||||
"这款商品和同类产品相比有什么特点?",
|
|
||||||
"新手使用是否容易上手?",
|
|
||||||
"这款商品建议多久使用一次?",
|
|
||||||
"它的质地和实际使用感怎么样?",
|
|
||||||
"使用后吸收速度快吗?会不会有黏腻感?",
|
|
||||||
"敏感人群可以使用这款商品吗?",
|
|
||||||
"不同季节使用时效果会有区别吗?",
|
|
||||||
"它在日常使用步骤中应该放在哪一步?",
|
|
||||||
"每次使用多少量比较合适?",
|
|
||||||
"通常需要坚持使用多久才能感受到效果?",
|
|
||||||
"这款商品可以和其他同类商品搭配使用吗?",
|
|
||||||
"包装是否方便携带和日常使用?",
|
|
||||||
"开封后应该如何保存?",
|
|
||||||
"这款商品的性价比怎么样?",
|
|
||||||
"第一次购买建议选择哪种规格?",
|
|
||||||
"使用过程中有哪些容易忽略的小细节?",
|
|
||||||
"实际体验后值得再次购买吗?",
|
|
||||||
]
|
|
||||||
questions = payload.get("questions") if isinstance(payload.get("questions"), list) else []
|
questions = payload.get("questions") if isinstance(payload.get("questions"), list) else []
|
||||||
reference_text = str(payload.get("reference_qa", ""))[:20000]
|
reference_text = str(payload.get("reference_qa", ""))[:20000]
|
||||||
if reference_text:
|
if not questions:
|
||||||
for line in reference_text.splitlines():
|
questions = generate_questions(payload, count, reference_text)
|
||||||
clean = line.strip().lstrip("-•*0123456789. ")
|
|
||||||
if clean.startswith(("问题:", "问题:", "Q:", "Q:")):
|
|
||||||
clean = clean.split(":", 1)[1].strip()
|
|
||||||
if clean and "回答" not in clean and len(clean) <= 200:
|
|
||||||
questions.append(clean)
|
|
||||||
# 自定义问题优先,不足的数量用候选问题补齐,保证返回数量与运营填写值一致。
|
# 自定义问题优先,不足的数量用候选问题补齐,保证返回数量与运营填写值一致。
|
||||||
generated: list[str] = []
|
generated: list[str] = []
|
||||||
for value in questions:
|
for value in questions:
|
||||||
@ -1023,18 +1128,8 @@ def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: S
|
|||||||
generated.append(question)
|
generated.append(question)
|
||||||
if len(generated) == count:
|
if len(generated) == count:
|
||||||
break
|
break
|
||||||
template_index = 0
|
if len(generated) < count:
|
||||||
while len(generated) < count:
|
generated.extend(generate_questions(payload, count - len(generated), reference_text))
|
||||||
base = templates[template_index % len(templates)]
|
|
||||||
round_no = template_index // len(templates)
|
|
||||||
if round_no == 0:
|
|
||||||
question = base
|
|
||||||
else:
|
|
||||||
scene = str(payload.get("usage_scene", "日常")).strip() or "日常"
|
|
||||||
question = f"在{scene}场景的第{round_no + 1}种使用情况下,{base}"
|
|
||||||
if question not in generated:
|
|
||||||
generated.append(question)
|
|
||||||
template_index += 1
|
|
||||||
for index, question in enumerate(generated): db.add(AskQuestion(task_id=task.id, sequence_no=index + 1, question_text=question, selected=True))
|
for index, question in enumerate(generated): db.add(AskQuestion(task_id=task.id, sequence_no=index + 1, question_text=question, selected=True))
|
||||||
db.commit(); db.refresh(task)
|
db.commit(); db.refresh(task)
|
||||||
return {"success": True, "data": _ask_task_data(task)}
|
return {"success": True, "data": _ask_task_data(task)}
|
||||||
@ -1085,6 +1180,20 @@ def cancel_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Sess
|
|||||||
return {"success": True, "data": _ask_task_data(task)}
|
return {"success": True, "data": _ask_task_data(task)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/ask-tasks/{task_id}/complete")
|
||||||
|
def complete_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||||
|
task = db.get(AskTask, task_id)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="提问任务不存在")
|
||||||
|
if task.status != "已发布":
|
||||||
|
raise HTTPException(status_code=409, detail="当前任务不能完成归档")
|
||||||
|
task.status = "已完成"
|
||||||
|
task.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
return {"success": True, "data": _ask_task_data(task)}
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/admin/answer-task-drafts/questions/{question_id}")
|
@app.patch("/api/admin/answer-task-drafts/questions/{question_id}")
|
||||||
def update_answer_question(question_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
def update_answer_question(question_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||||
question = db.get(AnswerRecognizedQuestion, question_id)
|
question = db.get(AnswerRecognizedQuestion, question_id)
|
||||||
@ -1109,6 +1218,8 @@ def publish_answer_task(submission_id: int, _: AdminUser = Depends(current_user)
|
|||||||
|
|
||||||
@app.get("/api/admin/task-board")
|
@app.get("/api/admin/task-board")
|
||||||
def admin_task_board(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
def admin_task_board(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||||
|
if _release_expired_ask_assignments(db):
|
||||||
|
db.commit()
|
||||||
task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"]
|
task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"]
|
||||||
records = db.scalars(
|
records = db.scalars(
|
||||||
select(AnswerSubmission).options(
|
select(AnswerSubmission).options(
|
||||||
@ -1172,6 +1283,12 @@ def admin_task_board_delete_ask_task(task_id: int, _: AdminUser = Depends(curren
|
|||||||
task = db.get(AskTask, task_id)
|
task = db.get(AskTask, task_id)
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="提问任务不存在")
|
raise HTTPException(status_code=404, detail="提问任务不存在")
|
||||||
|
# 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
|
||||||
|
# deletable without leaving invalid foreign keys behind.
|
||||||
|
for assignment in list(task.assignments):
|
||||||
|
db.delete(assignment)
|
||||||
db.delete(task)
|
db.delete(task)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"success": True, "data": {"id": task_id}}
|
return {"success": True, "data": {"id": task_id}}
|
||||||
@ -1469,9 +1586,22 @@ def review_decision(task_id: str, payload: dict, _: AdminUser = Depends(current_
|
|||||||
|
|
||||||
@app.post("/api/admin/qa/generate")
|
@app.post("/api/admin/qa/generate")
|
||||||
def qa_generate(payload: QAGenerateRequest, _: AdminUser = Depends(current_user)):
|
def qa_generate(payload: QAGenerateRequest, _: AdminUser = Depends(current_user)):
|
||||||
if not any(value.strip() for value in (payload.product_name, payload.category, payload.benefits, payload.selling_points)):
|
intent_error = _product_intent_error([payload.product_name, payload.category, payload.benefits, payload.selling_points])
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少填写一项商品信息")
|
if intent_error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=intent_error)
|
||||||
|
options_error = _generation_options_error(payload.model_dump(), include_answers=True)
|
||||||
|
if options_error:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=options_error)
|
||||||
|
try:
|
||||||
return {"success": True, "data": {"items": generate_qa(payload)}}
|
return {"success": True, "data": {"items": generate_qa(payload)}}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
# Do not leak a raw 500 to the UI. Unexpected provider/parser errors
|
||||||
|
# are surfaced as a retryable upstream error with a server traceback.
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).exception("QA generation failed")
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="问答生成服务异常,请检查大模型配置后重试") from exc
|
||||||
|
|
||||||
|
|
||||||
def _qa_library_data(entry: QALibraryEntry) -> dict:
|
def _qa_library_data(entry: QALibraryEntry) -> dict:
|
||||||
@ -1610,6 +1740,16 @@ def update_qa_library(entry_id: int, payload: dict, user: AdminUser = Depends(cu
|
|||||||
return {"success": True, "data": _qa_library_data(entry)}
|
return {"success": True, "data": _qa_library_data(entry)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/admin/qa-library/{entry_id}")
|
||||||
|
def delete_qa_library(entry_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||||
|
entry = db.get(QALibraryEntry, entry_id)
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(status_code=404, detail="问答记录不存在")
|
||||||
|
db.delete(entry)
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "data": {"id": entry_id}}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/auth/login")
|
@app.post("/api/admin/auth/login")
|
||||||
def login(payload: LoginRequest, request: Request, response: Response, db: Session = Depends(get_db)):
|
def login(payload: LoginRequest, request: Request, response: Response, db: Session = Depends(get_db)):
|
||||||
user = db.scalar(select(AdminUser).where(AdminUser.username == payload.username))
|
user = db.scalar(select(AdminUser).where(AdminUser.username == payload.username))
|
||||||
@ -1624,6 +1764,110 @@ def login(payload: LoginRequest, request: Request, response: Response, db: Sessi
|
|||||||
return {"success": True, "data": {"user": public_user(user)}}
|
return {"success": True, "data": {"user": public_user(user)}}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/reference-qa/parse")
|
||||||
|
async def parse_reference_qa(file: UploadFile = File(...), _: AdminUser = Depends(current_user)):
|
||||||
|
"""Extract readable question/answer text from an uploaded XLSX workbook.
|
||||||
|
|
||||||
|
XLSX is a ZIP/XML format; parsing it on the server avoids treating the
|
||||||
|
binary workbook as UTF-8 text in the browser (which produces mojibake).
|
||||||
|
"""
|
||||||
|
filename = (file.filename or "").lower()
|
||||||
|
if not filename.endswith(".xlsx"):
|
||||||
|
raise HTTPException(status_code=400, detail="仅支持解析 .xlsx 文件,请另存为 XLSX 后上传")
|
||||||
|
raw = await file.read()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
workbook = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
|
||||||
|
sheet = workbook.active
|
||||||
|
rows = [[str(value).strip() if value is not None else "" for value in row] for row in sheet.iter_rows(values_only=True)]
|
||||||
|
rows = [row for row in rows if any(row)]
|
||||||
|
workbook.close()
|
||||||
|
except ImportError:
|
||||||
|
rows = None
|
||||||
|
if rows is not None:
|
||||||
|
if not rows:
|
||||||
|
raise ValueError("工作表中没有可读取内容")
|
||||||
|
header = [str(value).lower() for value in rows[0]]
|
||||||
|
question_index = next((i for i, value in enumerate(header) if any(key in value for key in ("问题", "question", "q"))), None)
|
||||||
|
answer_index = next((i for i, value in enumerate(header) if any(key in value for key in ("回答", "问答", "answer", "a"))), None)
|
||||||
|
data_rows = rows[1:] if (question_index is not None or answer_index is not None) else rows
|
||||||
|
lines = []
|
||||||
|
for values in data_rows:
|
||||||
|
if question_index is not None and question_index < len(values):
|
||||||
|
question = values[question_index]
|
||||||
|
answer = values[answer_index] if answer_index is not None and answer_index < len(values) else ""
|
||||||
|
if question or answer:
|
||||||
|
lines.append(f"问题:{question}\n回答:{answer}")
|
||||||
|
else:
|
||||||
|
lines.append("\t".join(values))
|
||||||
|
text = "\n".join(lines).strip()[:20000]
|
||||||
|
return {"success": True, "data": {"text": text, "filename": file.filename}}
|
||||||
|
archive = zipfile.ZipFile(io.BytesIO(raw))
|
||||||
|
ns = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
||||||
|
def xml_root(path: str):
|
||||||
|
data = archive.read(path)
|
||||||
|
# Some Windows exports declare UTF-8 but actually store Chinese
|
||||||
|
# XML text as GB18030. Decode the XML bytes before parsing so the
|
||||||
|
# original Chinese strings are not replaced with <20> characters.
|
||||||
|
try:
|
||||||
|
decoded = data.decode("utf-8")
|
||||||
|
if "<EFBFBD>" in decoded:
|
||||||
|
decoded = data.decode("gb18030")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
decoded = data.decode("gb18030")
|
||||||
|
return ET.fromstring(decoded)
|
||||||
|
shared = []
|
||||||
|
if "xl/sharedStrings.xml" in archive.namelist():
|
||||||
|
root = xml_root("xl/sharedStrings.xml")
|
||||||
|
shared = ["".join(node.itertext()) for node in root.findall("x:si", ns)]
|
||||||
|
sheet_name = "xl/worksheets/sheet1.xml"
|
||||||
|
if sheet_name not in archive.namelist():
|
||||||
|
raise ValueError("工作簿缺少首个工作表")
|
||||||
|
root = xml_root(sheet_name)
|
||||||
|
rows = []
|
||||||
|
for row in root.findall(".//x:sheetData/x:row", ns):
|
||||||
|
values = []
|
||||||
|
for cell in row.findall("x:c", ns):
|
||||||
|
cell_type = cell.attrib.get("t", "")
|
||||||
|
value = cell.find("x:v", ns)
|
||||||
|
if cell_type == "s" and value is not None:
|
||||||
|
text = shared[int(value.text or "0")] if shared else ""
|
||||||
|
elif cell_type == "inlineStr":
|
||||||
|
text = "".join(cell.itertext())
|
||||||
|
else:
|
||||||
|
text = value.text if value is not None and value.text else ""
|
||||||
|
values.append(text.strip())
|
||||||
|
if any(values):
|
||||||
|
rows.append(values)
|
||||||
|
if not rows:
|
||||||
|
raise ValueError("工作表中没有可读取内容")
|
||||||
|
header = [str(value).lower() for value in rows[0]]
|
||||||
|
question_index = next((i for i, value in enumerate(header) if any(key in value for key in ("问题", "question", "q"))), None)
|
||||||
|
answer_index = next((i for i, value in enumerate(header) if any(key in value for key in ("回答", "问答", "answer", "a"))), None)
|
||||||
|
lines = []
|
||||||
|
header = [str(value).lower() for value in rows[0]]
|
||||||
|
question_index = next((i for i, value in enumerate(header) if any(key in value for key in ("问题", "question", "q"))), None)
|
||||||
|
answer_index = next((i for i, value in enumerate(header) if any(key in value for key in ("回答", "问答", "answer", "a"))), None)
|
||||||
|
data_rows = rows[1:] if (question_index is not None or answer_index is not None) else rows
|
||||||
|
for values in data_rows:
|
||||||
|
if question_index is not None and question_index < len(values):
|
||||||
|
question = values[question_index]
|
||||||
|
answer = values[answer_index] if answer_index is not None and answer_index < len(values) else ""
|
||||||
|
if question or answer:
|
||||||
|
lines.append(f"问题:{question}\n回答:{answer}")
|
||||||
|
else:
|
||||||
|
lines.append("\t".join(values))
|
||||||
|
text = "\n".join(lines).strip()[:20000]
|
||||||
|
return {"success": True, "data": {"text": text, "filename": file.filename}}
|
||||||
|
except (KeyError, ValueError, IndexError, ET.ParseError, zipfile.BadZipFile, UnicodeError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="无法读取该 XLSX 文件,请确认文件未损坏") from exc
|
||||||
|
except Exception as exc:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).exception("Reference XLSX parsing failed")
|
||||||
|
raise HTTPException(status_code=400, detail="读取参考文件失败,请确认文件格式正确后重试") from exc
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/auth/dingtalk/config")
|
@app.get("/api/admin/auth/dingtalk/config")
|
||||||
def dingtalk_login_config():
|
def dingtalk_login_config():
|
||||||
return {"success": True, "data": {"enabled": _dingtalk_configured(), "redirect_uri": settings.dingtalk_redirect_uri}}
|
return {"success": True, "data": {"enabled": _dingtalk_configured(), "redirect_uri": settings.dingtalk_redirect_uri}}
|
||||||
|
|||||||
@ -1 +1,12 @@
|
|||||||
<div id="root"></div><script type="module" src="/src/main.jsx"></script>
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>问大家</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { Check, ClipboardCheck, Clock3, Copy, FileCheck2, History, ImagePlus, LogOut, MessageCircle, RefreshCw, ShieldCheck } from 'lucide-react'
|
import { Check, ClipboardCheck, Clock3, Copy, FileCheck2, History, ImagePlus, LogOut, MessageCircle, RefreshCw, ShieldCheck } from 'lucide-react'
|
||||||
|
|
||||||
const SESSION_KEY = 'answer_user_session'
|
const SESSION_KEY = 'answer_user_session'
|
||||||
const STATUS_TEXT = { '待筛选': '截图审核中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '未通过': '审核不通过', '已撤销': '任务已撤销' }
|
const STATUS_TEXT = { '待筛选': '截图审核中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '已完成': '已返款', '未通过': '审核不通过', '已撤销': '任务已撤销' }
|
||||||
const maskPhone = (phone = '') => phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
const maskPhone = (phone = '') => phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||||
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
|
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
|
||||||
const fileKey = file => `${file.name}-${file.size}-${file.lastModified}`
|
const fileKey = file => `${file.name}-${file.size}-${file.lastModified}`
|
||||||
@ -84,6 +84,7 @@ function AssignmentStage({ api, session, latest, reload, setError }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FinalStage({ latest, reload, busy, reopen, startNew }) {
|
function FinalStage({ latest, reload, busy, reopen, startNew }) {
|
||||||
|
const paid = latest.status === '已完成'
|
||||||
const passed = ['已通过', '已完成'].includes(latest.status)
|
const passed = ['已通过', '已完成'].includes(latest.status)
|
||||||
const rejected = latest.status === '未通过'
|
const rejected = latest.status === '未通过'
|
||||||
const cancelled = latest.status === '已撤销'
|
const cancelled = latest.status === '已撤销'
|
||||||
@ -107,8 +108,8 @@ function FinalStage({ latest, reload, busy, reopen, startNew }) {
|
|||||||
</>
|
</>
|
||||||
return <>
|
return <>
|
||||||
<section className={`answer-v2-card status-card ${passed ? 'passed' : cancelled ? 'rejected' : ''}`}>
|
<section className={`answer-v2-card status-card ${passed ? 'passed' : cancelled ? 'rejected' : ''}`}>
|
||||||
<div className="status-title"><span>{passed ? <Check size={25}/> : cancelled ? '!' : <Clock3 size={24}/>}</span><div><h1>{passed ? '审核已完成' : cancelled ? '任务已撤销' : '完成凭证审核中'} <em>{STATUS_TEXT[latest.status]}</em></h1><p>{passed ? '运营已审核通过您提交的任务凭证,当前进入打款流程,请耐心等待打款。' : cancelled ? '运营已撤销本次任务,您无需继续提交。' : '您提交的回答完成截图正在审核,请耐心等待运营确认。'}</p></div></div>
|
<div className="status-title"><span>{passed ? <Check size={25}/> : cancelled ? '!' : <Clock3 size={24}/>}</span><div><h1>{paid ? '已返款' : passed ? '审核已完成' : cancelled ? '任务已撤销' : '完成凭证审核中'} <em>{STATUS_TEXT[latest.status]}</em></h1><p>{paid ? '运营已完成本次任务返款,请留意您的收款账户。' : passed ? '运营已审核通过您提交的任务凭证,当前进入打款流程,请耐心等待打款。' : cancelled ? '运营已撤销本次任务,您无需继续提交。' : '您提交的回答完成截图正在审核,请耐心等待运营确认。'}</p></div></div>
|
||||||
{passed && <div className="answer-v2-payment-wait"><strong>审核已完成,等待打款</strong><span>款项将按运营安排发放,请留意收款账户。</span></div>}
|
{passed && <div className={`answer-v2-payment-wait ${paid ? 'paid' : ''}`}><strong>{paid ? '返款已完成' : '审核已完成,等待打款'}</strong><span>{paid ? '款项已由运营确认发放,请留意收款账户。' : '款项将按运营安排发放,请留意收款账户。'}</span></div>}
|
||||||
<footer><small>提交编号:{latest.submission_no}</small><button onClick={() => reload()} disabled={busy}><RefreshCw size={14} className={busy ? 'spin' : ''}/>{busy ? '刷新中…' : '刷新状态'}</button></footer>
|
<footer><small>提交编号:{latest.submission_no}</small><button onClick={() => reload()} disabled={busy}><RefreshCw size={14} className={busy ? 'spin' : ''}/>{busy ? '刷新中…' : '刷新状态'}</button></footer>
|
||||||
</section>
|
</section>
|
||||||
{latest.completion_screenshots?.length > 0 && <MaterialCard latest={latest} completion/>}
|
{latest.completion_screenshots?.length > 0 && <MaterialCard latest={latest} completion/>}
|
||||||
|
|||||||
@ -746,6 +746,7 @@ export default function AskUserPortal({ api }) {
|
|||||||
|
|
||||||
async function loadAssignment(silent = false) {
|
async function loadAssignment(silent = false) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
const previousAssignmentId = assignment?.assignment_id;
|
||||||
if (!silent) setBusy(true);
|
if (!silent) setBusy(true);
|
||||||
try {
|
try {
|
||||||
const result = await api(
|
const result = await api(
|
||||||
@ -756,6 +757,10 @@ export default function AskUserPortal({ api }) {
|
|||||||
// Keep the current view. In particular, polling a pending review must
|
// Keep the current view. In particular, polling a pending review must
|
||||||
// not pull the user back to the audit screen after they chose to return
|
// not pull the user back to the audit screen after they chose to return
|
||||||
// to the claim page.
|
// to the claim page.
|
||||||
|
// Keep unsaved checkbox choices while a user is on the claim screen.
|
||||||
|
// If the five-hour reservation expires, the assignment id changes and
|
||||||
|
// the fresh allocation is selected instead.
|
||||||
|
if (!silent || result.data.assignment_id !== previousAssignmentId || assignment?.status !== "待选择") {
|
||||||
setSelected(
|
setSelected(
|
||||||
result.data.questions
|
result.data.questions
|
||||||
// Questions already submitted for review are consumed and must not
|
// Questions already submitted for review are consumed and must not
|
||||||
@ -763,6 +768,7 @@ export default function AskUserPortal({ api }) {
|
|||||||
.filter((item) => item.selected && !item.used)
|
.filter((item) => item.selected && !item.used)
|
||||||
.map((item) => item.id),
|
.map((item) => item.id),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
setError("");
|
setError("");
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
if (requestError.message === EXHAUSTED_MESSAGE) {
|
if (requestError.message === EXHAUSTED_MESSAGE) {
|
||||||
@ -791,8 +797,9 @@ export default function AskUserPortal({ api }) {
|
|||||||
loadAssignment();
|
loadAssignment();
|
||||||
}, [session?.token, taskNo]);
|
}, [session?.token, taskNo]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (assignment?.status !== "待审核") return undefined;
|
if (!assignment || !["待审核", "待选择", "进行中"].includes(assignment.status)) return undefined;
|
||||||
const timer = window.setInterval(() => loadAssignment(true), 10000);
|
const interval = assignment.status === "待审核" ? 10000 : 60000;
|
||||||
|
const timer = window.setInterval(() => loadAssignment(true), interval);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [assignment?.status, session?.token, taskNo]);
|
}, [assignment?.status, session?.token, taskNo]);
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export default function CreateAnswerTasksPageV2({ api }) {
|
|||||||
const [busy, setBusy] = useState('')
|
const [busy, setBusy] = useState('')
|
||||||
const [detailBusy, setDetailBusy] = useState('')
|
const [detailBusy, setDetailBusy] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [rejecting, setRejecting] = useState(null)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@ -119,6 +120,21 @@ export default function CreateAnswerTasksPageV2({ api }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reject(record) {
|
||||||
|
if (!record || busy) return
|
||||||
|
setBusy(record.id)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/answer-task-drafts/${record.id}/reject`, { method: 'POST' })
|
||||||
|
setRecords(current => current.filter(item => item.id !== record.id))
|
||||||
|
setRejecting(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<div className="create-page-heading">
|
<div className="create-page-heading">
|
||||||
<div><h2>创建任务</h2><p>通过顶部切换创建「提问任务」或「回答任务」。</p></div>
|
<div><h2>创建任务</h2><p>通过顶部切换创建「提问任务」或「回答任务」。</p></div>
|
||||||
@ -146,6 +162,7 @@ export default function CreateAnswerTasksPageV2({ api }) {
|
|||||||
{identifying && <span className="draft-identifying" role="status">正在识别,请等待…</span>}
|
{identifying && <span className="draft-identifying" role="status">正在识别,请等待…</span>}
|
||||||
<button className="draft-fold" disabled={identifying || detailBusy === record.id} onClick={() => toggleOpen(record)}>{detailBusy === record.id ? '正在加载图片…' : open ? '点击折叠' : '点击展开'}</button>
|
<button className="draft-fold" disabled={identifying || detailBusy === record.id} onClick={() => toggleOpen(record)}>{detailBusy === record.id ? '正在加载图片…' : open ? '点击折叠' : '点击展开'}</button>
|
||||||
<button className="primary-button" disabled={!!busy || locked || identifying} onClick={() => publish(record)}>{locked ? '已派发' : identifying ? '识别中…' : '生成任务'}</button>
|
<button className="primary-button" disabled={!!busy || locked || identifying} onClick={() => publish(record)}>{locked ? '已派发' : identifying ? '识别中…' : '生成任务'}</button>
|
||||||
|
<button className="draft-reject-button" disabled={!!busy || locked || identifying} onClick={() => setRejecting(record)}>打回</button>
|
||||||
</header>
|
</header>
|
||||||
{open && record.questions.map(question => <section className={`answer-draft-question ${question.selected ? 'selected' : ''}`} key={question.id}>
|
{open && record.questions.map(question => <section className={`answer-draft-question ${question.selected ? 'selected' : ''}`} key={question.id}>
|
||||||
<label className="question-check"><input className="task-checkbox" type="checkbox" checked={question.selected} disabled={locked} onChange={event => updateQuestion(question, { selected: event.target.checked })}/></label>
|
<label className="question-check"><input className="task-checkbox" type="checkbox" checked={question.selected} disabled={locked} onChange={event => updateQuestion(question, { selected: event.target.checked })}/></label>
|
||||||
@ -163,5 +180,12 @@ export default function CreateAnswerTasksPageV2({ api }) {
|
|||||||
</article>
|
</article>
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{rejecting && <div className="manual-modal-backdrop" onMouseDown={event => event.target === event.currentTarget && setRejecting(null)}>
|
||||||
|
<section className="manual-modal draft-reject-modal" role="dialog" aria-modal="true" aria-labelledby="draft-reject-title">
|
||||||
|
<div className="manual-modal-header"><h3 id="draft-reject-title">确认打回</h3><button type="button" onClick={() => setRejecting(null)}>×</button></div>
|
||||||
|
<p>确认打回用户 {rejecting.user_id} 的这条回答截图吗?打回后将直接从生成任务列表删除。</p>
|
||||||
|
<div className="manual-modal-actions"><button type="button" onClick={() => setRejecting(null)}>取消</button><button type="button" className="danger-solid-button" disabled={!!busy} onClick={() => reject(rejecting)}>{busy ? '处理中…' : '确认打回'}</button></div>
|
||||||
|
</section>
|
||||||
|
</div>}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Check, Link2, Plus, Sparkles } from "lucide-react";
|
import { Check, Link2, Plus, Sparkles } from "lucide-react";
|
||||||
|
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
|
||||||
|
|
||||||
const blankForm = {
|
const blankForm = {
|
||||||
product_link: "",
|
product_link: "",
|
||||||
@ -7,15 +8,21 @@ const blankForm = {
|
|||||||
category: "",
|
category: "",
|
||||||
benefits: "",
|
benefits: "",
|
||||||
selling_points: "",
|
selling_points: "",
|
||||||
language_style: "口语(亲切随性)",
|
language_style: "",
|
||||||
target_group: "女性",
|
target_group: "",
|
||||||
usage_scene: "居家",
|
usage_scene: "",
|
||||||
purchase_channel: "电商平台",
|
purchase_channel: "",
|
||||||
question_count: 5,
|
question_count: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
async function readReferenceFile(file) {
|
async function readReferenceFile(file, requestApi) {
|
||||||
if (!file) return "";
|
if (!file) return "";
|
||||||
|
if (/\.xlsx$/i.test(file.name)) {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", file);
|
||||||
|
const result = await requestApi("/api/admin/reference-qa/parse", { method: "POST", body });
|
||||||
|
return result.data.text || "";
|
||||||
|
}
|
||||||
return (await file.text()).slice(0, 20000);
|
return (await file.text()).slice(0, 20000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,15 +38,31 @@ export default function CreateAskTasksPage({ api }) {
|
|||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
|
const intentError = analyzeProductIntent([
|
||||||
|
form.product_name,
|
||||||
|
form.category,
|
||||||
|
form.benefits,
|
||||||
|
form.selling_points,
|
||||||
|
]);
|
||||||
|
if (intentError) {
|
||||||
|
setError(intentError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const optionsError = validateGenerationOptions(form, false);
|
||||||
|
if (optionsError) {
|
||||||
|
setError(optionsError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const reference_qa = await readReferenceFile(referenceFile);
|
const reference_qa = await readReferenceFile(referenceFile, api);
|
||||||
const result = await api("/api/admin/ask-tasks/generate", {
|
const result = await api("/api/admin/ask-tasks/generate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...form,
|
...form,
|
||||||
question_count: Number(form.question_count),
|
question_count: Number(form.question_count),
|
||||||
|
...(task ? { task_id: task.id } : {}),
|
||||||
reference_qa,
|
reference_qa,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -51,7 +74,7 @@ export default function CreateAskTasksPage({ api }) {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message || "生成失败,请检查参考文件和大模型配置后重试");
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@ -246,8 +269,9 @@ export default function CreateAskTasksPage({ api }) {
|
|||||||
</label>
|
</label>
|
||||||
<label className="reference-qa-upload">
|
<label className="reference-qa-upload">
|
||||||
真实问答参考(选填)
|
真实问答参考(选填)
|
||||||
<input type="file" accept=".txt,.csv,.json,.md,text/plain,text/csv,application/json,text/markdown" onChange={(event) => setReferenceFile(event.target.files?.[0] || null)} />
|
<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,生成提问时会参考其中的问题角度"}</small>
|
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown、XLSX,生成提问时会参考其中的问题角度"}</small>
|
||||||
|
{referenceFile && <button type="button" className="reference-qa-clear" onClick={() => setReferenceFile(null)}>清除已选文件</button>}
|
||||||
</label>
|
</label>
|
||||||
<h4>生成偏好与数量</h4>
|
<h4>生成偏好与数量</h4>
|
||||||
<div className="two-col">
|
<div className="two-col">
|
||||||
@ -259,6 +283,7 @@ export default function CreateAskTasksPage({ api }) {
|
|||||||
update("language_style", event.target.value)
|
update("language_style", event.target.value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择语言风格</option>
|
||||||
<option>口语(亲切随性)</option>
|
<option>口语(亲切随性)</option>
|
||||||
<option>专业</option>
|
<option>专业</option>
|
||||||
<option>幽默</option>
|
<option>幽默</option>
|
||||||
@ -270,6 +295,7 @@ export default function CreateAskTasksPage({ api }) {
|
|||||||
value={form.target_group}
|
value={form.target_group}
|
||||||
onChange={(event) => update("target_group", event.target.value)}
|
onChange={(event) => update("target_group", event.target.value)}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择目标人群</option>
|
||||||
<option>女性</option>
|
<option>女性</option>
|
||||||
<option>男性</option>
|
<option>男性</option>
|
||||||
<option>学生</option>
|
<option>学生</option>
|
||||||
@ -292,6 +318,7 @@ export default function CreateAskTasksPage({ api }) {
|
|||||||
value={form.usage_scene}
|
value={form.usage_scene}
|
||||||
onChange={(event) => update("usage_scene", event.target.value)}
|
onChange={(event) => update("usage_scene", event.target.value)}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择使用场景</option>
|
||||||
<option>居家</option>
|
<option>居家</option>
|
||||||
<option>旅行</option>
|
<option>旅行</option>
|
||||||
<option>通勤</option>
|
<option>通勤</option>
|
||||||
@ -305,6 +332,7 @@ export default function CreateAskTasksPage({ api }) {
|
|||||||
update("purchase_channel", event.target.value)
|
update("purchase_channel", event.target.value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择购买渠道</option>
|
||||||
<option>电商平台</option>
|
<option>电商平台</option>
|
||||||
<option>大型商超</option>
|
<option>大型商超</option>
|
||||||
<option>药房</option>
|
<option>药房</option>
|
||||||
@ -346,6 +374,9 @@ export default function CreateAskTasksPage({ api }) {
|
|||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
添加提问
|
添加提问
|
||||||
</button>
|
</button>
|
||||||
|
<button className="ghost-button" disabled={busy} onClick={generate}>
|
||||||
|
↻ 重新生成
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={
|
disabled={
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { MessageCircleMore, RefreshCw, ShieldCheck, X } from "lucide-react";
|
import { MessageCircleMore, RefreshCw, X } from "lucide-react";
|
||||||
|
|
||||||
const REASONS = [
|
const REASONS = [
|
||||||
"内容不清晰",
|
"内容不清晰",
|
||||||
@ -99,13 +99,6 @@ function ReviewDetail({ record, close, decide, busy }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div className="review-v2-assist">
|
|
||||||
<ShieldCheck size={16} />
|
|
||||||
<strong>系统智能助判</strong>
|
|
||||||
<span>
|
|
||||||
✓ 商品一致 ✓ 内容与要求匹配 ✓ 无重复提交 ✓ 无违规内容
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{record.latest_review && !pending && (
|
{record.latest_review && !pending && (
|
||||||
<div
|
<div
|
||||||
className={`review-v2-result ${record.latest_review.approved ? "approved" : ""}`}
|
className={`review-v2-result ${record.latest_review.approved ? "approved" : ""}`}
|
||||||
|
|||||||
@ -27,8 +27,12 @@ const maskPhone = (phone = "") =>
|
|||||||
function TaskDetail({ task, close, onConfirmRefund }) {
|
function TaskDetail({ task, close, onConfirmRefund }) {
|
||||||
const [refundOpen, setRefundOpen] = useState(false);
|
const [refundOpen, setRefundOpen] = useState(false);
|
||||||
const [refundBusy, setRefundBusy] = useState(false);
|
const [refundBusy, setRefundBusy] = useState(false);
|
||||||
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
const isAskTask =
|
const isAskTask =
|
||||||
task.task_type === "ask" || task.task_type_label === "提问任务";
|
task.task_type === "ask" || task.task_type_label === "提问任务";
|
||||||
|
const taskLink = isAskTask
|
||||||
|
? `${window.location.origin}/ask/${task.task_id}`
|
||||||
|
: "";
|
||||||
const proofScreenshots = [
|
const proofScreenshots = [
|
||||||
...(task.question_screenshots || []),
|
...(task.question_screenshots || []),
|
||||||
...(task.completion_screenshots || []),
|
...(task.completion_screenshots || []),
|
||||||
@ -45,6 +49,37 @@ function TaskDetail({ task, close, onConfirmRefund }) {
|
|||||||
setRefundBusy(false);
|
setRefundBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function copyTaskLink() {
|
||||||
|
if (!taskLink) return;
|
||||||
|
let copied = false;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(taskLink);
|
||||||
|
copied = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
copied = false;
|
||||||
|
}
|
||||||
|
if (!copied) {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = taskLink;
|
||||||
|
textarea.setAttribute("readonly", "");
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
try {
|
||||||
|
copied = document.execCommand("copy");
|
||||||
|
} catch {
|
||||||
|
copied = false;
|
||||||
|
}
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
if (copied) {
|
||||||
|
setLinkCopied(true);
|
||||||
|
window.setTimeout(() => setLinkCopied(false), 1800);
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="task-detail-backdrop"
|
className="task-detail-backdrop"
|
||||||
@ -62,6 +97,24 @@ function TaskDetail({ task, close, onConfirmRefund }) {
|
|||||||
<X size={21} />
|
<X size={21} />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
{taskLink && (
|
||||||
|
<div className="task-detail-share">
|
||||||
|
<span>提问链接</span>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={taskLink}
|
||||||
|
aria-label="提问任务链接"
|
||||||
|
onFocus={(event) => event.target.select()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={linkCopied ? "copied" : ""}
|
||||||
|
onClick={copyTaskLink}
|
||||||
|
>
|
||||||
|
{linkCopied ? "已复制" : "复制链接"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="task-detail-meta">
|
<div className="task-detail-meta">
|
||||||
<span>
|
<span>
|
||||||
商品名称<b>{task.product_name}</b>
|
商品名称<b>{task.product_name}</b>
|
||||||
@ -209,6 +262,7 @@ export default function TaskBoardPage({ api }) {
|
|||||||
const [busy, setBusy] = useState("");
|
const [busy, setBusy] = useState("");
|
||||||
const [batchBusy, setBatchBusy] = useState(false);
|
const [batchBusy, setBatchBusy] = useState(false);
|
||||||
const [batchConfirm, setBatchConfirm] = useState(null);
|
const [batchConfirm, setBatchConfirm] = useState(null);
|
||||||
|
const [completeConfirm, setCompleteConfirm] = useState(null);
|
||||||
async function load(showLoading = taskBoardCache === null) {
|
async function load(showLoading = taskBoardCache === null) {
|
||||||
if (showLoading) setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -366,6 +420,21 @@ export default function TaskBoardPage({ api }) {
|
|||||||
setBusy("");
|
setBusy("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function complete(task) {
|
||||||
|
if (!completeConfirm || busy) return;
|
||||||
|
setBusy(task.id);
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/ask-tasks/${task.id}/complete`, { method: "POST" });
|
||||||
|
setCompleteConfirm(null);
|
||||||
|
setSelectedIds((current) => current.filter((key) => key !== rowKey(task)));
|
||||||
|
await load();
|
||||||
|
if (detail?.id === task.id) setDetail(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
async function confirmRefund(task) {
|
async function confirmRefund(task) {
|
||||||
const result = await api(task.refund_endpoint, { method: "POST" });
|
const result = await api(task.refund_endpoint, { method: "POST" });
|
||||||
const updatedDetail = {
|
const updatedDetail = {
|
||||||
@ -444,7 +513,7 @@ export default function TaskBoardPage({ api }) {
|
|||||||
<button type="button" onClick={clearFilters}>清空筛选</button>
|
<button type="button" onClick={clearFilters}>清空筛选</button>
|
||||||
<button type="button" onClick={exportRows}>导出表格</button>
|
<button type="button" onClick={exportRows}>导出表格</button>
|
||||||
<button type="button" className="danger" disabled={batchBusy} onClick={batchClear}>
|
<button type="button" className="danger" disabled={batchBusy} onClick={batchClear}>
|
||||||
{batchBusy ? "清除中…" : `批量清除${selectedIds.length ? `(${selectedIds.length})` : ""}`}
|
{batchBusy ? "删除中…" : `批量删除${selectedIds.length ? `(${selectedIds.length})` : ""}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="form-error">{error}</div>}
|
{error && <div className="form-error">{error}</div>}
|
||||||
@ -517,6 +586,15 @@ export default function TaskBoardPage({ api }) {
|
|||||||
{busy === task.id ? "撤销中" : "撤销"}
|
{busy === task.id ? "撤销中" : "撤销"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{task.can_complete && task.task_type === "ask" && (
|
||||||
|
<button
|
||||||
|
className="complete"
|
||||||
|
disabled={busy === task.id}
|
||||||
|
onClick={() => setCompleteConfirm(task)}
|
||||||
|
>
|
||||||
|
完成任务
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="delete"
|
className="delete"
|
||||||
onClick={() => setBatchConfirm({ tasks: [task] })}
|
onClick={() => setBatchConfirm({ tasks: [task] })}
|
||||||
@ -555,6 +633,19 @@ export default function TaskBoardPage({ api }) {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{completeConfirm && (
|
||||||
|
<div className="board-confirm-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setCompleteConfirm(null)}>
|
||||||
|
<section className="board-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="complete-task-title">
|
||||||
|
<div className="board-confirm-icon complete-icon">✓</div>
|
||||||
|
<h2 id="complete-task-title">确认完成任务?</h2>
|
||||||
|
<p>该提问任务当前已完成 {completeConfirm.completed_count || 0} / {completeConfirm.total_count || completeConfirm.qa_count || 0} 条。确认后将强制归档到“已完成”,用户将不能继续提交。</p>
|
||||||
|
<div className="board-confirm-actions">
|
||||||
|
<button type="button" onClick={() => setCompleteConfirm(null)} disabled={busy === completeConfirm.id}>取消</button>
|
||||||
|
<button type="button" className="confirm-complete" onClick={() => complete(completeConfirm)} disabled={busy === completeConfirm.id}>{busy === completeConfirm.id ? "处理中…" : "确认完成"}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1806,7 +1806,7 @@
|
|||||||
.board-table-head,
|
.board-table-head,
|
||||||
.board-table-row {
|
.board-table-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 34px 1.05fr 1.05fr 1.9fr 0.75fr 0.55fr 0.75fr 0.75fr 1fr 0.7fr;
|
grid-template-columns: 34px 1.05fr 1.05fr 1.9fr 0.75fr 0.55fr 0.75fr 0.75fr 1fr minmax(150px, 1.2fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 13px 14px;
|
padding: 13px 14px;
|
||||||
@ -1928,12 +1928,19 @@
|
|||||||
}
|
}
|
||||||
.board-actions {
|
.board-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 9px;
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: max-content;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.board-actions button {
|
.board-actions button {
|
||||||
|
flex: 0 0 auto;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #1764e8;
|
color: #1764e8;
|
||||||
|
white-space: nowrap;
|
||||||
|
word-break: keep-all;
|
||||||
}
|
}
|
||||||
.board-actions button.cancel {
|
.board-actions button.cancel {
|
||||||
color: #d93450;
|
color: #d93450;
|
||||||
@ -1990,6 +1997,48 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: #8fa0b6;
|
color: #8fa0b6;
|
||||||
}
|
}
|
||||||
|
.task-detail-share {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #dce5ef;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f7faff;
|
||||||
|
}
|
||||||
|
.task-detail-share > span {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #536b88;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.task-detail-share input {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #cddceb;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff;
|
||||||
|
color: #345777;
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.task-detail-share button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #b9d1f7;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #f4f8ff;
|
||||||
|
color: #1764e8;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.task-detail-share button.copied {
|
||||||
|
border-color: #9fdfbd;
|
||||||
|
background: #ecfbf3;
|
||||||
|
color: #118453;
|
||||||
|
}
|
||||||
.task-detail-meta {
|
.task-detail-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.5fr 1fr 1fr 1.1fr;
|
grid-template-columns: 1.5fr 1fr 1fr 1.1fr;
|
||||||
@ -2219,6 +2268,17 @@
|
|||||||
.task-detail-meta {
|
.task-detail-meta {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
.task-detail-share {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.task-detail-share input {
|
||||||
|
flex-basis: 100%;
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
.task-detail-share button {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
.task-detail-table {
|
.task-detail-table {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@ -3015,11 +3075,14 @@
|
|||||||
.answer-v2-shell {
|
.answer-v2-shell {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: #f3f7fc;
|
background: #f3f7fc;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
.answer-v2-app {
|
.answer-v2-app {
|
||||||
|
width: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.answer-v2-body {
|
.answer-v2-body {
|
||||||
padding: 14px 10px 76px;
|
padding: 14px 10px 76px;
|
||||||
@ -3054,6 +3117,12 @@
|
|||||||
.status-meta {
|
.status-meta {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
.answer-v2-footer {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 5;
|
||||||
|
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 380px) {
|
@media (max-width: 380px) {
|
||||||
.answer-v2-top > span {
|
.answer-v2-top > span {
|
||||||
@ -3322,21 +3391,6 @@
|
|||||||
color: #8ca0b8;
|
color: #8ca0b8;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.review-v2-assist {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 18px;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid #8de3bb;
|
|
||||||
border-radius: 9px;
|
|
||||||
background: #edfff6;
|
|
||||||
color: #098852;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.review-v2-assist span {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
.review-v2-result {
|
.review-v2-result {
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@ -3470,13 +3524,6 @@
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.review-v2-assist {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.review-v2-assist span {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ask-selected-card article span {
|
.ask-selected-card article span {
|
||||||
@ -3589,6 +3636,51 @@
|
|||||||
color: #118453;
|
color: #118453;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
.adopt-success-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 75;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(8, 18, 37, 0.56);
|
||||||
|
}
|
||||||
|
.adopt-success-modal {
|
||||||
|
width: min(380px, 100%);
|
||||||
|
padding: 30px 28px 24px;
|
||||||
|
border: 1px solid #dce8f2;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 65px rgba(12, 33, 67, 0.24);
|
||||||
|
}
|
||||||
|
.adopt-success-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e8f9f0;
|
||||||
|
color: #07945c;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.adopt-success-modal h2 {
|
||||||
|
margin: 0 0 9px;
|
||||||
|
color: #142d4d;
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
.adopt-success-modal p {
|
||||||
|
margin: 0;
|
||||||
|
color: #6d829d;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.adopt-success-modal > button {
|
||||||
|
min-width: 110px;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
.qa-library-toolbar {
|
.qa-library-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -3663,6 +3755,69 @@
|
|||||||
.qa-library-card-tools button:hover {
|
.qa-library-card-tools button:hover {
|
||||||
background: #e8f1ff;
|
background: #e8f1ff;
|
||||||
}
|
}
|
||||||
|
.qa-library-card-tools .danger-button {
|
||||||
|
border-color: #ffc2c8;
|
||||||
|
background: #fff5f6;
|
||||||
|
color: #d9364f;
|
||||||
|
}
|
||||||
|
.qa-library-card-tools .danger-button:hover {
|
||||||
|
background: #ffe8eb;
|
||||||
|
}
|
||||||
|
.qa-library-delete-modal {
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
.qa-library-delete-modal > p {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #536b88;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.qa-delete-preview {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f6f9fd;
|
||||||
|
color: #294969;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.danger-solid-button {
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: 1px solid #d9364f;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #d9364f;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.danger-solid-button:hover { background: #bf2840; }
|
||||||
|
.danger-solid-button:disabled { opacity: .65; cursor: wait; }
|
||||||
|
.draft-reject-button {
|
||||||
|
padding: 8px 13px;
|
||||||
|
border: 1px solid #ffc2c8;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff5f6;
|
||||||
|
color: #d9364f;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.draft-reject-button:hover { background: #ffe8eb; }
|
||||||
|
.draft-reject-button:disabled { opacity: .6; cursor: not-allowed; }
|
||||||
|
.draft-reject-modal { width: min(430px, calc(100vw - 32px)); }
|
||||||
|
.draft-reject-modal > p { margin: 0 0 18px; color: #536b88; font-size: 13px; line-height: 1.7; }
|
||||||
|
.board-actions .complete {
|
||||||
|
color: #158d57;
|
||||||
|
}
|
||||||
|
.board-actions .complete:hover {
|
||||||
|
background: #e8f9f0;
|
||||||
|
}
|
||||||
|
.complete-icon {
|
||||||
|
background: #e8f9f0;
|
||||||
|
color: #158d57;
|
||||||
|
}
|
||||||
|
.confirm-complete {
|
||||||
|
border-color: #1764e8 !important;
|
||||||
|
background: #1764e8 !important;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
.qa-library-edit-modal {
|
.qa-library-edit-modal {
|
||||||
max-height: min(720px, calc(100vh - 32px));
|
max-height: min(720px, calc(100vh - 32px));
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@ -4119,6 +4274,7 @@ body img {
|
|||||||
padding: 32px;
|
padding: 32px;
|
||||||
background: rgba(4, 12, 28, .82);
|
background: rgba(4, 12, 28, .82);
|
||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.global-image-viewer img {
|
.global-image-viewer img {
|
||||||
@ -4130,7 +4286,114 @@ body img {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 20px 80px rgba(0, 0, 0, .45);
|
box-shadow: 0 20px 80px rgba(0, 0, 0, .45);
|
||||||
cursor: default;
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
transform-origin: center center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-image-viewer img.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-image-viewer-tools {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .2);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(13, 28, 51, .88);
|
||||||
|
color: #fff;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-image-viewer-tools button {
|
||||||
|
min-width: 32px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, .12);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-image-viewer-tools button:hover {
|
||||||
|
background: rgba(255, 255, 255, .24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-image-viewer-tools button.reset {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-image-viewer-tools span {
|
||||||
|
min-width: 48px;
|
||||||
|
color: rgba(255, 255, 255, .86);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile public task portals: use the full viewport and let the document
|
||||||
|
scroll naturally instead of clipping content inside the desktop card. */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.answer-v2-shell,
|
||||||
|
.ask-user-shell {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-v2-app,
|
||||||
|
.ask-user-app {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100dvh;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-v2-body {
|
||||||
|
min-height: 0;
|
||||||
|
padding-bottom: 86px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-v2-footer {
|
||||||
|
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-user-topbar {
|
||||||
|
padding-top: calc(13px + env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-user-profile,
|
||||||
|
.ask-user-task-id {
|
||||||
|
margin-inline: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-question-section,
|
||||||
|
.ask-confirm-body {
|
||||||
|
padding-inline: 10px;
|
||||||
|
padding-bottom: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-audit-body {
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-user-sticky {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10;
|
||||||
|
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.global-image-viewer-close {
|
.global-image-viewer-close {
|
||||||
@ -4158,4 +4421,8 @@ body img {
|
|||||||
top: 12px;
|
top: 12px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.global-image-viewer-tools {
|
||||||
|
bottom: 14px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
@ -23,6 +23,7 @@ import TaskBoardPage from "./TaskBoardPage.jsx";
|
|||||||
import ReviewCenterPage from "./ReviewCenterPage.jsx";
|
import ReviewCenterPage from "./ReviewCenterPage.jsx";
|
||||||
import CreateAskTasksPage from "./CreateAskTasksPage.jsx";
|
import CreateAskTasksPage from "./CreateAskTasksPage.jsx";
|
||||||
import AskUserPortal from "./AskUserPortal.jsx";
|
import AskUserPortal from "./AskUserPortal.jsx";
|
||||||
|
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
|
||||||
|
|
||||||
// 兼容公共回答链接末尾有无斜杠,避免 /answer 被识别为运营后台路由。
|
// 兼容公共回答链接末尾有无斜杠,避免 /answer 被识别为运营后台路由。
|
||||||
if (window.location.pathname === "/answer")
|
if (window.location.pathname === "/answer")
|
||||||
@ -51,12 +52,21 @@ async function api(path, options = {}) {
|
|||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) throw new Error(data.detail || "\u8bf7\u6c42\u5931\u8d25");
|
if (!response.ok) {
|
||||||
|
const message = data.detail || `请求失败(HTTP ${response.status})`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readReferenceFile(file) {
|
async function readReferenceFile(file, requestApi = api) {
|
||||||
if (!file) return "";
|
if (!file) return "";
|
||||||
|
if (/\.xlsx$/i.test(file.name)) {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", file);
|
||||||
|
const result = await requestApi("/api/admin/reference-qa/parse", { method: "POST", body });
|
||||||
|
return result.data.text || "";
|
||||||
|
}
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
if (file.name.toLowerCase().endsWith(".json")) {
|
if (file.name.toLowerCase().endsWith(".json")) {
|
||||||
try {
|
try {
|
||||||
@ -363,14 +373,14 @@ function AssistantPage() {
|
|||||||
category: "",
|
category: "",
|
||||||
benefits: "",
|
benefits: "",
|
||||||
selling_points: "",
|
selling_points: "",
|
||||||
language_style: "口语(亲切随性)",
|
language_style: "",
|
||||||
target_group: "女性",
|
target_group: "",
|
||||||
usage_scene: "居家",
|
usage_scene: "",
|
||||||
purchase_channel: "电商平台",
|
purchase_channel: "",
|
||||||
question_count: 5,
|
question_count: "",
|
||||||
answers_per_question: 2,
|
answers_per_question: "",
|
||||||
answer_requirement: "",
|
answer_requirement: "",
|
||||||
seed_style: true,
|
seed_style: false,
|
||||||
});
|
});
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -381,10 +391,26 @@ function AssistantPage() {
|
|||||||
const update = (key, value) =>
|
const update = (key, value) =>
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
async function generate() {
|
async function generate() {
|
||||||
|
const intentError = analyzeProductIntent([
|
||||||
|
form.product_name,
|
||||||
|
form.category,
|
||||||
|
form.benefits,
|
||||||
|
form.selling_points,
|
||||||
|
]);
|
||||||
|
if (intentError) {
|
||||||
|
setError(intentError);
|
||||||
|
setItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const optionsError = validateGenerationOptions(form);
|
||||||
|
if (optionsError) {
|
||||||
|
setError(optionsError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const reference_qa = referenceFile ? await readReferenceFile(referenceFile) : referenceQa;
|
const reference_qa = referenceFile ? await readReferenceFile(referenceFile, api) : referenceQa;
|
||||||
const result = await api("/api/admin/qa/generate", {
|
const result = await api("/api/admin/qa/generate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -457,7 +483,7 @@ function AssistantPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeading
|
<PageHeading
|
||||||
title="问答助手"
|
title="问答助手"
|
||||||
description="运营内容生产入口,输入商品要求即可快速生成 QA 并转采纳或发布任务。"
|
description="运营内容生产入口,输入商品要求即可快速生成 QA,并将采纳内容收录至问答库。"
|
||||||
/>
|
/>
|
||||||
<div className="qa-workspace">
|
<div className="qa-workspace">
|
||||||
<section className="config-card panel">
|
<section className="config-card panel">
|
||||||
@ -503,8 +529,9 @@ function AssistantPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="reference-qa-upload">
|
<label className="reference-qa-upload">
|
||||||
真实问答参考(选填)
|
真实问答参考(选填)
|
||||||
<input type="file" accept=".txt,.csv,.json,.md,text/plain,text/csv,application/json,text/markdown" onChange={(event) => setReferenceFile(event.target.files?.[0] || null)} />
|
<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,模型将参考其中的问答语气与角度"}</small>
|
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown、XLSX,模型将参考其中的问答语气与角度"}</small>
|
||||||
|
{referenceFile && <button type="button" className="reference-qa-clear" onClick={() => setReferenceFile(null)}>清除已选文件</button>}
|
||||||
</label>
|
</label>
|
||||||
<div className="rule" />
|
<div className="rule" />
|
||||||
<h4>生成偏好与数量</h4>
|
<h4>生成偏好与数量</h4>
|
||||||
@ -517,6 +544,7 @@ function AssistantPage() {
|
|||||||
update("language_style", event.target.value)
|
update("language_style", event.target.value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择语言风格</option>
|
||||||
<option>口语(亲切随性)</option>
|
<option>口语(亲切随性)</option>
|
||||||
<option>幽默</option>
|
<option>幽默</option>
|
||||||
<option>专业</option>
|
<option>专业</option>
|
||||||
@ -528,6 +556,7 @@ function AssistantPage() {
|
|||||||
value={form.target_group}
|
value={form.target_group}
|
||||||
onChange={(event) => update("target_group", event.target.value)}
|
onChange={(event) => update("target_group", event.target.value)}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择目标人群</option>
|
||||||
<option>女性</option>
|
<option>女性</option>
|
||||||
<option>男性</option>
|
<option>男性</option>
|
||||||
<option>学生</option>
|
<option>学生</option>
|
||||||
@ -568,6 +597,7 @@ function AssistantPage() {
|
|||||||
value={form.usage_scene}
|
value={form.usage_scene}
|
||||||
onChange={(event) => update("usage_scene", event.target.value)}
|
onChange={(event) => update("usage_scene", event.target.value)}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择使用场景</option>
|
||||||
<option>居家</option>
|
<option>居家</option>
|
||||||
<option>旅行</option>
|
<option>旅行</option>
|
||||||
<option>通勤</option>
|
<option>通勤</option>
|
||||||
@ -583,6 +613,7 @@ function AssistantPage() {
|
|||||||
update("purchase_channel", event.target.value)
|
update("purchase_channel", event.target.value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择购买渠道</option>
|
||||||
<option>电商平台</option>
|
<option>电商平台</option>
|
||||||
<option>大型商超</option>
|
<option>大型商超</option>
|
||||||
<option>药房</option>
|
<option>药房</option>
|
||||||
@ -663,12 +694,6 @@ function AssistantPage() {
|
|||||||
>
|
>
|
||||||
✓ 批量采纳
|
✓ 批量采纳
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
onClick={() => window.alert("发布任务将在任务管理模块接入")}
|
|
||||||
>
|
|
||||||
发布任务
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className="ghost-button"
|
className="ghost-button"
|
||||||
onClick={generate}
|
onClick={generate}
|
||||||
@ -936,14 +961,14 @@ function AssistantPageV2() {
|
|||||||
category: "",
|
category: "",
|
||||||
benefits: "",
|
benefits: "",
|
||||||
selling_points: "",
|
selling_points: "",
|
||||||
language_style: "口语(亲切随性)",
|
language_style: "",
|
||||||
target_group: "女性",
|
target_group: "",
|
||||||
usage_scene: "居家",
|
usage_scene: "",
|
||||||
purchase_channel: "电商平台",
|
purchase_channel: "",
|
||||||
question_count: 5,
|
question_count: "",
|
||||||
answers_per_question: 2,
|
answers_per_question: "",
|
||||||
answer_requirement: "",
|
answer_requirement: "",
|
||||||
seed_style: true,
|
seed_style: false,
|
||||||
});
|
});
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -951,6 +976,22 @@ function AssistantPageV2() {
|
|||||||
const update = (key, value) =>
|
const update = (key, value) =>
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
async function generate() {
|
async function generate() {
|
||||||
|
const intentError = analyzeProductIntent([
|
||||||
|
form.product_name,
|
||||||
|
form.category,
|
||||||
|
form.benefits,
|
||||||
|
form.selling_points,
|
||||||
|
]);
|
||||||
|
if (intentError) {
|
||||||
|
setError(intentError);
|
||||||
|
setItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const optionsError = validateGenerationOptions(form);
|
||||||
|
if (optionsError) {
|
||||||
|
setError(optionsError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -1009,7 +1050,7 @@ function AssistantPageV2() {
|
|||||||
<>
|
<>
|
||||||
<PageHeading
|
<PageHeading
|
||||||
title="问答助手"
|
title="问答助手"
|
||||||
description="运营内容生产入口,输入商品要求即可快速生成 QA 并转采纳或发布任务。"
|
description="运营内容生产入口,输入商品要求即可快速生成 QA,并将采纳内容收录至问答库。"
|
||||||
/>
|
/>
|
||||||
<div className="qa-workspace">
|
<div className="qa-workspace">
|
||||||
<section className="config-card panel">
|
<section className="config-card panel">
|
||||||
@ -1064,6 +1105,7 @@ function AssistantPageV2() {
|
|||||||
update("language_style", event.target.value)
|
update("language_style", event.target.value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择语言风格</option>
|
||||||
<option>口语(亲切随性)</option>
|
<option>口语(亲切随性)</option>
|
||||||
<option>幽默</option>
|
<option>幽默</option>
|
||||||
<option>专业</option>
|
<option>专业</option>
|
||||||
@ -1075,6 +1117,7 @@ function AssistantPageV2() {
|
|||||||
value={form.target_group}
|
value={form.target_group}
|
||||||
onChange={(event) => update("target_group", event.target.value)}
|
onChange={(event) => update("target_group", event.target.value)}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择目标人群</option>
|
||||||
<option>女性</option>
|
<option>女性</option>
|
||||||
<option>男性</option>
|
<option>男性</option>
|
||||||
<option>学生</option>
|
<option>学生</option>
|
||||||
@ -1115,6 +1158,7 @@ function AssistantPageV2() {
|
|||||||
value={form.usage_scene}
|
value={form.usage_scene}
|
||||||
onChange={(event) => update("usage_scene", event.target.value)}
|
onChange={(event) => update("usage_scene", event.target.value)}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择使用场景</option>
|
||||||
<option>居家</option>
|
<option>居家</option>
|
||||||
<option>旅行</option>
|
<option>旅行</option>
|
||||||
<option>通勤</option>
|
<option>通勤</option>
|
||||||
@ -1130,6 +1174,7 @@ function AssistantPageV2() {
|
|||||||
update("purchase_channel", event.target.value)
|
update("purchase_channel", event.target.value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择购买渠道</option>
|
||||||
<option>电商平台</option>
|
<option>电商平台</option>
|
||||||
<option>大型商超</option>
|
<option>大型商超</option>
|
||||||
<option>药房</option>
|
<option>药房</option>
|
||||||
@ -1204,12 +1249,6 @@ function AssistantPageV2() {
|
|||||||
>
|
>
|
||||||
✓ 批量采纳
|
✓ 批量采纳
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
onClick={() => window.alert("发布任务将在任务管理模块接入")}
|
|
||||||
>
|
|
||||||
发布任务
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className="ghost-button"
|
className="ghost-button"
|
||||||
onClick={generate}
|
onClick={generate}
|
||||||
@ -1246,6 +1285,8 @@ function QALibraryPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [editing, setEditing] = useState(null);
|
const [editing, setEditing] = useState(null);
|
||||||
|
const [deleting, setDeleting] = useState(null);
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
async function load(keyword = search) {
|
async function load(keyword = search) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -1266,6 +1307,20 @@ function QALibraryPage() {
|
|||||||
window.addEventListener("qa-library-changed", refresh);
|
window.addEventListener("qa-library-changed", refresh);
|
||||||
return () => window.removeEventListener("qa-library-changed", refresh);
|
return () => window.removeEventListener("qa-library-changed", refresh);
|
||||||
}, []);
|
}, []);
|
||||||
|
async function removeRecord() {
|
||||||
|
if (!deleting || deleteBusy) return;
|
||||||
|
setDeleteBusy(true);
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/qa-library/${deleting.id}`, { method: "DELETE" });
|
||||||
|
setRecords((current) => current.filter((item) => item.id !== deleting.id));
|
||||||
|
setDeleting(null);
|
||||||
|
window.dispatchEvent(new Event("qa-library-changed"));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeading
|
<PageHeading
|
||||||
@ -1311,6 +1366,7 @@ function QALibraryPage() {
|
|||||||
})}
|
})}
|
||||||
</small>
|
</small>
|
||||||
<button onClick={() => setEditing(record)}>编辑</button>
|
<button onClick={() => setEditing(record)}>编辑</button>
|
||||||
|
<button className="danger-button" onClick={() => setDeleting(record)}>删除</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<h3>Q:{record.question}</h3>
|
<h3>Q:{record.question}</h3>
|
||||||
@ -1346,6 +1402,22 @@ function QALibraryPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{deleting && (
|
||||||
|
<div className="manual-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setDeleting(null)}>
|
||||||
|
<section className="manual-modal qa-library-delete-modal" role="dialog" aria-modal="true" aria-labelledby="qa-delete-title">
|
||||||
|
<div className="manual-modal-header">
|
||||||
|
<h3 id="qa-delete-title">确认删除</h3>
|
||||||
|
<button type="button" onClick={() => setDeleting(null)}>×</button>
|
||||||
|
</div>
|
||||||
|
<p>确定要删除这条问答吗?删除后将无法恢复。</p>
|
||||||
|
<div className="qa-delete-preview">Q:{deleting.question}</div>
|
||||||
|
<div className="manual-modal-actions">
|
||||||
|
<button type="button" onClick={() => setDeleting(null)}>取消</button>
|
||||||
|
<button type="button" className="danger-solid-button" disabled={deleteBusy} onClick={removeRecord}>{deleteBusy ? "删除中…" : "确认删除"}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1415,30 +1487,47 @@ function AssistantPageFinal() {
|
|||||||
category: "",
|
category: "",
|
||||||
benefits: "",
|
benefits: "",
|
||||||
selling_points: "",
|
selling_points: "",
|
||||||
language_style: "口语(亲切随性)",
|
language_style: "",
|
||||||
target_group: "女性",
|
target_group: "",
|
||||||
usage_scene: "居家",
|
usage_scene: "",
|
||||||
purchase_channel: "电商平台",
|
purchase_channel: "",
|
||||||
question_count: 5,
|
question_count: "",
|
||||||
answers_per_question: 2,
|
answers_per_question: "",
|
||||||
answer_requirement: "",
|
answer_requirement: "",
|
||||||
seed_style: true,
|
seed_style: false,
|
||||||
});
|
});
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [savedMessage, setSavedMessage] = useState("");
|
const [savedMessage, setSavedMessage] = useState("");
|
||||||
|
const [adoptedDialog, setAdoptedDialog] = useState(null);
|
||||||
const [manualOpen, setManualOpen] = useState(false);
|
const [manualOpen, setManualOpen] = useState(false);
|
||||||
const [referenceFile, setReferenceFile] = useState(null);
|
const [referenceFile, setReferenceFile] = useState(null);
|
||||||
const [referenceQa, setReferenceQa] = useState("");
|
const [referenceQa, setReferenceQa] = useState("");
|
||||||
const update = (key, value) =>
|
const update = (key, value) =>
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
async function generate() {
|
async function generate() {
|
||||||
|
const intentError = analyzeProductIntent([
|
||||||
|
form.product_name,
|
||||||
|
form.category,
|
||||||
|
form.benefits,
|
||||||
|
form.selling_points,
|
||||||
|
]);
|
||||||
|
if (intentError) {
|
||||||
|
setError(intentError);
|
||||||
|
setItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const optionsError = validateGenerationOptions(form);
|
||||||
|
if (optionsError) {
|
||||||
|
setError(optionsError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const reference_qa = referenceFile ? await readReferenceFile(referenceFile) : referenceQa;
|
const reference_qa = referenceFile ? await readReferenceFile(referenceFile, api) : referenceQa;
|
||||||
const result = await api("/api/admin/qa/generate", {
|
const result = await api("/api/admin/qa/generate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -1456,7 +1545,7 @@ function AssistantPageFinal() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function adopt(targetItems) {
|
async function adopt(targetItems, showDialog = false) {
|
||||||
if (!targetItems.length || saving) return;
|
if (!targetItems.length || saving) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSavedMessage("");
|
setSavedMessage("");
|
||||||
@ -1484,6 +1573,7 @@ function AssistantPageFinal() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const { added_count: added, skipped_count: skipped } = result.data;
|
const { added_count: added, skipped_count: skipped } = result.data;
|
||||||
|
if (showDialog) setAdoptedDialog({ count: targetItems.length, added, skipped });
|
||||||
setSavedMessage(
|
setSavedMessage(
|
||||||
added > 0
|
added > 0
|
||||||
? `已将 ${added} 条问答存入问答库${skipped ? `,${skipped} 条重复内容已跳过` : ""}`
|
? `已将 ${added} 条问答存入问答库${skipped ? `,${skipped} 条重复内容已跳过` : ""}`
|
||||||
@ -1502,7 +1592,7 @@ function AssistantPageFinal() {
|
|||||||
if (target.accepted) return;
|
if (target.accepted) return;
|
||||||
adopt([target]);
|
adopt([target]);
|
||||||
}
|
}
|
||||||
const acceptAll = () => adopt(items);
|
const acceptAll = () => adopt(items, true);
|
||||||
const save = (id, question, answers) =>
|
const save = (id, question, answers) =>
|
||||||
setItems((current) =>
|
setItems((current) =>
|
||||||
current.map((item) =>
|
current.map((item) =>
|
||||||
@ -1532,7 +1622,7 @@ function AssistantPageFinal() {
|
|||||||
<>
|
<>
|
||||||
<PageHeading
|
<PageHeading
|
||||||
title="问答助手"
|
title="问答助手"
|
||||||
description="运营内容生产入口,输入商品要求即可快速生成 QA 并转采纳或发布任务。"
|
description="运营内容生产入口,输入商品要求即可快速生成 QA,并将采纳内容收录至问答库。"
|
||||||
/>
|
/>
|
||||||
<div className="qa-workspace">
|
<div className="qa-workspace">
|
||||||
<section className="config-card panel">
|
<section className="config-card panel">
|
||||||
@ -1578,8 +1668,9 @@ function AssistantPageFinal() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="reference-qa-upload">
|
<label className="reference-qa-upload">
|
||||||
真实问答参考(选填)
|
真实问答参考(选填)
|
||||||
<input type="file" accept=".txt,.csv,.json,.md,text/plain,text/csv,application/json,text/markdown" onChange={(event) => setReferenceFile(event.target.files?.[0] || null)} />
|
<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,模型将参考其中的问答语气与角度"}</small>
|
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown、XLSX,模型将参考其中的问答语气与角度"}</small>
|
||||||
|
{referenceFile && <button type="button" className="reference-qa-clear" onClick={() => setReferenceFile(null)}>清除已选文件</button>}
|
||||||
</label>
|
</label>
|
||||||
<div className="rule" />
|
<div className="rule" />
|
||||||
<h4>生成偏好与数量</h4>
|
<h4>生成偏好与数量</h4>
|
||||||
@ -1592,6 +1683,7 @@ function AssistantPageFinal() {
|
|||||||
update("language_style", event.target.value)
|
update("language_style", event.target.value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择语言风格</option>
|
||||||
<option>口语(亲切随性)</option>
|
<option>口语(亲切随性)</option>
|
||||||
<option>幽默</option>
|
<option>幽默</option>
|
||||||
<option>专业</option>
|
<option>专业</option>
|
||||||
@ -1603,6 +1695,7 @@ function AssistantPageFinal() {
|
|||||||
value={form.target_group}
|
value={form.target_group}
|
||||||
onChange={(event) => update("target_group", event.target.value)}
|
onChange={(event) => update("target_group", event.target.value)}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择目标人群</option>
|
||||||
<option>女性</option>
|
<option>女性</option>
|
||||||
<option>男性</option>
|
<option>男性</option>
|
||||||
<option>学生</option>
|
<option>学生</option>
|
||||||
@ -1643,6 +1736,7 @@ function AssistantPageFinal() {
|
|||||||
value={form.usage_scene}
|
value={form.usage_scene}
|
||||||
onChange={(event) => update("usage_scene", event.target.value)}
|
onChange={(event) => update("usage_scene", event.target.value)}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择使用场景</option>
|
||||||
<option>居家</option>
|
<option>居家</option>
|
||||||
<option>旅行</option>
|
<option>旅行</option>
|
||||||
<option>通勤</option>
|
<option>通勤</option>
|
||||||
@ -1658,6 +1752,7 @@ function AssistantPageFinal() {
|
|||||||
update("purchase_channel", event.target.value)
|
update("purchase_channel", event.target.value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<option value="">请选择购买渠道</option>
|
||||||
<option>电商平台</option>
|
<option>电商平台</option>
|
||||||
<option>大型商超</option>
|
<option>大型商超</option>
|
||||||
<option>药房</option>
|
<option>药房</option>
|
||||||
@ -1722,12 +1817,6 @@ function AssistantPageFinal() {
|
|||||||
>
|
>
|
||||||
{saving ? "正在存入…" : "✓ 批量采纳"}
|
{saving ? "正在存入…" : "✓ 批量采纳"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
onClick={() => window.alert("发布任务将在任务管理模块接入")}
|
|
||||||
>
|
|
||||||
发布任务
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className="ghost-button"
|
className="ghost-button"
|
||||||
onClick={generate}
|
onClick={generate}
|
||||||
@ -1757,6 +1846,19 @@ function AssistantPageFinal() {
|
|||||||
{manualOpen && (
|
{manualOpen && (
|
||||||
<ManualQAForm onClose={() => setManualOpen(false)} onSave={addManual} />
|
<ManualQAForm onClose={() => setManualOpen(false)} onSave={addManual} />
|
||||||
)}
|
)}
|
||||||
|
{adoptedDialog && (
|
||||||
|
<div
|
||||||
|
className="adopt-success-backdrop"
|
||||||
|
onMouseDown={(event) => event.target === event.currentTarget && setAdoptedDialog(null)}
|
||||||
|
>
|
||||||
|
<section className="adopt-success-modal" role="dialog" aria-modal="true" aria-labelledby="adopt-success-title">
|
||||||
|
<div className="adopt-success-icon">✓</div>
|
||||||
|
<h2 id="adopt-success-title">问答已采纳</h2>
|
||||||
|
<p>已采纳 {adoptedDialog.count} 条,采纳问答已收录至问答库。</p>
|
||||||
|
<button type="button" className="primary-button" onClick={() => setAdoptedDialog(null)}>知道了</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -3535,11 +3637,17 @@ function CompletionReviewPage() {
|
|||||||
}
|
}
|
||||||
function GlobalImageViewer() {
|
function GlobalImageViewer() {
|
||||||
const [src, setSrc] = useState("");
|
const [src, setSrc] = useState("");
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const dragStart = useRef(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClick = (event) => {
|
const handleClick = (event) => {
|
||||||
const image = event.target?.closest?.("img");
|
const image = event.target?.closest?.("img");
|
||||||
if (!image || !image.src) return;
|
if (!image || !image.src) return;
|
||||||
setSrc(image.src);
|
setSrc(image.src);
|
||||||
|
setScale(1);
|
||||||
|
setPosition({ x: 0, y: 0 });
|
||||||
};
|
};
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event) => {
|
||||||
if (event.key === "Escape") setSrc("");
|
if (event.key === "Escape") setSrc("");
|
||||||
@ -3552,10 +3660,68 @@ function GlobalImageViewer() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
if (!src) return null;
|
if (!src) return null;
|
||||||
|
const changeScale = (delta) => {
|
||||||
|
setScale((current) => {
|
||||||
|
const next = Math.min(5, Math.max(0.5, current + delta));
|
||||||
|
if (next <= 1) setPosition({ x: 0, y: 0 });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const resetView = () => {
|
||||||
|
setScale(1);
|
||||||
|
setPosition({ x: 0, y: 0 });
|
||||||
|
};
|
||||||
|
const beginDrag = (event) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
|
dragStart.current = {
|
||||||
|
x: event.clientX - position.x,
|
||||||
|
y: event.clientY - position.y,
|
||||||
|
};
|
||||||
|
setDragging(true);
|
||||||
|
};
|
||||||
|
const moveDrag = (event) => {
|
||||||
|
if (!dragStart.current) return;
|
||||||
|
setPosition({
|
||||||
|
x: event.clientX - dragStart.current.x,
|
||||||
|
y: event.clientY - dragStart.current.y,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const endDrag = () => {
|
||||||
|
dragStart.current = null;
|
||||||
|
setDragging(false);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="global-image-viewer" role="dialog" aria-modal="true" aria-label="图片大图预览" onClick={() => setSrc("")}>
|
<div
|
||||||
|
className="global-image-viewer"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="图片大图预览"
|
||||||
|
onClick={() => setSrc("")}
|
||||||
|
onWheel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
changeScale(event.deltaY > 0 ? -0.15 : 0.15);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button type="button" className="global-image-viewer-close" aria-label="关闭大图" onClick={() => setSrc("")}>×</button>
|
<button type="button" className="global-image-viewer-close" aria-label="关闭大图" onClick={() => setSrc("")}>×</button>
|
||||||
<img src={src} alt="大图预览" onClick={(event) => event.stopPropagation()} />
|
<div className="global-image-viewer-tools" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<button type="button" aria-label="缩小" onClick={() => changeScale(-0.25)}>−</button>
|
||||||
|
<span>{Math.round(scale * 100)}%</span>
|
||||||
|
<button type="button" aria-label="放大" onClick={() => changeScale(0.25)}>+</button>
|
||||||
|
<button type="button" className="reset" onClick={resetView}>重置</button>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt="大图预览"
|
||||||
|
draggable={false}
|
||||||
|
className={dragging ? "dragging" : ""}
|
||||||
|
style={{ transform: `translate(${position.x}px, ${position.y}px) scale(${scale})` }}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onPointerDown={beginDrag}
|
||||||
|
onPointerMove={moveDrag}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
49
frontend/src/productIntent.js
Normal file
49
frontend/src/productIntent.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
const REPEATED_PATTERN = /^(.{1,3})\1+$/u;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight intent guard used before calling the generation APIs. It keeps
|
||||||
|
* obvious placeholder input (for example, "111" or "aaaa") from being sent
|
||||||
|
* to the model while allowing normal Chinese/English product descriptions.
|
||||||
|
*/
|
||||||
|
export function analyzeProductIntent(values) {
|
||||||
|
const text = values
|
||||||
|
.map((value) => String(value || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
if (!text) return "请至少填写一项商品信息";
|
||||||
|
|
||||||
|
const compact = text.replace(/[\s\p{P}\p{S}]+/gu, "");
|
||||||
|
if (!compact) return "商品信息不能只有符号,请填写真实的商品内容";
|
||||||
|
if (/^\d+$/u.test(compact)) {
|
||||||
|
return "商品信息看起来像占位内容,请填写真实的商品名称、品类、功效或卖点";
|
||||||
|
}
|
||||||
|
if (REPEATED_PATTERN.test(compact)) {
|
||||||
|
return "商品信息看起来是重复的占位内容,请补充真实的商品信息";
|
||||||
|
}
|
||||||
|
if (!/[\u4e00-\u9fffA-Za-z]/u.test(compact)) {
|
||||||
|
return "请填写包含文字的真实商品信息";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 missing = required
|
||||||
|
.filter(([key]) => !String(form[key] ?? "").trim())
|
||||||
|
.map(([, label]) => label);
|
||||||
|
if (missing.length) return `请填写${missing.join("、")}`;
|
||||||
|
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 的整数";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
@ -219,6 +219,27 @@ button {
|
|||||||
color: #8296ae;
|
color: #8296ae;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
.reference-qa-upload input[type="file"] {
|
||||||
|
color: #466181;
|
||||||
|
}
|
||||||
|
.reference-qa-upload input[type="file"]::file-selector-button {
|
||||||
|
margin-right: 10px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
border: 1px solid #c7d8eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1764e8;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.reference-qa-clear {
|
||||||
|
width: max-content;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #f1a9b5;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff3f5;
|
||||||
|
color: #cc3150;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
.login-hint {
|
.login-hint {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
color: #9aabc1;
|
color: #9aabc1;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user