Compare commits

..

2 Commits

Author SHA1 Message Date
cf83de291c 修复bug,调整页面布局,优化移动端页面 2026-09-03 18:20:25 +08:00
4fea0a28fc feat: 上传问大家项目 2026-09-02 10:27:05 +08:00
31 changed files with 17074 additions and 0 deletions

26
.env.example Normal file
View File

@ -0,0 +1,26 @@
DATABASE_URL=mysql+pymysql://ai_agent_test:WGzc6mKawk@49.233.179.206:3306/wendajia
SECRET_KEY=replace-with-a-long-random-secret
COOKIE_SECURE=false
SESSION_HOURS=12
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-admin-password
OPERATOR_USERNAME=operator
OPERATOR_PASSWORD=change-operator-password
LLM_API_KEY=
LLM_BASE_URL=https://api.openlux.ai/v1
# 普通问答模型
LLM_MODEL=gpt-4o-mini
# 识图/OCR 模型;OpenLux 可填写 qwen3-vl-plus、qwen3-vl-32b-instruct 等视觉模型
LLM_VISION_MODEL=qwen3-vl-plus
LLM_TIMEOUT_SECONDS=90
# 开发环境显示接口返回的验证码;生产环境请设为 false 并接入短信服务
SMS_DEV_MODE=true
# 钉钉工作台登录(创建钉钉企业内部应用后填写)
DINGTALK_CLIENT_ID=
DINGTALK_CLIENT_SECRET=
DINGTALK_REDIRECT_URI=http://localhost:8000/api/admin/auth/dingtalk/callback
FRONTEND_URL=http://localhost:5173
# 可选:限制可登录的 unionId;多个值用逗号分隔
DINGTALK_ALLOWED_UNION_IDS=
# 可选:指定钉钉 unionId 自动授予超级管理员
DINGTALK_SUPER_ADMIN_UNION_IDS=

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.env
__pycache__/
*.py[cod]
.pytest_cache/
frontend/node_modules/
frontend/dist/

3
backend/app/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .main import app
__all__ = ["app"]

39
backend/app/config.py Normal file
View File

@ -0,0 +1,39 @@
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parents[2]
load_dotenv(BASE_DIR / ".env")
class Settings:
app_name = "问大家运营管理后台"
database_url = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'data' / 'app.db'}")
secret_key = os.getenv("SECRET_KEY", "change-this-secret-key-in-production")
cookie_name = "admin_session"
session_hours = int(os.getenv("SESSION_HOURS", "12"))
cookie_secure = os.getenv("COOKIE_SECURE", "false").lower() == "true"
admin_username = os.getenv("ADMIN_USERNAME", "admin")
admin_password = os.getenv("ADMIN_PASSWORD", "admin123456")
operator_username = os.getenv("OPERATOR_USERNAME", "operator")
operator_password = os.getenv("OPERATOR_PASSWORD", "operator123456")
llm_api_key = os.getenv("LLM_API_KEY", "")
llm_base_url = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1")
llm_model = os.getenv("LLM_MODEL", "gpt-4o-mini")
# OCR/识图可单独使用视觉模型,未配置时沿用普通问答模型。
llm_vision_model = os.getenv("LLM_VISION_MODEL", llm_model)
llm_timeout_seconds = int(os.getenv("LLM_TIMEOUT_SECONDS", "90"))
# 开发环境返回验证码,接入短信服务后请关闭并在发送函数中对接供应商。
sms_dev_mode = os.getenv("SMS_DEV_MODE", "true").lower() == "true"
# DingTalk workbench OAuth settings.
dingtalk_client_id = os.getenv("DINGTALK_CLIENT_ID", "")
dingtalk_client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "")
dingtalk_redirect_uri = os.getenv("DINGTALK_REDIRECT_URI", "http://localhost:8000/api/admin/auth/dingtalk/callback")
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
dingtalk_allowed_union_ids = {item.strip() for item in os.getenv("DINGTALK_ALLOWED_UNION_IDS", "").split(",") if item.strip()}
dingtalk_super_admin_union_ids = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_UNION_IDS", "").split(",") if item.strip()}
settings = Settings()

27
backend/app/database.py Normal file
View File

@ -0,0 +1,27 @@
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from .config import settings
class Base(DeclarativeBase):
pass
if settings.database_url.startswith("sqlite"):
Path(settings.database_url.replace("sqlite:///", "")).parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})
else:
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

236
backend/app/llm.py Normal file
View File

@ -0,0 +1,236 @@
import json
import base64
import logging
import re
import urllib.error
import urllib.request
from fastapi import HTTPException, status
from .config import settings
from .schemas import QAGenerateRequest
logger = logging.getLogger(__name__)
def _prompt(payload: QAGenerateRequest) -> str:
style = "带真实体验的轻度种草语气" if payload.seed_style else "客观自然,不刻意种草"
return f"""你是电商问大家内容策划。请根据商品条件生成真实、具体、互不重复的消费者提问和参考回答。
商品名称:{payload.product_name or '未提供'}
商品品类:{payload.category or '未提供'}
商品功效:{payload.benefits or '未提供'}
商品卖点:{payload.selling_points or '未提供'}
语言风格:{payload.language_style}
目标人群:{payload.target_group}
使用场景:{payload.usage_scene}
购买渠道:{payload.purchase_channel}
回答要求:{payload.answer_requirement or '直接回应问题,避免夸大功效'}
回答语气:{style}
真实问答参考:{payload.reference_qa or '未提供'}
请生成 {payload.question_count} 个问题,每个问题生成 {payload.answers_per_question} 个不同角度的参考回答。
约束:问题尽量不超过20字,回答尽量不超过50字;不虚构成分、检测、认证和医疗功效;表达像真实买家;不要重复商品名称作为开头。
如果提供了真实问答参考,请优先学习其中的提问角度、真实语气和回答结构,但不要照抄原文,不要把参考中的其他商品信息误套到当前商品。
只返回严格 JSON,不要 Markdown 代码块或额外解释,格式必须是:
{{"items":[{{"question":"问题文本","answers":["回答1","回答2"]}}]}}"""
def _parse_content(content: str, expected_answers: int) -> list[dict]:
cleaned = content.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", cleaned, flags=re.I | re.S).strip()
try:
parsed = json.loads(cleaned)
except json.JSONDecodeError as exc:
start, end = cleaned.find("{"), cleaned.rfind("}")
if start < 0 or end <= start:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型返回格式无法解析") from exc
parsed = json.loads(cleaned[start:end + 1])
raw_items = parsed.get("items") if isinstance(parsed, dict) else parsed
if not isinstance(raw_items, list):
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型未返回有效问答列表")
items = []
for index, raw in enumerate(raw_items):
if not isinstance(raw, dict) or not str(raw.get("question", "")).strip():
continue
answers = raw.get("answers", [])
if isinstance(answers, str):
answers = [answers]
answers = [str(answer).strip() for answer in answers if str(answer).strip()][:expected_answers]
if answers:
items.append({"id": f"Q{index + 1:03d}", "question": str(raw["question"]).strip(), "answers": answers, "source": "AI生成", "status": "待采纳", "accepted": False})
if not items:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型未返回有效问答内容")
return items
def generate_qa(payload: QAGenerateRequest) -> list[dict]:
if not settings.llm_api_key:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置大模型 API Key,请先设置 LLM_API_KEY")
url = settings.llm_base_url.rstrip("/") + "/chat/completions"
request_body = {"model": settings.llm_model, "temperature": 0.8, "messages": [{"role": "system", "content": "你只输出合法 JSON。"}, {"role": "user", "content": _prompt(payload)}]}
request = urllib.request.Request(url, data=json.dumps(request_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"))
except urllib.error.HTTPError as exc:
logger.error("LLM 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) as exc:
logger.exception("LLM request failed: url=%s reason=%s", url, exc)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型服务暂时不可用,请稍后重试") from exc
try:
content = result["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
logger.error("LLM response missing choices/message/content: keys=%s", list(result) if isinstance(result, dict) else type(result).__name__)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型响应缺少内容") from exc
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:
return f"结合{product_title}的使用体验来看,建议按自身肤质先少量试用,确认合适后再正常使用。"
url = settings.llm_base_url.rstrip("/") + "/chat/completions"
prompt = f"商品:{product_title}\n问题:{question}\n请生成一条50字以内、自然真实、不夸大功效的淘宝问大家回答。只输出回答正文。"
request_body = {"model": settings.llm_model, "temperature": 0.7, "messages": [{"role": "system", "content": "你是电商问大家回答助手。"}, {"role": "user", "content": prompt}]}
request = urllib.request.Request(url, data=json.dumps(request_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"))
answer = str(result["choices"][0]["message"]["content"]).strip()
return answer[:300]
except Exception:
logger.exception("Answer generation failed for simulated OCR question")
return f"结合{product_title}的使用体验来看,建议按自身肤质先少量试用,确认合适后再正常使用。"
def _parse_ocr_content(content: str) -> list[dict]:
if isinstance(content, list):
content = "".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
cleaned = str(content or "").strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", cleaned, flags=re.I | re.S).strip()
try:
parsed = json.loads(cleaned)
except json.JSONDecodeError:
start, end = cleaned.find("{"), cleaned.rfind("}")
if start < 0 or end <= start:
return []
try:
parsed = json.loads(cleaned[start:end + 1])
except json.JSONDecodeError:
return []
raw_items = parsed.get("items") if isinstance(parsed, dict) else parsed
if not isinstance(raw_items, list):
return []
items = []
seen = set()
for raw in raw_items:
if not isinstance(raw, dict):
continue
question = re.sub(r"^[\s\d.#、.)(()]+", "", str(raw.get("question", ""))).strip()
if len(question) < 4 or question in seen:
continue
if any(token in question for token in ("回答", "规则", "待回答", "已回答", "已提问")):
continue
seen.add(question)
items.append({
"product_title": str(raw.get("product_title", "待识别商品")).strip() or "待识别商品",
"question": question[:300],
})
return items[:100]
def extract_questions_from_images(images: list[tuple[bytes, str]]) -> list[dict]:
"""Extract consumer questions from one or more Taobao Q&A screenshots.
The configured OpenAI-compatible model must support image input. The
caller keeps a deterministic fallback when no vision model/API key is
configured, so uploading screenshots never blocks the task workflow.
"""
if not settings.llm_api_key or not images:
return []
content = [{
"type": "text",
"text": (
"请识别这些淘宝‘问答/待回答’页面截图中的消费者提问。只提取真正的问题句子,"
"不要提取导航、按钮、商品名、用户昵称、规则文字或页面标题。去除序号并保持原文。"
"多张截图合并去重。只返回严格 JSON:"
'{"items":[{"product_title":"商品名或待识别商品","question":"问题原文"}]}'
),
}]
for index, (image_data, content_type) in enumerate(images, start=1):
encoded = base64.b64encode(image_data).decode("ascii")
content.append({"type": "text", "text": f"截图 {index}"})
content.append({
"type": "image_url",
"image_url": {"url": f"data:{content_type};base64,{encoded}"},
})
request_body = {
"model": settings.llm_vision_model,
"temperature": 0,
"messages": [
{"role": "system", "content": "你是严谨的中文电商截图 OCR 提取器,只输出合法 JSON。"},
{"role": "user", "content": content},
],
}
url = settings.llm_base_url.rstrip("/") + "/chat/completions"
request = urllib.request.Request(
url,
data=json.dumps(request_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"]
return _parse_ocr_content(content)
except Exception:
logger.exception("Question OCR failed")
return []

2012
backend/app/main.py Normal file

File diff suppressed because it is too large Load Diff

300
backend/app/models.py Normal file
View File

@ -0,0 +1,300 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
class AdminUser(Base):
__tablename__ = "admin_users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
display_name: Mapped[str] = mapped_column(String(64))
role_code: Mapped[str] = mapped_column(String(32), default="operator", index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
login_logs: Mapped[list["AdminLoginLog"]] = relationship(back_populates="user")
class AdminLoginLog(Base):
__tablename__ = "admin_login_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True)
username: Mapped[str] = mapped_column(String(64), index=True)
success: Mapped[bool] = mapped_column(Boolean, index=True)
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
user: Mapped[AdminUser | None] = relationship(back_populates="login_logs")
class QALibraryEntry(Base):
__tablename__ = "qa_library_entries"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
fingerprint: Mapped[str] = mapped_column(String(64), unique=True, index=True)
product_name: Mapped[str] = mapped_column(String(255), default="", index=True)
category: Mapped[str] = mapped_column(String(100), default="", index=True)
benefits: Mapped[str] = mapped_column(Text, default="")
selling_points: Mapped[str] = mapped_column(Text, default="")
question_text: Mapped[str] = mapped_column(Text)
answers_json: Mapped[str] = mapped_column(Text)
source: Mapped[str] = mapped_column(String(64), default="AI生成")
created_by: Mapped[str] = mapped_column(String(64), default="运营后台")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
class AnswerUser(Base):
__tablename__ = "answer_users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
public_id: Mapped[str] = mapped_column(String(32), unique=True, index=True)
phone: Mapped[str] = mapped_column(String(20), unique=True, index=True)
auth_token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
last_login_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
submissions: Mapped[list["AnswerSubmission"]] = relationship(back_populates="user")
ask_assignments: Mapped[list["AskAssignment"]] = relationship(back_populates="user")
class AnswerSubmission(Base):
__tablename__ = "answer_submissions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
submission_no: Mapped[str] = mapped_column(String(40), unique=True, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("answer_users.id"), index=True)
# 用户上传受邀截图后立即进入运营筛选队列。
status: Mapped[str] = mapped_column(String(32), default="待筛选", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user: Mapped[AnswerUser] = relationship(back_populates="submissions", lazy="selectin")
questions: Mapped[list["AnswerRecognizedQuestion"]] = relationship(back_populates="submission", cascade="all, delete-orphan", lazy="selectin")
screenshots: Mapped[list["AnswerScreenshot"]] = relationship(back_populates="submission", cascade="all, delete-orphan")
review_decisions: Mapped[list["AnswerReviewDecision"]] = relationship(back_populates="submission", cascade="all, delete-orphan", lazy="selectin")
class AnswerScreenshot(Base):
__tablename__ = "answer_screenshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
submission_id: Mapped[int] = mapped_column(ForeignKey("answer_submissions.id"), index=True)
stage: Mapped[str] = mapped_column(String(20), index=True) # invited/completion
filename: Mapped[str] = mapped_column(String(255))
content_type: Mapped[str] = mapped_column(String(64))
image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
submission: Mapped[AnswerSubmission] = relationship(back_populates="screenshots")
class AnswerRecognizedQuestion(Base):
__tablename__ = "answer_recognized_questions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
submission_id: Mapped[int] = mapped_column(ForeignKey("answer_submissions.id"), index=True)
source_screenshot_id: Mapped[int | None] = mapped_column(ForeignKey("answer_screenshots.id"), nullable=True)
sequence_no: Mapped[int] = mapped_column(Integer)
product_title: Mapped[str] = mapped_column(String(255))
product_image_screenshot_id: Mapped[int | None] = mapped_column(ForeignKey("answer_screenshots.id"), nullable=True)
question_text: Mapped[str] = mapped_column(Text)
answer_text: Mapped[str] = mapped_column(Text, default="")
answer_status: Mapped[str] = mapped_column(String(32), default="已生成")
selected: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
submission: Mapped[AnswerSubmission] = relationship(back_populates="questions")
class AnswerReviewDecision(Base):
__tablename__ = "answer_review_decisions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
submission_id: Mapped[int] = mapped_column(ForeignKey("answer_submissions.id"), index=True)
approved: Mapped[bool] = mapped_column(Boolean, index=True)
reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
reason_text: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
submission: Mapped[AnswerSubmission] = relationship(back_populates="review_decisions")
class AskTask(Base):
__tablename__ = "ask_tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
task_no: Mapped[str] = mapped_column(String(40), unique=True, index=True)
product_link: Mapped[str] = mapped_column(String(1000))
product_name: Mapped[str] = mapped_column(String(255))
category: Mapped[str] = mapped_column(String(100), default="")
benefits: Mapped[str] = mapped_column(Text, default="")
selling_points: Mapped[str] = mapped_column(Text, default="")
language_style: Mapped[str] = mapped_column(String(40), default="口语(亲切随性)")
target_group: Mapped[str] = mapped_column(String(100), default="女性")
usage_scene: Mapped[str] = mapped_column(String(100), default="居家")
purchase_channel: Mapped[str] = mapped_column(String(100), default="电商平台")
status: Mapped[str] = mapped_column(String(32), default="草稿", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
questions: Mapped[list["AskQuestion"]] = relationship(back_populates="task", cascade="all, delete-orphan", lazy="selectin")
assignments: Mapped[list["AskAssignment"]] = relationship(back_populates="task", cascade="all, delete-orphan")
class AskQuestion(Base):
__tablename__ = "ask_questions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
task_id: Mapped[int] = mapped_column(ForeignKey("ask_tasks.id"), index=True)
sequence_no: Mapped[int] = mapped_column(Integer)
question_text: Mapped[str] = mapped_column(Text)
selected: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
task: Mapped[AskTask] = relationship(back_populates="questions", lazy="selectin")
assignment_items: Mapped[list["AskAssignmentQuestion"]] = relationship(back_populates="question")
class AskAssignment(Base):
__tablename__ = "ask_assignments"
__table_args__ = (UniqueConstraint("task_id", "user_id", name="uq_ask_assignment_task_user"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
task_id: Mapped[int] = mapped_column(ForeignKey("ask_tasks.id"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("answer_users.id"), index=True)
status: Mapped[str] = mapped_column(String(32), default="待选择", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
task: Mapped[AskTask] = relationship(back_populates="assignments", lazy="selectin")
user: Mapped[AnswerUser] = relationship(back_populates="ask_assignments", lazy="selectin")
items: Mapped[list["AskAssignmentQuestion"]] = relationship(back_populates="assignment", cascade="all, delete-orphan", lazy="selectin")
used_questions: Mapped[list["AskAssignmentUsedQuestion"]] = relationship(back_populates="assignment", cascade="all, delete-orphan", lazy="selectin")
screenshots: Mapped[list["AskAssignmentScreenshot"]] = relationship(back_populates="assignment", cascade="all, delete-orphan")
review_decisions: Mapped[list["AskAssignmentReview"]] = relationship(back_populates="assignment", cascade="all, delete-orphan", lazy="selectin")
batches: Mapped[list["AskAssignmentBatch"]] = relationship(back_populates="assignment", cascade="all, delete-orphan", lazy="selectin")
class AskAssignmentBatch(Base):
"""One submission/review batch for an ask-task assignment.
A user may submit the five assigned questions in multiple batches. The
parent assignment remains the question pool, while this table preserves
each submission independently for the admin review queue.
"""
__tablename__ = "ask_assignment_batches"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
assignment_id: Mapped[int] = mapped_column(ForeignKey("ask_assignments.id"), index=True)
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
selected_question_ids: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, index=True)
assignment: Mapped[AskAssignment] = relationship(back_populates="batches")
screenshots: Mapped[list["AskAssignmentBatchScreenshot"]] = relationship(back_populates="batch", cascade="all, delete-orphan")
review_decisions: Mapped[list["AskAssignmentBatchReview"]] = relationship(back_populates="batch", cascade="all, delete-orphan", lazy="selectin")
class AskAssignmentBatchScreenshot(Base):
__tablename__ = "ask_assignment_batch_screenshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
batch_id: Mapped[int] = mapped_column(ForeignKey("ask_assignment_batches.id"), index=True)
stage: Mapped[str] = mapped_column(String(20), index=True)
filename: Mapped[str] = mapped_column(String(255))
content_type: Mapped[str] = mapped_column(String(64))
image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
batch: Mapped[AskAssignmentBatch] = relationship(back_populates="screenshots")
class AskAssignmentBatchReview(Base):
__tablename__ = "ask_assignment_batch_reviews"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
batch_id: Mapped[int] = mapped_column(ForeignKey("ask_assignment_batches.id"), index=True)
approved: Mapped[bool] = mapped_column(Boolean, index=True)
reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
reason_text: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
batch: Mapped[AskAssignmentBatch] = relationship(back_populates="review_decisions")
class AskAssignmentQuestion(Base):
__tablename__ = "ask_assignment_questions"
__table_args__ = (
UniqueConstraint("assignment_id", "question_id", name="uq_ask_assignment_question"),
# One published question can only be allocated to one user. The
# question already belongs to a single task, so a global constraint on
# question_id also guarantees uniqueness within that task.
UniqueConstraint("question_id", name="uq_ask_assignment_question_global"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
assignment_id: Mapped[int] = mapped_column(ForeignKey("ask_assignments.id"), index=True)
question_id: Mapped[int] = mapped_column(ForeignKey("ask_questions.id"), index=True)
selected: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
assignment: Mapped[AskAssignment] = relationship(back_populates="items", lazy="selectin")
question: Mapped[AskQuestion] = relationship(back_populates="assignment_items", lazy="selectin")
class AskAssignmentUsedQuestion(Base):
"""Questions already consumed by a user after an approved submission.
This is kept in a separate table so existing installations do not need an
ALTER TABLE on ``ask_assignment_questions`` when the feature is enabled.
"""
__tablename__ = "ask_assignment_used_questions"
__table_args__ = (UniqueConstraint("assignment_id", "question_id", name="uq_ask_assignment_used_question"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
assignment_id: Mapped[int] = mapped_column(ForeignKey("ask_assignments.id"), index=True)
question_id: Mapped[int] = mapped_column(ForeignKey("ask_questions.id"), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
assignment: Mapped[AskAssignment] = relationship(back_populates="used_questions")
question: Mapped[AskQuestion] = relationship()
class AskAssignmentScreenshot(Base):
__tablename__ = "ask_assignment_screenshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
assignment_id: Mapped[int] = mapped_column(ForeignKey("ask_assignments.id"), index=True)
stage: Mapped[str] = mapped_column(String(20), index=True) # question/payment
filename: Mapped[str] = mapped_column(String(255))
content_type: Mapped[str] = mapped_column(String(64))
image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
assignment: Mapped[AskAssignment] = relationship(back_populates="screenshots")
class AskAssignmentReview(Base):
__tablename__ = "ask_assignment_reviews"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
assignment_id: Mapped[int] = mapped_column(ForeignKey("ask_assignments.id"), index=True)
approved: Mapped[bool] = mapped_column(Boolean, index=True)
reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
reason_text: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
assignment: Mapped[AskAssignment] = relationship(back_populates="review_decisions")

54
backend/app/schemas.py Normal file
View File

@ -0,0 +1,54 @@
from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=128)
class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=64)
class AdminUserCreate(BaseModel):
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=64)
role_code: str = Field(default="operator", pattern=r"^(super_admin|operator)$")
class AdminUserUpdate(BaseModel):
password: str | None = Field(default=None, min_length=8, max_length=128)
display_name: str | None = Field(default=None, min_length=1, max_length=64)
role_code: str | None = Field(default=None, pattern=r"^(super_admin|operator)$")
is_active: bool | None = None
class QAGenerateRequest(BaseModel):
product_name: str = Field(default="", max_length=200)
category: str = Field(default="", max_length=100)
benefits: str = Field(default="", max_length=1000)
selling_points: str = Field(default="", max_length=2000)
language_style: str = Field(default="口语(亲切随性)", max_length=40)
target_group: str = Field(default="女性", max_length=100)
usage_scene: str = Field(default="居家", max_length=100)
purchase_channel: str = Field(default="电商平台", max_length=100)
question_count: int = Field(default=5, ge=1, le=20)
answers_per_question: int = Field(default=2, ge=1, le=50)
answer_requirement: str = Field(default="", max_length=1000)
reference_qa: str = Field(default="", max_length=20000)
seed_style: bool = True
class UserOut(BaseModel):
id: int
username: str
display_name: str
role_code: str
class LoginResponse(BaseModel):
success: bool = True
data: dict

59
backend/app/security.py Normal file
View File

@ -0,0 +1,59 @@
import base64
import hashlib
import hmac
import json
import secrets
import time
from .config import settings
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 210_000)
return "pbkdf2_sha256$210000$%s$%s" % (
base64.urlsafe_b64encode(salt).decode(),
base64.urlsafe_b64encode(digest).decode(),
)
def verify_password(password: str, encoded: str) -> bool:
try:
scheme, rounds, salt_text, digest_text = encoded.split("$")
if scheme != "pbkdf2_sha256":
return False
salt = base64.urlsafe_b64decode(salt_text.encode())
expected = base64.urlsafe_b64decode(digest_text.encode())
actual = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, int(rounds))
return hmac.compare_digest(actual, expected)
except (ValueError, TypeError):
return False
def _b64(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode().rstrip("=")
def _unb64(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def create_session_token(user_id: int, role_code: str) -> str:
payload = {"sub": user_id, "role": role_code, "exp": int(time.time()) + settings.session_hours * 3600}
body = _b64(json.dumps(payload, separators=(",", ":")).encode())
signature = hmac.new(settings.secret_key.encode(), body.encode(), hashlib.sha256).digest()
return f"{body}.{_b64(signature)}"
def decode_session_token(token: str) -> dict | None:
try:
body, signature = token.split(".", 1)
expected = hmac.new(settings.secret_key.encode(), body.encode(), hashlib.sha256).digest()
if not hmac.compare_digest(_unb64(signature), expected):
return None
payload = json.loads(_unb64(body))
if int(payload.get("exp", 0)) <= int(time.time()):
return None
return payload
except (ValueError, TypeError, json.JSONDecodeError):
return None

6
backend/package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

7
backend/requirements.txt Normal file
View File

@ -0,0 +1,7 @@
fastapi>=0.115,<1.0
uvicorn[standard]>=0.34,<1.0
sqlalchemy>=2.0,<3.0
pymysql>=1.1,<2.0
python-multipart>=0.0.20,<1.0
httpx2>=2.12,<3.0
python-dotenv>=1.0,<2.0

33
backend/schema.sql Normal file
View File

@ -0,0 +1,33 @@
CREATE DATABASE IF NOT EXISTS wendajia DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE wendajia;
CREATE TABLE IF NOT EXISTS admin_users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(64) NOT NULL,
role_code VARCHAR(32) NOT NULL DEFAULT 'operator',
is_active TINYINT(1) NOT NULL DEFAULT 1,
last_login_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_admin_users_username (username),
KEY idx_admin_users_role (role_code),
KEY idx_admin_users_status (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS admin_login_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NULL,
username VARCHAR(64) NOT NULL,
success TINYINT(1) NOT NULL,
ip_address VARCHAR(45) NULL,
user_agent TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_admin_login_logs_user (user_id),
KEY idx_admin_login_logs_username (username),
KEY idx_admin_login_logs_created (created_at),
CONSTRAINT fk_admin_login_logs_user FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

395
backend/tests/test_auth.py Normal file
View File

@ -0,0 +1,395 @@
from fastapi.testclient import TestClient
from uuid import uuid4
from app.main import app
from app.config import settings
def test_admin_login_me_and_logout():
with TestClient(app) as client:
login = client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"})
assert login.status_code == 200
assert login.json()["data"]["user"]["role_code"] == "super_admin"
assert client.get("/api/admin/auth/me").status_code == 200
assert client.get("/api/admin/auth/login-logs").status_code == 200
assert client.post("/api/admin/auth/logout").status_code == 200
assert client.get("/api/admin/auth/me").status_code == 401
def test_wrong_password_is_rejected():
with TestClient(app) as client:
response = client.post("/api/admin/auth/login", json={"username": "admin", "password": "wrong"})
assert response.status_code == 401
def test_operator_cannot_access_super_admin_apis():
with TestClient(app) as client:
login = client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"})
assert login.status_code == 200
assert client.get("/api/admin/auth/login-logs").status_code == 403
assert client.get("/api/admin/users").status_code == 403
assert client.get("/api/admin/settings").status_code == 403
def test_register_and_super_admin_user_crud():
username = f"tester_{uuid4().hex[:8]}"
with TestClient(app) as client:
registration = client.post("/api/admin/auth/register", json={"username": username, "password": "password123", "display_name": "测试账号"})
assert registration.status_code == 201
assert registration.json()["data"]["user"]["role_code"] == "operator"
assert client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"}).status_code == 200
records = client.get("/api/admin/users").json()["data"]
created = next(item for item in records if item["username"] == username)
assert client.get(f"/api/admin/users/{created['id']}").status_code == 200
update = client.patch(f"/api/admin/users/{created['id']}", json={"display_name": "已修改账号", "is_active": False})
assert update.status_code == 200
assert client.delete(f"/api/admin/users/{created['id']}").status_code == 200
assert client.get(f"/api/admin/users/{created['id']}").status_code == 404
def test_qa_generation_requires_product_and_model_key():
with TestClient(app) as client:
assert client.post("/api/admin/qa/generate", json={"question_count": 2}).status_code == 401
login = client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"})
assert login.status_code == 200
assert client.post("/api/admin/qa/generate", json={"question_count": 2}).status_code == 400
def test_accepted_qa_is_saved_to_library_without_duplicates():
unique_product = f"问答库测试商品-{uuid4().hex[:8]}"
payload = {
"product_name": unique_product,
"category": "测试品类",
"benefits": "测试功效",
"selling_points": "测试卖点",
"items": [{
"question": "这款商品适合什么人群?",
"answers": ["适合关注日常使用体验的人群。", "建议结合自身需求选择。"],
"source": "AI生成",
}],
}
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
created = client.post("/api/admin/qa-library", json=payload)
assert created.status_code == 200
assert created.json()["data"]["added_count"] == 1
duplicate = client.post("/api/admin/qa-library", json=payload)
assert duplicate.status_code == 200
assert duplicate.json()["data"]["added_count"] == 0
assert duplicate.json()["data"]["skipped_count"] == 1
records = client.get(f"/api/admin/qa-library?search={unique_product}")
assert records.status_code == 200
assert len(records.json()["data"]) == 1
assert records.json()["data"][0]["answers"] == payload["items"][0]["answers"]
entry_id = records.json()["data"][0]["id"]
edited = client.patch(
f"/api/admin/qa-library/{entry_id}",
json={
**payload,
"question": "编辑后的问题?",
"answers": ["编辑后的参考回答。"],
},
)
assert edited.status_code == 200
assert edited.json()["data"]["question"] == "编辑后的问题?"
assert edited.json()["data"]["answers"] == ["编辑后的参考回答。"]
def test_task_board_requires_login():
with TestClient(app) as client:
assert client.get("/api/admin/task-board").status_code == 401
assert client.get("/api/admin/task-board/999999").status_code == 401
assert client.post("/api/admin/task-board/999999/cancel").status_code == 401
def test_task_board_is_available_to_operator():
with TestClient(app) as client:
login = client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"})
assert login.status_code == 200
response = client.get("/api/admin/task-board")
assert response.status_code == 200
assert isinstance(response.json()["data"], list)
counts = client.get("/api/admin/review-pending-count")
assert counts.status_code == 200
count_data = counts.json()["data"]
assert count_data["total"] == count_data["ask"] + count_data["answer"]
def test_answer_review_rejection_requires_reason():
with TestClient(app) as client:
login = client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"})
assert login.status_code == 200
missing = client.post("/api/admin/answer-completion-reviews/999999/decision", json={"approved": False})
assert missing.status_code == 404
def test_answer_rejection_reason_syncs_to_public_user():
previous_key = settings.llm_api_key
settings.llm_api_key = ""
phone = f"19{uuid4().int % 1_000_000_000:09d}"
try:
with TestClient(app) as client:
login = client.post("/api/answer/auth/phone-login", json={"phone": phone})
assert login.status_code == 200
token = login.json()["data"]["token"]
created = client.post(
"/api/answer/me/submissions",
data={"token": token},
files=[("files", ("invited.png", b"test-image", "image/png"))],
)
assert created.status_code == 200
submission = created.json()["data"]
question_id = submission["questions"][0]["id"]
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
assert client.patch(f"/api/admin/answer-task-drafts/questions/{question_id}", json={"selected": True}).status_code == 200
assert client.post(f"/api/admin/answer-task-drafts/{submission['id']}/publish").status_code == 200
completed = client.post(
f"/api/answer/me/submissions/{submission['id']}/completion",
data={"token": token},
files=[("files", ("done.png", b"done-image", "image/png"))],
)
assert completed.status_code == 200
no_reason = client.post(f"/api/admin/answer-completion-reviews/{submission['id']}/decision", json={"approved": False})
assert no_reason.status_code == 400
rejected = client.post(f"/api/admin/answer-completion-reviews/{submission['id']}/decision", json={"approved": False, "reason_code": "数量不足", "reason_text": "应完成1条回答,截图未完整展示"})
assert rejected.status_code == 200
current = client.get(f"/api/answer/me/submissions?token={token}").json()["data"][0]
assert current["status"] == "未通过"
assert current["latest_review"]["reason_code"] == "数量不足"
assert "截图未完整展示" in current["latest_review"]["reason_text"]
finally:
settings.llm_api_key = previous_key
def test_ask_task_publish_appears_on_task_board():
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
missing_link = client.post("/api/admin/ask-tasks/generate", json={"product_name": "测试商品"})
assert missing_link.status_code == 400
generated = client.post("/api/admin/ask-tasks/generate", json={"product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3})
assert generated.status_code == 200
task = generated.json()["data"]
published = client.post(f"/api/admin/ask-tasks/{task['id']}/publish")
assert published.status_code == 200
board = client.get("/api/admin/task-board").json()["data"]
created = next(item for item in board if item["task_id"] == task["task_id"])
assert created["task_type"] == "ask"
assert created["task_type_label"] == "提问任务"
assert created["qa_count"] == 3
def test_ask_task_generation_honors_question_count():
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
generated = client.post(
"/api/admin/ask-tasks/generate",
json={
"product_link": "https://item.example.com/count-test",
"product_name": "数量测试商品",
"question_count": 25,
},
)
assert generated.status_code == 200
questions = generated.json()["data"]["selected_questions"]
assert len(questions) == 25
assert len({item["question"] for item in questions}) == 25
def test_public_ask_user_gets_stable_unique_five_question_assignment():
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
generated = client.post(
"/api/admin/ask-tasks/generate",
json={
"product_link": "https://item.example.com/public-ask",
"product_name": "公共提问测试商品",
"question_count": 8,
},
).json()["data"]
assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200
phone = f"139{uuid4().int % 100_000_000:08d}"
session = client.post("/api/ask/auth/phone-login", json={"phone": phone}).json()["data"]
url = f"/api/ask/tasks/{generated['task_id']}?token={session['token']}"
first = client.get(url)
second = client.get(url)
assert first.status_code == 200
assert second.status_code == 200
first_questions = first.json()["data"]["questions"]
second_questions = second.json()["data"]["questions"]
assert len(first_questions) == 5
assert len({item["id"] for item in first_questions}) == 5
assert [item["id"] for item in first_questions] == [item["id"] for item in second_questions]
no_selection = client.post(
f"/api/ask/tasks/{generated['task_id']}/selection",
json={"token": session["token"], "question_ids": []},
)
assert no_selection.status_code == 400
chosen = [first_questions[0]["id"]]
selected = client.post(
f"/api/ask/tasks/{generated['task_id']}/selection",
json={"token": session["token"], "question_ids": chosen},
)
assert selected.status_code == 200
assert selected.json()["data"]["status"] == "进行中"
assert [item["id"] for item in selected.json()["data"]["questions"] if item["selected"]] == chosen
submitted = client.post(
f"/api/ask/tasks/{generated['task_id']}/submission",
data={"token": session["token"], "payee_name": "测试用户"},
files=[
("files", ("question.png", b"question-image", "image/png")),
("payment_file", ("payment.png", b"payment-image", "image/png")),
],
)
assert submitted.status_code == 200
assert submitted.json()["data"]["status"] == "待审核"
assert len(submitted.json()["data"]["question_screenshots"]) == 1
assert len(submitted.json()["data"]["payment_screenshots"]) == 1
reviews = client.get("/api/admin/ask-completion-reviews")
assert reviews.status_code == 200
review = next(item for item in reviews.json()["data"] if item["assignment_id"] == submitted.json()["data"]["assignment_id"])
assert review["status"] == "待审核"
decision = client.post(
f"/api/admin/ask-completion-reviews/{review['assignment_id']}/decision",
json={"approved": True},
)
assert decision.status_code == 200
assert decision.json()["data"]["status"] == "已通过"
board = client.get("/api/admin/task-board").json()["data"]
payout = next(
item
for item in board
if item.get("record_kind") == "ask_batch"
and item["id"] == review["assignment_id"]
)
assert payout["status_label"] == "待返款"
assert payout["task_type"] == "ask"
assert payout["qa_count"] == 1
payout_detail = client.get(payout["detail_endpoint"])
assert payout_detail.status_code == 200
assert len(payout_detail.json()["data"]["question_screenshots"]) == 1
assert len(payout_detail.json()["data"]["payment_screenshots"]) == 1
refunded = client.post(payout["refund_endpoint"])
assert refunded.status_code == 200
assert refunded.json()["data"]["status_label"] == "已完成"
assert refunded.json()["data"]["refund_endpoint"] is None
board_after_refund = client.get("/api/admin/task-board").json()["data"]
completed_payout = next(
item
for item in board_after_refund
if item.get("record_kind") == "ask_batch"
and item["id"] == review["assignment_id"]
)
assert completed_payout["status_label"] == "已完成"
audit_history = client.get("/api/admin/ask-completion-reviews").json()["data"]
assert not any(item["assignment_id"] == review["assignment_id"] for item in audit_history)
refreshed = client.get(url)
assert refreshed.json()["data"]["status"] == "已通过"
def test_approved_answer_appears_as_pending_refund_with_detail():
previous_key = settings.llm_api_key
settings.llm_api_key = ""
phone = f"19{uuid4().int % 1_000_000_000:09d}"
try:
with TestClient(app) as client:
login = client.post("/api/answer/auth/phone-login", json={"phone": phone})
token = login.json()["data"]["token"]
created = client.post(
"/api/answer/me/submissions",
data={"token": token},
files=[("files", ("invited.png", b"invited-image", "image/png"))],
).json()["data"]
question_id = created["questions"][0]["id"]
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
assert client.patch(f"/api/admin/answer-task-drafts/questions/{question_id}", json={"selected": True}).status_code == 200
assert client.post(f"/api/admin/answer-task-drafts/{created['id']}/publish").status_code == 200
completed = client.post(
f"/api/answer/me/submissions/{created['id']}/completion",
data={"token": token},
files=[
("files", ("done.png", b"done-image", "image/png")),
("payment_files", ("payment.png", b"payment-image", "image/png")),
],
)
assert completed.status_code == 200
approved = client.post(
f"/api/admin/answer-completion-reviews/{created['id']}/decision",
json={"approved": True},
)
assert approved.status_code == 200
board = client.get("/api/admin/task-board").json()["data"]
payout = next(
item
for item in board
if item.get("record_kind") == "answer_submission"
and item["id"] == created["id"]
)
assert payout["status_label"] == "待返款"
detail = client.get(f"/api/admin/task-board/{created['id']}")
assert detail.status_code == 200
assert len(detail.json()["data"]["completion_screenshots"]) == 1
assert len(detail.json()["data"]["payment_screenshots"]) == 1
refunded = client.post(payout["refund_endpoint"])
assert refunded.status_code == 200
assert refunded.json()["data"]["status_label"] == "已完成"
assert refunded.json()["data"]["refund_endpoint"] is None
board_after_refund = client.get("/api/admin/task-board").json()["data"]
completed_payout = next(
item
for item in board_after_refund
if item.get("record_kind") == "answer_submission"
and item["id"] == created["id"]
)
assert completed_payout["status_label"] == "已完成"
review_history = client.get("/api/admin/answer-completion-reviews").json()["data"]
assert not any(item["id"] == created["id"] for item in review_history)
finally:
settings.llm_api_key = previous_key
def test_public_ask_questions_are_unique_across_users_and_eventually_exhausted():
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
generated = client.post(
"/api/admin/ask-tasks/generate",
json={
"product_link": "https://item.example.com/unique-allocation",
"product_name": "跨用户唯一分配测试商品",
"question_count": 10,
},
).json()["data"]
assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200
sessions = []
for _ in range(3):
phone = f"138{uuid4().int % 100_000_000:08d}"
sessions.append(client.post("/api/ask/auth/phone-login", json={"phone": phone}).json()["data"])
first_url = f"/api/ask/tasks/{generated['task_id']}?token={sessions[0]['token']}"
second_url = f"/api/ask/tasks/{generated['task_id']}?token={sessions[1]['token']}"
third_url = f"/api/ask/tasks/{generated['task_id']}?token={sessions[2]['token']}"
first = client.get(first_url)
second = client.get(second_url)
assert first.status_code == 200
assert second.status_code == 200
first_ids = {item["id"] for item in first.json()["data"]["questions"]}
second_ids = {item["id"] for item in second.json()["data"]["questions"]}
assert len(first_ids) == 5
assert len(second_ids) == 5
assert first_ids.isdisjoint(second_ids)
assert len(first_ids | second_ids) == 10
# Returning users keep their original allocation.
assert {item["id"] for item in client.get(first_url).json()["data"]["questions"]} == first_ids
exhausted = client.get(third_url)
assert exhausted.status_code == 409
assert exhausted.json()["detail"] == "本次任务已被领取完,请关注下一个任务!"

BIN
data/app.db Normal file

Binary file not shown.

BIN
data/async-smoke.db Normal file

Binary file not shown.

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!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>

1213
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

9
frontend/package.json Normal file
View File

@ -0,0 +1,9 @@
{
"name": "wendajia-admin-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {"dev": "vite", "build": "vite build", "preview": "vite preview"},
"dependencies": {"@vitejs/plugin-react": "latest", "vite": "latest", "typescript": "latest", "react": "latest", "react-dom": "latest", "lucide-react": "latest"},
"devDependencies": {}
}

View File

@ -0,0 +1,196 @@
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 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}`
function Top({ session, latest, logout }) {
return <>
<header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div><span>ⓘ 规则说明</span></header>
<section className="answer-v2-user"><div><b>淘宝问答承接</b><span>● {latest ? STATUS_TEXT[latest.status] || latest.status : '待认领'}</span></div><p>用户ID:{session.user_id} · {maskPhone(session.phone)}{latest?.status === '待用户完成' && <> 派发任务编号:<strong>{latest.submission_no}</strong></>}</p><button title="退出当前用户" onClick={logout}><LogOut size={15}/></button></section>
</>
}
function Progress({ status }) {
const active = !status ? 1 : ['待筛选', '待运营派发', '已派发'].includes(status) ? 2 : ['待用户完成', '未通过'].includes(status) ? 3 : 4
const labels = ['被邀截图', '任务派发', '完成任务', '终审确认']
return <div className="answer-v2-progress">{labels.map((label, index) => { const step = index + 1; const done = step < active || (active === 4 && ['已通过', '已完成'].includes(status)); return <div key={label} className={step === active ? 'active' : done ? 'done' : ''}><b>{done ? <Check size={15}/> : step}</b><span>{label}</span></div> })}</div>
}
function Login({ api, onLogin }) {
const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); const [busy, setBusy] = useState(false); const [sending, setSending] = useState(false); const [cooldown, setCooldown] = useState(0); const [error, setError] = useState(''); const [devCode, setDevCode] = useState('')
useEffect(() => { if (!cooldown) return undefined; const timer = window.setInterval(() => setCooldown(value => Math.max(0, value - 1)), 1000); return () => window.clearInterval(timer) }, [cooldown])
async function sendCode() {
if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码')
setSending(true); setError('')
try { const result = await api('/api/answer/auth/send-code', { method: 'POST', body: JSON.stringify({ phone }) }); setCooldown(result.data.retry_after || 60); setDevCode(result.data.dev_code || '') }
catch (err) { setError(err.message) } finally { setSending(false) }
}
async function submit(event) {
event.preventDefault(); setError('')
if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码')
if (!/^\d{6}$/.test(code)) return setError('请输入6位验证码')
setBusy(true)
try { const result = await api('/api/answer/auth/phone-login', { method: 'POST', body: JSON.stringify({ phone, code }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) }
catch (err) { setError(err.message) } finally { setBusy(false) }
}
return <main className="answer-v2-shell"><section className="answer-v2-app login-only"><header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div></header><form className="answer-v2-login" onSubmit={submit}><span><MessageCircle size={28}/></span><h1>手机号登录</h1><p>使用短信验证码安全登录</p><label>手机号码<input value={phone} onChange={event => setPhone(event.target.value.replace(/\D/g, '').slice(0, 11))} inputMode="numeric" placeholder="请输入 11 位手机号码" autoFocus/></label><label>短信验证码<div className="answer-v2-code-row"><input value={code} onChange={event => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} inputMode="numeric" placeholder="请输入6位验证码"/><button type="button" onClick={sendCode} disabled={sending || cooldown > 0}>{cooldown ? `${cooldown}s后重发` : sending ? '发送中…' : '获取验证码'}</button></div></label>{devCode && <small className="answer-v2-dev-code">开发环境验证码:{devCode}</small>}{error && <div className="answer-v2-error">{error}</div>}<button disabled={busy}>{busy ? '登录中…' : '登录并进入任务中心'}</button></form></section></main>
}
function UploadStage({ api, session, onUploaded, setError }) {
const [files, setFiles] = useState([]); const [busy, setBusy] = useState(false); const inputRef = useRef(null)
const previews = useMemo(() => files.map(file => ({ file, url: URL.createObjectURL(file) })), [files])
useEffect(() => () => previews.forEach(item => URL.revokeObjectURL(item.url)), [previews])
function addFiles(list) { const images = Array.from(list).filter(file => /^image\/(jpeg|png|webp)$/.test(file.type)); setFiles(current => [...current, ...images].filter((file, index, all) => all.findIndex(row => fileKey(row) === fileKey(file)) === index).slice(0, 10)) }
async function submit() {
if (!files.length) return setError('请至少上传一张被邀请回答截图')
setBusy(true); setError(''); const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file))
try { await api('/api/answer/me/submissions', { method: 'POST', body }); await onUploaded() } catch (err) { setError(err.message) } finally { setBusy(false) }
}
return <><section className="answer-v2-hero purple"><small>✨ 任务认领与核验</small><h1>上传被邀请回答截图</h1><p>提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。</p></section><section className="answer-v2-card upload-card"><header><div><h2>上传被邀请回答截图 <em>截图 · 必传</em></h2><p>用于确认您的账号可回答指定问题(支持上传多张)。</p></div><b>{files.length}/10</b></header><div className="answer-v2-upload-grid">{previews.map(({ file, url }) => <figure key={fileKey(file)}><img src={url} alt={file.name}/><figcaption>{file.name}</figcaption><button onClick={() => setFiles(current => current.filter(row => fileKey(row) !== fileKey(file)))}>删除</button></figure>)}{files.length < 10 && <button type="button" className="answer-v2-add" onClick={() => inputRef.current?.click()}><ImagePlus size={28}/><strong>+ 上传被邀截图</strong><small>支持 JPG、PNG、WEBP</small></button>}</div><input ref={inputRef} hidden type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => addFiles(event.target.files)}/><p className="answer-v2-photo-note">▧ 请确保凭证清晰、信息完整、无遮挡</p></section><button className="answer-v2-primary wide" disabled={busy || !files.length} onClick={submit}>{busy ? '正在提交…' : '提交截图并进入审核'}</button></>
}
function MaterialCard({ latest, completion = false }) {
const images = completion ? latest.completion_screenshots : latest.invited_screenshots
return <section className="answer-v2-card material-card"><header><h2><FileCheck2 size={16}/>{completion ? '已提交任务凭证(只读)' : '已提交核验材料(只读)'}</h2><span>♙ 已锁定审核</span></header><div>{images.map((image, index) => { const src = image.url || image.data_url; return <figure key={image.id || src}><img src={src} alt={`${completion ? '完成凭证' : '被邀截图'}${index + 1}`}/><figcaption>◉ {completion ? '完成截图' : `被邀截图${index + 1}`}</figcaption></figure> })}</div></section>
}
function ReviewStage({ latest, reload, busy, startNew }) {
return <><section className="answer-v2-card status-card"><div className="status-title"><span><Clock3 size={24}/></span><div><h1>已进入任务派发审核 <em>截图审核中</em></h1><p>截图已提交,任务派发审核界面已创建。运营正在核验截图并整理可回答问题,识别完成后会自动显示在后台。</p></div></div><div className="status-meta"><span>提交时间<b>{formatTime(latest.created_at)}</b></span><span>当前状态<b>等待运营审核</b></span></div><footer><small>页面每 10 秒自动检查最新审核结果</small><button onClick={() => reload()} disabled={busy}><RefreshCw size={14} className={busy ? 'spin' : ''}/>{busy ? '刷新中…' : '刷新状态'}</button></footer></section><MaterialCard latest={latest}/><section className="answer-v2-wait"><ShieldCheck size={17}/><div><strong>等待运营确认并派发问题</strong><p>截图已进入审核队列,无需停留等待识别完成;问题整理好后会自动开放任务。</p></div></section><section className="answer-v2-new-task answer-v2-new-task-light"><strong>还可以继续提交新的任务</strong><p>当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。</p><button onClick={startNew}>+ 开始新的任务</button></section></>
}
function AssignmentStage({ api, session, latest, reload, setError }) {
const selected = latest.questions.filter(question => question.selected); const progressKey = `answer_progress_${latest.id}`
const [done, setDone] = useState(() => { try { return JSON.parse(localStorage.getItem(progressKey) || '[]') } catch { return [] } })
const [tab, setTab] = useState('list'); const [files, setFiles] = useState([]); const [payFiles, setPayFiles] = useState([]); const [busy, setBusy] = useState(false)
const completed = selected.filter(question => done.includes(question.id)).length
useEffect(() => {
if (latest.status !== '未通过' || !latest.latest_review) return
const reason = [latest.latest_review.reason_code, latest.latest_review.reason_text].filter(Boolean).join(';')
if (reason) setError(`审核不通过原因:${reason}`)
}, [latest.id, latest.status, latest.latest_review, setError])
function saveDone(next) { setDone(next); localStorage.setItem(progressKey, JSON.stringify(next)) }
function mark(id) { saveDone(done.includes(id) ? done.filter(value => value !== id) : [...done, id]) }
async function copy(question) { try { await navigator.clipboard.writeText(question.answer || ''); if (!done.includes(question.id)) saveDone([...done, question.id]) } catch { setError('复制失败,请长按参考回答手动复制') } }
async function complete(event) {
event.preventDefault(); setError(''); if (!files.length) return setError('请至少上传一张完成回答截图'); if (!payFiles.length) return setError('请上传收款二维码后再提交')
const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file)); payFiles.forEach(file => body.append('payment_files', file)); setBusy(true)
try { const result = await api(`/api/answer/me/submissions/${latest.id}/completion`, { method: 'POST', body }); await reload(result.data) } catch (err) { setError(err.message) } finally { setBusy(false) }
}
return <><section className="answer-v2-hero green"><small>✓ 任务已派发 · 完成任务阶段</small><h1>完成问答并上传任务凭证</h1><p>请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。</p></section><div className="answer-v2-inner-tabs"><button className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>▣ 待回答清单 <small>{completed}/{selected.length}</small></button><button className={tab === 'upload' ? 'active' : ''} onClick={() => setTab('upload')}>✣ 任务上传(截图与收款码)</button></div>{tab === 'list' ? <section className="answer-v2-card assignment-list"><header><div><h2>待回答问题清单</h2><p>点击复制参考回答即可前往淘宝粘贴提交</p></div><span>回答进度 <b>{completed}/{selected.length}</b><i><u style={{width:`${selected.length ? completed / selected.length * 100 : 0}%`}}/></i></span></header><div className="answer-v2-list-tip">完成所有问题后,进入“任务上传” <button onClick={() => saveDone(selected.map(q => q.id))}>一键全部标记</button></div>{selected.map((question, index) => <article key={question.id} className={done.includes(question.id) ? 'complete' : ''}><header><b>{String(index + 1).padStart(2, '0')}</b><h3>{question.question}</h3><button aria-label="标记完成" onClick={() => mark(question.id)}>{done.includes(question.id) ? <Check size={15}/> : ''}</button></header><small>#{question.product_title || '商品问题'}</small><div><strong>▱ 参考回答:</strong><p>{question.answer}</p></div><footer><span>{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}</span><button onClick={() => copy(question)}><Copy size={15}/>复制回答</button></footer></article>)}<button className="answer-v2-primary wide" disabled={!selected.length} onClick={() => setTab('upload')}>完成回答,进入任务上传</button></section> : <form className="answer-v2-card completion-form" onSubmit={complete}><h2>上传任务完成凭证</h2><p>请上传已发布回答的截图;收款二维码为必传材料,用于后续核验。</p><label>完成回答截图(必传)<input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => setFiles(Array.from(event.target.files).slice(0, 10))}/><small>已选择 {files.length} 张</small></label><label>收款二维码(必传)<input type="file" accept="image/jpeg,image/png,image/webp" onChange={event => setPayFiles(Array.from(event.target.files).slice(0, 1))}/><small>已选择 {payFiles.length} 张</small></label><button className="answer-v2-primary wide" disabled={busy || !files.length}>{busy ? '提交中…' : '提交任务凭证'}</button></form>}</>
}
function FinalStage({ latest, reload, busy, reopen, startNew }) {
const paid = latest.status === '已完成'
const passed = ['已通过', '已完成'].includes(latest.status)
const rejected = latest.status === '未通过'
const cancelled = latest.status === '已撤销'
const reason = latest.latest_review && !latest.latest_review.approved
? [latest.latest_review.reason_code, latest.latest_review.reason_text].filter(Boolean).join(';')
: ''
if (rejected) return <>
<section className="answer-v2-card answer-v2-rejected">
<div className="reject-title"><span>!</span><div><h1>审核未通过 · 需重做 <em>需修正</em></h1><p>请根据下方运营反馈的具体不通过原因重新核对问答,补齐截图后重新提交审核。</p></div></div>
<div className="reject-reason"><strong>▣ 审核不通过原因:</strong><p>{reason || '完成凭证不符合任务要求,请重新提交。'}</p></div>
<button onClick={reopen}>↻ 重新完成任务(开放修改)</button>
</section>
</>
if (latest.status === '待审核') return <>
<section className="answer-v2-card status-card">
<div className="status-title"><span><Clock3 size={24}/></span><div><h1>完成凭证审核中 <em>{STATUS_TEXT[latest.status]}</em></h1><p>您提交的回答完成截图正在审核,请耐心等待运营确认。</p></div></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/>}
<section className="answer-v2-new-task answer-v2-new-task-light"><strong>还可以开始新的任务</strong><p>当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。</p><button onClick={startNew}>+ 开始新的任务</button></section>
</>
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>{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/>}
{(passed || cancelled) && <section className="answer-v2-new-task"><strong>{passed ? '本次任务已完成' : '本次任务已结束'}</strong><p>您可以返回第一步,上传新的被邀请回答截图并开始下一项任务。原任务仍保留在上传历史中。</p><button onClick={startNew}>+ 开始新的任务</button></section>}
</>
}
function HistoryPage({ records, close, onContinue }) {
const canContinue = status => ['待筛选', '待运营派发', '已派发', '待用户完成', '未通过'].includes(status)
return <section className="answer-v2-history"><header><button onClick={close}>← 返回任务</button><h2>任务状态 / 上传历史({records.length})</h2></header>{records.length === 0 ? <div className="answer-v2-card">暂无历史记录</div> : records.map(record => <article className="answer-v2-card" key={record.id}><header><strong>{record.submission_no}</strong><span>{STATUS_TEXT[record.status] || record.status}</span></header><p>{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张</p><div>{record.invited_screenshots.slice(0, 4).map(image => { const src = image.url || image.data_url; return <img key={image.id || src} src={src}/> })}</div>{canContinue(record.status) && <button className="answer-v2-history-continue" onClick={() => onContinue(record)}>↗ 继续此任务</button>}</article>)}</section>
}
export default function AnswerUserPortalV2({ api }) {
const [session, setSession] = useState(() => { try { return JSON.parse(localStorage.getItem(SESSION_KEY) || 'null') } catch { return null } })
const [records, setRecords] = useState([]); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const [loaded, setLoaded] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [retrying, setRetrying] = useState(false); const [newTaskMode, setNewTaskMode] = useState(false); const [activeRecordId, setActiveRecordId] = useState(null); const [optimisticStatuses, setOptimisticStatuses] = useState({})
const imageLoadIds = useRef(new Set())
const loadVersion = useRef(0)
async function load(silent = false) {
if (!session) return
const version = ++loadVersion.current
if (!silent) setBusy(true)
try {
const result = await api(`/api/answer/me/submissions?token=${encodeURIComponent(session.token)}&include_images=false`)
if (version !== loadVersion.current) return
setRecords(current => result.data.map(record => {
const previous = current.find(item => item.id === record.id)
return previous ? {
...previous,
...record,
invited_screenshots: record.invited_screenshots?.length ? record.invited_screenshots : previous.invited_screenshots,
completion_screenshots: record.completion_screenshots?.length ? record.completion_screenshots : previous.completion_screenshots,
payment_screenshots: record.payment_screenshots?.length ? record.payment_screenshots : previous.payment_screenshots,
} : record
}))
setOptimisticStatuses(current => {
const next = { ...current }
result.data.forEach(record => {
if (next[record.id] === record.status) delete next[record.id]
})
return next
})
setError('')
} catch (err) {
if (version === loadVersion.current) setError(err.message)
} finally {
if (version === loadVersion.current) { setLoaded(true); if (!silent) setBusy(false) }
}
}
useEffect(() => { if (!session) return undefined; load(); const timer = window.setInterval(() => load(true), 10000); return () => window.clearInterval(timer) }, [session?.token])
useEffect(() => { if (historyOpen) setError('') }, [historyOpen])
async function loadSubmissionImages(record) {
if (!record?.id || imageLoadIds.current.has(record.id)) return
imageLoadIds.current.add(record.id)
try {
const result = await api(`/api/answer/me/submissions/${record.id}?token=${encodeURIComponent(session.token)}`)
setRecords(current => current.map(item => item.id === record.id ? { ...item, ...result.data, _imagesLoaded: true } : item))
} catch (err) { imageLoadIds.current.delete(record.id); setError(err.message) }
}
const latestRecord = records.find(record => record.id === activeRecordId) || records[0]
const latest = latestRecord && optimisticStatuses[latestRecord.id] ? { ...latestRecord, status: optimisticStatuses[latestRecord.id] } : latestRecord
const logout = () => { localStorage.removeItem(SESSION_KEY); setSession(null); setRecords([]); setLoaded(false); setOptimisticStatuses({}) }
function applySubmittedRecord(submitted) {
if (!submitted?.id) return
setOptimisticStatuses(current => ({ ...current, [submitted.id]: submitted.status }))
setRecords(current => current.map(record => record.id === submitted.id ? {
...record,
...submitted,
invited_screenshots: submitted.invited_screenshots?.length ? submitted.invited_screenshots : record.invited_screenshots,
completion_screenshots: submitted.completion_screenshots || [],
payment_screenshots: submitted.payment_screenshots || [],
} : record))
}
const visibleLatest = newTaskMode ? null : latest
useEffect(() => {
if (!visibleLatest || historyOpen || newTaskMode) return
loadSubmissionImages(visibleLatest)
}, [visibleLatest?.id, historyOpen, newTaskMode])
useEffect(() => {
if (!historyOpen) return
records.slice(0, 20).forEach(record => loadSubmissionImages(record))
}, [historyOpen, records.length])
if (!session) return <Login api={api} onLogin={setSession}/>
return <main className="answer-v2-shell"><section className="answer-v2-app"><Top session={session} latest={visibleLatest} logout={logout}/><div className="answer-v2-tabs"><button className={!historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(false)}><ClipboardCheck size={15}/>任务认领</button><button className={historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(true)}><History size={15}/>任务状态 <small>{visibleLatest ? STATUS_TEXT[visibleLatest.status] || visibleLatest.status : '待上传'}</small></button></div>{!historyOpen && <Progress status={visibleLatest?.status}/>}<div className="answer-v2-body">{error && <div className="answer-v2-error">{error}</div>}{!loaded ? <div className="answer-v2-card answer-v2-loading">正在读取任务状态…</div> : historyOpen ? <HistoryPage records={records} close={() => setHistoryOpen(false)} onContinue={record => { setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false) }}/> : !visibleLatest ? <UploadStage api={api} session={session} onUploaded={async () => { setNewTaskMode(false); setActiveRecordId(null); await load() }} setError={setError}/> : ['待筛选', '待运营派发', '已派发'].includes(visibleLatest.status) ? <ReviewStage latest={visibleLatest} reload={load} busy={busy} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/> : visibleLatest.status === '待用户完成' || (visibleLatest.status === '未通过' && retrying) ? <AssignmentStage api={api} session={session} latest={visibleLatest} reload={async submitted => { if (submitted) { applySubmittedRecord(submitted); setRetrying(false); load(true); return } await load(); setRetrying(false) }} setError={setError}/> : <FinalStage latest={visibleLatest} reload={load} busy={busy} reopen={() => setRetrying(true)} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/>}</div><footer className="answer-v2-footer"><button onClick={() => setHistoryOpen(true)}>上传历史({records.length})</button>{visibleLatest && <button className="primary" onClick={() => load()}><RefreshCw size={14}/>刷新状态</button>}</footer></section></main>
}

View File

@ -0,0 +1,932 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
Check,
ClipboardCheck,
Copy,
ExternalLink,
ImagePlus,
ListChecks,
LogOut,
MessageCircle,
PackageCheck,
ShieldCheck,
Trash2,
} from "lucide-react";
const SESSION_KEY = "ask_user_session";
const EXHAUSTED_MESSAGE = "本次任务已被领取完,请关注下一个任务!";
const fileKey = (file) => `${file.name}-${file.size}-${file.lastModified}`;
const maskPhone = (phone = "") =>
phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
function getTaskNo() {
const segments = window.location.pathname.split("/").filter(Boolean);
return segments[1] || "latest";
}
function PhoneLogin({ api, onLogin }) {
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [sending, setSending] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [error, setError] = useState("");
const [devCode, setDevCode] = useState("");
useEffect(() => {
if (!cooldown) return undefined;
const timer = window.setInterval(() => setCooldown((value) => Math.max(0, value - 1)), 1000);
return () => window.clearInterval(timer);
}, [cooldown]);
async function sendCode() {
if (!/^1\d{10}$/.test(phone)) return setError("请输入正确的 11 位手机号码");
setSending(true); setError("");
try {
const result = await api("/api/ask/auth/send-code", { method: "POST", body: JSON.stringify({ phone }) });
setCooldown(result.data.retry_after || 60); setDevCode(result.data.dev_code || "");
} catch (requestError) { setError(requestError.message); } finally { setSending(false); }
}
async function submit(event) {
event.preventDefault();
if (!/^1\d{10}$/.test(phone)) {
setError("请输入正确的 11 位手机号码");
return;
}
if (!/^\d{6}$/.test(code)) { setError("请输入6位验证码"); return; }
setBusy(true);
setError("");
try {
const result = await api("/api/ask/auth/phone-login", {
method: "POST",
body: JSON.stringify({ phone, code }),
});
localStorage.setItem(SESSION_KEY, JSON.stringify(result.data));
onLogin(result.data);
} catch (requestError) {
setError(requestError.message);
} finally {
setBusy(false);
}
}
return (
<main className="ask-user-shell">
<section className="ask-user-app ask-login-app">
<header className="ask-user-topbar">
<span>
<ShieldCheck size={14} />
淘宝问答运营官方承接通道
</span>
<em>官方认证</em>
</header>
<form className="ask-user-login" onSubmit={submit}>
<span className="ask-login-icon">
<MessageCircle size={28} />
</span>
<h1>手机号登录</h1>
<p>使用短信验证码安全登录</p>
<label>
手机号码
<input
autoFocus
inputMode="numeric"
maxLength="11"
value={phone}
onChange={(event) =>
setPhone(event.target.value.replace(/\D/g, ""))
}
placeholder="请输入 11 位手机号码"
/>
</label>
<label>短信验证码<div className="ask-code-row"><input inputMode="numeric" maxLength="6" value={code} onChange={(event) => setCode(event.target.value.replace(/\D/g, ""))} placeholder="请输入6位验证码"/><button type="button" onClick={sendCode} disabled={sending || cooldown > 0}>{cooldown ? `${cooldown}s后重发` : sending ? "发送中…" : "获取验证码"}</button></div></label>
{devCode && <small className="ask-dev-code">开发环境验证码:{devCode}</small>}
{error && <div className="ask-user-error">{error}</div>}
<button disabled={busy}>
{busy ? "登录中…" : "登录并领取提问任务"}
</button>
</form>
</section>
</main>
);
}
function TaskExhausted({ session, onLogout }) {
return (
<main className="ask-user-shell">
<section className="ask-user-app ask-exhausted-app">
<header className="ask-user-topbar">
<span>
<ShieldCheck size={14} />
淘宝问答运营官方承接通道
</span>
<em>官方认证</em>
</header>
<section className="ask-exhausted-card">
<span className="ask-exhausted-icon">
<PackageCheck size={34} />
</span>
<em>任务已领完</em>
<h1>本次任务已被领取完</h1>
<p>当前链接中的问题已全部分配给其他用户,请关注下一个任务!</p>
<small>
当前账号:{session.user_id} · {maskPhone(session.phone)}
</small>
<button type="button" onClick={onLogout}>
更换登录账号
</button>
</section>
</section>
</main>
);
}
function Header({
session,
assignment,
onLogout,
step,
view,
onOpenHistory,
onOpenClaim,
}) {
return (
<>
<header className="ask-user-topbar">
<span>
<ShieldCheck size={14} />
淘宝问答运营官方承接通道
</span>
<em>官方认证</em>
</header>
<section className="ask-user-profile">
<div className="ask-user-avatar">问</div>
<div>
<strong>问大家提问用户</strong>
<i>手机已绑定</i>
<small>
ID:{session.user_id} · {maskPhone(session.phone)}
</small>
</div>
<button title="退出登录" onClick={onLogout}>
<LogOut size={16} />
</button>
</section>
<nav className="ask-user-tabs">
<button
className={`ask-tab-button ${view === "claim" ? "active" : ""}`}
onClick={onOpenClaim}
>
<ClipboardCheck size={16} />
任务认领 <b>{assignment?.questions.length || 0}</b>
</button>
<button
className={`ask-tab-button ${view === "status" ? "active" : ""}`}
onClick={onOpenHistory}
>
<ListChecks size={16} />
任务状态 <b>{step === 1 ? "待选择" : "进行中"}</b>
</button>
</nav>
</>
);
}
function SelectQuestions({
assignment,
selected,
setSelected,
onConfirm,
busy,
}) {
const isSubmitted = ["待审核", "已通过"].includes(assignment.status);
const isUsed = (item) => item.used || (isSubmitted && item.selected);
const usedCount = assignment.used_count ?? assignment.questions.filter(isUsed).length;
const remainingCount = assignment.remaining_count ?? Math.max(assignment.questions.length - usedCount, 0);
const selectableQuestions = assignment.questions.filter((item) => !isUsed(item));
const allSelected = selectableQuestions.length > 0 && selectableQuestions.every((item) =>
selected.includes(item.id),
);
function toggle(id) {
if (isUsed(assignment.questions.find((item) => item.id === id))) return;
setSelected((current) =>
current.includes(id)
? current.filter((value) => value !== id)
: [...current, id],
);
}
return (
<>
<section className="ask-user-task-id">
<span>
<ShieldCheck size={16} />
任务ID
</span>
<code>{assignment.task_id}</code>
<button
onClick={() => navigator.clipboard?.writeText(assignment.task_id)}
>
<Copy size={14} />
复制
</button>
</section>
<section className="ask-claim-summary">
<div>
<strong>本任务认领进度</strong>
<span>每个链接最多认领 {assignment.questions.length} 条</span>
</div>
<b>
已认领 {usedCount} / {assignment.questions.length}
</b>
<div className="ask-claim-progress">
<i
style={{
width: `${assignment.questions.length ? (usedCount / assignment.questions.length) * 100 : 0}%`,
}}
/>
</div>
<small>
{remainingCount
? `还可继续选择 ${remainingCount} 条问题`
: "本任务认领额度已达上限"}
</small>
</section>
<section className="ask-question-section">
<header>
<strong>
<ListChecks size={17} />
请选择提问内容(共 {assignment.questions.length} 题)
</strong>
<button
onClick={() =>
setSelected(
allSelected
? selected.filter(
(id) =>
isUsed(assignment.questions.find((item) => item.id === id)),
)
: assignment.questions.map((item) => item.id),
)
}
>
{allSelected ? "取消全选" : "全选"}
</button>
</header>
<div className="ask-question-list">
{assignment.questions.map((item) => {
const active = selected.includes(item.id);
return (
<button
key={item.id}
className={`${active ? "active" : ""} ${isUsed(item) ? "used" : ""}`}
disabled={isUsed(item)}
onClick={() => toggle(item.id)}
>
<i>{active && <Check size={16} />}</i>
<span>{item.question}</span>
{isUsed(item) && <em>已认领 / 已使用</em>}
</button>
);
})}
</div>
</section>
<footer className="ask-user-sticky">
<span>
已选择 <b>{selected.length}</b> / {assignment.questions.length} 题
</span>
<button disabled={!selected.length || busy} onClick={onConfirm}>
{busy ? "确认中…" : "确认"}
</button>
</footer>
</>
);
}
function ConfirmStage({
assignment,
api,
session,
taskNo,
onSubmitted,
setError,
}) {
const selectedQuestions = assignment.questions.filter(
(item) => item.selected,
);
const [files, setFiles] = useState([]);
const [paymentFile, setPaymentFile] = useState(null);
const [payeeName, setPayeeName] = useState("");
const [reviewOpen, setReviewOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [copiedId, setCopiedId] = useState(null);
const inputRef = useRef(null);
const paymentInputRef = useRef(null);
const previews = useMemo(
() => files.map((file) => ({ file, url: URL.createObjectURL(file) })),
[files],
);
useEffect(
() => () => previews.forEach((item) => URL.revokeObjectURL(item.url)),
[previews],
);
function addFiles(list) {
const images = Array.from(list).filter((file) =>
/^image\/(jpeg|png|webp)$/.test(file.type),
);
setFiles((current) =>
[...current, ...images].filter(
(file, index, all) =>
all.findIndex((row) => fileKey(row) === fileKey(file)) === index,
),
);
}
async function copyQuestion(item) {
try {
await navigator.clipboard.writeText(item.question);
setCopiedId(item.id);
window.setTimeout(
() => setCopiedId((current) => (current === item.id ? null : current)),
1500,
);
} catch {
setCopiedId(null);
}
}
async function submit() {
setBusy(true);
setError("");
const body = new FormData();
body.append("token", session.token);
files.forEach((file) => body.append("files", file));
body.append("payment_file", paymentFile);
body.append("payee_name", payeeName);
try {
const result = await api(
`/api/ask/tasks/${encodeURIComponent(taskNo)}/submission`,
{
method: "POST",
body,
},
);
setReviewOpen(false);
onSubmitted(result.data);
} catch (requestError) {
setError(requestError.message);
} finally {
setBusy(false);
}
}
return (
<div className="ask-confirm-body">
<section className="ask-product-card">
<header>
<ShieldCheck size={15} />
<strong>问大家核验商品</strong>
<code>{assignment.task_id}</code>
</header>
<div>
<span className="ask-product-placeholder">
<PackageCheck size={31} />
</span>
<div>
<small>官方任务商品</small>
<h2>{assignment.product_name}</h2>
</div>
</div>
<footer>
<code>{assignment.product_link}</code>
<button
onClick={() =>
navigator.clipboard?.writeText(assignment.product_link)
}
>
<Copy size={13} />
复制链接
</button>
<a href={assignment.product_link} target="_blank" rel="noreferrer">
去淘宝提问 <ExternalLink size={13} />
</a>
</footer>
</section>
<section className="ask-selected-card">
<header>
<strong>已选提问内容(共 {selectedQuestions.length} 题)</strong>
<span>请在淘宝提问对应内容</span>
</header>
{selectedQuestions.map((item, index) => (
<article key={item.id}>
<b>#{index + 1}</b>
<span>{item.question}</span>
<button onClick={() => copyQuestion(item)}>
{copiedId === item.id ? <Check size={13} /> : <Copy size={13} />}
{copiedId === item.id ? "已复制" : "复制"}
</button>
</article>
))}
</section>
<section className="ask-upload-card">
<header>
<div>
<b>1</b>
<strong>上传截图(支持多图上传)</strong>
<small>请上传淘宝“我的提问”记录截图凭证</small>
</div>
<button>查看范例</button>
</header>
<div className="ask-upload-summary">
<span>支持多图 · 可多次添加 · JPG/PNG/WEBP</span>
<b>已上传 {files.length} 张</b>
</div>
<div className="ask-upload-grid">
{previews.map(({ file, url }, index) => (
<figure key={fileKey(file)}>
<img src={url} alt={`提问截图${index + 1}`} />
<button
onClick={() =>
setFiles((current) =>
current.filter((row) => fileKey(row) !== fileKey(file)),
)
}
>
<Trash2 size={14} />
</button>
<figcaption>图 {index + 1}</figcaption>
</figure>
))}
<button
className="ask-upload-add"
onClick={() => inputRef.current?.click()}
>
<ImagePlus size={25} />
<strong>上传提问截图</strong>
<small>支持 JPG、PNG、WEBP</small>
</button>
</div>
<input
ref={inputRef}
hidden
multiple
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(event) => addFiles(event.target.files)}
/>
</section>
<section className="ask-upload-card ask-payment-card">
<header>
<div>
<b>2</b>
<strong>上传收款码</strong>
<small>审核通过后将按原路径结算至此账户</small>
</div>
<span>{paymentFile ? "已上传" : "未上传"}</span>
</header>
{paymentFile ? (
<div className="ask-payment-file">
<span>{paymentFile.name}</span>
<button onClick={() => paymentInputRef.current?.click()}>
重新上传
</button>
<button className="delete" onClick={() => setPaymentFile(null)}>
删除
</button>
</div>
) : (
<button
className="ask-payment-empty"
onClick={() => paymentInputRef.current?.click()}
>
<ImagePlus size={22} />
上传收款码
</button>
)}
<input
ref={paymentInputRef}
hidden
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(event) => setPaymentFile(event.target.files?.[0] || null)}
/>
<label>
收款人真实姓名(选填,用于核验一致性)
<input
value={payeeName}
onChange={(event) => setPayeeName(event.target.value)}
placeholder="请输入收款人姓名"
/>
</label>
</section>
<button
className="ask-submit-review"
disabled={!files.length || !paymentFile}
onClick={() => setReviewOpen(true)}
>
下一步:核对并提交审核
</button>
{reviewOpen && (
<div className="ask-submit-backdrop">
<section className="ask-submit-modal">
<header>
<ShieldCheck size={24} />
<button onClick={() => setReviewOpen(false)}>×</button>
<h2>提交审核核对</h2>
<p>请确认凭证信息无误,提交后将锁定审核</p>
</header>
<div className="ask-submit-summary">
<p>
<span>任务编号(TaskID)</span>
<code>{assignment.task_id}</code>
</p>
<p>
<span>已完成问题</span>
<b>{selectedQuestions.length} 条(已认领)</b>
</p>
{selectedQuestions.map((item) => (
<article key={item.id}>{item.question}</article>
))}
<p>
<span>提问截图</span>
<b>已上传({files.length}张)</b>
</p>
<p>
<span>收款码</span>
<b>已上传</b>
</p>
</div>
<aside>
<strong>防作弊与审核锁定声明</strong>
<p>
点击提交后,任务状态将变更为“审核中”,凭证不可撤销修改;审核通常在
1~24 小时内完成。
</p>
</aside>
<footer>
<button onClick={() => setReviewOpen(false)}>返回修改</button>
<button className="confirm" disabled={busy} onClick={submit}>
{busy ? "提交中…" : "确认提交审核"}
</button>
</footer>
</section>
</div>
)}
</div>
);
}
function AuditStatus({ assignment, reload, busy, onBackHome }) {
const pending = assignment.status === "待审核";
const passed = assignment.status === "已通过";
const review = assignment.latest_review;
return (
<div className="ask-audit-body">
<section
className={`ask-audit-card ${passed ? "passed" : assignment.status === "未通过" ? "rejected" : ""}`}
>
<span>{passed ? "✓" : assignment.status === "未通过" ? "!" : "⌁"}</span>
<em>{assignment.status}</em>
<h1>
{pending
? "任务正在审核,请耐心等待。"
: passed
? "运营审核已通过"
: "运营审核未通过"}
</h1>
<p>
{pending
? "审核专员正在核验您的淘宝提问截图与发布有效性,核验通过后将自动更新状态。"
: passed
? "您提交的提问凭证已经核验通过,本次任务审核完成。"
: "请根据运营反馈修改截图后重新提交审核。"}
</p>
<div>
<strong>提交记录凭据</strong>
<dl>
<dt>任务ID:</dt>
<dd>{assignment.task_id}</dd>
<dt>提交问题:</dt>
<dd>
{assignment.questions.filter((item) => item.selected).length} 条
</dd>
<dt>提交时间:</dt>
<dd>
{new Date(assignment.updated_at).toLocaleString("zh-CN", {
hour12: false,
})}
</dd>
<dt>锁定状态:</dt>
<dd>{pending ? "已锁定不可撤销" : assignment.status}</dd>
</dl>
</div>
{review && !review.approved && (
<aside>
<strong>审核未通过原因</strong>
<p>
{[review.reason_code, review.reason_text]
.filter(Boolean)
.join(":")}
</p>
</aside>
)}
<button onClick={reload} disabled={busy}>
{busy ? "刷新中…" : "刷新审核状态"}
</button>
<button className="ask-back-home" onClick={onBackHome}>
返回任务首页
</button>
</section>
</div>
);
}
function TaskStatusPage({ history, onView }) {
const [filter, setFilter] = useState("all");
const inProgress = history.filter((item) =>
["待选择", "进行中", "待审核"].includes(item.status),
).length;
const completed = history.filter((item) =>
["已通过", "未通过"].includes(item.status),
).length;
const filtered = history.filter(
(item) =>
filter === "all" ||
(filter === "progress"
? ["待选择", "进行中", "待审核"].includes(item.status)
: ["已通过", "未通过"].includes(item.status)),
);
return (
<div className="ask-status-page">
<div className="ask-history-tabs">
<button
className={filter === "all" ? "active" : ""}
onClick={() => setFilter("all")}
>
全部任务 <b>{history.length}</b>
</button>
<button
className={filter === "progress" ? "active" : ""}
onClick={() => setFilter("progress")}
>
进行中 <b>{inProgress}</b>
</button>
<button
className={filter === "done" ? "active" : ""}
onClick={() => setFilter("done")}
>
已完成 <b>{completed}</b>
</button>
<b>全部任务({history.length})</b>
<span>进行中 {inProgress}</span>
<span>已完成 {completed}</span>
</div>
{filtered.length === 0 ? (
<div className="ask-user-loading">暂无历史提交任务</div>
) : (
filtered.map((item) => (
<article
className={`ask-history-item ${item.status === "待审核" ? "pending" : item.status === "已通过" ? "done" : ""}`}
key={item.assignment_id}
>
<header>
<div>
<code>{item.task_id}</code>
<small>ID:{item.user_id}</small>
</div>
<em>{item.status}</em>
</header>
<h3>
{item.questions
.filter((question) => question.selected)
.map((question) => question.question)
.join("、") || "尚未选择问题"}
</h3>
<p>
认领问题{" "}
{item.questions.filter((question) => question.selected).length} 条
· 更新时间{" "}
{new Date(item.updated_at).toLocaleTimeString("zh-CN", {
hour12: false,
})}
</p>
<footer>
<span>
{item.status === "待审核"
? "凭证已锁定,专员核验中"
: item.status === "已通过"
? "审核已通过"
: item.status === "未通过"
? "需要重新提交"
: "等待选择问题"}
</span>
<button onClick={() => onView(item)}>查看进度</button>
</footer>
</article>
))
)}
</div>
);
}
export default function AskUserPortal({ api }) {
const taskNo = getTaskNo();
const [session, setSession] = useState(() => {
try {
return JSON.parse(localStorage.getItem(SESSION_KEY) || "null");
} catch {
return null;
}
});
const [assignment, setAssignment] = useState(null);
const [selected, setSelected] = useState([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [history, setHistory] = useState([]);
const [view, setView] = useState("claim");
const [notice, setNotice] = useState("");
const [claimEdit, setClaimEdit] = useState(false);
const [exhausted, setExhausted] = useState(false);
async function loadAssignment(silent = false) {
if (!session) return;
const previousAssignmentId = assignment?.assignment_id;
if (!silent) setBusy(true);
try {
const result = await api(
`/api/ask/tasks/${encodeURIComponent(taskNo)}?token=${encodeURIComponent(session.token)}`,
);
setAssignment(result.data);
setExhausted(false);
// 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
// be counted as a new selection when the user returns to claim more.
.filter((item) => item.selected && !item.used)
.map((item) => item.id),
);
}
setError("");
} catch (requestError) {
if (requestError.message === EXHAUSTED_MESSAGE) {
setAssignment(null);
setExhausted(true);
setError("");
} else {
setError(requestError.message);
}
} finally {
if (!silent) setBusy(false);
}
}
async function loadHistory() {
try {
const result = await api(
`/api/ask/me/assignments?token=${encodeURIComponent(session.token)}`,
);
setHistory(result.data);
setView("status");
} catch (requestError) {
setError(requestError.message);
}
}
useEffect(() => {
loadAssignment();
}, [session?.token, taskNo]);
useEffect(() => {
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]);
const logout = () => {
localStorage.removeItem(SESSION_KEY);
setSession(null);
setAssignment(null);
setExhausted(false);
};
if (!session) return <PhoneLogin api={api} onLogin={setSession} />;
if (exhausted)
return <TaskExhausted session={session} onLogout={logout} />;
async function confirm() {
if (!selected.length) return;
setBusy(true);
setError("");
try {
const result = await api(
`/api/ask/tasks/${encodeURIComponent(taskNo)}/selection`,
{
method: "POST",
body: JSON.stringify({
token: session.token,
question_ids: selected,
}),
},
);
setAssignment(result.data);
// Selection is complete; continue directly to the upload/submit stage.
setClaimEdit(false);
setSelected([]);
} catch (requestError) {
setError(requestError.message);
} finally {
setBusy(false);
}
}
const step = assignment?.status === "待选择" ? 1 : 2;
return (
<main className="ask-user-shell">
<section className="ask-user-app">
<Header
session={session}
assignment={assignment}
onLogout={logout}
step={step}
view={view}
onOpenHistory={loadHistory}
onOpenClaim={() => {
setView("claim");
// If a previous batch has completed and questions remain, opening
// the claim tab should show the next selectable batch directly.
setClaimEdit(
Boolean(
assignment &&
(assignment.remaining_count ?? 0) > 0 &&
assignment.status === "已通过",
),
);
setNotice("");
}}
/>
{error && <div className="ask-user-error page">{error}</div>}
{notice && <div className="ask-user-notice">{notice}</div>}
{view === "status" ? (
<TaskStatusPage
history={history}
onView={(item) => {
setAssignment(item);
setSelected(
item.questions
.filter((question) => question.selected)
.map((question) => question.id),
);
setView("claim");
}}
/>
) : !assignment ? (
<div className="ask-user-loading">
{busy ? "正在随机分配问题…" : "暂时无法读取任务"}
</div>
) : claimEdit || assignment.status === "待选择" ? (
<SelectQuestions
assignment={assignment}
selected={selected}
setSelected={setSelected}
onConfirm={confirm}
busy={busy}
/>
) : ["待审核", "已通过"].includes(assignment.status) ? (
<AuditStatus
assignment={assignment}
reload={() => loadAssignment()}
busy={busy}
onBackHome={() => {
const consumed = assignment.questions.filter(
(item) => item.used || (["待审核", "已通过"].includes(assignment.status) && item.selected),
);
const selectedCount = assignment.used_count ?? consumed.length;
// Start a fresh selection containing only the remaining items;
// consumed questions stay visibly locked in the list.
setSelected([]);
if (selectedCount >= assignment.questions.length) {
setView("status");
loadHistory();
setNotice("本次任务您的认领额度已达上限,请关注下一次任务");
} else {
setView("claim");
setClaimEdit(true);
setNotice(
`本次任务还可以继续选择 ${assignment.questions.length - selectedCount} 个问题`,
);
}
}}
/>
) : (
<ConfirmStage
assignment={assignment}
api={api}
session={session}
taskNo={taskNo}
onSubmitted={setAssignment}
setError={setError}
/>
)}
</section>
</main>
);
}

View File

@ -0,0 +1,191 @@
import React, { useEffect, useMemo, useState } from 'react'
export default function CreateAnswerTasksPageV2({ api }) {
const [records, setRecords] = useState([])
const [expanded, setExpanded] = useState({})
const [busy, setBusy] = useState('')
const [detailBusy, setDetailBusy] = useState('')
const [error, setError] = useState('')
const [rejecting, setRejecting] = useState(null)
async function load() {
try {
const result = await api('/api/admin/answer-task-drafts')
setRecords(current => result.data.map(record => {
const previous = current.find(item => item.id === record.id)
if (!previous) return record
const previousQuestions = new Map(previous.questions.map(question => [question.id, question]))
return {
...record,
questions: record.questions.map(question => ({
...question,
product_image: question.product_image || previousQuestions.get(question.id)?.product_image || null,
source_image: question.source_image || previousQuestions.get(question.id)?.source_image || null,
})),
}
}))
setExpanded(current => {
const next = { ...current }
result.data.forEach((record, index) => {
if (next[record.id] === undefined) next[record.id] = false
})
return next
})
} catch (err) {
setError(err.message)
}
}
useEffect(() => {
load()
const timer = window.setInterval(() => load(), 5000)
return () => window.clearInterval(timer)
}, [])
async function toggleOpen(record) {
const open = !!expanded[record.id]
setExpanded(current => ({ ...current, [record.id]: !open }))
if (open || !record.questions.length || record.questions.some(question => question.source_image || question.product_image)) return
setDetailBusy(record.id)
try {
const result = await api(`/api/admin/answer-task-drafts/${record.id}`)
setRecords(current => current.map(item => item.id === record.id ? result.data : item))
} catch (err) {
setError(err.message)
} finally {
setDetailBusy('')
}
}
const editableQuestions = useMemo(
() => records.filter(record => record.status !== '待用户完成').flatMap(record => record.questions),
[records],
)
const selectedCount = editableQuestions.filter(question => question.selected).length
function setQuestionLocal(questionId, patch) {
setRecords(current => current.map(record => ({
...record,
questions: record.questions.map(question => question.id === questionId ? { ...question, ...patch } : question),
})))
}
async function updateQuestion(question, patch) {
const previous = { ...question }
setQuestionLocal(question.id, patch)
setBusy(question.id)
setError('')
try {
const result = await api(`/api/admin/answer-task-drafts/questions/${question.id}`, {
method: 'PATCH', body: JSON.stringify(patch),
})
setQuestionLocal(question.id, { answer: result.data.answer, selected: result.data.selected })
} catch (err) {
setQuestionLocal(question.id, previous)
setError(err.message)
} finally {
setBusy('')
}
}
async function toggleMany(questions, selected) {
const editable = questions.filter(question => !question.locked)
editable.forEach(question => setQuestionLocal(question.id, { selected }))
setError('')
try {
await Promise.all(editable.map(question => api(`/api/admin/answer-task-drafts/questions/${question.id}`, {
method: 'PATCH', body: JSON.stringify({ selected }),
})))
} catch (err) {
setError(err.message)
await load()
}
}
async function publish(record) {
if (!record.questions.some(question => question.selected)) {
setError(`请先在用户 ${record.user_id} 下选择至少一个问题`)
setExpanded(current => ({ ...current, [record.id]: true }))
return
}
setBusy(record.id)
setError('')
try {
await api(`/api/admin/answer-task-drafts/${record.id}/publish`, { method: 'POST' })
await load()
} catch (err) {
setError(err.message)
} finally {
setBusy('')
}
}
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>
<div className="create-mode-tabs"><button onClick={() => window.dispatchEvent(new CustomEvent('switch-create-ask'))}>创建提问任务</button><button className="active">创建回答任务</button></div>
</div>
<div className="create-guide"><b>ⓘ 回答截图审核</b><span>用户提交截图后会立即进入此审核队列。按「用户ID」分组核对识别的问题,勾选需要派发的问题,回答可重新生成或直接编辑,确认后生成任务。</span></div>
{error && <div className="form-error create-task-error">{error}</div>}
<div className="create-select-all">
<label><input className="task-checkbox" type="checkbox" checked={editableQuestions.length > 0 && selectedCount === editableQuestions.length} disabled={!editableQuestions.length} onChange={event => toggleMany(editableQuestions, event.target.checked)}/><span>全选所有问题</span></label>
<em>已选 {selectedCount} / {editableQuestions.length} 题</em>
</div>
<div className="answer-draft-list">
{records.length === 0 && <div className="empty-results panel">暂无待审核的回答截图</div>}
{records.map(record => {
const locked = record.status === '待用户完成'
const selected = record.questions.filter(question => question.selected).length
const allSelected = record.questions.length > 0 && selected === record.questions.length
const identifying = record.questions.length === 0 && ['待筛选', '待运营派发'].includes(record.status)
const open = expanded[record.id]
return <article className="answer-draft-group panel" key={record.id}>
<header className="draft-group-header">
<button className="draft-expand" disabled={identifying || detailBusy === record.id} onClick={() => toggleOpen(record)}>{open ? '⌄' : '›'}</button>
<label className="draft-group-select"><input className="task-checkbox" type="checkbox" checked={allSelected} disabled={locked || identifying} onChange={event => toggleMany(record.questions.map(question => ({ ...question, locked })), event.target.checked)}/></label>
<div className="draft-user-meta"><strong>{record.user_id}</strong><span>用户_{record.user_id.slice(1)}</span><code>{record.submission_no}</code><em>已选 {selected}/{record.questions.length} 题</em></div>
{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>
<div className="answer-draft-content">
<div className="draft-question-title"><h3>Q{question.sequence_no}:{question.question}</h3></div>
<div className="answer-product-line">
{question.product_image && <img src={question.product_image} alt="商品主图"/>}
<span><b>{question.product_title}</b><small>来源:模拟OCR识别</small></span>
{question.source_image && <div className="source-image-wrap"><small>识图原始截图:</small><img className="source-shot" src={question.source_image} alt="识别原始截图"/></div>}
</div>
<textarea value={question.answer} disabled={locked} onChange={event => setQuestionLocal(question.id, { answer: event.target.value })} onBlur={event => updateQuestion({ ...question, answer: event.target.value }, { answer: event.target.value })}/>
</div>
<button className="draft-icon-button" title="重新生成回答" disabled={!!busy || locked} onClick={() => updateQuestion(question, { regenerate: true })}>↻</button>
</section>)}
</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

@ -0,0 +1,437 @@
import React, { useState } from "react";
import { Check, Link2, Plus, Sparkles } from "lucide-react";
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
const blankForm = {
product_link: "",
product_name: "",
category: "",
benefits: "",
selling_points: "",
language_style: "",
target_group: "",
usage_scene: "",
purchase_channel: "",
question_count: "",
};
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);
}
export default function CreateAskTasksPage({ api }) {
const [form, setForm] = useState(blankForm);
const [task, setTask] = useState(null);
const [questions, setQuestions] = useState([]);
const [editing, setEditing] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [referenceFile, setReferenceFile] = useState(null);
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);
return;
}
const optionsError = validateGenerationOptions(form, false);
if (optionsError) {
setError(optionsError);
return;
}
setBusy(true);
setError("");
try {
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,
}),
});
setTask(result.data);
setQuestions(
result.data.selected_questions.map((item) => ({
...item,
selected: true,
})),
);
} catch (err) {
setError(err.message || "生成失败,请检查参考文件和大模型配置后重试");
} finally {
setBusy(false);
}
}
async function saveQuestion(index, value) {
setQuestions((current) =>
current.map((item, i) =>
i === index ? { ...item, question: value } : item,
),
);
const question = questions[index];
if (
task &&
question &&
!String(question.id).startsWith("new-") &&
value.trim()
)
await api(`/api/admin/ask-tasks/${task.id}/questions/${question.id}`, {
method: "PATCH",
body: JSON.stringify({ question: value.trim() }),
});
}
async function toggle(index) {
const question = questions[index];
const selected = !question.selected;
setQuestions((current) =>
current.map((item, i) => (i === index ? { ...item, selected } : item)),
);
if (task && !String(question.id).startsWith("new-"))
await api(`/api/admin/ask-tasks/${task.id}/questions/${question.id}`, {
method: "PATCH",
body: JSON.stringify({ selected }),
});
}
async function addQuestion() {
const value = window.prompt("请输入提问内容");
if (!value?.trim()) return;
if (!task)
return setQuestions((current) => [
...current,
{ id: `new-${Date.now()}`, question: value.trim(), selected: true },
]);
try {
const result = await api(`/api/admin/ask-tasks/${task.id}/questions`, {
method: "POST",
body: JSON.stringify({ question: value.trim() }),
});
setQuestions((current) => [...current, result.data]);
} catch (err) {
setError(err.message);
}
}
async function publish() {
if (!task) return setError("请先生成提问");
setBusy(true);
setError("");
try {
const result = await api(`/api/admin/ask-tasks/${task.id}/publish`, {
method: "POST",
});
setTask(result.data);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
if (task?.status === "已发布")
return (
<>
<div className="create-page-heading">
<div>
<h2>创建任务</h2>
<p>提问任务已发布,可在任务看板查看。</p>
</div>
</div>
<section className="ask-created-card panel">
<div className="ask-created-icon">
<Check size={34} />
</div>
<h2>任务创建成功!</h2>
<p>
任务ID:<strong>{task.task_id}</strong>
</p>
<div className="ask-link-box">
<span>
<Link2 size={14} />
提问任务链接
</span>
<code>
{window.location.origin}/ask/{task.task_id}
</code>
<small>当前链接为任务预留入口,后续接入提问用户页面。</small>
</div>
<div>
<button
className="primary-button"
onClick={() =>
window.dispatchEvent(new CustomEvent("open-task-board"))
}
>
前往任务看板查看
</button>
<button
className="ghost-button"
onClick={() => {
setForm(blankForm);
setQuestions([]);
setTask(null);
}}
>
继续创建新任务
</button>
</div>
</section>
</>
);
return (
<>
<div className="create-page-heading">
<div>
<h2>创建任务</h2>
<p>通过顶部切换创建「提问任务」或「回答任务」。</p>
</div>
<div className="create-mode-tabs">
<button className="active">创建提问任务</button>
<button
onClick={() =>
window.dispatchEvent(new CustomEvent("switch-create-answer"))
}
>
创建回答任务
</button>
</div>
</div>
<div className="ask-stepper">
<b>1</b>
<strong>Step 1 生成并勾选提问</strong>
<i />
<span>2 Step 2 发布成功</span>
</div>
<div className="ask-workspace panel">
<section className="ask-config">
<div className="panel-title">
<Sparkles size={17} />
生成条件配置 <small>* 商品链接为必填项</small>
</div>
<label>
商品链接 *
<input
value={form.product_link}
onChange={(event) => update("product_link", event.target.value)}
placeholder="粘贴商品链接,例如:https://item.jd.com/100028981.html"
/>
</label>
<h4>商品信息</h4>
<label>
商品名称
<input
value={form.product_name}
onChange={(event) => update("product_name", event.target.value)}
placeholder="请输入商品名称"
/>
</label>
<div className="two-col">
<label>
商品品类
<input
value={form.category}
onChange={(event) => update("category", event.target.value)}
placeholder="例如:面部精华"
/>
</label>
<label>
商品功效
<input
value={form.benefits}
onChange={(event) => update("benefits", event.target.value)}
placeholder="例如:舒缓泛红、修护屏障"
/>
</label>
</div>
<label>
商品卖点
<textarea
value={form.selling_points}
onChange={(event) => update("selling_points", event.target.value)}
placeholder="例如:油敏肌救星,植萃配方"
/>
</label>
<label className="reference-qa-upload">
真实问答参考(选填)
<input type="file" accept=".txt,.csv,.json,.md,.xlsx,text/plain,text/csv,application/json,text/markdown,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => { setReferenceFile(event.target.files?.[0] || null); setError(""); }} />
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown、XLSX,生成提问时会参考其中的问题角度"}</small>
{referenceFile && <button type="button" className="reference-qa-clear" onClick={() => setReferenceFile(null)}>清除已选文件</button>}
</label>
<h4>生成偏好与数量</h4>
<div className="two-col">
<label>
语言风格
<select
value={form.language_style}
onChange={(event) =>
update("language_style", event.target.value)
}
>
<option value="">请选择语言风格</option>
<option>口语(亲切随性)</option>
<option>专业</option>
<option>幽默</option>
</select>
</label>
<label>
目标人群
<select
value={form.target_group}
onChange={(event) => update("target_group", event.target.value)}
>
<option value="">请选择目标人群</option>
<option>女性</option>
<option>男性</option>
<option>学生</option>
</select>
</label>
</div>
<label>
提问生成条数
<input
type="number"
min="1"
value={form.question_count}
onChange={(event) => update("question_count", event.target.value)}
/>
</label>
<div className="two-col">
<label>
使用场景
<select
value={form.usage_scene}
onChange={(event) => update("usage_scene", event.target.value)}
>
<option value="">请选择使用场景</option>
<option>居家</option>
<option>旅行</option>
<option>通勤</option>
</select>
</label>
<label>
购买渠道
<select
value={form.purchase_channel}
onChange={(event) =>
update("purchase_channel", event.target.value)
}
>
<option value="">请选择购买渠道</option>
<option>电商平台</option>
<option>大型商超</option>
<option>药房</option>
</select>
</label>
</div>
{error && <div className="form-error">{error}</div>}
<button
className="answer-v2-primary wide"
disabled={busy}
onClick={generate}
>
<Sparkles size={16} />
{busy ? "生成中…" : "生成提问"}
</button>
</section>
<section className="ask-results">
<div className="ask-toolbar">
<label>
<input
type="checkbox"
checked={
questions.length > 0 &&
questions.every((item) => item.selected)
}
onChange={(event) =>
setQuestions((current) =>
current.map((item) => ({
...item,
selected: event.target.checked,
})),
)
}
/>{" "}
全选 ({questions.filter((item) => item.selected).length}/
{questions.length})
</label>
<button className="ghost-button" onClick={addQuestion}>
<Plus size={14} />
添加提问
</button>
<button className="ghost-button" disabled={busy} onClick={generate}>
↻ 重新生成
</button>
<button
className="primary-button"
disabled={
!questions.some((item) => item.selected) || !task || busy
}
onClick={publish}
>
发布任务
</button>
</div>
{questions.length === 0 ? (
<div className="ask-empty">
点击左侧「生成提问」,AI 生成的提问将展示在这里,可逐条勾选。
</div>
) : (
questions.map((question, index) => (
<article
className={question.selected ? "selected" : ""}
key={question.id}
>
<input
type="checkbox"
checked={question.selected}
onChange={() => toggle(index)}
/>
<div>
<header>
<b>Q{index + 1}</b>
{editing === index ? (
<input
autoFocus
value={question.question}
onChange={(event) =>
saveQuestion(index, event.target.value)
}
onBlur={() => setEditing(null)}
/>
) : (
<strong>{question.question}</strong>
)}
<button
onClick={() =>
setEditing(editing === index ? null : index)
}
>
{editing === index ? "完成" : "编辑"}
</button>
</header>
<small>来源:AI生成 · 可编辑后发布</small>
</div>
</article>
))
)}
</section>
</div>
</>
);
}

View File

@ -0,0 +1,624 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { MessageCircleMore, RefreshCw, X } from "lucide-react";
const REASONS = [
"内容不清晰",
"数量不足",
"问题不匹配",
"回答不符合要求",
"商品不一致",
"其他",
];
const STATUSES = ["全部", "待审核"];
const formatTime = (value) =>
value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
const productName = (record) =>
[
...new Set(
record.questions.filter((q) => q.selected).map((q) => q.product_title),
),
].join(" / ") || "待识别商品";
const StatusBadge = ({ status }) => (
<em className={`review-v2-status ${status}`}>{status}</em>
);
function ReviewDetail({ record, close, decide, busy }) {
const [reason, setReason] = useState(REASONS[0]);
const [customReason, setCustomReason] = useState("");
const selected = record.questions.filter((q) => q.selected);
const pending = record.status === "待审核";
return (
<div
className="review-v2-backdrop"
onMouseDown={(event) => event.target === event.currentTarget && close()}
>
<section className="review-v2-modal">
<header>
<div>
<h2>
回答审核 · 用户任务列表 <StatusBadge status={record.status} />
</h2>
<p>任务ID:{record.submission_no}</p>
</div>
<button onClick={close}>
<X size={22} />
</button>
</header>
<div className="review-v2-modal-body">
<div className="review-v2-summary">
<code>{record.user_id}</code>
<code>{record.submission_no}</code>
<span>商品:{productName(record)}</span>
<span>提交时间:{formatTime(record.updated_at)}</span>
</div>
<h3>① 问答记录(发布问题 ↔ 用户回答)</h3>
<div className="review-v2-qa">
{selected.map((question, index) => (
<article key={question.id}>
<strong>
Q{index + 1}:{question.question}
</strong>
<p>
<b>A:</b>
{question.answer}
</p>
</article>
))}
</div>
<div className="review-v2-media">
<section>
<h3>② 用户上传的任务截图</h3>
<div>
{record.completion_screenshots.map((image, index) => (
<figure key={image.id}>
<img
src={image.data_url}
alt={`任务截图${index + 1}`}
/>
<figcaption>任务截图 {index + 1}</figcaption>
</figure>
))}
</div>
</section>
<section>
<h3>③ 返款二维码截图</h3>
<div>
{record.payment_screenshots?.length ? (
record.payment_screenshots.map((image) => (
<figure key={image.id}>
<img
src={image.data_url}
alt="收款码"
/>
<figcaption>审核通过后用于后续人工处理</figcaption>
</figure>
))
) : (
<div className="review-v2-no-payment">未单独上传收款码</div>
)}
</div>
</section>
</div>
{record.latest_review && !pending && (
<div
className={`review-v2-result ${record.latest_review.approved ? "approved" : ""}`}
>
<strong>
{record.latest_review.approved ? "审核已通过" : "审核未通过"}
</strong>
{!record.latest_review.approved && (
<p>
原因:{record.latest_review.reason_code}
{record.latest_review.reason_text
? `;${record.latest_review.reason_text}`
: ""}
</p>
)}
</div>
)}
</div>
{pending ? (
<footer className="review-v2-sticky-actions">
<div>
<span>审核不通过原因:</span>
<select
value={reason}
onChange={(event) => setReason(event.target.value)}
>
{REASONS.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<input
value={customReason}
onChange={(event) => setCustomReason(event.target.value)}
placeholder={
reason === "其他"
? "请输入自定义原因(必填)"
: "补充说明(选填)"
}
/>
</div>
<button
className="reject"
disabled={busy || (reason === "其他" && !customReason.trim())}
onClick={() => decide(record, false, reason, customReason.trim())}
>
{busy ? "处理中…" : "审核不通过(需重做)"}
</button>
<button
className="approve"
disabled={busy}
onClick={() => decide(record, true)}
>
{busy ? "处理中…" : "审核通过"}
</button>
</footer>
) : (
<footer className="review-v2-processed-actions">
<span>
该任务已完成审核,当前状态:
<StatusBadge status={record.status} />
</span>
<button onClick={close}>关闭</button>
</footer>
)}
</section>
</div>
);
}
function AskReviewDetail({ record, close, onChanged, api }) {
const [reason, setReason] = useState("截图不清晰");
const [reasonText, setReasonText] = useState("");
const [busy, setBusy] = useState(false);
const selected = record.questions.filter((item) => item.selected);
async function decide(approved) {
setBusy(true);
try {
await api(
`/api/admin/ask-completion-reviews/${record.batch_id || record.assignment_id}/decision`,
{
method: "POST",
body: JSON.stringify({
approved,
reason_code: approved ? "" : reason,
reason_text: reasonText.trim(),
}),
},
);
close();
await onChanged();
window.dispatchEvent(new Event("review-count-changed"));
} catch (error) {
window.alert(error.message);
} finally {
setBusy(false);
}
}
return (
<div
className="review-v2-backdrop"
onMouseDown={(event) => event.target === event.currentTarget && close()}
>
<section className="review-v2-modal">
<header>
<div>
<h2>
提问审核 · 用户任务详情 <StatusBadge status={record.status} />
</h2>
<p>任务ID:{record.task_id}</p>
</div>
<button onClick={close}>
<X size={22} />
</button>
</header>
<div className="review-v2-modal-body">
<div className="review-v2-summary">
<code>{record.user_id}</code>
<code>{record.task_id}</code>
<span>商品:{record.product_name}</span>
<span>提交时间:{formatTime(record.updated_at)}</span>
</div>
<h3>① 用户认领并完成的提问</h3>
<div className="review-v2-qa">
{selected.map((item, index) => (
<article key={item.id}>
<strong>
Q{index + 1}:{item.question}
</strong>
</article>
))}
</div>
<div className="review-v2-media">
<section>
<h3>② 用户上传的提问截图</h3>
<div>
{record.question_screenshots.map((image, index) => (
<figure key={image.id}>
<img
src={image.data_url}
alt={`提问截图${index + 1}`}
/>
<figcaption>提问截图 {index + 1}</figcaption>
</figure>
))}
</div>
</section>
<section>
<h3>③ 用户上传的收款码</h3>
<div>
{record.payment_screenshots.map((image) => (
<figure key={image.id}>
<img
src={image.data_url}
alt="收款码"
/>
<figcaption>收款码截图</figcaption>
</figure>
))}
</div>
</section>
</div>
{record.latest_review && record.status !== "待审核" && (
<div
className={`review-v2-result ${record.latest_review.approved ? "approved" : ""}`}
>
<strong>
{record.latest_review.approved ? "审核已通过" : "审核未通过"}
</strong>
{!record.latest_review.approved && (
<p>
原因:{record.latest_review.reason_code}
{record.latest_review.reason_text
? `;${record.latest_review.reason_text}`
: ""}
</p>
)}
</div>
)}
</div>
{record.status === "待审核" ? (
<footer className="review-v2-sticky-actions">
<div>
<span>审核不通过原因:</span>
<select
value={reason}
onChange={(event) => setReason(event.target.value)}
>
<option>截图不清晰</option>
<option>数量不足</option>
<option>问题不匹配</option>
<option>商品不一致</option>
<option>重复提交</option>
<option>其他</option>
</select>
<input
value={reasonText}
onChange={(event) => setReasonText(event.target.value)}
placeholder="补充说明(选填)"
/>
</div>
<button
className="reject"
disabled={busy}
onClick={() => decide(false)}
>
审核不通过
</button>
<button
className="approve"
disabled={busy}
onClick={() => decide(true)}
>
审核通过
</button>
</footer>
) : (
<footer className="review-v2-processed-actions">
<span>
当前审核状态:
<StatusBadge status={record.status} />
</span>
<button onClick={close}>关闭</button>
</footer>
)}
</section>
</div>
);
}
function AskReviewQueue({ api }) {
const [records, setRecords] = useState([]);
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true);
async function openDetail(record) {
try {
const result = await api(
`/api/admin/ask-completion-reviews/${record.batch_id || record.assignment_id}`,
);
setDetail(result.data);
} catch (error) {
window.alert(error.message);
}
}
async function load() {
setLoading(true);
try {
const result = await api("/api/admin/ask-completion-reviews");
setRecords(result.data);
} catch (error) {
window.alert(error.message);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
return (
<>
<section className="review-v2-table panel">
<div className="head">
<span>用户 / 任务</span>
<span>商品</span>
<span>提交时间</span>
<span>提问数</span>
<span>审核状态</span>
<span>操作</span>
</div>
{loading ? (
<div className="review-v2-empty">正在加载提问审核任务…</div>
) : records.length === 0 ? (
<div className="review-v2-empty">暂无提问审核任务</div>
) : (
records.map((record) => (
<div className="row" key={record.batch_id || record.assignment_id}>
<span>
<b>
<code>{record.user_id}</code>
<code>{record.task_id}</code>
</b>
<small>
{record.phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")}
</small>
</span>
<span>{record.product_name}</span>
<span>{formatTime(record.updated_at)}</span>
<span>
<strong>
{record.questions.filter((item) => item.selected).length}
</strong>{" "}
条提问
</span>
<span>
<StatusBadge status={record.status} />
</span>
<span>
<button onClick={() => openDetail(record)}>
{record.status === "待审核" ? "查看并审核" : "查看详情"}
</button>
</span>
</div>
))
)}
</section>
{detail && (
<AskReviewDetail
record={detail}
close={() => setDetail(null)}
onChanged={load}
api={api}
/>
)}
</>
);
}
export default function ReviewCenterPage({ api }) {
const [queue, setQueue] = useState("ask");
const [statusFilter, setStatusFilter] = useState("待审核");
const [records, setRecords] = useState([]);
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const loadVersion = useRef(0);
async function openAnswerDetail(record) {
setDetailLoading(true);
try {
const result = await api(
`/api/admin/answer-completion-reviews/${record.id}`,
);
setDetail(result.data);
} catch (err) {
window.alert(err.message);
} finally {
setDetailLoading(false);
}
}
async function load() {
const version = ++loadVersion.current;
setLoading(true);
try {
const result = await api("/api/admin/answer-completion-reviews");
if (version !== loadVersion.current) return;
setRecords(result.data);
setError("");
} catch (err) {
if (version === loadVersion.current) setError(err.message);
} finally {
if (version === loadVersion.current) setLoading(false);
}
}
useEffect(() => {
load();
}, []);
useEffect(() => {
if (queue !== "answer") return undefined;
const timer = window.setInterval(() => load(), 5000);
return () => window.clearInterval(timer);
}, [queue]);
const allRows = useMemo(
() => records.filter((record) => record.status === "待审核"),
[records],
);
const rows = useMemo(
() =>
statusFilter === "全部"
? allRows
: allRows.filter((record) => record.status === statusFilter),
[allRows, statusFilter],
);
const count = (status) =>
status === "全部"
? allRows.length
: allRows.filter((record) => record.status === status).length;
async function decide(record, approved, reasonCode = "", reasonText = "") {
setBusy(true);
try {
await api(`/api/admin/answer-completion-reviews/${record.id}/decision`, {
method: "POST",
body: JSON.stringify({
approved,
reason_code: reasonCode,
reason_text: reasonText,
}),
});
setDetail(null);
await load();
window.dispatchEvent(new Event("review-count-changed"));
setStatusFilter("待审核");
} catch (err) {
window.alert(err.message);
} finally {
setBusy(false);
}
}
return (
<>
<div className="review-v2-heading">
<div>
<h2>审核中心</h2>
<p>提问审核与回答审核,对照用户提交截图进行任务终审。</p>
</div>
<div>
<button
className={queue === "ask" ? "active" : ""}
onClick={() => setQueue("ask")}
>
队列一:提问审核
</button>
<button
className={queue === "answer" ? "active" : ""}
onClick={() => {
setQueue("answer");
load();
}}
>
队列二:回答审核
</button>
<button
className="review-v2-refresh"
type="button"
onClick={() => load()}
disabled={loading}
title="刷新审核列表"
>
<RefreshCw size={14} className={loading ? "spin" : ""} />
{loading ? "刷新中…" : "刷新列表"}
</button>
</div>
</div>
<div className={`review-v2-guide ${queue}`}>
<MessageCircleMore size={17} />
<div>
<strong>
{queue === "answer" ? "回答审核说明" : "提问审核说明"}
</strong>
<p>
{queue === "answer"
? "同一用户在同一任务下的多组问答按「用户ID + 任务ID」归并为一条;点击查看任务列表,逐组对照问答和用户完成截图。"
: "审核提问用户提交的问题、截图及任务完成情况。提问任务链路接入后会显示在这里。"}
</p>
</div>
</div>
{error && <div className="form-error">{error}</div>}
{queue === "ask" ? (
<AskReviewQueue api={api} />
) : (
<>
<div className="review-v2-status-filter">
<strong>审核状态:</strong>
{STATUSES.map((status) => (
<button
key={status}
className={statusFilter === status ? "active" : ""}
onClick={() => setStatusFilter(status)}
>
{status}
<small>{count(status)}</small>
</button>
))}
</div>
<section className="review-v2-table panel">
<div className="head">
<span>用户 / 任务</span>
<span>商品</span>
<span>提交时间</span>
<span>问答数</span>
<span>审核状态</span>
<span>操作</span>
</div>
{loading ? (
<div className="review-v2-empty">正在加载审核任务…</div>
) : rows.length === 0 ? (
<div className="review-v2-empty">当前状态暂无回答审核任务</div>
) : (
rows.map((record) => (
<div className="row" key={record.id}>
<span>
<b>
<code>{record.user_id}</code>
<code>{record.submission_no}</code>
</b>
<small>
{record.phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")}
</small>
</span>
<span>{productName(record)}</span>
<span>{formatTime(record.updated_at)}</span>
<span>
<strong>
{record.questions.filter((q) => q.selected).length}
</strong>{" "}
组问答
</span>
<span>
<StatusBadge status={record.status} />
</span>
<span>
<button onClick={() => openAnswerDetail(record)}>
{record.status === "待审核"
? "查看并审核"
: "查看任务列表"}
</button>
</span>
</div>
))
)}
</section>
</>
)}
{detail && (
<ReviewDetail
record={detail}
close={() => setDetail(null)}
decide={decide}
busy={busy}
/>
)}
</>
);
}

View File

@ -0,0 +1,651 @@
import React, { useEffect, useState } from "react";
import { Search, X } from "lucide-react";
const STATUS_FILTERS = [
["all", "全部任务"],
["进行中", "进行中"],
["待审核", "待审核"],
["已完成", "已完成"],
["待返款", "待返款"],
["已撤销", "已撤销"],
];
const STATUS_ALIASES = {
"待用户完成": "进行中",
"未通过": "进行中",
"已发布": "进行中",
};
// Keep the last lightweight board response while operators switch pages.
// Returning to the board renders immediately and refreshes silently.
let taskBoardCache = null;
const formatTime = (value) =>
value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
const maskPhone = (phone = "") =>
phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
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 || []),
];
const paymentScreenshots = task.payment_screenshots || [];
async function confirmRefund() {
setRefundBusy(true);
try {
await onConfirmRefund(task);
setRefundOpen(false);
} catch (error) {
window.alert(error.message);
} finally {
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"
onMouseDown={(event) => event.target === event.currentTarget && close()}
>
<section className="task-detail-modal">
<header>
<div>
<strong>{task.task_id}</strong>
<span className={`board-status ${task.status_label}`}>
{task.status_label}
</span>
</div>
<button onClick={close}>
<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>
</span>
<span>
任务类型<b>{task.task_type_label}</b>
</span>
<span>
创建人<b>{task.creator_name}</b>
</span>
<span>
创建时间<b>{formatTime(task.created_at)}</b>
</span>
</div>
<h3>认领用户的问题与回答(共 {task.assigned_users.length} 位)</h3>
<div className="task-detail-table">
<div className="head">
<span>序号</span>
<span>关联问题与回答</span>
<span>认领用户</span>
<span>状态</span>
</div>
{task.selected_questions.length === 0 ? (
<div className="task-detail-empty">暂无问题</div>
) : (
task.selected_questions.map((question, index) => (
<div className="row" key={question.id}>
<span>{index + 1}</span>
<span>
<b>Q:{question.question}</b>
<small>A:{question.answer}</small>
</span>
<span>
{task.user_id}
<small>{maskPhone(task.phone)}</small>
</span>
<span>
<em className={`board-status ${task.status_label}`}>
{task.status_label}
</em>
</span>
</div>
))
)}
</div>
<div className="task-detail-evidence">
<section>
<h3>{isAskTask ? "提问完成截图" : "回答完成截图"}</h3>
{proofScreenshots.length ? (
<div>
{proofScreenshots.map((image) => (
<button
type="button"
key={image.id}
>
<img src={image.data_url || image.url} alt={image.name || "完成截图"} />
<span>点击查看大图</span>
</button>
))}
</div>
) : (
<p>未上传完成截图</p>
)}
</section>
<section className="payment">
<h3>收款码</h3>
{paymentScreenshots.length ? (
<div>
{paymentScreenshots.map((image) => (
<button
type="button"
key={image.id}
>
<img src={image.data_url || image.url} alt={image.name || "收款码"} />
<span>点击核对收款码</span>
</button>
))}
</div>
) : (
<p>用户未单独上传收款码</p>
)}
</section>
</div>
<footer>
{task.refund_endpoint && (
<button
type="button"
className="confirm-refund"
onClick={() => setRefundOpen(true)}
>
确认返款
</button>
)}
<button onClick={close}>关闭</button>
</footer>
{refundOpen && (
<div
className="refund-confirm-backdrop"
onMouseDown={(event) =>
event.target === event.currentTarget && setRefundOpen(false)
}
>
<section className="refund-confirm-modal">
<span>✓</span>
<h2>确认已经完成返款?</h2>
<p>
请确认已按用户上传的收款码完成付款。确认后,该任务将归入“已完成”,此操作不可撤销。
</p>
<div>
<button
type="button"
disabled={refundBusy}
onClick={() => setRefundOpen(false)}
>
取消
</button>
<button
type="button"
className="confirm"
disabled={refundBusy}
onClick={confirmRefund}
>
{refundBusy ? "确认中…" : "确定返款完成"}
</button>
</div>
</section>
</div>
)}
</section>
</div>
);
}
export default function TaskBoardPage({ api }) {
const [tasks, setTasks] = useState(() => taskBoardCache || []);
const [type, setType] = useState("all");
const [status, setStatus] = useState("all");
const [keyword, setKeyword] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [selectedIds, setSelectedIds] = useState([]);
const [detail, setDetail] = useState(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(() => taskBoardCache === null);
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 {
const result = await api("/api/admin/task-board");
taskBoardCache = result.data;
setTasks(result.data);
setError("");
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => {
load(taskBoardCache === null);
}, []);
const rowKey = (task) => task.board_key || `${task.task_type}-${task.id}`;
// Keep filtering as a direct derivation so changing either filter always
// immediately updates the table (without relying on a stale memoized list).
const rows = tasks.filter((task) => {
const taskType = String(task.task_type || "")
.trim()
.toLowerCase();
const taskTypeLabel = String(task.task_type_label || "").trim();
const rawStatus = String(task.status_label || task.status || "").trim();
const taskStatus = STATUS_ALIASES[rawStatus] || rawStatus;
const typeMatch =
type === "all" ||
(type === "ask" &&
(taskType === "ask" || taskTypeLabel === "提问任务")) ||
(type === "answer" &&
(taskType === "answer" || taskTypeLabel === "回答任务"));
const statusMatch = status === "all" || taskStatus === status;
const text = `${task.user_id || ""} ${task.task_id || ""} ${task.product_name || ""}`.toLowerCase();
const createdDate = String(task.created_at || task.updated_at || "").slice(0, 10);
const fromMatch = !dateFrom || (createdDate && createdDate >= dateFrom);
const toMatch = !dateTo || (createdDate && createdDate <= dateTo);
return typeMatch && statusMatch && text.includes(keyword.trim().toLowerCase()) && fromMatch && toMatch;
});
const visibleKeys = rows.map(rowKey);
const allVisibleSelected = visibleKeys.length > 0 && visibleKeys.every((key) => selectedIds.includes(key));
function toggleSelected(task) {
const key = rowKey(task);
setSelectedIds((current) => current.includes(key) ? current.filter((item) => item !== key) : [...current, key]);
}
function toggleAllVisible() {
setSelectedIds((current) => {
if (allVisibleSelected) return current.filter((key) => !visibleKeys.includes(key));
return [...new Set([...current, ...visibleKeys])];
});
}
function clearFilters() {
setKeyword("");
setDateFrom("");
setDateTo("");
setType("all");
setStatus("all");
}
function exportRows() {
if (!rows.length) {
window.alert("当前没有可导出的任务");
return;
}
const headers = ["用户ID", "任务ID", "商品信息", "类型", "Q&A数", "待审/完成", "状态", "创建人", "创建时间"];
const csvCell = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`;
const lines = [headers, ...rows.map((task) => [
task.user_id,
task.task_id,
task.product_name,
task.task_type_label,
task.qa_count,
`${task.pending_review_count} / ${task.completed_count}`,
task.status_label,
task.creator_name,
formatTime(task.created_at),
])].map((line) => line.map(csvCell).join(","));
const blob = new Blob([`\uFEFF${lines.join("\r\n")}`], { type: "text/csv;charset=utf-8" });
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `任务看板-${new Date().toISOString().slice(0, 10)}.csv`;
anchor.click();
window.URL.revokeObjectURL(url);
}
async function batchClear() {
const selected = tasks.filter((task) => selectedIds.includes(rowKey(task)));
if (!selected.length) {
window.alert("请先选择要清除的任务");
return;
}
setBatchConfirm({ tasks: selected });
}
async function confirmBatchClear() {
if (!batchConfirm) return;
const { tasks: selected } = batchConfirm;
setBatchConfirm(null);
setBatchBusy(true);
try {
const results = await Promise.allSettled(selected.map((task) => api(
task.delete_endpoint || (task.task_type === "ask"
? `/api/admin/ask-tasks/${task.id}`
: `/api/admin/task-board/${task.id}`),
{ method: "DELETE" },
)));
const failedCount = results.filter((result) => result.status === "rejected").length;
setSelectedIds([]);
if (detail && selected.some((task) => task.id === detail.id && task.task_type === (detail.task_type || task.task_type))) {
setDetail(null);
}
await load();
if (failedCount) {
window.alert(`${selected.length - failedCount} 条任务已删除,${failedCount} 条任务处理失败,请刷新后重试。`);
}
} catch (err) {
window.alert(err.message);
} finally {
setBatchBusy(false);
}
}
async function openDetail(task) {
try {
if (task.detail_endpoint) {
const result = await api(task.detail_endpoint);
setDetail(result.data);
} else if (task.task_type === "ask") {
setDetail(task);
} else {
const result = await api(`/api/admin/task-board/${task.id}`);
setDetail(result.data);
}
} catch (err) {
window.alert(err.message);
}
}
async function cancel(task) {
if (
!window.confirm(
`确定撤销任务 ${task.task_id} 吗?撤销后用户将不能继续提交。`,
)
)
return;
setBusy(task.id);
try {
await api(
task.task_type === "ask"
? `/api/admin/ask-tasks/${task.id}/cancel`
: `/api/admin/task-board/${task.id}/cancel`,
{ method: "POST" },
);
await load();
if (detail?.id === task.id) setDetail(null);
} catch (err) {
window.alert(err.message);
} finally {
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 = {
...task,
...result.data,
question_screenshots:
task.question_screenshots || result.data.question_screenshots || [],
completion_screenshots:
task.completion_screenshots || result.data.completion_screenshots || [],
payment_screenshots:
task.payment_screenshots || result.data.payment_screenshots || [],
};
setDetail(updatedDetail);
setTasks((current) => {
const next = current.map((item) =>
item.board_key === result.data.board_key
? { ...item, ...result.data }
: item,
);
taskBoardCache = next;
return next;
});
}
return (
<>
<div className="board-heading">
<h2>任务看板</h2>
<p>监控全量问答任务执行状态、完成进度与审核链路。</p>
</div>
<div className="board-type-row">
<span>任务类型:</span>
{[
["all", "全部类型"],
["ask", "提问任务"],
["answer", "回答任务"],
].map(([value, label]) => (
<button
type="button"
className={type === value ? "active" : ""}
key={value}
onClick={() => setType(value)}
>
{label}
</button>
))}
</div>
<div className="board-status-row">
{STATUS_FILTERS.map(([value, label]) => (
<button
type="button"
className={status === value ? "active" : ""}
key={value}
onClick={() => setStatus(value)}
>
{label}
</button>
))}
</div>
<div className="board-search">
<Search size={17} />
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索用户ID或任务ID…"
/>
</div>
<div className="board-tools">
<label>
<span>开始日期</span>
<input type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} />
</label>
<label>
<span>结束日期</span>
<input type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} />
</label>
<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})` : ""}`}
</button>
</div>
{error && <div className="form-error">{error}</div>}
<section
className="board-table panel"
key={`${type}|${status}|${keyword.trim().toLowerCase()}|${dateFrom}|${dateTo}`}
>
<div className="board-table-head">
<span className="board-select-cell">
<input type="checkbox" checked={allVisibleSelected} onChange={toggleAllVisible} aria-label="全选当前任务" />
</span>
<span>用户ID</span>
<span>任务ID</span>
<span>商品信息</span>
<span>类型</span>
<span>Q&A数</span>
<span>待审/完成</span>
<span>状态</span>
<span>创建人</span>
<span>操作</span>
</div>
{loading ? (
<div className="board-empty">正在加载任务…</div>
) : rows.length === 0 ? (
<div className="board-empty">
暂无符合条件的任务
</div>
) : (
rows.map((task) => (
<div className="board-table-row" key={rowKey(task)}>
<span className="board-select-cell">
<input type="checkbox" checked={selectedIds.includes(rowKey(task))} onChange={() => toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} />
</span>
<span>
<code>{task.user_id}</code>
</span>
<span>
<b>{task.task_id}</b>
</span>
<span>
<strong>{task.product_name}</strong>
<small>{maskPhone(task.phone)}</small>
</span>
<span>
<em className={`board-type ${task.task_type}`}>
{task.task_type_label}
</em>
</span>
<span>{task.qa_count}</span>
<span>
{task.pending_review_count} / {task.completed_count}
</span>
<span>
<em className={`board-status ${task.status_label}`}>
{task.status_label}
</em>
</span>
<span>
{task.creator_name}
<small>{formatTime(task.created_at)}</small>
</span>
<span className="board-actions">
<button onClick={() => openDetail(task)}>详情</button>
{task.can_cancel && (
<button
className="cancel"
disabled={busy === task.id}
onClick={() => cancel(task)}
>
{busy === task.id ? "撤销中" : "撤销"}
</button>
)}
{task.can_complete && task.task_type === "ask" && (
<button
className="complete"
disabled={busy === task.id}
onClick={() => setCompleteConfirm(task)}
>
完成任务
</button>
)}
<button
className="delete"
onClick={() => setBatchConfirm({ tasks: [task] })}
>
删除
</button>
</span>
</div>
))
)}
</section>
{detail && (
<TaskDetail
task={detail}
close={() => setDetail(null)}
onConfirmRefund={confirmRefund}
/>
)}
{batchConfirm && (
<div
className="board-confirm-backdrop"
onMouseDown={(event) => event.target === event.currentTarget && setBatchConfirm(null)}
>
<section className="board-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="batch-clear-title">
<div className="board-confirm-icon">!</div>
<h2 id="batch-clear-title">确认是否删除?</h2>
<p>
确定删除已选的 {batchConfirm.tasks.length} 条任务吗?删除后将从任务看板和系统记录中永久移除,无法恢复。
</p>
<div className="board-confirm-actions">
<button type="button" onClick={() => setBatchConfirm(null)} disabled={batchBusy}>取消</button>
<button type="button" className="danger" onClick={confirmBatchClear} disabled={batchBusy}>
{batchBusy ? "删除中…" : "确认删除"}
</button>
</div>
</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>
)}
</>
);
}

4428
frontend/src/admin.css Normal file

File diff suppressed because one or more lines are too long

3886
frontend/src/main.jsx Normal file

File diff suppressed because it is too large Load Diff

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

632
frontend/src/styles.css Normal file
View File

@ -0,0 +1,632 @@
:root {
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #12213d;
background: #eef3f9;
font-size: 14px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
button,
input,
textarea,
select {
font: inherit;
}
button {
cursor: pointer;
border: 0;
}
.loading-screen {
height: 100vh;
display: grid;
place-items: center;
color: #4c6385;
}
.login-shell {
height: 100vh;
min-width: 900px;
display: grid;
place-items: center;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #eef4ff 0%, #f8fbff 55%, #edf5ff 100%);
}
.login-decoration {
position: absolute;
inset: 0;
overflow: hidden;
}
.orb {
position: absolute;
border-radius: 50%;
filter: blur(2px);
opacity: 0.55;
}
.orb-a {
width: 420px;
height: 420px;
background: #c9dcff;
top: -150px;
right: -90px;
}
.orb-b {
width: 360px;
height: 360px;
background: #dff4ff;
bottom: -150px;
left: -100px;
}
.grid-lines {
position: absolute;
inset: 0;
opacity: 0.35;
background-image:
linear-gradient(#c8d7ec 1px, transparent 1px),
linear-gradient(90deg, #c8d7ec 1px, transparent 1px);
background-size: 50px 50px;
mask-image: linear-gradient(
to bottom,
transparent,
#000 25%,
#000 75%,
transparent
);
}
.login-card {
position: relative;
width: 410px;
padding: 42px 44px 35px;
border: 1px solid #e2eaf5;
border-radius: 20px;
background: rgba(255, 255, 255, 0.93);
box-shadow: 0 20px 60px rgba(45, 89, 155, 0.15);
}
.brand-mark {
width: 48px;
height: 48px;
border-radius: 14px;
display: grid;
place-items: center;
color: white;
background: #2864e8;
box-shadow: 0 7px 16px rgba(40, 100, 232, 0.25);
}
.brand-title {
position: absolute;
top: 51px;
left: 105px;
color: #0e2445;
font-size: 19px;
font-weight: 800;
}
.login-card h1 {
margin: 38px 0 7px;
font-size: 28px;
letter-spacing: 0.5px;
}
.login-subtitle {
margin: 0 0 30px;
color: #7890ad;
}
.login-form {
display: grid;
gap: 18px;
}
.login-form label,
.config-card label {
display: grid;
gap: 8px;
color: #435d80;
font-size: 13px;
}
.login-form input,
.config-card input,
.config-card textarea,
.config-card select {
border: 1px solid #ccd9ea;
border-radius: 8px;
padding: 12px 13px;
color: #173258;
outline: none;
background: #fff;
}
.login-form input:focus,
.config-card input:focus,
.config-card textarea:focus,
.config-card select:focus {
border-color: #3b73ed;
box-shadow: 0 0 0 3px rgba(59, 115, 237, 0.1);
}
.form-error {
padding: 10px 12px;
border-radius: 7px;
color: #c53f54;
background: #fff0f2;
}
.primary-button,
.success-button,
.ghost-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border-radius: 8px;
padding: 10px 14px;
font-weight: 600;
}
.primary-button {
color: #fff;
background: #2864e8;
box-shadow: 0 5px 12px rgba(40, 100, 232, 0.2);
}
.primary-button:disabled {
opacity: 0.65;
cursor: wait;
}
.login-button {
width: 100%;
padding: 13px;
margin-top: 4px;
font-size: 15px;
}
.dingtalk-login-button {
width: 100%;
margin-top: 10px;
padding: 11px;
border: 1px solid #2f80ed;
border-radius: 8px;
background: #eaf4ff;
color: #1764c0;
font-weight: 700;
}
.dingtalk-login-button:disabled {
opacity: 0.65;
cursor: wait;
}
.login-divider {
display: flex;
align-items: center;
gap: 10px;
margin-top: 14px;
color: #9aabc1;
font-size: 11px;
}
.login-divider::before,
.login-divider::after {
content: "";
height: 1px;
flex: 1;
background: #e0e8f2;
}
.reference-qa-upload {
display: grid;
gap: 7px;
margin-top: 12px;
color: #466181;
font-size: 12px;
}
.reference-qa-upload input {
padding: 10px;
border: 1px dashed #9db8d8;
border-radius: 8px;
background: #f8fbff;
}
.reference-qa-upload small {
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;
font-size: 12px;
text-align: center;
}
.app-shell {
display: flex;
height: 100vh;
overflow: hidden;
background: #eef3f9;
}
.sidebar {
width: 252px;
height: 100vh;
flex: 0 0 252px;
overflow: hidden;
background: #0d172c;
color: #d6e1f4;
display: flex;
flex-direction: column;
}
.side-brand {
height: 106px;
padding: 22px 20px;
display: flex;
gap: 11px;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.brand-icon {
width: 37px;
height: 37px;
border-radius: 10px;
background: #2864e8;
color: #fff;
display: grid;
place-items: center;
}
.side-brand strong {
display: block;
color: #fff;
font-size: 16px;
}
.side-brand span {
display: block;
margin-top: 5px;
color: #8ea4c4;
font-size: 11px;
}
.nav-group {
padding: 15px 12px;
}
.nav-title {
padding: 4px 12px 10px;
color: #8295b5;
font-size: 12px;
}
.task-title {
margin-top: 19px;
}
.nav-item {
width: 100%;
height: 40px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 14px;
margin: 3px 0;
border-radius: 8px;
color: #c5d2e8;
background: transparent;
text-align: left;
}
.nav-item:hover,
.nav-item.active {
color: #fff;
background: #2864e8;
}
.nav-item em {
font-style: normal;
font-size: 11px;
margin-left: auto;
opacity: 0.85;
}
.nav-item b {
margin-left: auto;
min-width: 22px;
padding: 2px 6px;
border-radius: 10px;
color: #fff;
background: #a62b58;
text-align: center;
font-size: 11px;
}
.system-status {
margin-top: auto;
min-height: 58px;
box-sizing: border-box;
padding: 14px 13px 17px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #7690b6;
font-size: 11px;
line-height: 1;
white-space: nowrap;
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
flex: 0 0 8px;
order: 2;
border-radius: 50%;
background: #16b88b;
}
.main-shell {
min-width: 0;
height: 100vh;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.topbar {
height: 74px;
flex: 0 0 74px;
background: #fff;
border-bottom: 1px solid #dfe7f2;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
}
.top-actions {
display: flex;
align-items: center;
gap: 18px;
}
.bell {
color: #405a7b;
}
.divider {
width: 1px;
height: 28px;
background: #e0e8f2;
}
.user-menu {
display: flex;
align-items: center;
gap: 9px;
color: #405979;
}
.user-menu strong,
.user-menu span {
display: block;
}
.user-menu strong {
font-size: 13px;
color: #1e3558;
}
.user-menu span {
margin-top: 3px;
font-size: 11px;
color: #8aa0bc;
}
.avatar {
width: 38px;
height: 38px;
border-radius: 50%;
display: grid;
place-items: center;
color: #fff;
background: linear-gradient(145deg, #5b82dc, #9d72d6);
border: 2px solid #d9e5fb;
}
.logout-btn {
padding: 6px;
color: #6b82a0;
background: transparent;
}
.dashboard {
min-height: 0;
flex: 1;
overflow-y: auto;
padding: 25px 24px 40px;
}
.page-heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin: 0 0 24px;
}
.page-heading h2 {
margin: 0 0 6px;
font-size: 22px;
color: #10284c;
}
.page-heading p {
margin: 0;
color: #68809f;
}
.heading-actions {
display: flex;
gap: 10px;
}
.ghost-button {
color: #33557e;
background: #f3f7fc;
border: 1px solid #dbe5f1;
}
.success-button {
color: #009a7a;
background: #e8fbf5;
border: 1px solid #a8e8d4;
}
.dashboard-grid {
display: grid;
grid-template-columns: minmax(460px, 1fr) minmax(520px, 1.08fr);
gap: 24px;
align-items: start;
}
.panel,
.qa-card,
.result-toolbar {
background: #fff;
border: 1px solid #e1e9f3;
border-radius: 12px;
box-shadow: 0 2px 5px rgba(34, 65, 105, 0.04);
}
.config-card {
padding: 20px;
}
.panel-title {
display: flex;
align-items: center;
gap: 7px;
font-size: 16px;
font-weight: 700;
color: #203d68;
}
.panel-title svg {
color: #2864e8;
}
.panel-title small {
font-size: 12px;
font-weight: 400;
color: #f05d73;
}
.rule {
height: 1px;
background: #edf1f6;
margin: 16px 0 20px;
}
.config-card h4 {
margin: 0 0 13px;
color: #304b70;
font-size: 13px;
}
.config-card textarea {
min-height: 60px;
resize: vertical;
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin: 12px 0;
}
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 18px 0 16px;
color: #385477;
}
.switch {
width: 44px;
height: 25px;
padding: 3px;
border-radius: 20px;
background: #b9c6d8;
}
.switch.on {
background: #2864e8;
}
.switch i {
display: block;
width: 19px;
height: 19px;
border-radius: 50%;
background: #fff;
transition: 0.2s;
}
.switch.on i {
transform: translateX(19px);
}
.generate-btn {
width: 100%;
padding: 13px;
font-size: 15px;
}
.results-area {
display: grid;
gap: 14px;
}
.result-toolbar {
padding: 13px 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
.result-toolbar > div {
display: flex;
gap: 7px;
}
.result-toolbar .primary-button,
.result-toolbar .ghost-button,
.result-toolbar .success-button {
padding: 8px 10px;
font-size: 12px;
}
.check-label {
display: flex;
align-items: center;
gap: 8px;
color: #355274;
font-size: 13px;
}
.qa-card {
padding: 16px;
}
.qa-meta {
display: flex;
align-items: center;
gap: 9px;
color: #7890ad;
font-size: 12px;
}
.qa-meta code {
padding: 4px 7px;
border-radius: 3px;
background: #f4f7fb;
color: #466486;
}
.qa-meta strong {
padding: 3px 7px;
border-radius: 4px;
color: #009b7a;
background: #dff9f0;
font-size: 11px;
font-weight: 500;
}
.qa-actions {
margin-left: auto;
color: #466486;
}
.qa-actions span {
color: #00a07c;
}
.qa-card h3 {
margin: 15px 0 10px;
color: #1459e4;
font-size: 14px;
}
.qa-card p {
margin: 7px 0;
padding: 10px;
background: #f7f9fc;
color: #405a7d;
font-size: 12px;
}
.qa-card p b {
color: #1459e4;
}
@media (max-width: 1100px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.sidebar {
width: 220px;
flex-basis: 220px;
}
.status-dot {
margin-left: 0;
}
}

12
frontend/vite.config.js Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': { target: 'http://127.0.0.1:8000', changeOrigin: true },
},
},
})

View File

@ -0,0 +1,599 @@
# 【问大家】问答与任务管理系统PRD
范围:智能问答+ 任务管理系统版本:v0.2| 
日期:2026-08-21状态:待评审
# 1. 项目背景与概述
## 1.1 项目定位
本系统由两个相互衔接的模块组成,覆盖运营在【问大家】的完整工作流:
* 模块一 · 智能问答库:用 AI 批量生成"问题"和"回答",并由运营采纳入库形成可复用知识资产。含两个入口——提问助手、回答助手。
* 模块二 · 任务管理系统:运营把采纳的问答建任务、发链接;提问用户(用户A)与回答用户(用户B,两拨不同的人)各自认领并完成;上传截图与收款码;运营审核。
**【重要边界】**本期"返款自动打款"(微信/支付宝退款 API 自动调用),本期任务闭环构建至"运营审核通过 → 生成待返款记录"为止,实际打款由后续返款模块实现。
## 1.2 业务背景与痛点
**运营在【问大家】区域的全链路耗时:生成问题/回答 + 挑选问题/回答 + 用户认领问题/回答 + 审核 + 返款。:**
* 提问生成效率低:运营需手动向豆包等通用 AI 提问,入口分散、质量不一、来回切换。
* 任务分配繁琐:手动拉群、人工校对用户账号与截图,无法快速验证真实性,逐一联系效率低、无法监控任务状态、无法溯源。
* 截图审核易错:多人多图易漏审/误审
_(如两人各答 30+ 问题批量发图;要求答 25 个、用户只答 23 个)。_
* 返款易错:人工核对任务 ID + 截图,效率低、记录难追溯,存在漏返、重复返款风险。
**一句话:运营 80% 精力耗在"生成 + 挑选 + 分配 + 审核 + 返款"的机械劳动上,而非"判断什么内容真正有用"。**
## 1.3 项目目标
本项目打造统一问答生成与任务管理平台,整合提、问、分配、审核,内容合规生活化并自动入库查重;任务实时通知、全程可溯,提交后系统自动审核、人工兜底,同时沉淀数据,为后续自动返款与智能化运营奠定基础。
## 1.4 非目标(Non-goals,本期不做)
| **不做的事** | **原因 / 后续安排** |
| --- | --- |
| 返款自动打款(微信/支付宝退款 API) | 本期仅构建至"审核通过→待返款"记录,实际打款由后续模块实现。 |
| 不替代运营在审核与返款中的最终决策责任 | 自动审核后保留人工复核兜底。 |
| 不覆盖淘宝以外渠道(京东/抖音等) | 先聚焦淘宝【问大家】单一场景验证价值。 |
| 不接入 C 端用户真实提问流 | 生成“样例/种子”问答供运营筛选,不实时接入买家真实提问。 |
| 不做多模态(图文混合回答) | 先交付纯文本,降低技术风险 |
## 1.5 关键假设与依赖
* 依赖商品资料库(名称/品类/功效/卖点/成分字段)可读。
* 依赖历史评价 + 竞品评价数据接口(用于卖点提炼,P1)。
* 依赖大模型 API + 内容安全 API,需提前申请配额。
* 依赖 OCR 识别能力(回答模块识图提取产品图与问题文字)。
* 依赖用户微信登录及任务 ID 绑定能力(移动端链接场景)。
* 返款退款 API 为后续依赖,本期不接入(接口预留)。
* 假设运营具备基础商品知识,能判断生成内容是否合适。
# 2. 竞品分析
**公司内部特殊使用工具较难寻找到可参考的竞品,但可根据已有的工具基础上沿用思路改善****:**
## 2.1 第一类:通用 AI 对话工具
* 代表:豆包、通义千问、ChatGPT、DeepSeek 等。
* 优势:自然语言理解强、可自由写 Prompt、生成灵活、覆盖多场景。
* 劣势:运营要自己写 Prompt;商品信息需人工整理;无公司商品知识默认上下文;结果不一定符合【问大家】规范;每次都要重述需求。
**本产品机会:**把"运营写 Prompt"变成"运营填业务字段"。例:通用 AI 要写"帮我针对这款身体乳生成 10 个自然问题…";++本工具只需填 品类=身体乳/功效=保湿滋润/人群=熟龄/风格=口语,系统自动完成 Prompt 构建与知识检索。++
**2.****2** **第****二****类:公司内部 AI 内容工具 / 任务众包**
* 传统内部工具通常是:输入 Prompt → AI 生成 → 人工复制;任务则靠微信群手工拉群+人工校对。
* 本产品升级为:商品信息输入 → AI 分析 → 生成问答 → 人工筛选采纳入库 → 建任务发链接 → 用户认领/完成/上传 → 系统审核。
**核心竞争力:不是"模型本身更强",而是 业务知识 + 商品知识 + 用户关注点 + 场景化 Prompt + 内容规则 + 任务闭环 的组合。**
# 3. 用户画像
## 3.1 主要用户:运营人员(生成 + 初审 + 分配)
* 身份:个护化妆品淘宝店铺运营 / 内容运营。
* 目标:快速产出大量、合规、像真人的问答;高效把任务分配给用户并审核完成。
* 痛点:手动切 AI、复制粘贴繁、怕踩违规词;拉群分配繁琐、审核易漏。
* 诉求:少填点、多出点、一键复制;任务建好发链接即可,状态看得见。
## 3.2 次要用户:审核岗 / 运营主管 / 财务
* 主管:复核系统审核结果、管理生成规则与任务分配;关注效率与合规。
* 财务/对账:核对返款记录、处理异常告警(返款模块上线后);关注资金准确与可追溯。
## 3.3 任务承接用户
* 用户A(提问):被邀在【问大家】发布指定问题的人群
* 用户B(回答):已购买公司品牌商品、被邀回答的人群
**诉求:进链接就能看到自己能做的任务、规则清楚、提交便捷、状态有反馈。**
## 3.4 不服务的用户
* 财务部
## 3.5 用户故事
| **编号** | **用户故事** |
| --- | --- |
| US-1 | 作为运营,我希望填入商品字段和风格标签就能一键生成多个问题,以便省去手动找 AI 和复制的麻烦。 |
| US-2 | 作为运营,我希望结果同页展示、支持单条/多选并带分页。 |
| US-3 | 作为运营,我也自己会刷一些竞品问答,我也有自己的知识沉淀敏感度,我希望生成后可编辑/补充优化系统给我生成的东西,且系统提示库内相似提问时能够提示我做出更好的决策。 |
| US-4 | 作为用户A,我希望进链接就能看到可认领的问题和领取规则(一天最多5个),选完即绑定我的ID,以便清楚知道自己领了什么。 |
| US-5 | 作为用户B,我希望上传被邀回答页截图后系统能识别出产品图和问题,并显示可回答的问题与参考回答,以便复制去完成。 |
| US-6 | 作为用户A/B,我希望提交截图+收款码后能看到"审核中/已通过/未通过"状态,以便知道进度。 |
| US-7 | 作为审核,我希望管理界面直接显示某用户任务ID下被分配的提问与回答,便于和截图对照,以便不漏审不错审。 |
**4.** **优化后流程**![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/jP2lRY2BZ3RDZO8g/img/6ffc899c-8dc7-4acb-9a02-cb71336175cd.png)
# 产品方案设计
## 4.1 系统架构总览(三大页面)
**按用户需求,系统由三大部分构成;运营侧为后台,用户侧为两个独立任务链接(人群不同,入口分离):**
* ① 运营管理系统(Web 后台):问答库查看 + 任务管理 + 任务分配 + 审核。运营在这里生成问答、采纳入库、建任务、发链接、审截图。
* ② 提问用户任务链接(用户A):微信登录 → 绑定任务ID → 认领问题(≤5h)→ 完成提问后上传截图①+收款码。
* ③ 回答用户任务链接(用户B):微信登录 → 上传截图②(被邀回答页)→ 系统 OCR 识别 → 运营选可回答问题并匹配回答(相似≥60%)→ 用户复制回答完成 → 上传截图③+收款码。
**【页面关系】**运营管理系统的"任务管理/分配"负责创建任务并生成两个链接(提问链接、回答链接);用户侧链接提交的数据回流到运营管理系统的"审核"。
## 4.2 用户角色与旅程图
**运营人员旅程:**
| **阶段** | **运营行为** | **当前痛点** |
| --- | --- | --- |
| 提问准备 | 随机挑提问用户A、建A群 | 用户来源分散,依赖人工筛选 |
| 任务分发 | 筛选问题、去重、指定用户发送、发话术 | 挑选琐碎耗时、用户可能领到重复问题 |
| 提问审核 | 审核截图①(提问页) | 多人多图易漏审/错审 |
| 返款 | 人工返款(微信/Excel) | 记录靠人工登记(本期待定) |
| 回答准备 | 筛选已购用户B、建B群 | — |
| 回答分发 | 审核截图②是否作答(≤3答/问,靠感觉) | 纯靠人工判断与记忆 |
| 回答审核 | 审核截图③数量与准确性 | 批量图易漏审(如应25实23) |
| 返款 | 人工返款 | (本期待定) |
**用户A(提问)旅程:**
| **阶段** | **用户行为** | **备注** |
| --- | --- | --- |
| 收到任务 | 进入微信群 | — |
| 确认任务 | 认领提问话术、打开商品链接 | 单任务一人一天最多5条;一淘宝号一问,可多号多问,可一号隔天问 |
| 完成任务 | 发布指定问题 | — |
| 提交截图 | 上传截图① | 截图①:个人淘宝"我的提问"页截图 |
| 收到返款 | 确认到账 | (本期待定) |
**用户B(回答)旅程:**
| **阶段** | **用户行为** | **备注** |
| --- | --- | --- |
| 收到任务 | 进入微信群 | 需确认是否购买公司品牌商品 |
| 确定任务 | 查看【问大家】被邀请回答页并截图② | 截图②:被邀评价回答页截图 |
| 被分配任务 | 等运营确认要回答的问题和答案 | 单任务一人最多20–30条;一淘宝号可多答 |
| 完成任务 | 发布回答后回群上传截图③ | 可能漏答,运营会提醒确认 |
| 提交截图 | 上传截图③ | 截图③:实际做出回答的页截图 |
| 收到返款 | 确认到账 | (本期待定) |
## 4.3 提问任务闭环业务流程(用户A)
**运营侧 → 用户侧 → 回运营审核****:**
![图片 1](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/J9LnW65Vr8ry8lvD/img/32a4e0c1-a310-4c55-a5d4-e41112080c25.png)
**①****创建任务ID:**运营输入品名 + 条件设置(含商品链接),每个任务ID下挂问题/回答编号(运营用 AI生成 / 生成后编辑 / 自己补录 任一方式采纳问答入库)+用户id
_\-__系统显示库内采用过的相似提问,询问是否继续添加相似提问。_
例:任务ID:TASK20260822001
├── Q001 → 用户A
      └── 用户A完成提问 → 截图 → 审核 → 待返款
├── Q002 → 用户B
      └── 用户B完成提问 → 截图 → 审核 → 待返款
├── A001-1 → 用户C
       └── 用户C完成回答 → 截图 → 审核 → 待返款
├── A001-2 → 用户D
       └── 用户D完成回答 → 截图 → 审核 → 待返款
**② 发布任务:**运营把提问链接发到 A 群 → 用户进链接选择问题;被选中的问题其任务ID 绑定用户ID(用户领取任务);单日最多认领 5 个问题;链接页明示领取规则。
**③ 完成与提交:**用户在淘宝完成提问 → 返回链接上传截图①+收款码 →(本期)运营审核 → 审核通过生成"待返款"记录 → 完成任务数据留存。
## 4.4 回答任务闭环业务流程(用户B)
**用户侧上传 → 运营选答匹配 → 用户侧完成:**
![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/jP2lRY2BZ3RDZO8g/img/14c974fb-83f2-4a5d-b72b-b27740d13be2.png)
**①创建任务ID:**运营输入品名 + 条件设置(含商品链接),每个任务ID下挂问题/回答编号运营用 AI生成 / 生成后编辑 / 自己补录 任一方式采纳问答入库)+用户id;
_\-__系统显示库内采用过的__相似提问采用过该回答__,询问是否继续添加__相同字段回答__。_
例:任务ID:TASK20260822002
├── Q001 → 用户A
      └── 用户A完成提问 → 截图 → 审核 → 待返款
├── Q002 → 用户B
      └── 用户B完成提问 → 截图 → 审核 → 待返款
├── A001-1 → 用户C
       └── 用户C完成回答 → 截图 → 审核 → 待返款
├── A001-2 → 用户D
       └── 用户D完成回答 → 截图 → 审核 → 待返款
**② 上传邀答截图:**用户通过运营发的链接上传"被邀回答页"截图② → 系统 OCR 识别提取产品图与对应问题文字,形成选项。
**③运营选答匹配:**运营在后台选择可回答的问题,系统自动匹配库内对应回答(问题与回答相似性达 60% 即给出对应回答);运营发布任务。
**③ 用户完成提交:**用户通过链接看到可回答的问题与回答,复制回答去完成 → 回来上传截图③+收款码(此时运营管理界面显示该用户任务ID下被分配的提问与回答,便于与截图对照审核)→运营审核 → 审核通过生成"待返款"记录 → 完成任务数据留存。
## 4.5 智能问答助手 模块方案
### 4.5.1 核心生成流程
运营输入/选择个性化商品信息 → AI将卖点/功效提取 → 竞品评价抓取 → AI 结合多方数据输出多条答案→ 生成针对性回答 → 内容规则校验 → 结果展示 → 采纳/重新生成/编辑/添加→发布任务
**【设计要点】"****运营经验沉淀** **+ 竞品评价"是差异化关键——让问题来自真实买家关心的事,而非 AI 凭空编。若评价数据暂未就绪,MVP 可先用商品资料+卖点,评价联动作为 P1 增强。**
### 4.5.2 内容生成策略与约束
| **要求** | **规则(提问)** | **规则(回答)** |
| --- | --- | --- |
| 字数 | 单条 ≤20 字,超长自动压缩 | 单条 ≤50 字,超长自动压缩 |
| 语言 | 自然语言、真实消费者提问习惯 | 直接回答、自然、接近真实用户表达 |
| 关联 | 与商品明确关联、有购买决策价值 | 直接回应问题、不机械复述详情页 |
| 多元化 | 同商品多批不雷同 | 同问题多条角度不同 |
* 下次问答生成时优先级:竞品数据>运营采纳过的问答数据>AI自行生成
### 4.5.3 个性化输入字段体系
**提问****要求**
* 商品信息(以下至少填一项即可生成):商品名称、商品品类、商品功效、商品卖点。
* 语言风格(选填):单选(幽默 / 口语 / 专业),默认"口语"。只影响表达方式,不改变事实。
* 目标人群(选填):多选(女性 / 男性 / 学生 / 熟龄人群)。
* 使用部位(选填):多选(脸部 / 身体 / 头发/ 手部 / 唇部 / 头皮 / 足部)。
* 使用场景(选填):多选,后续可根据运营需求配(居家 / 旅行 / 通勤 / 运动 / 换季)。
* 购买渠道(选填):多选、(大型商超 / 药房 / 美妆店 / 电商平台),生成时作为场景后缀融入回答。
* 生成条数(选填):可输入,默认条数5条。
**回答要求**
* 回答要求:选填,告诉 AI 回答必须包含的信息(如"包含吸收速度和使用感"),为空则 AI 自动判断重点。
* 种草式回答(选填,默认勾选)
* 购买渠道(选填):多选、(大型商超 / 药房 / 美妆店 / 电商平台),生成时作为场景后缀融入回答。
* 种草式回答:开关,默认关闭;开启后在真实自然前提下增强体验表达与购买吸引力。
* 每个问题默认生成10 条回答;
**结果展示:**
* 结果按"提问"分组展示。
### 4.5.5 技术实现
* 生成引擎:大语言模型 API;卖点提炼阶段用 RAG 检索商品资料与RPA竞品评价回答数据结合后生成。
* 问答入库:采纳的问答写入问答库,建立"商品→关注点→问题→回答"结构;新生成时对库内相似提问做重复提示。
* 可观测:每次生成记录入参、出参、耗时、命中规则,支撑指标看板。
* 竞品内容提取:通过 RPA + OCR 抓取竞品/同类商品的【问大家】内容作为生成素材;生成结果打"来源标签"(AI生成 / 竞品提取)区分,便于运营挑选入库。
## 4.6 任务创建 模块方案
**将运营下发的提问/回答任务,按 商品URL + 任务ID 分配给指定承接用户,并提供任务状态跟踪。**
* 按商品品名(ID)+ 任务ID,由运营分配给指定用户群(A 群 / B 群)。
* 用户群接收入口后认领任务,提交状态实时同步到任务状态看板(待发布 / 进行中 / 已完成 / 待返款)。
* 单商品URL映射下展示:任务ID 列表 + 该问答的引用次数(防重复分配)。
* 支持任务消息通知,替代手动逐一联系,提升分配与触达效率。
**【认领上限】**提问侧:单任务一人一天最多认领 5 个问题;回答侧:单任务一人最多 20–30 条。规则需在用户链接页明示。
## 4.7 截图与任务提交 模块方案
**为用户提供统一提交入口,在任务ID下提交任务截图与收款码截图。**
* PC 端(运营/审核)+ 移动端链接(用户)双入口;移动端支持多图上传与格式验证。
* 支持任务ID输入验证与提交记录查询。
* 提交状态通知:审核中 / 已通过 / 未通过。
* 回答模块:① OCR 识图提取截图文字,整合成文字筛选模块;② 截图提问与问答库做次数比对,回答次数>3 时提醒干预(一条提问下最多约 3 个回答)。
* 支持用户微信登录并绑定任务ID。
**【截图定义】**截图①=个人淘宝"我的提问"页(用户A提交);截图②=被邀回答页(用户B首次上传,供 OCR);截图③=实际做出回答的页(用户B完成提交)。
## 4.8 审核中心 模块方案
**4.8.2 类型一 · 回答筛选审核(回答发布前内容筛选)**
* 触发:回答用户B 上传截图②(被邀回答页)后,系统 OCR 提取产品图与对应问题文字,生成候选问题清单,进入"待筛选"状态。
* 运营在"待筛选"列表中勾选"可回答的提问",并对每个问题确认/调整"对应的答案"——系统按相似度≥60%自动匹配问答库内已有回答,运营可采纳、替换或补充。
* 目的:在任务正式发布给回答用户前,先完成"哪些问题值得回答 + 答案是否可用"的质量把关。
* 通过处置:确认后的"问题—答案"对发布为可回答任务,进入回答用户链接供用户认领完成。_关_
**4.8.1 类型二 ·** **任务审核****(任务完成后结算审核)**
* 触发:提问用户A / 回答用户B 完成任务后,上传截图①/③ + 收款码,提交进入审核队列:提问审核和回答审核。
* 自动校验任务ID有效性(存在、未重复认领、与用户绑定一致)。
* 对照审核:管理界面显示"该用户任务ID下被分配的提问与回答",与上传截图①/③(及OCR出的问题)左右对照,核验数量、相关性、合规性。
* 审核截图合规性:包含商品信息、与任务相关、无违规内容。
* 人工复核处理系统无法判断的复杂情况;审核结果通知用户(审核中/已通过/未通过)。
* 审核通过后生成"已完结"记录(本期不打款,供后续返款模块消费)。_务结算__。_
## 4.9 返款模块说明(本期待定)
**【本期不做】**以下为理想流程与接口预留,供后续立项参考。
* 理想流程:审核通过后自动触发返款,支持返款记录查询导出及异常告警。
* 技术要求(规划):微信/支付宝退款 API 自动调用;异常处理与日志记录;财务对账。
* 本期替代:审核通过即生成"待返款"状态 + 留存收款码,运营可据此手工返款或由后续模块接入。
* 资金风险缓解(规划):保证人工二次校验、异常告警,避免纯依赖 AI 识别自动返款造成资损。
# 5. 功能需求说明
**编号:FR-Q=提问助手,FR-A=回答助手,FR-T=任务管理,FR-S=截图提交,FR-R=审核,FR-C=公共。优先级 P0 必做,P1 增强。**
## 5.1 智能Q&A助手
| **编号** | **功能** | **说明** | **优先级** |
| --- | --- | --- | --- |
| FR-Q1 | 商品字段输入 | 商品名称/品类/功效/卖点,至少填一项方可生成 | P0 |
| FR-QA2 | 语言风格(选填) | 单选:幽默/口语/专业,默认口语 | P0 |
| FR-Q3 | 目标人群(选填) | 多选:女性/男性/学生/熟龄人群 | P0 |
| FR-QA4 | 生成条数 | 默认提问5条,回答10条 | P0 |
| FR-Q5 | 一键生成 | 点击"生成Q&A"同页展示结果 | P0 |
| FR-QA6 | 单条勾选 | 单条勾选后采纳,如果未勾选弹窗空白提示 | P0 |
| FR-QA7 | 多选勾选 | 勾选多条 → 批量采纳 | P0 |
| FR-A8 | 种草式回答 | 开关,默认开启 | P0 |
| FR-A9 | 回答要求(选填) | 指定回答需包含的要点 | P0 |
| FR-QA10 | 全选 | 结果区"全选"勾选当前页 | P0 |
| FR-QA9 | 重新生成 | 保留输入条件,换一批、尽量不重复 | P0 |
| FR-QA10 | 分页 | 结果过多时按页展示,每条问答结果内容超5条回答加滚动条显示 | P0 |
| FR-Q11 | 字数校验 | 超 50 字自动压缩 | P0 |
| FR-QA12 | 采纳入库+相似提示 | 采纳进问答库;新生成提示库内相似提问 | P0 |
| FR-QA13a | 使用部位 | 多选:脸部/身体/头发(可扩展手部/唇部/头皮/足部) | P1 |
| FR-QA13b | 使用场景(选填) | 多选:居家/旅行/通勤/运动/换季 | P1 |
| FR-Q14 | 卖点提炼(评价联动) | 接入历史/竞品评价数据做出提取产出 | P1 |
| FR-Q14 | 竞品内容提取+来源tag | RPA+OCR 抓取竞品问大家内容,结果打"AI生成/竞品提取"标签 | P1 |
## 5.2 任务管理 · 创建发布
| **编号** | **功能** | **说明** | **优先级** |
| --- | --- | --- | --- |
| FR-T1 | 发布任务链接 | 两处入口:问答库问题与回答生成时可发布提问任务,审核中心发布回答任务链接<br>输入品名+条件+商品链接,生成任务ID,下挂问题/回答编号 | P0 |
| FR-T2 | 采纳问答入库 | AI生成/编辑/补录任一方式采纳;显示库内相似提问,询问是否继续添加 | P0 |
| FR-T3 | 生成任务链接 | 为 A 群/B 群分别生成提问链接/回答链接 | P0 |
| FR-T4 | 用户认领(提问) | 进链接选问题→任务ID绑定用户ID;单日≤5 问题;页示领取规则 | P0 |
| FR-T5 | 引用次数展示 | 单商品URL下展示任务ID与问答引用次数 | P0 |
| FR-T6 | 任务状态看板 | 待发布/进行中/已完成/待返款/已撤销 | P0 |
| FR-T7 | 创建任务 | 可自由选择创建任务类型与提问与回答的任务链接 | P1 |
| FR-T8 | 回答匹配(≥60%) | 运营选可回答问题,系统匹配库内回答(相似≥60%) | P0 |
## 5.3 任务管理 · 审核中心
_类型1:任务审核(看用户完成任务的截图和收款码)_
_类型2:回答筛选审核(看完成回答任务的用户要回答哪些问题)_
| **编号** | **类型** | **功能** | **说明** | **优先级** |
| --- | --- | --- | --- | --- |
| FR-R1 | 1 | 任务ID自动校验 | 有效性/未重复认领/与用户绑定一致 | P0 |
| FR-R2 | 1 | 截图审核 | 含商品信息、与任务相关、无违规 | P0 |
| FR-R4 | 1 | 对照审核视图 | 管理界面显示该用户任务ID下分配的提问或回答 | P0 |
| FR-R5 | 1 | 审核结果通知 | 通过/未通过通知用户 | P0 |
| FR-R6 | 1 | 待返款记录 | 审核通过生成"待返款"记录(本期不打款) | P0 |
| FR-R7 | 2 | OCR识图提取 | 用户上传截图后,对截图中的提问文字做提取和勾选选项 | P1 |
| FR-R7 | 2 | 用户B的截图问题采纳审核 | 勾选是否做该条提问任务,并给出匹配回答 | P0 |
| FR-R8 | 2 | 筛选发布 | 确认后"问题—答案"对发布为可回答任务 | P0 |
| FR-S9 | 2 | 回答次数>3提醒 | 截图提问与问答库比对,>3 提醒干预 | P1 |
## 5.4 任务管理 · 截图提交(用户链接)
| **编号** | **功能** | **说明** | **优先级** |
| --- | --- | --- | --- |
| FR-S1 | 微信登录绑定 | 用户微信登录并绑定任务ID | P0 |
| FR-S2 | 提问链接提交 | 用户A上传截图①+收款码 | P0 |
| FR-S3 | 回答链接-上传截图② | 用户B上传被邀回答页,OCR提取产品图+问题文字 | P0 |
| FR-S4 | 回答链接-查看与完成 | 用户B看到可回答问题与参考回答,复制完成,上传截图③+收款码 | P0 |
| FR-S5 | 多图上传+格式验证 | PC/移动端,格式与大小校验 | P0 |
| FR-S6 | 任务ID验证+查询 | 输入验证、提交记录查询 | P0 |
| FR-S7 | 提交状态通知 | 审核中/已通过/未通过 | P0 |
## 5.5 其他公共能力
| **编号** | **功能** | **说明** | **优先级** |
| --- | --- | --- | --- |
| FR-C1 | 必填校验 | 提问/回答/任务字段校验与提示 | P0 |
| FR-C2 | 生成中态 | 按钮置灰禁重复点击 | P0 |
| FR-C3 | 失败处理 | 失败提示、保留输入 | P0 |
| FR-C4 | 权限校验 | 无权限角色看不到对应入口 | P0 |
| FR-C5 | 生成历史 | 查看历史记录(问答库) | P1 |
# 6. 页面交互说明
系统根据实际使用角色划分为三类页面:
**1.****运营任务管理界面:**运营人员使用的 Web 后台,完成问答生成、问答入库、任务创建、任务发布、任务跟踪、回答筛选及任务审核。
**2.****提问用户链接页面:**用户A使用的移动端任务页面,完成问题认领、淘宝提问及截图提交。
**3.回答用户链接页面:**用户B使用的移动端任务页面,完成邀答截图上传、等待运营筛选、执行回答及截图提交。
## 6.1 运营任务管理界面
[https://operatepage.netlify.app](https://operatepage.netlify.app)
## 6.2 提问用户链接页面
https://askuserpage.netlify.app
## 6.3 回答用户链接页面
https://answerstaskpage.netlify.app
## 6.4三端状态统一
| **阶段** | **运营后台** | **用户A** | **用户B** |
| --- | --- | --- | --- |
| 任务创建 | 待发布 | \- | \- |
| 用户认领 | 进行中 | 已认领 | \- |
| 用户B上传截图② | 待筛选 | \- | 待筛选 |
| 运营发布回答 | 待完成 | \- | 待完成 |
| 用户提交截图 | 待审核 | 审核中 | 审核中 |
| 审核通过 | 已完成/待返款 | 已通过 | 已通过 |
| 审核不通过 | 需重做 | 未通过 | 未通过 |
| 运营撤销 | 已撤销 | 已撤销 | 已撤销 |
## 6.5 通用交互细节
| **场景** | **交互** |
| --- | --- |
| 生成中 | 按钮"生成中..."置灰禁重复点击 |
| 结果为空/失败 | 提问提示"请至少填写一项商品信息";回答提示"请先输入要回答的问题";失败"生成失败,请稍后重试" |
| 多选任务无勾选 | Toast"请先选择要发布的内容" |
| 重新生成 | 保留表单输入,换一批、尽量不重复 |
| 分页 | 结果过多时按页展示;翻页勾选状态重置 |
| 领取超限 | 提问链接提示"今日认领已达上限(5个)" |
| 提交状态 | 审核中/已通过/未通过 状态标签 + 通知 |
| OCR 中 | 回答链接提示"正在审核回答问题,请稍候查看任务" |
# 7. 权限设计(RBAC)
| **角色** | **智能问答库** | **创建/分配任务** | **审核** | **查看问答库** | **查看看板/对账** |
| --- | --- | --- | --- | --- | --- |
| 运营 | ✓ | ✓ | ✓ | ✓ | ✓ |
| 运营主管 | ✓ | ✓ | ✓ | ✓ | ✓ |
| 审核岗 | ✓ | × | ✓ | ✓ | ✓ |
| AI 管理员 | ✓ | ✓ | ✓ | ✓ | ✓(规则配置) |
| 用户A(提问) | × | 领取(限5/天) | × | × | × |
| 用户B(回答) | × | 领取(限20-30) | × | × | × |
| 财务/对账 | × | × | × | × | ✓(返款上线后) |
| 普通员工 | × | × | × | × | × |
**权限控制原则:**
* 生成与任务权限控制在业务运营范围内;AI 规则/Prompt/知识库不开放给普通运营与 C 端用户。
* C 端用户(A/B)仅能在被授权任务ID下操作自己的认领与提交,看不到他人数据与他人收款码。
* 审核岗可复核但不能改生成规则;主管/管理员可调整规则。
**数据权限:商品资料、评价数据按店铺/类目隔离;生成记录归属操作者,管理员可见全量用于质检。**
**隐私合规:涉及用户账号、订单、截图、收款码等个人信息,按《个保法》最小必要采集;评价数据仅用于提炼关注点,不留存个人明细。C 端链接提示"微信风控"时引导复制链接到浏览器打开。**
# 8. 异常流程与边界情况
| **场景** | **处理方式** |
| --- | --- |
| 商品信息不足 | 如只填"身体乳",可生成但降低具体功效判断;提示建议补充功效/卖点 |
| 超长文本 | 超字数 AI 自动压缩;仍不满足则拦截重生成 |
| 模型/OCR 服务异常 | "AI/OCR 服务暂时不可用,请稍后重试",保留用户输入 |
| 输入为空/不完整 | 拦截提交并提示对应必填项 |
| API 超时/限流 | 运营端展示失败原因,提供重新生成;限流排队提示 |
| 内容安全命中 | 极限词自动改/过滤;严重违规/事实错误/虚构功效拦截并说明,但是运营可以点击继续 |
| 批量粘贴格式杂 | 自动识别问题边界,去除无效序号/换行 |
| 网络中断/刷新 | 会话历史本地暂存可回看;未复制内容提示先复制 |
| 认领超限 | 提问单日>5 提示已达上限;回答单任务>20-30 提示已达上限 |
| 重复认领 | 同一任务ID同问题已被该用户领取则拦截 |
| OCR 识别失败 | 无法识别产品图/问题时,转人工在后台补录问题文字 |
| 截图与任务不符 | 审核自动比对商品信息/问题文字,不一致则标记待人工复核 |
| 回答次数>3 | 运营端在选择回答时,系统提醒干预"该提问下已有约3个回答,是否继续分配任务" |
| 收款码缺失/异常 | 审核不通过并提示补传;本期不打款 |
| 用户微信风控 | 提示复制链接到浏览器打开 |
# 9. 项目排期
**以下为预估排期(自然日),需与研发评审后锁定;建议 MVP 先交付 P0。**
| **阶段** | **关键交付** | **预估工期** | **依赖** |
| --- | --- | --- | --- |
| 需求评审 | PRD 对齐、范围冻结 | 3 天 | 本文档 |
| 开发(MVP P0) | 问答生成+采纳入库+任务创建/分配+用户链接+截图提交+审核 | 20 天 | 大模型/安全/OCR/微信登录就绪 |
| 联调 | 接口联调 + 内容质量 + OCR 调优 | 2天 | 开发完成 |
| 灰度 | 小流量运营+用户试用 + 指标采集 | 5天 | 联调通过 |
| 全量 | 开放全部运营 + 看板 | 3 天 | 灰度达标 |
| P1 增强 | 评价联动/维度分组/OCR深度/通知 | 10 天 | 全量稳定后 |
| 返款模块 | (另立项,本期不做) | — | 待决策 |
**【排期备注】**MVP 约 7–8 周(含任务管理系统);返款模块单列、本期不排期。所有工期为预估,最终以研发评估为准。
# 10. 风险预估
| **风险** | **说明** | **缓解措施** |
| --- | --- | --- |
| 合规风险 | 淘宝内容规范/政策变动致内容被屏蔽 | 内容安全 API + 人工复核兜底;预留规则热更新 |
| 数据隐私 | 评价/用户账号/订单/截图涉个人信息 | 按《个保法》最小必要采集,不留存个人明细;C 端隔离 |
| 授权/配额风险 | API 配额或授权受限 | 提前申请配额,设计降级(少条数/重试) |
| 内容质量风险 | AI 幻觉、同质化、不像真人 | RAG 约束 + 多元化采样 + 运营审核闭环 |
| 采纳率不及预期 | 生成内容运营不愿用 | 灰度看采纳率,低于 35% 优化 Prompt 与标签 |
| OCR 识别风险 | 截图识别错误致匹配偏差 | OCR 结果转人工补录;相似度阈值可配 |
| 用户刷单风险 | 用户漏答/虚假提交 | 领取规则 + 截图与任务比对 + 人工复核对照 |
| 资金风险 | (返款模块上线后)误触发资损 | 保证人工二次校验、异常告警(本期仅留待返款记录) |
| 微信风控 | 用户链接被微信拦截 | 提示复制链接到浏览器打开 |
| 解耦风险 | 任务与问答库边界不清致返工 | 明确数据关联(任务ID→问答编号),接口预留 |
# 附录 A:文档内容释义集
| **术语** | **解释** |
| --- | --- |
| 【问大家】 | 淘宝商品详情页里的买家社区问答板块。 |
| 智能问答库 | 本次功能:用 AI 帮运营批量生成"问大家"里的问题和回答(不直接发给用户)。 |
| 个护化妆品 | 个人护理类(洗护/身体/头发)。公司不做彩妆。 |
| 内容安全审核 | 生成后自动查违规词/敏感内容,避免被平台屏蔽。 |
| 种草式回答 | 带一点安利语气的回答风格,内容结合产品卖点,但仍真实合理。 |
| 任务管理系统 | 本期新增:运营把采纳的问答入库、建任务、发链接,用户认领并完成、上传截图,运营审核。 |
| 任务ID | 运营创建的任务编号,下挂若干"问题 / 回答编号、用户id",用于追踪与返款。 |
| 用户A / 用户B | 两拨不同的人群:用户A 负责"提问",用户B 负责"回答",不是同一批人。 |
| 认领 | 用户通过任务链接选择问题/回答,系统把"任务ID + 用户ID"绑定,相当于抢到任务。 |
| RBAC | 基于角色的权限控制:不同角色看到/能做的事不同。 |
# 附录 B:评审待确认清单