feat: 上传问大家项目
This commit is contained in:
parent
6e9b16d5f5
commit
4fea0a28fc
26
.env.example
Normal file
26
.env.example
Normal 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
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
3
backend/app/__init__.py
Normal file
3
backend/app/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from .main import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
39
backend/app/config.py
Normal file
39
backend/app/config.py
Normal 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
27
backend/app/database.py
Normal 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()
|
||||||
193
backend/app/llm.py
Normal file
193
backend/app/llm.py
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
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_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 []
|
||||||
1768
backend/app/main.py
Normal file
1768
backend/app/main.py
Normal file
File diff suppressed because it is too large
Load Diff
300
backend/app/models.py
Normal file
300
backend/app/models.py
Normal 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
54
backend/app/schemas.py
Normal 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
59
backend/app/security.py
Normal 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
6
backend/package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal 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
33
backend/schema.sql
Normal 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
395
backend/tests/test_auth.py
Normal 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
BIN
data/app.db
Normal file
Binary file not shown.
BIN
data/async-smoke.db
Normal file
BIN
data/async-smoke.db
Normal file
Binary file not shown.
1
frontend/index.html
Normal file
1
frontend/index.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
<div id="root"></div><script type="module" src="/src/main.jsx"></script>
|
||||||
1213
frontend/package-lock.json
generated
Normal file
1213
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
frontend/package.json
Normal file
9
frontend/package.json
Normal 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": {}
|
||||||
|
}
|
||||||
195
frontend/src/AnswerUserPortal.jsx
Normal file
195
frontend/src/AnswerUserPortal.jsx
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
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 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>{passed ? '审核已完成' : cancelled ? '任务已撤销' : '完成凭证审核中'} <em>{STATUS_TEXT[latest.status]}</em></h1><p>{passed ? '运营已审核通过您提交的任务凭证,当前进入打款流程,请耐心等待打款。' : cancelled ? '运营已撤销本次任务,您无需继续提交。' : '您提交的回答完成截图正在审核,请耐心等待运营确认。'}</p></div></div>
|
||||||
|
{passed && <div className="answer-v2-payment-wait"><strong>审核已完成,等待打款</strong><span>款项将按运营安排发放,请留意收款账户。</span></div>}
|
||||||
|
<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>
|
||||||
|
}
|
||||||
925
frontend/src/AskUserPortal.jsx
Normal file
925
frontend/src/AskUserPortal.jsx
Normal file
@ -0,0 +1,925 @@
|
|||||||
|
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;
|
||||||
|
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.
|
||||||
|
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?.status !== "待审核") return undefined;
|
||||||
|
const timer = window.setInterval(() => loadAssignment(true), 10000);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
frontend/src/CreateAnswerTasksPage.jsx
Normal file
167
frontend/src/CreateAnswerTasksPage.jsx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
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('')
|
||||||
|
|
||||||
|
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('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
406
frontend/src/CreateAskTasksPage.jsx
Normal file
406
frontend/src/CreateAskTasksPage.jsx
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Check, Link2, Plus, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
const blankForm = {
|
||||||
|
product_link: "",
|
||||||
|
product_name: "",
|
||||||
|
category: "",
|
||||||
|
benefits: "",
|
||||||
|
selling_points: "",
|
||||||
|
language_style: "口语(亲切随性)",
|
||||||
|
target_group: "女性",
|
||||||
|
usage_scene: "居家",
|
||||||
|
purchase_channel: "电商平台",
|
||||||
|
question_count: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readReferenceFile(file) {
|
||||||
|
if (!file) return "";
|
||||||
|
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() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const reference_qa = await readReferenceFile(referenceFile);
|
||||||
|
const result = await api("/api/admin/ask-tasks/generate", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
...form,
|
||||||
|
question_count: Number(form.question_count),
|
||||||
|
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,text/plain,text/csv,application/json,text/markdown" onChange={(event) => setReferenceFile(event.target.files?.[0] || null)} />
|
||||||
|
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown,生成提问时会参考其中的问题角度"}</small>
|
||||||
|
</label>
|
||||||
|
<h4>生成偏好与数量</h4>
|
||||||
|
<div className="two-col">
|
||||||
|
<label>
|
||||||
|
语言风格
|
||||||
|
<select
|
||||||
|
value={form.language_style}
|
||||||
|
onChange={(event) =>
|
||||||
|
update("language_style", event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option>口语(亲切随性)</option>
|
||||||
|
<option>专业</option>
|
||||||
|
<option>幽默</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
目标人群
|
||||||
|
<select
|
||||||
|
value={form.target_group}
|
||||||
|
onChange={(event) => update("target_group", event.target.value)}
|
||||||
|
>
|
||||||
|
<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>居家</option>
|
||||||
|
<option>旅行</option>
|
||||||
|
<option>通勤</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
购买渠道
|
||||||
|
<select
|
||||||
|
value={form.purchase_channel}
|
||||||
|
onChange={(event) =>
|
||||||
|
update("purchase_channel", event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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="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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
631
frontend/src/ReviewCenterPage.jsx
Normal file
631
frontend/src/ReviewCenterPage.jsx
Normal file
@ -0,0 +1,631 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { MessageCircleMore, RefreshCw, ShieldCheck, 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>
|
||||||
|
<div className="review-v2-assist">
|
||||||
|
<ShieldCheck size={16} />
|
||||||
|
<strong>系统智能助判</strong>
|
||||||
|
<span>
|
||||||
|
✓ 商品一致 ✓ 内容与要求匹配 ✓ 无重复提交 ✓ 无违规内容
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{record.latest_review && !pending && (
|
||||||
|
<div
|
||||||
|
className={`review-v2-result ${record.latest_review.approved ? "approved" : ""}`}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
560
frontend/src/TaskBoardPage.jsx
Normal file
560
frontend/src/TaskBoardPage.jsx
Normal file
@ -0,0 +1,560 @@
|
|||||||
|
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 isAskTask =
|
||||||
|
task.task_type === "ask" || task.task_type_label === "提问任务";
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
<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);
|
||||||
|
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 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>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
4161
frontend/src/admin.css
Normal file
4161
frontend/src/admin.css
Normal file
File diff suppressed because one or more lines are too long
3720
frontend/src/main.jsx
Normal file
3720
frontend/src/main.jsx
Normal file
File diff suppressed because it is too large
Load Diff
611
frontend/src/styles.css
Normal file
611
frontend/src/styles.css
Normal file
@ -0,0 +1,611 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
|
.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
12
frontend/vite.config.js
Normal 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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
599
【问大家】问答与任务管理系统PRD.md
Normal file
599
【问大家】问答与任务管理系统PRD.md
Normal 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.** **优化后流程**
|
||||||
|
|
||||||
|
# 产品方案设计
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
**运营侧 → 用户侧 → 回运营审核****:**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**①****创建任务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)
|
||||||
|
|
||||||
|
**用户侧上传 → 运营选答匹配 → 用户侧完成:**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**①创建任务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:评审待确认清单
|
||||||
Loading…
x
Reference in New Issue
Block a user