修复bug,调整页面布局,优化移动端页面

This commit is contained in:
kx_guo 2026-09-03 18:20:25 +08:00
parent 4fea0a28fc
commit cf83de291c
13 changed files with 1116 additions and 168 deletions

View File

@ -88,6 +88,49 @@ def generate_qa(payload: QAGenerateRequest) -> list[dict]:
return _parse_content(content, payload.answers_per_question)
def generate_questions(payload: dict, count: int, reference_qa: str = "") -> list[str]:
"""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:
"""Generate one concise answer. Falls back to a safe draft during the simulated-OCR MVP."""
if not settings.llm_api_key:

View File

@ -4,11 +4,15 @@ import base64
import hashlib
import json
import random
import re
import secrets
import time
import urllib.parse
import urllib.request
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.middleware.cors import CORSMiddleware
@ -17,7 +21,7 @@ from sqlalchemy.orm import Session, noload, selectinload
from .config import settings
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 .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QAGenerateRequest, RegisterRequest
from .security import create_session_token, decode_session_token, hash_password, verify_password
@ -32,6 +36,82 @@ app.add_middleware(
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 数据暂存于内存:截图只在当前服务进程中保留,后续接入对象存储/持久化表。
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:
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 {
"id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task",
"task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务",
"product_name": task.product_name, "product_link": task.product_link, "status": task.status,
"status_label": {"已发布": "进行中", "已撤销": "已撤销", "已完成": "已完成"}.get(task.status, task.status),
"qa_count": len([q for q in questions if q.selected]), "claimed_count": 0, "submitted_count": 0,
"pending_review_count": 0, "completed_count": 0, "total_count": len(questions),
"qa_count": len(selected_questions), "claimed_count": assigned_count, "submitted_count": completed_count,
"pending_review_count": pending_count, "completed_count": completed_count, "total_count": len(selected_questions),
"creator_name": "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(),
"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}",
"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,
"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)):
user = _answer_user_from_token(token, db)
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))
if not assignment:
# Allocation is permanent as soon as the task link assigns the
# questions. Exclude every question already allocated to any user,
# even if that user has not selected or submitted it yet.
# Keep each question reserved while the user is working. Expired
# unfinished reservations have already been removed above, so these
# are the currently allocated questions that must stay excluded.
allocated_ids = set(db.scalars(
select(AskAssignmentQuestion.question_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")
def public_ask_assignments(token: str, db: Session = Depends(get_db)):
user = _answer_user_from_token(token, db)
if _release_expired_ask_assignments(db):
db.commit()
records = db.scalars(
select(AskAssignment)
.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)):
user = _answer_user_from_token(str(payload.get("token", "")), 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))
if not assignment:
raise HTTPException(status_code=409, detail="请先领取提问任务")
@ -726,6 +819,8 @@ async def submit_public_ask_task(
):
user = _answer_user_from_token(token, 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))
if not assignment:
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)}
@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")
def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
product_link = str(payload.get("product_link", "")).strip()
product_name = str(payload.get("product_name", "")).strip()
if not product_link: raise HTTPException(status_code=400, detail="商品链接为必填项")
if not product_name and not any(str(payload.get(key, "")).strip() for key in ("category", "benefits", "selling_points")):
raise HTTPException(status_code=400, detail="请至少填写一项商品信息")
intent_error = _product_intent_error([product_name, payload.get("category"), payload.get("benefits"), payload.get("selling_points")])
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:
count = int(payload.get("question_count", 5))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="提问生成条数必须是正整数")
if count < 1:
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()
templates = [
"这款商品适合什么人群使用?",
"使用起来方便吗?效果怎么样?",
"日常使用有什么需要注意的吗?",
"这款商品和同类产品相比有什么特点?",
"新手使用是否容易上手?",
"这款商品建议多久使用一次?",
"它的质地和实际使用感怎么样?",
"使用后吸收速度快吗?会不会有黏腻感?",
"敏感人群可以使用这款商品吗?",
"不同季节使用时效果会有区别吗?",
"它在日常使用步骤中应该放在哪一步?",
"每次使用多少量比较合适?",
"通常需要坚持使用多久才能感受到效果?",
"这款商品可以和其他同类商品搭配使用吗?",
"包装是否方便携带和日常使用?",
"开封后应该如何保存?",
"这款商品的性价比怎么样?",
"第一次购买建议选择哪种规格?",
"使用过程中有哪些容易忽略的小细节?",
"实际体验后值得再次购买吗?",
]
questions = payload.get("questions") if isinstance(payload.get("questions"), list) else []
reference_text = str(payload.get("reference_qa", ""))[:20000]
if reference_text:
for line in reference_text.splitlines():
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)
if not questions:
questions = generate_questions(payload, count, reference_text)
# 自定义问题优先,不足的数量用候选问题补齐,保证返回数量与运营填写值一致。
generated: list[str] = []
for value in questions:
@ -1023,18 +1128,8 @@ def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: S
generated.append(question)
if len(generated) == count:
break
template_index = 0
while len(generated) < count:
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
if len(generated) < count:
generated.extend(generate_questions(payload, count - len(generated), reference_text))
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)
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)}
@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}")
def update_answer_question(question_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
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")
def admin_task_board(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
if _release_expired_ask_assignments(db):
db.commit()
task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"]
records = db.scalars(
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)
if not task:
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.commit()
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")
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)):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少填写一项商品信息")
intent_error = _product_intent_error([payload.product_name, payload.category, payload.benefits, payload.selling_points])
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)}}
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:
@ -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)}
@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")
def login(payload: LoginRequest, request: Request, response: Response, db: Session = Depends(get_db)):
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)}}
@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")
def dingtalk_login_config():
return {"success": True, "data": {"enabled": _dingtalk_configured(), "redirect_uri": settings.dingtalk_redirect_uri}}

View File

@ -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>

View File

@ -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'
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 formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
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 }) {
const paid = latest.status === '已完成'
const passed = ['已通过', '已完成'].includes(latest.status)
const rejected = latest.status === '未通过'
const cancelled = latest.status === '已撤销'
@ -107,8 +108,8 @@ function FinalStage({ latest, reload, busy, reopen, startNew }) {
</>
return <>
<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>
{passed && <div className="answer-v2-payment-wait"><strong>审核已完成,等待打款</strong><span>款项将按运营安排发放,请留意收款账户。</span></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 ${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>
</section>
{latest.completion_screenshots?.length > 0 && <MaterialCard latest={latest} completion/>}

View File

@ -746,6 +746,7 @@ export default function AskUserPortal({ api }) {
async function loadAssignment(silent = false) {
if (!session) return;
const previousAssignmentId = assignment?.assignment_id;
if (!silent) setBusy(true);
try {
const result = await api(
@ -756,6 +757,10 @@ export default function AskUserPortal({ api }) {
// 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
// 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(
result.data.questions
// 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)
.map((item) => item.id),
);
}
setError("");
} catch (requestError) {
if (requestError.message === EXHAUSTED_MESSAGE) {
@ -791,8 +797,9 @@ export default function AskUserPortal({ api }) {
loadAssignment();
}, [session?.token, taskNo]);
useEffect(() => {
if (assignment?.status !== "待审核") return undefined;
const timer = window.setInterval(() => loadAssignment(true), 10000);
if (!assignment || !["待审核", "待选择", "进行中"].includes(assignment.status)) return undefined;
const interval = assignment.status === "待审核" ? 10000 : 60000;
const timer = window.setInterval(() => loadAssignment(true), interval);
return () => window.clearInterval(timer);
}, [assignment?.status, session?.token, taskNo]);

View File

@ -6,6 +6,7 @@ export default function CreateAnswerTasksPageV2({ api }) {
const [busy, setBusy] = useState('')
const [detailBusy, setDetailBusy] = useState('')
const [error, setError] = useState('')
const [rejecting, setRejecting] = useState(null)
async function load() {
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 <>
<div className="create-page-heading">
<div><h2>创建任务</h2><p>通过顶部切换创建「提问任务」或「回答任务」。</p></div>
@ -146,6 +162,7 @@ export default function CreateAnswerTasksPageV2({ api }) {
{identifying && <span className="draft-identifying" role="status">正在识别,请等待…</span>}
<button className="draft-fold" disabled={identifying || detailBusy === record.id} onClick={() => toggleOpen(record)}>{detailBusy === record.id ? '正在加载图片…' : open ? '点击折叠' : '点击展开'}</button>
<button className="primary-button" disabled={!!busy || locked || identifying} onClick={() => publish(record)}>{locked ? '已派发' : identifying ? '识别中…' : '生成任务'}</button>
<button className="draft-reject-button" disabled={!!busy || locked || identifying} onClick={() => setRejecting(record)}>打回</button>
</header>
{open && record.questions.map(question => <section className={`answer-draft-question ${question.selected ? 'selected' : ''}`} key={question.id}>
<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>
})}
</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>}
</>
}

View File

@ -1,5 +1,6 @@
import React, { useState } from "react";
import { Check, Link2, Plus, Sparkles } from "lucide-react";
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
const blankForm = {
product_link: "",
@ -7,15 +8,21 @@ const blankForm = {
category: "",
benefits: "",
selling_points: "",
language_style: "口语(亲切随性)",
target_group: "女性",
usage_scene: "居家",
purchase_channel: "电商平台",
question_count: 5,
language_style: "",
target_group: "",
usage_scene: "",
purchase_channel: "",
question_count: "",
};
async function readReferenceFile(file) {
async function readReferenceFile(file, requestApi) {
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);
}
@ -31,15 +38,31 @@ export default function CreateAskTasksPage({ api }) {
setForm((current) => ({ ...current, [key]: value }));
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);
setError("");
try {
const reference_qa = await readReferenceFile(referenceFile);
const reference_qa = await readReferenceFile(referenceFile, api);
const result = await api("/api/admin/ask-tasks/generate", {
method: "POST",
body: JSON.stringify({
...form,
question_count: Number(form.question_count),
...(task ? { task_id: task.id } : {}),
reference_qa,
}),
});
@ -51,7 +74,7 @@ export default function CreateAskTasksPage({ api }) {
})),
);
} catch (err) {
setError(err.message);
setError(err.message || "生成失败,请检查参考文件和大模型配置后重试");
} finally {
setBusy(false);
}
@ -246,8 +269,9 @@ export default function CreateAskTasksPage({ api }) {
</label>
<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)} />
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown,生成提问时会参考其中的问题角度"}</small>
<input type="file" accept=".txt,.csv,.json,.md,.xlsx,text/plain,text/csv,application/json,text/markdown,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => { setReferenceFile(event.target.files?.[0] || null); setError(""); }} />
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown、XLSX,生成提问时会参考其中的问题角度"}</small>
{referenceFile && <button type="button" className="reference-qa-clear" onClick={() => setReferenceFile(null)}>清除已选文件</button>}
</label>
<h4>生成偏好与数量</h4>
<div className="two-col">
@ -259,6 +283,7 @@ export default function CreateAskTasksPage({ api }) {
update("language_style", event.target.value)
}
>
<option value="">请选择语言风格</option>
<option>口语(亲切随性)</option>
<option>专业</option>
<option>幽默</option>
@ -270,6 +295,7 @@ export default function CreateAskTasksPage({ api }) {
value={form.target_group}
onChange={(event) => update("target_group", event.target.value)}
>
<option value="">请选择目标人群</option>
<option>女性</option>
<option>男性</option>
<option>学生</option>
@ -292,6 +318,7 @@ export default function CreateAskTasksPage({ api }) {
value={form.usage_scene}
onChange={(event) => update("usage_scene", event.target.value)}
>
<option value="">请选择使用场景</option>
<option>居家</option>
<option>旅行</option>
<option>通勤</option>
@ -305,6 +332,7 @@ export default function CreateAskTasksPage({ api }) {
update("purchase_channel", event.target.value)
}
>
<option value="">请选择购买渠道</option>
<option>电商平台</option>
<option>大型商超</option>
<option>药房</option>
@ -346,6 +374,9 @@ export default function CreateAskTasksPage({ api }) {
<Plus size={14} />
添加提问
</button>
<button className="ghost-button" disabled={busy} onClick={generate}>
↻ 重新生成
</button>
<button
className="primary-button"
disabled={

View File

@ -1,5 +1,5 @@
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 = [
"内容不清晰",
@ -99,13 +99,6 @@ function ReviewDetail({ record, close, decide, busy }) {
</div>
</section>
</div>
<div className="review-v2-assist">
<ShieldCheck size={16} />
<strong>系统智能助判</strong>
<span>
✓ 商品一致 ✓ 内容与要求匹配 ✓ 无重复提交 ✓ 无违规内容
</span>
</div>
{record.latest_review && !pending && (
<div
className={`review-v2-result ${record.latest_review.approved ? "approved" : ""}`}

View File

@ -27,8 +27,12 @@ const maskPhone = (phone = "") =>
function TaskDetail({ task, close, onConfirmRefund }) {
const [refundOpen, setRefundOpen] = useState(false);
const [refundBusy, setRefundBusy] = useState(false);
const [linkCopied, setLinkCopied] = useState(false);
const isAskTask =
task.task_type === "ask" || task.task_type_label === "提问任务";
const taskLink = isAskTask
? `${window.location.origin}/ask/${task.task_id}`
: "";
const proofScreenshots = [
...(task.question_screenshots || []),
...(task.completion_screenshots || []),
@ -45,6 +49,37 @@ function TaskDetail({ task, close, onConfirmRefund }) {
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 (
<div
className="task-detail-backdrop"
@ -62,6 +97,24 @@ function TaskDetail({ task, close, onConfirmRefund }) {
<X size={21} />
</button>
</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">
<span>
商品名称<b>{task.product_name}</b>
@ -209,6 +262,7 @@ export default function TaskBoardPage({ api }) {
const [busy, setBusy] = useState("");
const [batchBusy, setBatchBusy] = useState(false);
const [batchConfirm, setBatchConfirm] = useState(null);
const [completeConfirm, setCompleteConfirm] = useState(null);
async function load(showLoading = taskBoardCache === null) {
if (showLoading) setLoading(true);
try {
@ -366,6 +420,21 @@ export default function TaskBoardPage({ api }) {
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) {
const result = await api(task.refund_endpoint, { method: "POST" });
const updatedDetail = {
@ -444,7 +513,7 @@ export default function TaskBoardPage({ api }) {
<button type="button" onClick={clearFilters}>清空筛选</button>
<button type="button" onClick={exportRows}>导出表格</button>
<button type="button" className="danger" disabled={batchBusy} onClick={batchClear}>
{batchBusy ? "清除中…" : `批量清除${selectedIds.length ? `(${selectedIds.length})` : ""}`}
{batchBusy ? "删除中…" : `批量删除${selectedIds.length ? `(${selectedIds.length})` : ""}`}
</button>
</div>
{error && <div className="form-error">{error}</div>}
@ -517,6 +586,15 @@ export default function TaskBoardPage({ api }) {
{busy === task.id ? "撤销中" : "撤销"}
</button>
)}
{task.can_complete && task.task_type === "ask" && (
<button
className="complete"
disabled={busy === task.id}
onClick={() => setCompleteConfirm(task)}
>
完成任务
</button>
)}
<button
className="delete"
onClick={() => setBatchConfirm({ tasks: [task] })}
@ -555,6 +633,19 @@ export default function TaskBoardPage({ api }) {
</section>
</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>
)}
</>
);
}

View File

@ -1806,7 +1806,7 @@
.board-table-head,
.board-table-row {
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;
gap: 10px;
padding: 13px 14px;
@ -1928,12 +1928,19 @@
}
.board-actions {
display: flex;
gap: 9px;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
min-width: max-content;
white-space: nowrap;
}
.board-actions button {
flex: 0 0 auto;
padding: 4px 0;
background: transparent;
color: #1764e8;
white-space: nowrap;
word-break: keep-all;
}
.board-actions button.cancel {
color: #d93450;
@ -1990,6 +1997,48 @@
background: transparent;
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 {
display: grid;
grid-template-columns: 1.5fr 1fr 1fr 1.1fr;
@ -2219,6 +2268,17 @@
.task-detail-meta {
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 {
overflow-x: auto;
}
@ -3015,11 +3075,14 @@
.answer-v2-shell {
padding: 0;
background: #f3f7fc;
overflow-x: hidden;
}
.answer-v2-app {
width: 100%;
min-height: 100vh;
border-radius: 0;
box-shadow: none;
overflow: visible;
}
.answer-v2-body {
padding: 14px 10px 76px;
@ -3054,6 +3117,12 @@
.status-meta {
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) {
.answer-v2-top > span {
@ -3322,21 +3391,6 @@
color: #8ca0b8;
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 {
margin-top: 15px;
padding: 12px;
@ -3470,13 +3524,6 @@
align-items: stretch;
flex-direction: column;
}
.review-v2-assist {
align-items: flex-start;
flex-direction: column;
}
.review-v2-assist span {
margin: 0;
}
}
.ask-selected-card article span {
@ -3589,6 +3636,51 @@
color: #118453;
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 {
display: flex;
@ -3663,6 +3755,69 @@
.qa-library-card-tools button:hover {
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 {
max-height: min(720px, calc(100vh - 32px));
overflow-y: auto;
@ -4119,6 +4274,7 @@ body img {
padding: 32px;
background: rgba(4, 12, 28, .82);
cursor: zoom-out;
overflow: hidden;
}
.global-image-viewer img {
@ -4130,7 +4286,114 @@ body img {
object-fit: contain;
border-radius: 10px;
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 {
@ -4158,4 +4421,8 @@ body img {
top: 12px;
right: 12px;
}
.global-image-viewer-tools {
bottom: 14px;
}
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
Bell,
@ -23,6 +23,7 @@ import TaskBoardPage from "./TaskBoardPage.jsx";
import ReviewCenterPage from "./ReviewCenterPage.jsx";
import CreateAskTasksPage from "./CreateAskTasksPage.jsx";
import AskUserPortal from "./AskUserPortal.jsx";
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
// 兼容公共回答链接末尾有无斜杠,避免 /answer 被识别为运营后台路由。
if (window.location.pathname === "/answer")
@ -51,12 +52,21 @@ async function api(path, options = {}) {
...options,
});
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;
}
async function readReferenceFile(file) {
async function readReferenceFile(file, requestApi = api) {
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();
if (file.name.toLowerCase().endsWith(".json")) {
try {
@ -363,14 +373,14 @@ function AssistantPage() {
category: "",
benefits: "",
selling_points: "",
language_style: "口语(亲切随性)",
target_group: "女性",
usage_scene: "居家",
purchase_channel: "电商平台",
question_count: 5,
answers_per_question: 2,
language_style: "",
target_group: "",
usage_scene: "",
purchase_channel: "",
question_count: "",
answers_per_question: "",
answer_requirement: "",
seed_style: true,
seed_style: false,
});
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
@ -381,10 +391,26 @@ function AssistantPage() {
const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value }));
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("");
setLoading(true);
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", {
method: "POST",
body: JSON.stringify({
@ -457,7 +483,7 @@ function AssistantPage() {
<>
<PageHeading
title="问答助手"
description="运营内容生产入口,输入商品要求即可快速生成 QA 并转采纳或发布任务。"
description="运营内容生产入口,输入商品要求即可快速生成 QA,并将采纳内容收录至问答库。"
/>
<div className="qa-workspace">
<section className="config-card panel">
@ -503,8 +529,9 @@ function AssistantPage() {
</label>
<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)} />
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown,模型将参考其中的问答语气与角度"}</small>
<input type="file" accept=".txt,.csv,.json,.md,.xlsx,text/plain,text/csv,application/json,text/markdown,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => setReferenceFile(event.target.files?.[0] || null)} />
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown、XLSX,模型将参考其中的问答语气与角度"}</small>
{referenceFile && <button type="button" className="reference-qa-clear" onClick={() => setReferenceFile(null)}>清除已选文件</button>}
</label>
<div className="rule" />
<h4>生成偏好与数量</h4>
@ -517,6 +544,7 @@ function AssistantPage() {
update("language_style", event.target.value)
}
>
<option value="">请选择语言风格</option>
<option>口语(亲切随性)</option>
<option>幽默</option>
<option>专业</option>
@ -528,6 +556,7 @@ function AssistantPage() {
value={form.target_group}
onChange={(event) => update("target_group", event.target.value)}
>
<option value="">请选择目标人群</option>
<option>女性</option>
<option>男性</option>
<option>学生</option>
@ -568,6 +597,7 @@ function AssistantPage() {
value={form.usage_scene}
onChange={(event) => update("usage_scene", event.target.value)}
>
<option value="">请选择使用场景</option>
<option>居家</option>
<option>旅行</option>
<option>通勤</option>
@ -583,6 +613,7 @@ function AssistantPage() {
update("purchase_channel", event.target.value)
}
>
<option value="">请选择购买渠道</option>
<option>电商平台</option>
<option>大型商超</option>
<option>药房</option>
@ -663,12 +694,6 @@ function AssistantPage() {
>
✓ 批量采纳
</button>
<button
className="primary-button"
onClick={() => window.alert("发布任务将在任务管理模块接入")}
>
发布任务
</button>
<button
className="ghost-button"
onClick={generate}
@ -936,14 +961,14 @@ function AssistantPageV2() {
category: "",
benefits: "",
selling_points: "",
language_style: "口语(亲切随性)",
target_group: "女性",
usage_scene: "居家",
purchase_channel: "电商平台",
question_count: 5,
answers_per_question: 2,
language_style: "",
target_group: "",
usage_scene: "",
purchase_channel: "",
question_count: "",
answers_per_question: "",
answer_requirement: "",
seed_style: true,
seed_style: false,
});
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
@ -951,6 +976,22 @@ function AssistantPageV2() {
const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value }));
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("");
setLoading(true);
try {
@ -1009,7 +1050,7 @@ function AssistantPageV2() {
<>
<PageHeading
title="问答助手"
description="运营内容生产入口,输入商品要求即可快速生成 QA 并转采纳或发布任务。"
description="运营内容生产入口,输入商品要求即可快速生成 QA,并将采纳内容收录至问答库。"
/>
<div className="qa-workspace">
<section className="config-card panel">
@ -1064,6 +1105,7 @@ function AssistantPageV2() {
update("language_style", event.target.value)
}
>
<option value="">请选择语言风格</option>
<option>口语(亲切随性)</option>
<option>幽默</option>
<option>专业</option>
@ -1075,6 +1117,7 @@ function AssistantPageV2() {
value={form.target_group}
onChange={(event) => update("target_group", event.target.value)}
>
<option value="">请选择目标人群</option>
<option>女性</option>
<option>男性</option>
<option>学生</option>
@ -1115,6 +1158,7 @@ function AssistantPageV2() {
value={form.usage_scene}
onChange={(event) => update("usage_scene", event.target.value)}
>
<option value="">请选择使用场景</option>
<option>居家</option>
<option>旅行</option>
<option>通勤</option>
@ -1130,6 +1174,7 @@ function AssistantPageV2() {
update("purchase_channel", event.target.value)
}
>
<option value="">请选择购买渠道</option>
<option>电商平台</option>
<option>大型商超</option>
<option>药房</option>
@ -1204,12 +1249,6 @@ function AssistantPageV2() {
>
✓ 批量采纳
</button>
<button
className="primary-button"
onClick={() => window.alert("发布任务将在任务管理模块接入")}
>
发布任务
</button>
<button
className="ghost-button"
onClick={generate}
@ -1246,6 +1285,8 @@ function QALibraryPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [editing, setEditing] = useState(null);
const [deleting, setDeleting] = useState(null);
const [deleteBusy, setDeleteBusy] = useState(false);
async function load(keyword = search) {
setLoading(true);
try {
@ -1266,6 +1307,20 @@ function QALibraryPage() {
window.addEventListener("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 (
<>
<PageHeading
@ -1311,6 +1366,7 @@ function QALibraryPage() {
})}
</small>
<button onClick={() => setEditing(record)}>编辑</button>
<button className="danger-button" onClick={() => setDeleting(record)}>删除</button>
</div>
</header>
<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: "",
benefits: "",
selling_points: "",
language_style: "口语(亲切随性)",
target_group: "女性",
usage_scene: "居家",
purchase_channel: "电商平台",
question_count: 5,
answers_per_question: 2,
language_style: "",
target_group: "",
usage_scene: "",
purchase_channel: "",
question_count: "",
answers_per_question: "",
answer_requirement: "",
seed_style: true,
seed_style: false,
});
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [savedMessage, setSavedMessage] = useState("");
const [adoptedDialog, setAdoptedDialog] = useState(null);
const [manualOpen, setManualOpen] = useState(false);
const [referenceFile, setReferenceFile] = useState(null);
const [referenceQa, setReferenceQa] = useState("");
const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value }));
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("");
setLoading(true);
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", {
method: "POST",
body: JSON.stringify({
@ -1456,7 +1545,7 @@ function AssistantPageFinal() {
setLoading(false);
}
}
async function adopt(targetItems) {
async function adopt(targetItems, showDialog = false) {
if (!targetItems.length || saving) return;
setSaving(true);
setSavedMessage("");
@ -1484,6 +1573,7 @@ function AssistantPageFinal() {
),
);
const { added_count: added, skipped_count: skipped } = result.data;
if (showDialog) setAdoptedDialog({ count: targetItems.length, added, skipped });
setSavedMessage(
added > 0
? `已将 ${added} 条问答存入问答库${skipped ? `,${skipped} 条重复内容已跳过` : ""}`
@ -1502,7 +1592,7 @@ function AssistantPageFinal() {
if (target.accepted) return;
adopt([target]);
}
const acceptAll = () => adopt(items);
const acceptAll = () => adopt(items, true);
const save = (id, question, answers) =>
setItems((current) =>
current.map((item) =>
@ -1532,7 +1622,7 @@ function AssistantPageFinal() {
<>
<PageHeading
title="问答助手"
description="运营内容生产入口,输入商品要求即可快速生成 QA 并转采纳或发布任务。"
description="运营内容生产入口,输入商品要求即可快速生成 QA,并将采纳内容收录至问答库。"
/>
<div className="qa-workspace">
<section className="config-card panel">
@ -1578,8 +1668,9 @@ function AssistantPageFinal() {
</label>
<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)} />
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown,模型将参考其中的问答语气与角度"}</small>
<input type="file" accept=".txt,.csv,.json,.md,.xlsx,text/plain,text/csv,application/json,text/markdown,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => setReferenceFile(event.target.files?.[0] || null)} />
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown、XLSX,模型将参考其中的问答语气与角度"}</small>
{referenceFile && <button type="button" className="reference-qa-clear" onClick={() => setReferenceFile(null)}>清除已选文件</button>}
</label>
<div className="rule" />
<h4>生成偏好与数量</h4>
@ -1592,6 +1683,7 @@ function AssistantPageFinal() {
update("language_style", event.target.value)
}
>
<option value="">请选择语言风格</option>
<option>口语(亲切随性)</option>
<option>幽默</option>
<option>专业</option>
@ -1603,6 +1695,7 @@ function AssistantPageFinal() {
value={form.target_group}
onChange={(event) => update("target_group", event.target.value)}
>
<option value="">请选择目标人群</option>
<option>女性</option>
<option>男性</option>
<option>学生</option>
@ -1643,6 +1736,7 @@ function AssistantPageFinal() {
value={form.usage_scene}
onChange={(event) => update("usage_scene", event.target.value)}
>
<option value="">请选择使用场景</option>
<option>居家</option>
<option>旅行</option>
<option>通勤</option>
@ -1658,6 +1752,7 @@ function AssistantPageFinal() {
update("purchase_channel", event.target.value)
}
>
<option value="">请选择购买渠道</option>
<option>电商平台</option>
<option>大型商超</option>
<option>药房</option>
@ -1722,12 +1817,6 @@ function AssistantPageFinal() {
>
{saving ? "正在存入…" : "✓ 批量采纳"}
</button>
<button
className="primary-button"
onClick={() => window.alert("发布任务将在任务管理模块接入")}
>
发布任务
</button>
<button
className="ghost-button"
onClick={generate}
@ -1757,6 +1846,19 @@ function AssistantPageFinal() {
{manualOpen && (
<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() {
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(() => {
const handleClick = (event) => {
const image = event.target?.closest?.("img");
if (!image || !image.src) return;
setSrc(image.src);
setScale(1);
setPosition({ x: 0, y: 0 });
};
const handleKeyDown = (event) => {
if (event.key === "Escape") setSrc("");
@ -3552,10 +3660,68 @@ function GlobalImageViewer() {
};
}, []);
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 (
<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>
<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>
);
}

View 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 "";
}

View File

@ -219,6 +219,27 @@ button {
color: #8296ae;
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 {
margin-top: 24px;
color: #9aabc1;