diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..57c4524
--- /dev/null
+++ b/.env.example
@@ -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=
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9786d39
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+.env
+__pycache__/
+*.py[cod]
+.pytest_cache/
+frontend/node_modules/
+frontend/dist/
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..de1660b
--- /dev/null
+++ b/backend/app/__init__.py
@@ -0,0 +1,3 @@
+from .main import app
+
+__all__ = ["app"]
diff --git a/backend/app/config.py b/backend/app/config.py
new file mode 100644
index 0000000..7f98292
--- /dev/null
+++ b/backend/app/config.py
@@ -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()
diff --git a/backend/app/database.py b/backend/app/database.py
new file mode 100644
index 0000000..c1a049f
--- /dev/null
+++ b/backend/app/database.py
@@ -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()
diff --git a/backend/app/llm.py b/backend/app/llm.py
new file mode 100644
index 0000000..a9b12f8
--- /dev/null
+++ b/backend/app/llm.py
@@ -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 []
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..d8befb6
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,1768 @@
+from datetime import datetime, timedelta
+
+import base64
+import hashlib
+import json
+import random
+import secrets
+import time
+import urllib.parse
+import urllib.request
+import uuid
+
+from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status
+from fastapi.middleware.cors import CORSMiddleware
+from sqlalchemy import func, or_, select
+from sqlalchemy.orm import Session, noload, selectinload
+
+from .config import settings
+from .database import Base, SessionLocal, engine, get_db
+from .llm import extract_questions_from_images, generate_answer_for_question, generate_qa
+from .models import AdminLoginLog, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QALibraryEntry
+from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QAGenerateRequest, RegisterRequest
+from .security import create_session_token, decode_session_token, hash_password, verify_password
+
+
+app = FastAPI(title=settings.app_name, version="0.1.0")
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
+ allow_credentials=True,
+ allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
+ allow_headers=["Content-Type"],
+)
+
+
+# 回答用户链接的 MVP 数据暂存于内存:截图只在当前服务进程中保留,后续接入对象存储/持久化表。
+ANSWER_TASKS = {
+ "TASK20260822002": {
+ "task_id": "TASK20260822002",
+ "token": "demo-answer-token",
+ "product_name": "泊泉雅三色修护凝精华",
+ "status": "待上传",
+ "questions": [
+ {"id": "Q001", "question": "这个精华敏感肌可以使用吗?", "answer": "质地温和,使用前建议先做局部测试。"},
+ {"id": "Q002", "question": "用了会不会觉得油腻?", "answer": "吸收速度较快,肤感清爽不黏腻。"},
+ {"id": "Q003", "question": "这款成分里面有致痘成分吗?", "answer": "配方清爽,建议结合自己的肤质进行选择。"},
+ {"id": "Q004", "question": "吸收速度怎么样?使用感如何?", "answer": "延展性好,吸收后肤感柔润,日常使用很舒服。"},
+ ],
+ "invited_screenshots": [],
+ "final_screenshots": [],
+ "recognized_questions": [],
+ "selected_question_ids": [],
+ "user_name": "",
+ "updated_at": datetime.utcnow().isoformat(),
+ }
+}
+
+PUBLIC_ANSWER_SUBMISSIONS: list[dict] = []
+PHONE_CODES: dict[str, dict] = {}
+DINGTALK_STATES: dict[str, float] = {}
+
+
+ROLE_LABELS = {"super_admin": "超级管理员", "operator": "普通运营"}
+ROLE_PERMISSIONS = {
+ "super_admin": ["assistant", "library", "create", "board", "review", "users", "logs", "settings"],
+ "operator": ["assistant", "library", "create", "board", "review"],
+}
+
+
+def public_user(user: AdminUser) -> dict:
+ return {
+ "id": user.id,
+ "username": user.username,
+ "display_name": user.display_name,
+ "role_code": user.role_code,
+ "role_label": ROLE_LABELS.get(user.role_code, user.role_code),
+ "permissions": ROLE_PERMISSIONS.get(user.role_code, []),
+ }
+
+
+def seed_users(db: Session):
+ if db.scalar(select(AdminUser).limit(1)):
+ return
+ db.add_all([
+ AdminUser(username=settings.admin_username, password_hash=hash_password(settings.admin_password), display_name="系统管理员", role_code="super_admin"),
+ AdminUser(username=settings.operator_username, password_hash=hash_password(settings.operator_password), display_name="普通运营", role_code="operator"),
+ ])
+ db.commit()
+
+
+@app.on_event("startup")
+def startup():
+ Base.metadata.create_all(bind=engine)
+ db = next(get_db())
+ try:
+ seed_users(db)
+ finally:
+ db.close()
+
+
+def current_user(request: Request, db: Session = Depends(get_db)) -> AdminUser:
+ token = request.cookies.get(settings.cookie_name)
+ payload = decode_session_token(token) if token else None
+ if not payload:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效,请重新登录")
+ user = db.get(AdminUser, int(payload["sub"]))
+ if not user or not user.is_active:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不可用")
+ return user
+
+
+def require_super_admin(user: AdminUser = Depends(current_user)) -> AdminUser:
+ if user.role_code != "super_admin":
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可访问此功能")
+ return user
+
+
+@app.get("/api/health")
+def health():
+ return {"success": True, "message": "ok"}
+
+
+def _answer_task(task_id: str, token: str) -> dict:
+ task = ANSWER_TASKS.get(task_id)
+ if not task or token != task["token"]:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务链接无效或已失效")
+ return task
+
+
+def _public_answer_task(task: dict) -> dict:
+ selected = set(task.get("selected_question_ids", []))
+ return {
+ "task_id": task["task_id"],
+ "product_name": task["product_name"],
+ "status": task["status"],
+ "recognized_questions": task.get("recognized_questions", []),
+ "selected_questions": [q for q in task["questions"] if q["id"] in selected],
+ "updated_at": task["updated_at"],
+ }
+
+
+def _answer_user_from_token(token: str, db: Session) -> AnswerUser:
+ user = db.scalar(select(AnswerUser).where(AnswerUser.auth_token == token))
+ if not user:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="回答用户登录已失效")
+ return user
+
+
+def _phone_number(payload: dict) -> str:
+ phone = str(payload.get("phone", "")).strip()
+ if not phone.isdigit() or len(phone) != 11 or not phone.startswith("1"):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请输入正确的11位手机号")
+ return phone
+
+
+def _send_phone_code(payload: dict) -> dict:
+ phone = _phone_number(payload)
+ now = datetime.utcnow()
+ previous = PHONE_CODES.get(phone)
+ if previous and (now - previous["sent_at"]).total_seconds() < 60:
+ wait = 60 - int((now - previous["sent_at"]).total_seconds())
+ raise HTTPException(status_code=429, detail=f"验证码发送过于频繁,请{max(1, wait)}秒后再试")
+ code = f"{random.randint(0, 999999):06d}"
+ PHONE_CODES[phone] = {"code": code, "sent_at": now, "expires_at": now + timedelta(minutes=5), "attempts": 0}
+ result = {"expires_in": 300, "retry_after": 60}
+ if settings.sms_dev_mode:
+ result["dev_code"] = code
+ return result
+
+
+def _phone_login(payload: dict, db: Session) -> AnswerUser:
+ phone = _phone_number(payload)
+ code = str(payload.get("code", "")).strip()
+ challenge = PHONE_CODES.get(phone)
+ if not challenge:
+ raise HTTPException(status_code=400, detail="请先获取短信验证码")
+ if challenge["attempts"] >= 5:
+ PHONE_CODES.pop(phone, None)
+ raise HTTPException(status_code=400, detail="验证码错误次数过多,请重新获取验证码")
+ if datetime.utcnow() > challenge["expires_at"]:
+ PHONE_CODES.pop(phone, None)
+ raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
+ if code != challenge["code"]:
+ challenge["attempts"] += 1
+ raise HTTPException(status_code=400, detail="验证码错误,请重新输入")
+ PHONE_CODES.pop(phone, None)
+ user = db.scalar(select(AnswerUser).where(AnswerUser.phone == phone))
+ if not user:
+ user = AnswerUser(public_id=f"U{uuid.uuid4().int % 10_000_000:07d}", phone=phone, auth_token=uuid.uuid4().hex)
+ db.add(user)
+ else:
+ user.last_login_at = datetime.utcnow()
+ if not user.auth_token:
+ user.auth_token = uuid.uuid4().hex
+ db.commit(); db.refresh(user)
+ return user
+
+
+def _public_phone_session(user: AnswerUser) -> dict:
+ return {"user_id": user.public_id, "phone": user.phone, "token": user.auth_token}
+
+
+def _dingtalk_configured() -> bool:
+ return bool(settings.dingtalk_client_id and settings.dingtalk_client_secret)
+
+
+def _dingtalk_exchange_code(code: str) -> dict:
+ request = urllib.request.Request(
+ "https://api.dingtalk.com/v1.0/oauth2/userAccessToken",
+ data=json.dumps({
+ "clientId": settings.dingtalk_client_id,
+ "clientSecret": settings.dingtalk_client_secret,
+ "code": code,
+ "grantType": "authorization_code",
+ }).encode("utf-8"),
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=10) as response:
+ return json.loads(response.read().decode("utf-8"))
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail="钉钉授权码校验失败,请稍后重试") from exc
+
+
+def _dingtalk_user(access_token: str) -> dict:
+ request = urllib.request.Request(
+ "https://api.dingtalk.com/v1.0/contact/users/me",
+ headers={"x-acs-dingtalk-access-token": access_token},
+ method="GET",
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=10) as response:
+ return json.loads(response.read().decode("utf-8"))
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail="无法读取钉钉用户信息,请稍后重试") from exc
+
+
+def _dingtalk_login(code: str, db: Session, response: Response) -> AdminUser:
+ if not _dingtalk_configured():
+ raise HTTPException(status_code=503, detail="钉钉登录尚未配置,请联系管理员")
+ code = code.strip()
+ if not code:
+ raise HTTPException(status_code=400, detail="钉钉授权码不能为空")
+ token_data = _dingtalk_exchange_code(code)
+ access_token = token_data.get("accessToken") or token_data.get("access_token")
+ if not access_token:
+ raise HTTPException(status_code=502, detail="钉钉未返回有效授权令牌")
+ profile = _dingtalk_user(access_token)
+ union_id = str(profile.get("unionId") or profile.get("unionid") or profile.get("openId") or profile.get("openId") or "").strip()
+ if not union_id:
+ raise HTTPException(status_code=502, detail="钉钉用户信息缺少唯一标识")
+ if settings.dingtalk_allowed_union_ids and union_id not in settings.dingtalk_allowed_union_ids:
+ raise HTTPException(status_code=403, detail="当前钉钉账号未被授权使用运营后台")
+ username = f"dingtalk_{union_id}"[:64]
+ user = db.scalar(select(AdminUser).where(AdminUser.username == username))
+ display_name = str(profile.get("nick") or profile.get("name") or "钉钉用户").strip()[:64]
+ role_code = "super_admin" if union_id in settings.dingtalk_super_admin_union_ids else "operator"
+ if not user:
+ user = AdminUser(username=username, password_hash=hash_password(secrets.token_urlsafe(32)), display_name=display_name, role_code=role_code)
+ db.add(user)
+ elif not user.is_active:
+ raise HTTPException(status_code=403, detail="运营账号已被停用")
+ else:
+ user.display_name = display_name or user.display_name
+ if union_id in settings.dingtalk_super_admin_union_ids:
+ user.role_code = "super_admin"
+ user.last_login_at = datetime.utcnow()
+ db.flush()
+ db.add(AdminLoginLog(user_id=user.id, username=username, success=True))
+ db.commit(); db.refresh(user)
+ response.set_cookie(settings.cookie_name, create_session_token(user.id, user.role_code), httponly=True, secure=settings.cookie_secure, samesite="lax", max_age=settings.session_hours * 3600)
+ return user
+
+
+def _published_ask_task(task_no: str, db: Session, *, lock: bool = False) -> AskTask:
+ if task_no == "latest":
+ statement = select(AskTask).where(AskTask.status == "已发布").order_by(AskTask.created_at.desc())
+ else:
+ statement = select(AskTask).where(AskTask.task_no == task_no, AskTask.status == "已发布")
+ # Serialize allocation for the same task. This prevents two users who
+ # open the link at the same moment from receiving the same question.
+ if lock:
+ statement = statement.with_for_update()
+ task = db.scalar(statement)
+ if not task:
+ raise HTTPException(status_code=404, detail="提问任务链接无效、未发布或已结束")
+ return task
+
+
+def _ask_assignment_data(assignment: AskAssignment, include_images: bool = True) -> dict:
+ items = sorted(assignment.items, key=lambda item: item.question.sequence_no)
+ used_ids = {item.question_id for item in assignment.used_questions}
+ # List endpoints must not touch the screenshot relationship. The image
+ # BLOBs are large and loading them for every row was the main cause of the
+ # multi-second task-board/review-center delay.
+ screenshots = list(assignment.screenshots) if include_images else []
+ question_screenshots = [item for item in screenshots if item.stage == "question"]
+ payment_screenshots = [item for item in screenshots if item.stage == "payment"]
+ latest_review = (
+ max(assignment.review_decisions, key=lambda item: item.created_at)
+ if include_images and assignment.review_decisions
+ else None
+ )
+ # Backfill the read model for approvals created before the used-question
+ # table was introduced. New approvals are persisted in that table below.
+ if assignment.status in {"待审核", "已通过"}:
+ # Submission itself consumes the selected questions. The fallback
+ # also makes records created before the used-question table behave
+ # correctly without a data migration.
+ used_ids.update(item.question_id for item in items if item.selected)
+ return {
+ "assignment_id": assignment.id,
+ "status": assignment.status,
+ "max_questions": len(items),
+ "used_count": len(used_ids),
+ "remaining_count": max(len(items) - len(used_ids), 0),
+ "task_id": assignment.task.task_no,
+ "product_name": assignment.task.product_name,
+ "product_link": assignment.task.product_link,
+ "user_id": assignment.user.public_id,
+ "phone": assignment.user.phone,
+ "created_at": assignment.created_at.isoformat(),
+ "updated_at": assignment.updated_at.isoformat(),
+ "question_screenshots": [{"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"} for image in question_screenshots] if include_images else [],
+ "payment_screenshots": [{"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"} for image in payment_screenshots] if include_images else [],
+ "latest_review": ({
+ "approved": latest_review.approved,
+ "reason_code": latest_review.reason_code,
+ "reason_text": latest_review.reason_text,
+ "created_at": latest_review.created_at.isoformat(),
+ } if latest_review else None),
+ "questions": [{
+ "id": item.question.id,
+ "sequence_no": item.question.sequence_no,
+ "question": item.question.question_text,
+ "selected": item.selected,
+ "used": item.question_id in used_ids,
+ } for item in items],
+ }
+
+
+def _ask_batch_data(batch: AskAssignmentBatch, include_images: bool = True) -> dict:
+ """Serialize one independent submission batch for review/history views."""
+ assignment = batch.assignment
+ parent = _ask_assignment_data(assignment, include_images=False)
+ try:
+ selected_ids = {int(value) for value in (batch.selected_question_ids or "").split(",") if value}
+ except ValueError:
+ selected_ids = set()
+ used_ids = {item.question_id for item in assignment.used_questions}
+ latest_review = (
+ max(batch.review_decisions, key=lambda item: item.created_at)
+ if include_images and batch.review_decisions
+ else None
+ )
+ screenshots = list(batch.screenshots) if include_images else []
+ question_images = [item for item in screenshots if item.stage == "question"]
+ payment_images = [item for item in screenshots if item.stage == "payment"]
+ questions = []
+ for item in sorted(assignment.items, key=lambda value: value.question.sequence_no):
+ questions.append({
+ "id": item.question.id,
+ "sequence_no": item.question.sequence_no,
+ "question": item.question.question_text,
+ "selected": item.question_id in selected_ids,
+ "used": item.question_id in used_ids,
+ })
+ data = {
+ **parent,
+ "assignment_id": batch.id,
+ "batch_id": batch.id,
+ "parent_assignment_id": assignment.id,
+ # A paid batch remains an approved audit record in the review center;
+ # payout completion is represented separately on the task board.
+ "status": {"pending": "待审核", "approved": "已通过", "paid": "已通过", "rejected": "未通过"}.get(batch.status, batch.status),
+ "created_at": batch.created_at.isoformat(),
+ "updated_at": batch.updated_at.isoformat(),
+ "questions": questions,
+ "question_screenshots": [
+ {"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"}
+ for image in question_images
+ ] if include_images else [],
+ "payment_screenshots": [
+ {"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"}
+ for image in payment_images
+ ] if include_images else [],
+ "latest_review": ({
+ "approved": latest_review.approved,
+ "reason_code": latest_review.reason_code,
+ "reason_text": latest_review.reason_text,
+ "created_at": latest_review.created_at.isoformat(),
+ } if latest_review else None),
+ }
+ return data
+
+
+def _sync_ask_assignment_status(assignment: AskAssignment) -> None:
+ """Keep the aggregate assignment status aligned with its latest batch."""
+ if not assignment.batches:
+ return
+ # Batch order means submission order; a later review decision must not
+ # make an older batch replace the user's newest pending batch.
+ latest = max(assignment.batches, key=lambda value: value.created_at)
+ assignment.status = {"pending": "待审核", "approved": "已通过", "rejected": "未通过"}.get(latest.status, latest.status)
+ assignment.updated_at = datetime.utcnow()
+
+
+def _image_url(image: AnswerScreenshot) -> str:
+ return f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"
+
+
+def _submission_data(record: AnswerSubmission, include_images: bool = True) -> dict:
+ # List endpoints deliberately omit screenshot BLOBs. Do not even touch
+ # the lazy relationship in that mode, otherwise SQLAlchemy fetches every
+ # image from remote MySQL before returning the lightweight response.
+ screenshots = list(record.screenshots) if include_images else []
+ invited = [x for x in screenshots if x.stage == "invited"]
+ completion = [x for x in screenshots if x.stage == "completion"]
+ payment = [x for x in screenshots if x.stage == "payment"]
+ images = {x.id: x for x in screenshots}
+ latest_review = max(record.review_decisions, key=lambda value: value.created_at) if record.review_decisions else None
+ return {
+ "id": record.id,
+ "submission_no": record.submission_no,
+ "user_id": record.user.public_id,
+ "phone": record.user.phone,
+ "status": record.status,
+ "created_at": record.created_at.isoformat(),
+ "updated_at": record.updated_at.isoformat(),
+ "invited_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x)} for x in invited] if include_images else [],
+ "completion_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x)} for x in completion] if include_images else [],
+ "payment_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x)} for x in payment] if include_images else [],
+ "latest_review": ({
+ "approved": latest_review.approved,
+ "reason_code": latest_review.reason_code,
+ "reason_text": latest_review.reason_text,
+ "created_at": latest_review.created_at.isoformat(),
+ } if latest_review else None),
+ "questions": [{
+ "id": q.id, "sequence_no": q.sequence_no, "product_title": q.product_title,
+ "product_image": _image_url(images[q.product_image_screenshot_id]) if include_images and q.product_image_screenshot_id in images else None,
+ "source_image": _image_url(images[q.source_screenshot_id]) if include_images and q.source_screenshot_id in images else None,
+ "question": q.question_text, "answer": q.answer_text, "answer_status": q.answer_status, "selected": q.selected,
+ } for q in sorted(record.questions, key=lambda value: value.sequence_no)],
+ }
+
+
+def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> dict:
+ selected = [q for q in sorted(record.questions, key=lambda value: value.sequence_no) if q.selected]
+ product_titles = list(dict.fromkeys(q.product_title for q in selected if q.product_title))
+ summary = _submission_data(record, include_images=True) if include_images else {
+ "id": record.id,
+ "submission_no": record.submission_no,
+ "user_id": record.user.public_id,
+ "phone": record.user.phone,
+ "status": record.status,
+ "created_at": record.created_at.isoformat(),
+ "updated_at": record.updated_at.isoformat(),
+ "invited_screenshots": [],
+ "completion_screenshots": [],
+ "payment_screenshots": [],
+ "latest_review": None,
+ "questions": [],
+ }
+ status_labels = {
+ "待用户完成": "进行中",
+ "待审核": "待审核",
+ # Passing the content audit means the submission is ready for the
+ # finance check; it is not fully complete until the refund is handled.
+ "已通过": "待返款",
+ "已完成": "已完成",
+ "未通过": "进行中",
+ "已撤销": "已撤销",
+ }
+ completed_count = len(selected) if record.status in {"待审核", "已通过", "已完成"} else 0
+ return {
+ **summary,
+ "board_key": f"answer-{record.id}", "record_kind": "answer_submission",
+ "task_id": record.submission_no, "task_type": "answer", "task_type_label": "回答任务",
+ "status_label": status_labels.get(record.status, record.status), "product_name": " / ".join(product_titles) or "待识别商品",
+ "qa_count": len(selected), "claimed_count": 1, "submitted_count": 1 if record.status in {"待审核", "已通过", "已完成"} else 0,
+ "pending_review_count": 1 if record.status == "待审核" else 0, "completed_count": completed_count, "total_count": len(selected),
+ "creator_name": "运营后台", "can_cancel": record.status in {"待用户完成", "待审核", "未通过"},
+ "can_delete": True,
+ "delete_endpoint": f"/api/admin/task-board/{record.id}",
+ "refund_endpoint": f"/api/admin/task-board/{record.id}/confirm-refund" if record.status == "已通过" else None,
+ "assigned_users": [{"user_id": record.user.public_id, "phone": record.user.phone, "status": status_labels.get(record.status, record.status)}],
+ "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": q.answer_text, "product_title": q.product_title} for q in selected] if include_images else []),
+ }
+
+
+def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict:
+ questions = sorted(task.questions, key=lambda value: value.sequence_no)
+ return {
+ "id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task",
+ "task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务",
+ "product_name": task.product_name, "product_link": task.product_link, "status": task.status,
+ "status_label": {"已发布": "进行中", "已撤销": "已撤销", "已完成": "已完成"}.get(task.status, task.status),
+ "qa_count": len([q for q in questions if q.selected]), "claimed_count": 0, "submitted_count": 0,
+ "pending_review_count": 0, "completed_count": 0, "total_count": len(questions),
+ "creator_name": "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(),
+ "can_cancel": task.status == "已发布", "can_delete": True,
+ "delete_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}",
+ "user_id": "--", "phone": "", "questions": [],
+ "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name} for q in questions if q.selected] if include_questions else []),
+ "assigned_users": [], "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None,
+ "detail_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}" if not include_questions else None,
+ }
+
+
+def _answer_review_data(record: AnswerSubmission, include_images: bool = True) -> dict:
+ """Keep completed refunds visible as approved audit history."""
+ data = _submission_data(record, include_images=include_images)
+ if record.status == "已完成":
+ data["status"] = "已通过"
+ data["refund_completed"] = True
+ else:
+ data["refund_completed"] = False
+ return data
+
+
+def _ask_review_board_data(record: AskAssignmentBatch | AskAssignment, include_images: bool = False) -> dict:
+ """Convert one approved ask submission into an independent payout row."""
+ is_batch = isinstance(record, AskAssignmentBatch)
+ assignment = record.assignment if is_batch else record
+ if include_images:
+ data = _ask_batch_data(record, include_images=True) if is_batch else _ask_assignment_data(record, include_images=True)
+ selected = [question for question in data["questions"] if question["selected"]]
+ elif is_batch:
+ selected_ids = {int(value) for value in (record.selected_question_ids or "").split(",") if value}
+ selected = []
+ data = {
+ "assignment_id": record.id,
+ "status": "已通过",
+ "task_id": assignment.task.task_no,
+ "product_name": assignment.task.product_name,
+ "product_link": assignment.task.product_link,
+ "user_id": assignment.user.public_id,
+ "phone": assignment.user.phone,
+ "created_at": record.created_at.isoformat(),
+ "updated_at": record.updated_at.isoformat(),
+ "questions": [],
+ "question_screenshots": [],
+ "payment_screenshots": [],
+ "latest_review": None,
+ }
+ else:
+ data = _ask_assignment_data(record, include_images=False)
+ selected = [question for question in data["questions"] if question["selected"]]
+ selected_ids = {question["id"] for question in selected}
+ data["questions"] = []
+ refund_completed = record.status == "paid" if is_batch else record.status == "已完成"
+ selected_count = len(selected) if include_images else len(selected_ids)
+ return {
+ **data,
+ "id": record.id,
+ "board_key": f"ask-{'batch' if is_batch else 'assignment'}-{record.id}",
+ "record_kind": "ask_batch" if is_batch else "ask_assignment",
+ "task_type": "ask",
+ "task_type_label": "提问任务",
+ "status_label": "已完成" if refund_completed else "待返款",
+ "product_name": assignment.task.product_name,
+ "qa_count": selected_count,
+ "claimed_count": selected_count,
+ "submitted_count": selected_count,
+ "pending_review_count": 0,
+ "completed_count": selected_count,
+ "total_count": selected_count,
+ "creator_name": "运营后台",
+ "can_cancel": False,
+ "can_delete": True,
+ "delete_endpoint": (
+ f"/api/admin/task-board/ask-batches/{record.id}"
+ if is_batch
+ else f"/api/admin/task-board/ask-assignments/{record.id}"
+ ),
+ "user_id": assignment.user.public_id,
+ "phone": assignment.user.phone,
+ "assigned_users": [{
+ "user_id": assignment.user.public_id,
+ "phone": assignment.user.phone,
+ "status": "已完成" if refund_completed else "待返款",
+ }],
+ "selected_questions": ([{
+ "id": question["id"],
+ "sequence_no": question["sequence_no"],
+ "question": question["question"],
+ "answer": "",
+ "product_title": assignment.task.product_name,
+ } for question in selected] if include_images else []),
+ "detail_endpoint": (
+ f"/api/admin/task-board/ask-batches/{record.id}"
+ if is_batch
+ else f"/api/admin/task-board/ask-assignments/{record.id}"
+ ),
+ "refund_endpoint": (
+ None
+ if refund_completed
+ else (
+ f"/api/admin/task-board/ask-batches/{record.id}/confirm-refund"
+ if is_batch
+ else f"/api/admin/task-board/ask-assignments/{record.id}/confirm-refund"
+ )
+ ),
+ }
+
+
+@app.post("/api/answer/auth/phone-login")
+def answer_phone_login(payload: dict, db: Session = Depends(get_db)):
+ return {"success": True, "data": _public_phone_session(_phone_login(payload, db))}
+
+
+@app.post("/api/answer/auth/send-code")
+def answer_send_code(payload: dict):
+ return {"success": True, "data": _send_phone_code(payload)}
+
+
+@app.post("/api/ask/auth/phone-login")
+def ask_phone_login(payload: dict, db: Session = Depends(get_db)):
+ return {"success": True, "data": _public_phone_session(_phone_login(payload, db))}
+
+
+@app.post("/api/ask/auth/send-code")
+def ask_send_code(payload: dict):
+ return {"success": True, "data": _send_phone_code(payload)}
+
+
+@app.get("/api/ask/tasks/{task_no}")
+def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)):
+ user = _answer_user_from_token(token, db)
+ task = _published_ask_task(task_no, db, lock=True)
+ assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
+ if not assignment:
+ # Allocation is permanent as soon as the task link assigns the
+ # questions. Exclude every question already allocated to any user,
+ # even if that user has not selected or submitted it yet.
+ allocated_ids = set(db.scalars(
+ select(AskAssignmentQuestion.question_id)
+ .join(AskAssignment, AskAssignmentQuestion.assignment_id == AskAssignment.id)
+ .where(AskAssignment.task_id == task.id)
+ ).all())
+ available = [
+ question
+ for question in task.questions
+ if question.selected and question.id not in allocated_ids
+ ]
+ if not available:
+ raise HTTPException(
+ status_code=409,
+ detail="本次任务已被领取完,请关注下一个任务!",
+ )
+ assigned = random.sample(available, min(5, len(available)))
+ assignment = AskAssignment(task_id=task.id, user_id=user.id, status="待选择")
+ db.add(assignment); db.flush()
+ for question in assigned:
+ db.add(AskAssignmentQuestion(assignment_id=assignment.id, question_id=question.id, selected=False))
+ db.commit(); db.refresh(assignment)
+ latest_batch = db.scalar(
+ select(AskAssignmentBatch)
+ .where(AskAssignmentBatch.assignment_id == assignment.id)
+ .order_by(AskAssignmentBatch.created_at.desc())
+ )
+ # While the user is selecting a new batch, the parent assignment contains
+ # the current selection. Do not replace it with the previous submitted
+ # batch or a refresh would appear to undo the user's choices.
+ if latest_batch and assignment.status != "进行中":
+ return {"success": True, "data": _ask_batch_data(latest_batch)}
+ return {"success": True, "data": _ask_assignment_data(assignment)}
+
+
+@app.get("/api/ask/me/assignments")
+def public_ask_assignments(token: str, db: Session = Depends(get_db)):
+ user = _answer_user_from_token(token, db)
+ records = db.scalars(
+ select(AskAssignment)
+ .where(AskAssignment.user_id == user.id)
+ .order_by(AskAssignment.updated_at.desc())
+ ).unique().all()
+ data = []
+ for record in records:
+ batches = sorted(record.batches, key=lambda value: value.created_at, reverse=True)
+ if batches:
+ data.extend(_ask_batch_data(batch, include_images=False) for batch in batches)
+ else:
+ data.append(_ask_assignment_data(record, include_images=False))
+ return {"success": True, "data": data}
+
+
+@app.post("/api/ask/tasks/{task_no}/selection")
+def select_public_ask_questions(task_no: str, payload: dict, db: Session = Depends(get_db)):
+ user = _answer_user_from_token(str(payload.get("token", "")), db)
+ task = _published_ask_task(task_no, db)
+ assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
+ if not assignment:
+ raise HTTPException(status_code=409, detail="请先领取提问任务")
+ try:
+ selected_ids = {int(value) for value in payload.get("question_ids", [])}
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail="问题选择数据无效")
+ assigned_ids = {item.question_id for item in assignment.items}
+ if not selected_ids:
+ raise HTTPException(status_code=400, detail="请至少选择一个问题")
+ if not selected_ids.issubset(assigned_ids):
+ raise HTTPException(status_code=400, detail="选择中包含未分配给当前用户的问题")
+ used_ids = {item.question_id for item in assignment.used_questions}
+ if selected_ids & used_ids:
+ raise HTTPException(status_code=400, detail="已认领的问题不能重复选择")
+ for item in assignment.items:
+ # The selection represents only the next batch. Questions in
+ # ``used_ids`` remain locked/read-only and are not resubmitted.
+ item.selected = item.question_id in selected_ids
+ assignment.status = "进行中"
+ db.commit(); db.refresh(assignment)
+ return {"success": True, "data": _ask_assignment_data(assignment)}
+
+
+@app.post("/api/ask/tasks/{task_no}/submission")
+async def submit_public_ask_task(
+ task_no: str,
+ token: str = Form(...),
+ files: list[UploadFile] = File(...),
+ payment_file: UploadFile = File(...),
+ payee_name: str = Form(default=""),
+ db: Session = Depends(get_db),
+):
+ user = _answer_user_from_token(token, db)
+ task = _published_ask_task(task_no, db)
+ assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
+ if not assignment:
+ raise HTTPException(status_code=404, detail="提问任务不存在")
+ if assignment.status not in {"进行中", "未通过"}:
+ raise HTTPException(status_code=409, detail="当前任务不能提交审核")
+ if not any(item.selected for item in assignment.items):
+ raise HTTPException(status_code=400, detail="请至少选择一个问题")
+ if not files:
+ raise HTTPException(status_code=400, detail="请至少上传一张提问截图")
+ allowed = {"image/jpeg", "image/png", "image/webp"}
+ if any(upload.content_type not in allowed for upload in [*files, payment_file]):
+ raise HTTPException(status_code=400, detail="仅支持 JPG、PNG、WEBP 图片")
+ selected_ids = [item.question_id for item in assignment.items if item.selected]
+ batch = AskAssignmentBatch(
+ assignment_id=assignment.id,
+ status="pending",
+ selected_question_ids=",".join(str(value) for value in selected_ids),
+ )
+ db.add(batch)
+ db.flush()
+ for upload in files:
+ db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="question", filename=upload.filename or "question", content_type=upload.content_type, image_data=await upload.read()))
+ db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="payment", filename=payment_file.filename or "payment", content_type=payment_file.content_type, image_data=await payment_file.read()))
+ assignment.status = "待审核"
+ used_ids = {item.question_id for item in assignment.used_questions}
+ for item in assignment.items:
+ if item.selected and item.question_id not in used_ids:
+ db.add(AskAssignmentUsedQuestion(assignment_id=assignment.id, question_id=item.question_id))
+ used_ids.add(item.question_id)
+ assignment.updated_at = datetime.utcnow()
+ db.commit(); db.refresh(batch); db.refresh(assignment)
+ return {"success": True, "data": _ask_batch_data(batch)}
+
+
+@app.get("/api/admin/ask-completion-reviews")
+def ask_completion_reviews(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ batches = db.scalars(
+ select(AskAssignmentBatch)
+ .where(AskAssignmentBatch.status == "pending")
+ .order_by(AskAssignmentBatch.updated_at.desc())
+ ).unique().all()
+ legacy = db.scalars(
+ select(AskAssignment).where(
+ AskAssignment.status == "待审核",
+ ~AskAssignment.batches.any(),
+ AskAssignment.screenshots.any(),
+ ).order_by(AskAssignment.updated_at.desc())
+ ).unique().all()
+ data = [_ask_batch_data(batch, include_images=False) for batch in batches]
+ data.extend(_ask_assignment_data(record, include_images=False) for record in legacy)
+ return {"success": True, "data": data}
+
+
+@app.get("/api/admin/review-pending-count")
+def review_pending_count(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ """Return lightweight sidebar counts without loading review rows or images."""
+ answer_count = db.scalar(
+ select(func.count(AnswerSubmission.id)).where(AnswerSubmission.status == "待审核")
+ ) or 0
+ ask_batch_count = db.scalar(
+ select(func.count(AskAssignmentBatch.id)).where(AskAssignmentBatch.status == "pending")
+ ) or 0
+ legacy_ask_count = db.scalar(
+ select(func.count(AskAssignment.id)).where(
+ AskAssignment.status == "待审核",
+ ~AskAssignment.batches.any(),
+ AskAssignment.screenshots.any(),
+ )
+ ) or 0
+ ask_count = ask_batch_count + legacy_ask_count
+ return {
+ "success": True,
+ "data": {
+ "ask": ask_count,
+ "answer": answer_count,
+ "total": ask_count + answer_count,
+ },
+ }
+
+
+@app.get("/api/admin/ask-completion-reviews/{assignment_id}")
+def ask_completion_review_detail(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ batch = db.get(AskAssignmentBatch, assignment_id)
+ if batch:
+ return {"success": True, "data": _ask_batch_data(batch)}
+ assignment = db.get(AskAssignment, assignment_id)
+ if not assignment:
+ raise HTTPException(status_code=404, detail="提问审核任务不存在")
+ return {"success": True, "data": _ask_assignment_data(assignment)}
+
+
+@app.post("/api/admin/ask-completion-reviews/{assignment_id}/decision")
+def ask_completion_decision(assignment_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ batch = db.get(AskAssignmentBatch, assignment_id)
+ if batch:
+ if batch.status != "pending":
+ raise HTTPException(status_code=409, detail="review already completed")
+ approved = bool(payload.get("approved"))
+ reason_code = str(payload.get("reason_code", "")).strip() or None
+ reason_text = str(payload.get("reason_text", "")).strip() or None
+ if not approved and not (reason_code or reason_text):
+ raise HTTPException(status_code=400, detail="review reason is required")
+ db.add(AskAssignmentBatchReview(batch_id=batch.id, approved=approved, reason_code=reason_code, reason_text=reason_text))
+ batch.status = "approved" if approved else "rejected"
+ _sync_ask_assignment_status(batch.assignment)
+ db.commit(); db.refresh(batch)
+ return {"success": True, "data": _ask_batch_data(batch)}
+ assignment = db.get(AskAssignment, assignment_id)
+ if not assignment:
+ raise HTTPException(status_code=404, detail="提问审核任务不存在")
+ if assignment.status != "待审核":
+ raise HTTPException(status_code=409, detail="任务不在待审核状态")
+ approved = bool(payload.get("approved"))
+ reason_code = str(payload.get("reason_code", "")).strip() or None
+ reason_text = str(payload.get("reason_text", "")).strip() or None
+ if not approved and not (reason_code or reason_text):
+ raise HTTPException(status_code=400, detail="请选择或填写审核不通过原因")
+ db.add(AskAssignmentReview(assignment_id=assignment.id, approved=approved, reason_code=reason_code, reason_text=reason_text))
+ if approved:
+ used_ids = {item.question_id for item in assignment.used_questions}
+ for item in assignment.items:
+ if item.selected and item.question_id not in used_ids:
+ db.add(AskAssignmentUsedQuestion(assignment_id=assignment.id, question_id=item.question_id))
+ used_ids.add(item.question_id)
+ assignment.status = "已通过" if approved else "未通过"
+ assignment.updated_at = datetime.utcnow()
+ db.commit(); db.refresh(assignment)
+ return {"success": True, "data": _ask_assignment_data(assignment)}
+
+
+@app.get("/api/answer/me/submissions")
+def answer_my_submissions(token: str, include_images: bool = False, db: Session = Depends(get_db)):
+ user = _answer_user_from_token(token, db)
+ records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.user_id == user.id).order_by(AnswerSubmission.created_at.desc())).unique().all()
+ return {"success": True, "data": [_submission_data(record, include_images=include_images) for record in records]}
+
+
+@app.get("/api/answer/me/submissions/{submission_id}")
+def answer_submission_detail(submission_id: int, token: str, db: Session = Depends(get_db)):
+ user = _answer_user_from_token(token, db)
+ record = db.get(AnswerSubmission, submission_id)
+ if not record or record.user_id != user.id:
+ raise HTTPException(status_code=404, detail="任务不存在")
+ return {"success": True, "data": _submission_data(record, include_images=True)}
+
+
+def _process_answer_submission(submission_id: int):
+ """Run OCR and answer drafting after the upload response has been sent."""
+ db = SessionLocal()
+ try:
+ record = db.get(AnswerSubmission, submission_id)
+ if not record or record.status != "待筛选":
+ return
+ saved = [shot for shot in record.screenshots if shot.stage == "invited"]
+ extracted = extract_questions_from_images([(shot.image_data, shot.content_type) for shot in saved])
+ if not extracted:
+ extracted = [
+ {"product_title": "待识别商品", "question": "这款商品使用起来感觉怎么样?"},
+ {"product_title": "待识别商品", "question": "敏感肌可以使用这款商品吗?"},
+ {"product_title": "待识别商品", "question": "使用后的肤感和效果怎么样?"},
+ ][:max(1, min(3, len(saved)))]
+ for index, item in enumerate(extracted):
+ product, question = item["product_title"], item["question"]
+ db.add(AnswerRecognizedQuestion(
+ submission_id=record.id,
+ source_screenshot_id=saved[index % len(saved)].id,
+ product_image_screenshot_id=saved[index % len(saved)].id,
+ sequence_no=index + 1,
+ product_title=product,
+ question_text=question,
+ answer_text=generate_answer_for_question(product, question),
+ answer_status="已生成",
+ ))
+ record.updated_at = datetime.utcnow()
+ db.commit()
+ except Exception:
+ db.rollback()
+ # Keep the submission visible to operators even if OCR/model is unavailable.
+ try:
+ record = db.get(AnswerSubmission, submission_id)
+ saved = [shot for shot in (record.screenshots if record else []) if shot.stage == "invited"]
+ if record and not record.questions and saved:
+ for index, question in enumerate((
+ "这款商品使用起来感觉怎么样?",
+ "敏感肌可以使用这款商品吗?",
+ "使用后的肤感和效果怎么样?",
+ )[:max(1, min(3, len(saved)))]):
+ db.add(AnswerRecognizedQuestion(
+ submission_id=record.id,
+ source_screenshot_id=saved[index % len(saved)].id,
+ product_image_screenshot_id=saved[index % len(saved)].id,
+ sequence_no=index + 1,
+ product_title="待识别商品",
+ question_text=question,
+ answer_text="建议结合自身需求和肤质先少量试用。",
+ answer_status="待补充",
+ ))
+ db.commit()
+ except Exception:
+ db.rollback()
+ finally:
+ db.close()
+
+
+@app.post("/api/answer/me/submissions")
+async def answer_create_submission(
+ background_tasks: BackgroundTasks,
+ token: str = Form(...),
+ files: list[UploadFile] = File(...),
+ db: Session = Depends(get_db),
+):
+ user = _answer_user_from_token(token, db)
+ if not files or len(files) > 10:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最多上传10张截图")
+ # 先落库并立即返回;OCR/回答生成放到响应后的后台任务执行。
+ record = AnswerSubmission(submission_no=f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", user_id=user.id, status="待筛选")
+ db.add(record); db.flush()
+ saved = []
+ for upload in files:
+ if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}:
+ db.rollback(); raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片")
+ shot = AnswerScreenshot(submission_id=record.id, stage="invited", filename=upload.filename or "screenshot", content_type=upload.content_type, image_data=await upload.read())
+ db.add(shot); db.flush(); saved.append(shot)
+ db.commit(); db.refresh(record)
+ background_tasks.add_task(_process_answer_submission, record.id)
+ # 先返回轻量结果,图片由后续状态查询按需加载。
+ return {"success": True, "data": _submission_data(record, include_images=False)}
+
+
+@app.get("/api/admin/answer-task-drafts")
+def admin_answer_task_drafts(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.status.in_(["待筛选", "待运营派发", "已派发", "待用户完成"])).order_by(AnswerSubmission.created_at.desc())).unique().all()
+ return {"success": True, "data": [_submission_data(record, include_images=False) for record in records]}
+
+
+@app.get("/api/admin/answer-task-drafts/{submission_id}")
+def admin_answer_task_draft_detail(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ record = db.get(AnswerSubmission, submission_id)
+ if not record or record.status not in {"待筛选", "待运营派发", "已派发", "待用户完成"}:
+ raise HTTPException(status_code=404, detail="回答任务不存在")
+ return {"success": True, "data": _submission_data(record, include_images=True)}
+
+
+@app.post("/api/admin/ask-tasks/generate")
+def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ product_link = str(payload.get("product_link", "")).strip()
+ product_name = str(payload.get("product_name", "")).strip()
+ if not product_link: raise HTTPException(status_code=400, detail="商品链接为必填项")
+ if not product_name and not any(str(payload.get(key, "")).strip() for key in ("category", "benefits", "selling_points")):
+ raise HTTPException(status_code=400, detail="请至少填写一项商品信息")
+ try:
+ count = int(payload.get("question_count", 5))
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail="提问生成条数必须是正整数")
+ if count < 1:
+ raise HTTPException(status_code=400, detail="提问生成条数必须大于 0")
+ task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", product_link=product_link, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "口语(亲切随性)")), target_group=str(payload.get("target_group", "女性")), usage_scene=str(payload.get("usage_scene", "居家")), purchase_channel=str(payload.get("purchase_channel", "电商平台")), status="草稿")
+ db.add(task); db.flush()
+ templates = [
+ "这款商品适合什么人群使用?",
+ "使用起来方便吗?效果怎么样?",
+ "日常使用有什么需要注意的吗?",
+ "这款商品和同类产品相比有什么特点?",
+ "新手使用是否容易上手?",
+ "这款商品建议多久使用一次?",
+ "它的质地和实际使用感怎么样?",
+ "使用后吸收速度快吗?会不会有黏腻感?",
+ "敏感人群可以使用这款商品吗?",
+ "不同季节使用时效果会有区别吗?",
+ "它在日常使用步骤中应该放在哪一步?",
+ "每次使用多少量比较合适?",
+ "通常需要坚持使用多久才能感受到效果?",
+ "这款商品可以和其他同类商品搭配使用吗?",
+ "包装是否方便携带和日常使用?",
+ "开封后应该如何保存?",
+ "这款商品的性价比怎么样?",
+ "第一次购买建议选择哪种规格?",
+ "使用过程中有哪些容易忽略的小细节?",
+ "实际体验后值得再次购买吗?",
+ ]
+ questions = payload.get("questions") if isinstance(payload.get("questions"), list) else []
+ reference_text = str(payload.get("reference_qa", ""))[:20000]
+ if reference_text:
+ for line in reference_text.splitlines():
+ clean = line.strip().lstrip("-•*0123456789. ")
+ if clean.startswith(("问题:", "问题:", "Q:", "Q:")):
+ clean = clean.split(":", 1)[1].strip()
+ if clean and "回答" not in clean and len(clean) <= 200:
+ questions.append(clean)
+ # 自定义问题优先,不足的数量用候选问题补齐,保证返回数量与运营填写值一致。
+ generated: list[str] = []
+ for value in questions:
+ question = str(value).strip()
+ if question and question not in generated:
+ generated.append(question)
+ if len(generated) == count:
+ break
+ template_index = 0
+ while len(generated) < count:
+ base = templates[template_index % len(templates)]
+ round_no = template_index // len(templates)
+ if round_no == 0:
+ question = base
+ else:
+ scene = str(payload.get("usage_scene", "日常")).strip() or "日常"
+ question = f"在{scene}场景的第{round_no + 1}种使用情况下,{base}"
+ if question not in generated:
+ generated.append(question)
+ template_index += 1
+ for index, question in enumerate(generated): db.add(AskQuestion(task_id=task.id, sequence_no=index + 1, question_text=question, selected=True))
+ db.commit(); db.refresh(task)
+ return {"success": True, "data": _ask_task_data(task)}
+
+
+@app.patch("/api/admin/ask-tasks/{task_id}/questions/{question_id}")
+def update_ask_question(task_id: int, question_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ question = db.scalar(select(AskQuestion).where(AskQuestion.id == question_id, AskQuestion.task_id == task_id))
+ if not question: raise HTTPException(status_code=404, detail="提问不存在")
+ if question.task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能修改")
+ if "question" in payload:
+ value = str(payload.get("question", "")).strip()
+ if not value: raise HTTPException(status_code=400, detail="提问内容不能为空")
+ question.question_text = value
+ if "selected" in payload: question.selected = bool(payload["selected"])
+ db.commit(); db.refresh(question)
+ return {"success": True, "data": {"id": question.id, "question": question.question_text, "selected": question.selected}}
+
+
+@app.post("/api/admin/ask-tasks/{task_id}/questions")
+def add_ask_question(task_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ task = db.get(AskTask, task_id)
+ text = str(payload.get("question", "")).strip()
+ if not task: raise HTTPException(status_code=404, detail="提问任务不存在")
+ if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能添加提问")
+ if not text: raise HTTPException(status_code=400, detail="提问内容不能为空")
+ question = AskQuestion(task_id=task.id, sequence_no=len(task.questions) + 1, question_text=text, selected=True)
+ db.add(question); db.commit(); db.refresh(question)
+ return {"success": True, "data": {"id": question.id, "sequence_no": question.sequence_no, "question": question.question_text, "selected": question.selected}}
+
+
+@app.post("/api/admin/ask-tasks/{task_id}/publish")
+def publish_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ task = db.get(AskTask, task_id)
+ if not task: raise HTTPException(status_code=404, detail="提问任务不存在")
+ if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布")
+ if not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问")
+ task.status = "已发布"; db.commit(); db.refresh(task)
+ return {"success": True, "data": _ask_task_data(task)}
+
+
+@app.post("/api/admin/ask-tasks/{task_id}/cancel")
+def cancel_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ task = db.get(AskTask, task_id)
+ if not task: raise HTTPException(status_code=404, detail="提问任务不存在")
+ if task.status != "已发布": raise HTTPException(status_code=409, detail="当前任务不能撤销")
+ task.status = "已撤销"; db.commit(); db.refresh(task)
+ return {"success": True, "data": _ask_task_data(task)}
+
+
+@app.patch("/api/admin/answer-task-drafts/questions/{question_id}")
+def update_answer_question(question_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ question = db.get(AnswerRecognizedQuestion, question_id)
+ if not question: raise HTTPException(status_code=404, detail="问题不存在")
+ if "selected" in payload: question.selected = bool(payload["selected"])
+ if str(payload.get("answer", "")).strip(): question.answer_text = str(payload["answer"]).strip()
+ if payload.get("regenerate"):
+ question.answer_text = generate_answer_for_question(question.product_title, question.question_text)
+ db.commit(); db.refresh(question)
+ return {"success": True, "data": {"id": question.id, "answer": question.answer_text, "selected": question.selected}}
+
+
+@app.post("/api/admin/answer-task-drafts/{submission_id}/publish")
+def publish_answer_task(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ record = db.get(AnswerSubmission, submission_id)
+ if not record: raise HTTPException(status_code=404, detail="提交记录不存在")
+ if record.status not in {"待筛选", "待运营派发", "已派发"}: raise HTTPException(status_code=409, detail="当前任务已经发布或不能发布")
+ if not any(q.selected for q in record.questions): raise HTTPException(status_code=400, detail="请至少选择一个问题")
+ record.status = "待用户完成"; db.commit()
+ return {"success": True, "data": _submission_data(record, include_images=False)}
+
+
+@app.get("/api/admin/task-board")
+def admin_task_board(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"]
+ records = db.scalars(
+ select(AnswerSubmission).options(
+ selectinload(AnswerSubmission.user),
+ selectinload(AnswerSubmission.questions),
+ noload(AnswerSubmission.screenshots),
+ noload(AnswerSubmission.review_decisions),
+ )
+ .where(AnswerSubmission.status.in_(task_statuses))
+ .order_by(AnswerSubmission.updated_at.desc())
+ ).unique().all()
+ ask_tasks = db.scalars(select(AskTask).where(AskTask.status.in_(["已发布", "已完成", "已撤销"])).order_by(AskTask.updated_at.desc())).unique().all()
+ approved_ask_batches = db.scalars(
+ select(AskAssignmentBatch)
+ .options(
+ selectinload(AskAssignmentBatch.assignment).selectinload(AskAssignment.task),
+ selectinload(AskAssignmentBatch.assignment).selectinload(AskAssignment.user),
+ noload(AskAssignmentBatch.screenshots),
+ noload(AskAssignmentBatch.review_decisions),
+ )
+ .where(AskAssignmentBatch.status.in_(["approved", "paid"]))
+ .order_by(AskAssignmentBatch.updated_at.desc())
+ ).unique().all()
+ approved_legacy_ask_assignments = db.scalars(
+ select(AskAssignment)
+ .options(
+ selectinload(AskAssignment.task),
+ selectinload(AskAssignment.user),
+ selectinload(AskAssignment.items).selectinload(AskAssignmentQuestion.question),
+ selectinload(AskAssignment.used_questions),
+ noload(AskAssignment.screenshots),
+ noload(AskAssignment.review_decisions),
+ )
+ .where(
+ AskAssignment.status.in_(["已通过", "已完成"]),
+ ~AskAssignment.batches.any(),
+ AskAssignment.screenshots.any(),
+ )
+ .order_by(AskAssignment.updated_at.desc())
+ ).unique().all()
+ data = (
+ [_task_board_data(record, include_images=False) for record in records]
+ + [_ask_task_data(task, include_questions=False) for task in ask_tasks]
+ + [_ask_review_board_data(batch, include_images=False) for batch in approved_ask_batches]
+ + [_ask_review_board_data(assignment, include_images=False) for assignment in approved_legacy_ask_assignments]
+ )
+ data.sort(key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True)
+ return {"success": True, "data": data}
+
+
+@app.get("/api/admin/task-board/ask-tasks/{task_id}")
+def admin_task_board_ask_task_detail(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ task = db.get(AskTask, task_id)
+ if not task or task.status not in {"已发布", "已完成", "已撤销"}:
+ raise HTTPException(status_code=404, detail="提问任务不存在")
+ return {"success": True, "data": _ask_task_data(task, include_questions=True)}
+
+
+@app.delete("/api/admin/task-board/ask-tasks/{task_id}")
+def admin_task_board_delete_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ task = db.get(AskTask, task_id)
+ if not task:
+ raise HTTPException(status_code=404, detail="提问任务不存在")
+ db.delete(task)
+ db.commit()
+ return {"success": True, "data": {"id": task_id}}
+
+
+@app.get("/api/admin/task-board/ask-batches/{batch_id}")
+def admin_task_board_ask_batch_detail(batch_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ batch = db.get(AskAssignmentBatch, batch_id)
+ if not batch or batch.status not in {"approved", "paid"}:
+ raise HTTPException(status_code=404, detail="待返款提问记录不存在")
+ return {"success": True, "data": _ask_review_board_data(batch, include_images=True)}
+
+
+@app.delete("/api/admin/task-board/ask-batches/{batch_id}")
+def admin_task_board_delete_ask_batch(batch_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ batch = db.get(AskAssignmentBatch, batch_id)
+ if not batch:
+ raise HTTPException(status_code=404, detail="提问提交记录不存在")
+ db.delete(batch)
+ db.commit()
+ return {"success": True, "data": {"id": batch_id}}
+
+
+@app.get("/api/admin/task-board/ask-assignments/{assignment_id}")
+def admin_task_board_ask_assignment_detail(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ assignment = db.get(AskAssignment, assignment_id)
+ if not assignment or assignment.status not in {"已通过", "已完成"}:
+ raise HTTPException(status_code=404, detail="待返款提问记录不存在")
+ return {"success": True, "data": _ask_review_board_data(assignment, include_images=True)}
+
+
+@app.delete("/api/admin/task-board/ask-assignments/{assignment_id}")
+def admin_task_board_delete_ask_assignment(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ assignment = db.get(AskAssignment, assignment_id)
+ if not assignment:
+ raise HTTPException(status_code=404, detail="提问分配记录不存在")
+ db.delete(assignment)
+ db.commit()
+ return {"success": True, "data": {"id": assignment_id}}
+
+
+@app.post("/api/admin/task-board/ask-batches/{batch_id}/confirm-refund")
+def confirm_ask_batch_refund(batch_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ batch = db.get(AskAssignmentBatch, batch_id)
+ if not batch:
+ raise HTTPException(status_code=404, detail="待返款提问记录不存在")
+ if batch.status == "paid":
+ raise HTTPException(status_code=409, detail="该任务已经完成返款")
+ if batch.status != "approved":
+ raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
+ batch.status = "paid"
+ batch.updated_at = datetime.utcnow()
+ db.commit(); db.refresh(batch)
+ return {"success": True, "data": _ask_review_board_data(batch, include_images=False)}
+
+
+@app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund")
+def confirm_ask_assignment_refund(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ assignment = db.get(AskAssignment, assignment_id)
+ if not assignment:
+ raise HTTPException(status_code=404, detail="待返款提问记录不存在")
+ if assignment.status == "已完成":
+ raise HTTPException(status_code=409, detail="该任务已经完成返款")
+ if assignment.status != "已通过":
+ raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
+ assignment.status = "已完成"
+ assignment.updated_at = datetime.utcnow()
+ db.commit(); db.refresh(assignment)
+ return {"success": True, "data": _ask_review_board_data(assignment, include_images=False)}
+
+
+@app.get("/api/admin/task-board/{submission_id}")
+def admin_task_board_detail(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ record = db.get(AnswerSubmission, submission_id)
+ if not record or record.status not in {"待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"}:
+ raise HTTPException(status_code=404, detail="任务不存在")
+ return {"success": True, "data": _task_board_data(record)}
+
+
+@app.delete("/api/admin/task-board/{submission_id}")
+def admin_task_board_delete_submission(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ record = db.get(AnswerSubmission, submission_id)
+ if not record:
+ raise HTTPException(status_code=404, detail="任务不存在")
+ db.delete(record)
+ db.commit()
+ return {"success": True, "data": {"id": submission_id}}
+
+
+@app.post("/api/admin/task-board/{submission_id}/confirm-refund")
+def confirm_answer_refund(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ record = db.get(AnswerSubmission, submission_id)
+ if not record:
+ raise HTTPException(status_code=404, detail="待返款回答记录不存在")
+ if record.status == "已完成":
+ raise HTTPException(status_code=409, detail="该任务已经完成返款")
+ if record.status != "已通过":
+ raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
+ record.status = "已完成"
+ db.commit(); db.refresh(record)
+ return {"success": True, "data": _task_board_data(record, include_images=False)}
+
+
+@app.post("/api/admin/task-board/{submission_id}/cancel")
+def admin_task_board_cancel(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ record = db.get(AnswerSubmission, submission_id)
+ if not record:
+ raise HTTPException(status_code=404, detail="任务不存在")
+ if record.status == "已撤销":
+ raise HTTPException(status_code=409, detail="任务已经撤销")
+ if record.status == "已通过":
+ raise HTTPException(status_code=409, detail="已完成任务不能撤销")
+ if record.status not in {"待用户完成", "待审核", "未通过"}:
+ raise HTTPException(status_code=400, detail="当前状态不能撤销")
+ record.status = "已撤销"
+ db.commit(); db.refresh(record)
+ return {"success": True, "data": _task_board_data(record)}
+
+
+@app.post("/api/answer/me/submissions/{submission_id}/completion")
+async def answer_completion(submission_id: int, token: str = Form(...), files: list[UploadFile] = File(...), payment_files: list[UploadFile] = File(default=[]), db: Session = Depends(get_db)):
+ user = _answer_user_from_token(token, db); record = db.get(AnswerSubmission, submission_id)
+ if not record or record.user_id != user.id: raise HTTPException(status_code=404, detail="任务不存在")
+ if record.status not in ("待用户完成", "未通过"): raise HTTPException(status_code=400, detail="当前任务不能提交完成截图")
+ if not files or len(files) > 10: raise HTTPException(status_code=400, detail="最多上传10张截图")
+ if not payment_files or len(payment_files) > 1: raise HTTPException(status_code=400, detail="请上传收款二维码后再提交")
+ if record.status == "未通过":
+ for old_screenshot in list(record.screenshots):
+ if old_screenshot.stage in {"completion", "payment"}:
+ db.delete(old_screenshot)
+ for upload in files:
+ if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=400, detail="仅支持图片")
+ db.add(AnswerScreenshot(submission_id=record.id, stage="completion", filename=upload.filename or "completion", content_type=upload.content_type, image_data=await upload.read()))
+ for upload in payment_files[:1]:
+ if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=400, detail="收款码仅支持图片")
+ db.add(AnswerScreenshot(submission_id=record.id, stage="payment", filename=upload.filename or "payment", content_type=upload.content_type, image_data=await upload.read()))
+ record.status = "待审核"; db.commit()
+ # 只返回轻量状态,避免重新编码截图阻塞审核状态切换。
+ return {"success": True, "data": _submission_data(record, include_images=False)}
+
+
+@app.get("/api/admin/answer-completion-reviews")
+def answer_completion_reviews(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ records = db.scalars(
+ select(AnswerSubmission)
+ .where(AnswerSubmission.status == "待审核")
+ .order_by(AnswerSubmission.updated_at.desc())
+ ).unique().all()
+ return {"success": True, "data": [_answer_review_data(record, include_images=False) for record in records]}
+
+
+@app.get("/api/admin/answer-completion-reviews/{submission_id}")
+def answer_completion_review_detail(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ record = db.get(AnswerSubmission, submission_id)
+ if not record:
+ raise HTTPException(status_code=404, detail="任务不存在")
+ return {"success": True, "data": _answer_review_data(record)}
+
+
+@app.post("/api/admin/answer-completion-reviews/{submission_id}/decision")
+def answer_completion_decision(submission_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ record = db.get(AnswerSubmission, submission_id)
+ if not record: raise HTTPException(status_code=404, detail="任务不存在")
+ if record.status != "待审核": raise HTTPException(status_code=409, detail="任务不在待审核状态")
+ approved = bool(payload.get("approved"))
+ reason_code = str(payload.get("reason_code", "")).strip() or None
+ reason_text = str(payload.get("reason_text", "")).strip() or None
+ if not approved and not (reason_code or reason_text):
+ raise HTTPException(status_code=400, detail="请选择或填写审核不通过原因")
+ db.add(AnswerReviewDecision(submission_id=record.id, approved=approved, reason_code=reason_code, reason_text=reason_text))
+ record.status = "已通过" if approved else "未通过"; db.commit(); db.refresh(record)
+ return {"success": True, "data": _submission_data(record)}
+
+
+@app.post("/api/answer/public/submissions")
+async def public_answer_submission(
+ client_id: str = Form(default="anonymous-browser"),
+ user_name: str = Form(default=""),
+ files: list[UploadFile] = File(...),
+):
+ if not files or len(files) > 10:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最多上传10张截图")
+ screenshots = []
+ for upload in files:
+ if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片")
+ raw = await upload.read()
+ screenshots.append({"name": upload.filename or "screenshot", "content_type": upload.content_type, "data_url": f"data:{upload.content_type};base64,{base64.b64encode(raw).decode()}"})
+ submission_id = f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}"
+ recognized = [{"id": f"Q{index + 1:03d}", "question": question["question"], "source_image": screenshots[index % len(screenshots)]["name"]} for index, question in enumerate(ANSWER_TASKS["TASK20260822002"]["questions"])]
+ record = {"task_id": submission_id, "client_id": client_id.strip()[:64], "user_name": user_name.strip()[:64] or "未填写", "product_name": "待识别商品", "status": "待筛选", "recognized_questions": recognized, "invited_screenshots": screenshots, "final_screenshots": [], "selected_question_ids": [], "updated_at": datetime.utcnow().isoformat()}
+ PUBLIC_ANSWER_SUBMISSIONS.append(record)
+ return {"success": True, "data": record}
+
+
+@app.get("/api/answer/public/submissions")
+def public_answer_submission_history(client_id: str):
+ if not client_id:
+ return {"success": True, "data": []}
+ records = [item for item in PUBLIC_ANSWER_SUBMISSIONS if item.get("client_id") == client_id]
+ return {"success": True, "data": sorted(records, key=lambda item: item["updated_at"], reverse=True)}
+
+
+@app.get("/api/answer/tasks/{task_id}")
+def answer_task_detail(task_id: str, token: str):
+ return {"success": True, "data": _public_answer_task(_answer_task(task_id, token))}
+
+
+@app.post("/api/answer/tasks/{task_id}/invited-screenshots")
+async def upload_invited_screenshots(
+ task_id: str,
+ token: str = Form(...),
+ user_name: str = Form(default=""),
+ files: list[UploadFile] = File(...),
+):
+ task = _answer_task(task_id, token)
+ if not files or len(files) > 10:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最多上传10张截图")
+ screenshots = []
+ for upload in files:
+ if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片")
+ raw = await upload.read()
+ # MVP 暂存内存,后续接入对象存储;不写入数据库或磁盘。
+ screenshots.append({"name": upload.filename or "screenshot", "content_type": upload.content_type, "data_url": f"data:{upload.content_type};base64,{base64.b64encode(raw).decode()}"})
+ task["invited_screenshots"] = screenshots
+ task["user_name"] = user_name.strip()[:64]
+ task["recognized_questions"] = [
+ {"id": question["id"], "question": question["question"], "source_image": screenshots[index % len(screenshots)]["name"]}
+ for index, question in enumerate(task["questions"])
+ ]
+ task["status"] = "待筛选"
+ task["updated_at"] = datetime.utcnow().isoformat()
+ return {"success": True, "data": {"status": "已提交审核", "recognized_count": len(task["recognized_questions"])}}
+
+
+@app.post("/api/answer/tasks/{task_id}/select")
+def select_answer_questions(task_id: str, payload: dict):
+ task = _answer_task(task_id, str(payload.get("token", "")))
+ question_ids = payload.get("question_ids") or []
+ valid_ids = {question["id"] for question in task["questions"]}
+ task["selected_question_ids"] = [str(value) for value in question_ids if str(value) in valid_ids]
+ task["status"] = "待完成"
+ task["updated_at"] = datetime.utcnow().isoformat()
+ return {"success": True, "data": _public_answer_task(task)}
+
+
+@app.post("/api/answer/tasks/{task_id}/final-screenshots")
+async def upload_final_screenshots(
+ task_id: str,
+ token: str = Form(...),
+ files: list[UploadFile] = File(...),
+):
+ task = _answer_task(task_id, token)
+ if not files or len(files) > 10:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最多上传10张截图")
+ screenshots = []
+ for upload in files:
+ if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片")
+ raw = await upload.read()
+ screenshots.append({"name": upload.filename or "screenshot", "content_type": upload.content_type, "data_url": f"data:{upload.content_type};base64,{base64.b64encode(raw).decode()}"})
+ task["final_screenshots"] = screenshots
+ task["status"] = "待审核"
+ task["updated_at"] = datetime.utcnow().isoformat()
+ return {"success": True, "data": {"status": "已提交审核"}}
+
+
+@app.get("/api/admin/reviews")
+def reviews(_: AdminUser = Depends(current_user)):
+ records = list(PUBLIC_ANSWER_SUBMISSIONS)
+ for task in ANSWER_TASKS.values():
+ if task["status"] in {"待筛选", "待审核", "已通过", "未通过"}:
+ records.append({
+ "task_id": task["task_id"], "user_name": task.get("user_name") or "未填写",
+ "status": task["status"], "product_name": task["product_name"],
+ "recognized_questions": task.get("recognized_questions", []),
+ "selected_questions": [q for q in task["questions"] if q["id"] in set(task.get("selected_question_ids", []))],
+ "invited_screenshots": [{"name": x["name"], "data_url": x["data_url"]} for x in task.get("invited_screenshots", [])],
+ "final_screenshots": [{"name": x["name"], "data_url": x["data_url"]} for x in task.get("final_screenshots", [])],
+ "updated_at": task["updated_at"],
+ })
+ return {"success": True, "data": records}
+
+
+@app.post("/api/admin/reviews/{task_id}/decision")
+def review_decision(task_id: str, payload: dict, _: AdminUser = Depends(current_user)):
+ task = ANSWER_TASKS.get(task_id) or next((item for item in PUBLIC_ANSWER_SUBMISSIONS if item["task_id"] == task_id), None)
+ if not task:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审核记录不存在")
+ task["status"] = "已通过" if bool(payload.get("approved")) else "未通过"
+ task["updated_at"] = datetime.utcnow().isoformat()
+ return {"success": True, "data": {"task_id": task_id, "status": task["status"]}}
+
+
+@app.post("/api/admin/qa/generate")
+def qa_generate(payload: QAGenerateRequest, _: AdminUser = Depends(current_user)):
+ if not any(value.strip() for value in (payload.product_name, payload.category, payload.benefits, payload.selling_points)):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少填写一项商品信息")
+ return {"success": True, "data": {"items": generate_qa(payload)}}
+
+
+def _qa_library_data(entry: QALibraryEntry) -> dict:
+ try:
+ answers = json.loads(entry.answers_json)
+ except (TypeError, ValueError):
+ answers = [entry.answers_json] if entry.answers_json else []
+ return {
+ "id": entry.id,
+ "product_name": entry.product_name,
+ "category": entry.category,
+ "benefits": entry.benefits,
+ "selling_points": entry.selling_points,
+ "question": entry.question_text,
+ "answers": answers,
+ "source": entry.source,
+ "created_by": entry.created_by,
+ "created_at": entry.created_at.isoformat(),
+ }
+
+
+@app.get("/api/admin/qa-library")
+def qa_library(search: str = "", _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ statement = select(QALibraryEntry)
+ keyword = search.strip()
+ if keyword:
+ pattern = f"%{keyword}%"
+ statement = statement.where(
+ or_(
+ QALibraryEntry.product_name.like(pattern),
+ QALibraryEntry.category.like(pattern),
+ QALibraryEntry.question_text.like(pattern),
+ QALibraryEntry.answers_json.like(pattern),
+ )
+ )
+ records = db.scalars(statement.order_by(QALibraryEntry.created_at.desc()).limit(500)).all()
+ return {"success": True, "data": [_qa_library_data(record) for record in records]}
+
+
+@app.post("/api/admin/qa-library")
+def save_qa_library(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ raw_items = payload.get("items") or []
+ if not isinstance(raw_items, list) or not raw_items:
+ raise HTTPException(status_code=400, detail="请选择需要采纳的问答")
+ product_name = str(payload.get("product_name", "")).strip()
+ category = str(payload.get("category", "")).strip()
+ benefits = str(payload.get("benefits", "")).strip()
+ selling_points = str(payload.get("selling_points", "")).strip()
+ prepared = []
+ for item in raw_items:
+ question = str(item.get("question", "")).strip()
+ answers = [str(value).strip() for value in (item.get("answers") or []) if str(value).strip()]
+ if not question or not answers:
+ continue
+ normalized = json.dumps(
+ {"product": product_name, "question": question, "answers": answers},
+ ensure_ascii=False,
+ sort_keys=True,
+ )
+ fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
+ prepared.append((fingerprint, question, answers, str(item.get("source", "AI生成")).strip() or "AI生成"))
+ if not prepared:
+ raise HTTPException(status_code=400, detail="问答内容不能为空")
+ fingerprints = [item[0] for item in prepared]
+ existing = set(
+ db.scalars(select(QALibraryEntry.fingerprint).where(QALibraryEntry.fingerprint.in_(fingerprints))).all()
+ )
+ created = []
+ for fingerprint, question, answers, source in prepared:
+ if fingerprint in existing:
+ continue
+ entry = QALibraryEntry(
+ fingerprint=fingerprint,
+ product_name=product_name,
+ category=category,
+ benefits=benefits,
+ selling_points=selling_points,
+ question_text=question,
+ answers_json=json.dumps(answers, ensure_ascii=False),
+ source=source,
+ created_by=user.display_name,
+ )
+ db.add(entry)
+ created.append(entry)
+ existing.add(fingerprint)
+ db.commit()
+ for entry in created:
+ db.refresh(entry)
+ return {
+ "success": True,
+ "data": {
+ "added_count": len(created),
+ "skipped_count": len(prepared) - len(created),
+ "items": [_qa_library_data(entry) for entry in created],
+ },
+ }
+
+
+@app.patch("/api/admin/qa-library/{entry_id}")
+def update_qa_library(entry_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ entry = db.get(QALibraryEntry, entry_id)
+ if not entry:
+ raise HTTPException(status_code=404, detail="问答记录不存在")
+ question = str(payload.get("question", entry.question_text)).strip()
+ answers = [str(value).strip() for value in (payload.get("answers") or []) if str(value).strip()]
+ if not question or not answers:
+ raise HTTPException(status_code=400, detail="问题和回答不能为空")
+ product_name = str(payload.get("product_name", entry.product_name)).strip()
+ category = str(payload.get("category", entry.category)).strip()
+ benefits = str(payload.get("benefits", entry.benefits)).strip()
+ selling_points = str(payload.get("selling_points", entry.selling_points)).strip()
+ normalized = json.dumps(
+ {"product": product_name, "question": question, "answers": answers},
+ ensure_ascii=False,
+ sort_keys=True,
+ )
+ fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
+ duplicate = db.scalar(
+ select(QALibraryEntry).where(
+ QALibraryEntry.fingerprint == fingerprint,
+ QALibraryEntry.id != entry.id,
+ )
+ )
+ if duplicate:
+ raise HTTPException(status_code=409, detail="修改后的内容已存在于问答库")
+ entry.fingerprint = fingerprint
+ entry.product_name = product_name
+ entry.category = category
+ entry.benefits = benefits
+ entry.selling_points = selling_points
+ entry.question_text = question
+ entry.answers_json = json.dumps(answers, ensure_ascii=False)
+ entry.created_by = user.display_name
+ db.commit()
+ db.refresh(entry)
+ return {"success": True, "data": _qa_library_data(entry)}
+
+
+@app.post("/api/admin/auth/login")
+def login(payload: LoginRequest, request: Request, response: Response, db: Session = Depends(get_db)):
+ user = db.scalar(select(AdminUser).where(AdminUser.username == payload.username))
+ valid = bool(user and user.is_active and verify_password(payload.password, user.password_hash))
+ db.add(AdminLoginLog(user_id=user.id if user else None, username=payload.username, success=valid, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent")))
+ if not valid:
+ db.commit()
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
+ user.last_login_at = datetime.utcnow()
+ db.commit()
+ response.set_cookie(settings.cookie_name, create_session_token(user.id, user.role_code), httponly=True, secure=settings.cookie_secure, samesite="lax", max_age=settings.session_hours * 3600)
+ return {"success": True, "data": {"user": public_user(user)}}
+
+
+@app.get("/api/admin/auth/dingtalk/config")
+def dingtalk_login_config():
+ return {"success": True, "data": {"enabled": _dingtalk_configured(), "redirect_uri": settings.dingtalk_redirect_uri}}
+
+
+@app.get("/api/admin/auth/dingtalk/start")
+def dingtalk_login_start():
+ if not _dingtalk_configured():
+ raise HTTPException(status_code=503, detail="钉钉登录尚未配置,请联系管理员")
+ state = secrets.token_urlsafe(24)
+ DINGTALK_STATES[state] = time.time() + 300
+ params = urllib.parse.urlencode({"redirect_uri": settings.dingtalk_redirect_uri, "client_id": settings.dingtalk_client_id, "response_type": "code", "scope": "openid", "state": state})
+ return {"success": True, "data": {"url": f"https://login.dingtalk.com/oauth2/auth?{params}"}}
+
+
+@app.get("/api/admin/auth/dingtalk/callback")
+def dingtalk_login_callback(response: Response, code: str = "", state: str = "", db: Session = Depends(get_db)):
+ expires_at = DINGTALK_STATES.pop(state, 0)
+ if not expires_at or expires_at < time.time():
+ raise HTTPException(status_code=400, detail="钉钉登录链接已失效,请重新从工作台打开")
+ user = _dingtalk_login(code, db, response)
+ response.headers["Location"] = f"{settings.frontend_url}/?dingtalk=success"
+ response.status_code = 302
+ return response
+
+
+@app.post("/api/admin/auth/register", status_code=status.HTTP_201_CREATED)
+def register(payload: RegisterRequest, db: Session = Depends(get_db)):
+ if db.scalar(select(AdminUser).where(AdminUser.username == payload.username)):
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已存在")
+ user = AdminUser(username=payload.username, password_hash=hash_password(payload.password), display_name=payload.display_name, role_code="operator")
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+ return {"success": True, "data": {"user": public_user(user)}}
+
+
+@app.get("/api/admin/auth/me")
+def me(user: AdminUser = Depends(current_user)):
+ return {"success": True, "data": {"user": public_user(user)}}
+
+
+@app.post("/api/admin/auth/logout")
+def logout(response: Response):
+ response.delete_cookie(settings.cookie_name)
+ return {"success": True}
+
+
+@app.get("/api/admin/auth/login-logs")
+def login_logs(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
+ logs = db.scalars(select(AdminLoginLog).order_by(AdminLoginLog.created_at.desc()).limit(100)).all()
+ return {"success": True, "data": [{"username": x.username, "success": x.success, "ip_address": x.ip_address, "created_at": x.created_at.isoformat()} for x in logs]}
+
+
+@app.get("/api/admin/users")
+def users(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
+ records = db.scalars(select(AdminUser).order_by(AdminUser.id.asc())).all()
+ return {
+ "success": True,
+ "data": [
+ {
+ "id": user.id,
+ "username": user.username,
+ "display_name": user.display_name,
+ "role_code": user.role_code,
+ "role_label": ROLE_LABELS.get(user.role_code, user.role_code),
+ "is_active": user.is_active,
+ "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
+ }
+ for user in records
+ ],
+ }
+
+
+@app.get("/api/admin/users/{user_id}")
+def user_detail(user_id: int, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
+ user = db.get(AdminUser, user_id)
+ if not user:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
+ return {"success": True, "data": public_user(user) | {"is_active": user.is_active, "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None}}
+
+
+@app.post("/api/admin/users", status_code=status.HTTP_201_CREATED)
+def create_user(payload: AdminUserCreate, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
+ if db.scalar(select(AdminUser).where(AdminUser.username == payload.username)):
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已存在")
+ user = AdminUser(username=payload.username, password_hash=hash_password(payload.password), display_name=payload.display_name, role_code=payload.role_code)
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+ return {"success": True, "data": {"user": public_user(user)}}
+
+
+@app.patch("/api/admin/users/{user_id}")
+def update_user(user_id: int, payload: AdminUserUpdate, current: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
+ user = db.get(AdminUser, user_id)
+ if not user:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
+ changes = payload.model_dump(exclude_unset=True)
+ if user.id == current.id and (changes.get("is_active") is False or changes.get("role_code") == "operator"):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能停用或降级当前登录账号")
+ if changes.get("role_code") == "operator" and user.role_code == "super_admin":
+ super_count = db.scalar(select(func.count()).select_from(AdminUser).where(AdminUser.role_code == "super_admin", AdminUser.is_active.is_(True))) or 0
+ if super_count <= 1:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少需要保留一个启用中的超级管理员")
+ if "password" in changes:
+ user.password_hash = hash_password(changes.pop("password"))
+ for key, value in changes.items():
+ setattr(user, key, value)
+ db.commit()
+ db.refresh(user)
+ return {"success": True, "data": {"user": public_user(user)}}
+
+
+@app.delete("/api/admin/users/{user_id}")
+def delete_user(user_id: int, current: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
+ user = db.get(AdminUser, user_id)
+ if not user:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
+ if user.id == current.id:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除当前登录账号")
+ if user.role_code == "super_admin":
+ super_count = db.scalar(select(func.count()).select_from(AdminUser).where(AdminUser.role_code == "super_admin", AdminUser.is_active.is_(True))) or 0
+ if super_count <= 1:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少需要保留一个启用中的超级管理员")
+ db.delete(user)
+ db.commit()
+ return {"success": True}
+
+
+@app.get("/api/admin/settings")
+def system_settings(_: AdminUser = Depends(require_super_admin)):
+ return {
+ "success": True,
+ "data": {
+ "app_name": settings.app_name,
+ "session_hours": settings.session_hours,
+ "database_driver": settings.database_url.split(":", 1)[0],
+ "cookie_secure": settings.cookie_secure,
+ "available_roles": [{"code": code, "label": label} for code, label in ROLE_LABELS.items()],
+ },
+ }
diff --git a/backend/app/models.py b/backend/app/models.py
new file mode 100644
index 0000000..e143a52
--- /dev/null
+++ b/backend/app/models.py
@@ -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")
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
new file mode 100644
index 0000000..eca56e5
--- /dev/null
+++ b/backend/app/schemas.py
@@ -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
diff --git a/backend/app/security.py b/backend/app/security.py
new file mode 100644
index 0000000..16454e0
--- /dev/null
+++ b/backend/app/security.py
@@ -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
diff --git a/backend/package-lock.json b/backend/package-lock.json
new file mode 100644
index 0000000..dfb18f1
--- /dev/null
+++ b/backend/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "backend",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..c724680
--- /dev/null
+++ b/backend/requirements.txt
@@ -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
diff --git a/backend/schema.sql b/backend/schema.sql
new file mode 100644
index 0000000..954c43a
--- /dev/null
+++ b/backend/schema.sql
@@ -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;
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
new file mode 100644
index 0000000..5744597
--- /dev/null
+++ b/backend/tests/test_auth.py
@@ -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"] == "本次任务已被领取完,请关注下一个任务!"
diff --git a/data/app.db b/data/app.db
new file mode 100644
index 0000000..8a0e5ed
Binary files /dev/null and b/data/app.db differ
diff --git a/data/async-smoke.db b/data/async-smoke.db
new file mode 100644
index 0000000..2c3d059
Binary files /dev/null and b/data/async-smoke.db differ
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..fb91b48
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1 @@
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..4d9509b
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,1213 @@
+{
+ "name": "wendajia-admin-web",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "wendajia-admin-web",
+ "version": "0.1.0",
+ "dependencies": {
+ "@vitejs/plugin-react": "latest",
+ "lucide-react": "latest",
+ "react": "latest",
+ "react-dom": "latest",
+ "typescript": "latest",
+ "vite": "latest"
+ },
+ "devDependencies": {}
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.146.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz",
+ "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz",
+ "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz",
+ "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz",
+ "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz",
+ "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz",
+ "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz",
+ "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz",
+ "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz",
+ "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz",
+ "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz",
+ "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz",
+ "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz",
+ "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz",
+ "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz",
+ "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz",
+ "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "license": "MIT"
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz",
+ "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.145.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "1.34.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.34.0.tgz",
+ "integrity": "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz",
+ "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==",
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.146.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.5",
+ "@rolldown/binding-android-arm64": "1.2.5",
+ "@rolldown/binding-darwin-arm64": "1.2.5",
+ "@rolldown/binding-darwin-x64": "1.2.5",
+ "@rolldown/binding-freebsd-x64": "1.2.5",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.5",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.5",
+ "@rolldown/binding-linux-arm64-musl": "1.2.5",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.5",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.5",
+ "@rolldown/binding-linux-x64-gnu": "1.2.5",
+ "@rolldown/binding-linux-x64-musl": "1.2.5",
+ "@rolldown/binding-openharmony-arm64": "1.2.5",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.5",
+ "@rolldown/binding-win32-x64-msvc": "1.2.5"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.26",
+ "rolldown": "~1.2.4",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0 || ^0.5.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..cc60149
--- /dev/null
+++ b/frontend/package.json
@@ -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": {}
+}
diff --git a/frontend/src/AnswerUserPortal.jsx b/frontend/src/AnswerUserPortal.jsx
new file mode 100644
index 0000000..adc0097
--- /dev/null
+++ b/frontend/src/AnswerUserPortal.jsx
@@ -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 <>
+
+ 淘宝问答承接● {latest ? STATUS_TEXT[latest.status] || latest.status : '待认领'}
用户ID:{session.user_id} · {maskPhone(session.phone)}{latest?.status === '待用户完成' && <> 派发任务编号:{latest.submission_no}>}
+ >
+}
+
+function Progress({ status }) {
+ const active = !status ? 1 : ['待筛选', '待运营派发', '已派发'].includes(status) ? 2 : ['待用户完成', '未通过'].includes(status) ? 3 : 4
+ const labels = ['被邀截图', '任务派发', '完成任务', '终审确认']
+ return {labels.map((label, index) => { const step = index + 1; const done = step < active || (active === 4 && ['已通过', '已完成'].includes(status)); return
{done ? : step}{label}
})}
+}
+
+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
+}
+
+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 <>✨ 任务认领与核验上传被邀请回答截图
提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。
上传被邀请回答截图 截图 · 必传
用于确认您的账号可回答指定问题(支持上传多张)。
{files.length}/10{previews.map(({ file, url }) =>
{file.name})}{files.length < 10 &&
}
addFiles(event.target.files)}/>▧ 请确保凭证清晰、信息完整、无遮挡
>
+}
+
+function MaterialCard({ latest, completion = false }) {
+ const images = completion ? latest.completion_screenshots : latest.invited_screenshots
+ return {completion ? '已提交任务凭证(只读)' : '已提交核验材料(只读)'}
♙ 已锁定审核{images.map((image, index) => { const src = image.url || image.data_url; return
◉ {completion ? '完成截图' : `被邀截图${index + 1}`} })}
+}
+
+function ReviewStage({ latest, reload, busy, startNew }) {
+ return <>已进入任务派发审核 截图审核中
截图已提交,任务派发审核界面已创建。运营正在核验截图并整理可回答问题,识别完成后会自动显示在后台。
提交时间{formatTime(latest.created_at)}当前状态等待运营审核
等待运营确认并派发问题截图已进入审核队列,无需停留等待识别完成;问题整理好后会自动开放任务。
还可以继续提交新的任务当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。
>
+}
+
+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 <>✓ 任务已派发 · 完成任务阶段完成问答并上传任务凭证
请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。
{tab === 'list' ? 待回答问题清单
点击复制参考回答即可前往淘宝粘贴提交
回答进度 {completed}/{selected.length}完成所有问题后,进入“任务上传”
{selected.map((question, index) => {String(index + 1).padStart(2, '0')}{question.question}
#{question.product_title || '商品问题'})} : }>
+}
+
+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 <>
+
+ !审核未通过 · 需重做 需修正
请根据下方运营反馈的具体不通过原因重新核对问答,补齐截图后重新提交审核。
+ ▣ 审核不通过原因:{reason || '完成凭证不符合任务要求,请重新提交。'}
+
+
+ >
+ if (latest.status === '待审核') return <>
+
+ 完成凭证审核中 {STATUS_TEXT[latest.status]}
您提交的回答完成截图正在审核,请耐心等待运营确认。
+
+
+ {latest.completion_screenshots?.length > 0 && }
+ 还可以开始新的任务当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。
+ >
+ return <>
+
+ {passed ? : cancelled ? '!' : }{passed ? '审核已完成' : cancelled ? '任务已撤销' : '完成凭证审核中'} {STATUS_TEXT[latest.status]}
{passed ? '运营已审核通过您提交的任务凭证,当前进入打款流程,请耐心等待打款。' : cancelled ? '运营已撤销本次任务,您无需继续提交。' : '您提交的回答完成截图正在审核,请耐心等待运营确认。'}
+ {passed && 审核已完成,等待打款款项将按运营安排发放,请留意收款账户。
}
+
+
+ {latest.completion_screenshots?.length > 0 && }
+ {(passed || cancelled) && {passed ? '本次任务已完成' : '本次任务已结束'}您可以返回第一步,上传新的被邀请回答截图并开始下一项任务。原任务仍保留在上传历史中。
}
+ >
+}
+
+function HistoryPage({ records, close, onContinue }) {
+ const canContinue = status => ['待筛选', '待运营派发', '已派发', '待用户完成', '未通过'].includes(status)
+ return 任务状态 / 上传历史({records.length})
{records.length === 0 ? 暂无历史记录
: records.map(record => {record.submission_no}{STATUS_TEXT[record.status] || record.status}{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张
{record.invited_screenshots.slice(0, 4).map(image => { const src = image.url || image.data_url; return
![]()
})}
{canContinue(record.status) && })}
+}
+
+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
+ return {!historyOpen &&
+}
diff --git a/frontend/src/AskUserPortal.jsx b/frontend/src/AskUserPortal.jsx
new file mode 100644
index 0000000..4d4fed6
--- /dev/null
+++ b/frontend/src/AskUserPortal.jsx
@@ -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 (
+
+
+
+
+
+ 淘宝问答运营官方承接通道
+
+ 官方认证
+
+
+
+
+ );
+}
+
+function TaskExhausted({ session, onLogout }) {
+ return (
+
+
+
+
+
+ 淘宝问答运营官方承接通道
+
+ 官方认证
+
+
+
+
+
+ 任务已领完
+ 本次任务已被领取完
+ 当前链接中的问题已全部分配给其他用户,请关注下一个任务!
+
+ 当前账号:{session.user_id} · {maskPhone(session.phone)}
+
+
+
+
+
+ );
+}
+
+function Header({
+ session,
+ assignment,
+ onLogout,
+ step,
+ view,
+ onOpenHistory,
+ onOpenClaim,
+}) {
+ return (
+ <>
+
+
+
+ 淘宝问答运营官方承接通道
+
+ 官方认证
+
+
+ 问
+
+ 问大家提问用户
+ 手机已绑定
+
+ ID:{session.user_id} · {maskPhone(session.phone)}
+
+
+
+
+
+ >
+ );
+}
+
+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 (
+ <>
+
+
+
+ 任务ID
+
+ {assignment.task_id}
+
+
+
+
+ 本任务认领进度
+ 每个链接最多认领 {assignment.questions.length} 条
+
+
+ 已认领 {usedCount} / {assignment.questions.length}
+
+
+
+
+
+ {remainingCount
+ ? `还可继续选择 ${remainingCount} 条问题`
+ : "本任务认领额度已达上限"}
+
+
+
+
+
+
+ 请选择提问内容(共 {assignment.questions.length} 题)
+
+
+
+
+ {assignment.questions.map((item) => {
+ const active = selected.includes(item.id);
+ return (
+
+ );
+ })}
+
+
+
+ >
+ );
+}
+
+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 (
+
+
+
+
+ 问大家核验商品
+ {assignment.task_id}
+
+
+
+
+
+
+ 官方任务商品
+
{assignment.product_name}
+
+
+
+
+
+
+ 已选提问内容(共 {selectedQuestions.length} 题)
+ 请在淘宝提问对应内容
+
+ {selectedQuestions.map((item, index) => (
+
+ #{index + 1}
+ {item.question}
+
+
+ ))}
+
+
+
+
+ 支持多图 · 可多次添加 · JPG/PNG/WEBP
+ 已上传 {files.length} 张
+
+
+ {previews.map(({ file, url }, index) => (
+
+
+
+ 图 {index + 1}
+
+ ))}
+
+
+ addFiles(event.target.files)}
+ />
+
+
+
+ {reviewOpen && (
+
+
+
+
+
+ 提交审核核对
+ 请确认凭证信息无误,提交后将锁定审核
+
+
+
+ 任务编号(TaskID)
+ {assignment.task_id}
+
+
+ 已完成问题
+ {selectedQuestions.length} 条(已认领)
+
+ {selectedQuestions.map((item) => (
+
{item.question}
+ ))}
+
+ 提问截图
+ 已上传({files.length}张)
+
+
+ 收款码
+ 已上传
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+function AuditStatus({ assignment, reload, busy, onBackHome }) {
+ const pending = assignment.status === "待审核";
+ const passed = assignment.status === "已通过";
+ const review = assignment.latest_review;
+ return (
+
+
+ {passed ? "✓" : assignment.status === "未通过" ? "!" : "⌁"}
+ {assignment.status}
+
+ {pending
+ ? "任务正在审核,请耐心等待。"
+ : passed
+ ? "运营审核已通过"
+ : "运营审核未通过"}
+
+
+ {pending
+ ? "审核专员正在核验您的淘宝提问截图与发布有效性,核验通过后将自动更新状态。"
+ : passed
+ ? "您提交的提问凭证已经核验通过,本次任务审核完成。"
+ : "请根据运营反馈修改截图后重新提交审核。"}
+
+
+
提交记录凭据
+
+ - 任务ID:
+ - {assignment.task_id}
+ - 提交问题:
+ -
+ {assignment.questions.filter((item) => item.selected).length} 条
+
+ - 提交时间:
+ -
+ {new Date(assignment.updated_at).toLocaleString("zh-CN", {
+ hour12: false,
+ })}
+
+ - 锁定状态:
+ - {pending ? "已锁定不可撤销" : assignment.status}
+
+
+ {review && !review.approved && (
+
+ )}
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+ 全部任务({history.length})
+ 进行中 {inProgress}
+ 已完成 {completed}
+
+ {filtered.length === 0 ? (
+
暂无历史提交任务
+ ) : (
+ filtered.map((item) => (
+
+
+
+ {item.questions
+ .filter((question) => question.selected)
+ .map((question) => question.question)
+ .join("、") || "尚未选择问题"}
+
+
+ 认领问题{" "}
+ {item.questions.filter((question) => question.selected).length} 条
+ · 更新时间{" "}
+ {new Date(item.updated_at).toLocaleTimeString("zh-CN", {
+ hour12: false,
+ })}
+
+
+
+ ))
+ )}
+
+ );
+}
+
+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 ;
+ if (exhausted)
+ return ;
+ 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 (
+
+
+
+ );
+}
diff --git a/frontend/src/CreateAnswerTasksPage.jsx b/frontend/src/CreateAnswerTasksPage.jsx
new file mode 100644
index 0000000..36e7322
--- /dev/null
+++ b/frontend/src/CreateAnswerTasksPage.jsx
@@ -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 <>
+
+
创建任务
通过顶部切换创建「提问任务」或「回答任务」。
+
+
+ ⓘ 回答截图审核用户提交截图后会立即进入此审核队列。按「用户ID」分组核对识别的问题,勾选需要派发的问题,回答可重新生成或直接编辑,确认后生成任务。
+ {error && {error}
}
+
+
+ 已选 {selectedCount} / {editableQuestions.length} 题
+
+
+ {records.length === 0 &&
暂无待审核的回答截图
}
+ {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
+
+ {open && record.questions.map(question =>
+
+
+
Q{question.sequence_no}:{question.question}
+
+ {question.product_image &&

}
+
{question.product_title}来源:模拟OCR识别
+ {question.source_image &&
识图原始截图:
}
+
+
+
+ )}
+
+ })}
+
+ >
+}
diff --git a/frontend/src/CreateAskTasksPage.jsx b/frontend/src/CreateAskTasksPage.jsx
new file mode 100644
index 0000000..35e8194
--- /dev/null
+++ b/frontend/src/CreateAskTasksPage.jsx
@@ -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 (
+ <>
+
+
+
创建任务
+
提问任务已发布,可在任务看板查看。
+
+
+
+
+
+
+ 任务创建成功!
+
+ 任务ID:{task.task_id}
+
+
+
+
+ 提问任务链接
+
+
+ {window.location.origin}/ask/{task.task_id}
+
+ 当前链接为任务预留入口,后续接入提问用户页面。
+
+
+
+
+
+
+ >
+ );
+
+ return (
+ <>
+
+
+
创建任务
+
通过顶部切换创建「提问任务」或「回答任务」。
+
+
+
+
+
+
+
+ 1
+ Step 1 生成并勾选提问
+
+ 2 Step 2 发布成功
+
+
+
+
+
+
+
+
+
+ {questions.length === 0 ? (
+
+ 点击左侧「生成提问」,AI 生成的提问将展示在这里,可逐条勾选。
+
+ ) : (
+ questions.map((question, index) => (
+
+ toggle(index)}
+ />
+
+
+ 来源:AI生成 · 可编辑后发布
+
+
+ ))
+ )}
+
+
+ >
+ );
+}
diff --git a/frontend/src/ReviewCenterPage.jsx b/frontend/src/ReviewCenterPage.jsx
new file mode 100644
index 0000000..540ee2a
--- /dev/null
+++ b/frontend/src/ReviewCenterPage.jsx
@@ -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 }) => (
+ {status}
+);
+
+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 (
+ event.target === event.currentTarget && close()}
+ >
+
+
+
+
+ {record.user_id}
+ {record.submission_no}
+ 商品:{productName(record)}
+ 提交时间:{formatTime(record.updated_at)}
+
+
① 问答记录(发布问题 ↔ 用户回答)
+
+ {selected.map((question, index) => (
+
+
+ Q{index + 1}:{question.question}
+
+
+ A:
+ {question.answer}
+
+
+ ))}
+
+
+
+ ② 用户上传的任务截图
+
+ {record.completion_screenshots.map((image, index) => (
+
+
+ 任务截图 {index + 1}
+
+ ))}
+
+
+
+ ③ 返款二维码截图
+
+ {record.payment_screenshots?.length ? (
+ record.payment_screenshots.map((image) => (
+
+
+ 审核通过后用于后续人工处理
+
+ ))
+ ) : (
+
未单独上传收款码
+ )}
+
+
+
+
+
+ 系统智能助判
+
+ ✓ 商品一致 ✓ 内容与要求匹配 ✓ 无重复提交 ✓ 无违规内容
+
+
+ {record.latest_review && !pending && (
+
+
+ {record.latest_review.approved ? "审核已通过" : "审核未通过"}
+
+ {!record.latest_review.approved && (
+
+ 原因:{record.latest_review.reason_code}
+ {record.latest_review.reason_text
+ ? `;${record.latest_review.reason_text}`
+ : ""}
+
+ )}
+
+ )}
+
+ {pending ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+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 (
+ event.target === event.currentTarget && close()}
+ >
+
+
+
+
+ 提问审核 · 用户任务详情
+
+
任务ID:{record.task_id}
+
+
+
+
+
+ {record.user_id}
+ {record.task_id}
+ 商品:{record.product_name}
+ 提交时间:{formatTime(record.updated_at)}
+
+
① 用户认领并完成的提问
+
+ {selected.map((item, index) => (
+
+
+ Q{index + 1}:{item.question}
+
+
+ ))}
+
+
+
+ ② 用户上传的提问截图
+
+ {record.question_screenshots.map((image, index) => (
+
+
+ 提问截图 {index + 1}
+
+ ))}
+
+
+
+ ③ 用户上传的收款码
+
+ {record.payment_screenshots.map((image) => (
+
+
+ 收款码截图
+
+ ))}
+
+
+
+ {record.latest_review && record.status !== "待审核" && (
+
+
+ {record.latest_review.approved ? "审核已通过" : "审核未通过"}
+
+ {!record.latest_review.approved && (
+
+ 原因:{record.latest_review.reason_code}
+ {record.latest_review.reason_text
+ ? `;${record.latest_review.reason_text}`
+ : ""}
+
+ )}
+
+ )}
+
+ {record.status === "待审核" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+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 (
+ <>
+
+
+ 用户 / 任务
+ 商品
+ 提交时间
+ 提问数
+ 审核状态
+ 操作
+
+ {loading ? (
+ 正在加载提问审核任务…
+ ) : records.length === 0 ? (
+ 暂无提问审核任务
+ ) : (
+ records.map((record) => (
+
+
+
+ {record.user_id}
+ {record.task_id}
+
+
+ {record.phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")}
+
+
+ {record.product_name}
+ {formatTime(record.updated_at)}
+
+
+ {record.questions.filter((item) => item.selected).length}
+ {" "}
+ 条提问
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+ {detail && (
+ 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 (
+ <>
+
+
+
审核中心
+
提问审核与回答审核,对照用户提交截图进行任务终审。
+
+
+
+
+
+
+
+
+
+
+
+ {queue === "answer" ? "回答审核说明" : "提问审核说明"}
+
+
+ {queue === "answer"
+ ? "同一用户在同一任务下的多组问答按「用户ID + 任务ID」归并为一条;点击查看任务列表,逐组对照问答和用户完成截图。"
+ : "审核提问用户提交的问题、截图及任务完成情况。提问任务链路接入后会显示在这里。"}
+
+
+
+ {error && {error}
}
+ {queue === "ask" ? (
+
+ ) : (
+ <>
+
+ 审核状态:
+ {STATUSES.map((status) => (
+
+ ))}
+
+
+
+ 用户 / 任务
+ 商品
+ 提交时间
+ 问答数
+ 审核状态
+ 操作
+
+ {loading ? (
+ 正在加载审核任务…
+ ) : rows.length === 0 ? (
+ 当前状态暂无回答审核任务
+ ) : (
+ rows.map((record) => (
+
+
+
+ {record.user_id}
+ {record.submission_no}
+
+
+ {record.phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")}
+
+
+ {productName(record)}
+ {formatTime(record.updated_at)}
+
+
+ {record.questions.filter((q) => q.selected).length}
+ {" "}
+ 组问答
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+ >
+ )}
+ {detail && (
+ setDetail(null)}
+ decide={decide}
+ busy={busy}
+ />
+ )}
+ >
+ );
+}
diff --git a/frontend/src/TaskBoardPage.jsx b/frontend/src/TaskBoardPage.jsx
new file mode 100644
index 0000000..b6e9fd7
--- /dev/null
+++ b/frontend/src/TaskBoardPage.jsx
@@ -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 (
+ event.target === event.currentTarget && close()}
+ >
+
+
+
+
+ 商品名称{task.product_name}
+
+
+ 任务类型{task.task_type_label}
+
+
+ 创建人{task.creator_name}
+
+
+ 创建时间{formatTime(task.created_at)}
+
+
+ 认领用户的问题与回答(共 {task.assigned_users.length} 位)
+
+
+ 序号
+ 关联问题与回答
+ 认领用户
+ 状态
+
+ {task.selected_questions.length === 0 ? (
+
暂无问题
+ ) : (
+ task.selected_questions.map((question, index) => (
+
+ {index + 1}
+
+ Q:{question.question}
+ A:{question.answer}
+
+
+ {task.user_id}
+ {maskPhone(task.phone)}
+
+
+
+ {task.status_label}
+
+
+
+ ))
+ )}
+
+
+
+ {isAskTask ? "提问完成截图" : "回答完成截图"}
+ {proofScreenshots.length ? (
+
+ {proofScreenshots.map((image) => (
+
+ ))}
+
+ ) : (
+ 未上传完成截图
+ )}
+
+
+ 收款码
+ {paymentScreenshots.length ? (
+
+ {paymentScreenshots.map((image) => (
+
+ ))}
+
+ ) : (
+ 用户未单独上传收款码
+ )}
+
+
+
+ {refundOpen && (
+
+ event.target === event.currentTarget && setRefundOpen(false)
+ }
+ >
+
+ ✓
+ 确认已经完成返款?
+
+ 请确认已按用户上传的收款码完成付款。确认后,该任务将归入“已完成”,此操作不可撤销。
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+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 (
+ <>
+
+
任务看板
+
监控全量问答任务执行状态、完成进度与审核链路。
+
+
+ 任务类型:
+ {[
+ ["all", "全部类型"],
+ ["ask", "提问任务"],
+ ["answer", "回答任务"],
+ ].map(([value, label]) => (
+
+ ))}
+
+
+ {STATUS_FILTERS.map(([value, label]) => (
+
+ ))}
+
+
+
+ setKeyword(event.target.value)}
+ placeholder="搜索用户ID或任务ID…"
+ />
+
+
+
+
+
+
+
+
+ {error && {error}
}
+
+ {detail && (
+ setDetail(null)}
+ onConfirmRefund={confirmRefund}
+ />
+ )}
+ {batchConfirm && (
+ event.target === event.currentTarget && setBatchConfirm(null)}
+ >
+
+ !
+ 确认是否删除?
+
+ 确定删除已选的 {batchConfirm.tasks.length} 条任务吗?删除后将从任务看板和系统记录中永久移除,无法恢复。
+
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/admin.css b/frontend/src/admin.css
new file mode 100644
index 0000000..7b623b7
--- /dev/null
+++ b/frontend/src/admin.css
@@ -0,0 +1,4161 @@
+.admin-panel {
+ padding: 20px;
+ min-height: 260px;
+}
+.ask-user-notice{margin:12px 16px;padding:13px 16px;border:1px solid #ffb9c1;border-radius:12px;background:linear-gradient(135deg,#fff4f5,#fff9ea);color:#c43b4a;font-size:12px;font-weight:700;text-align:left;box-shadow:0 4px 12px rgba(198,66,84,.08)}
+.ask-exhausted-app{min-height:calc(100vh - 44px)}
+.ask-exhausted-card{display:flex;align-items:center;flex-direction:column;width:min(420px,calc(100% - 32px));margin:90px auto 24px;padding:34px 24px;border:1px solid #f2cd71;border-radius:20px;background:linear-gradient(180deg,#fffdf7,#fff);text-align:center;box-shadow:0 12px 32px rgba(76,57,18,.1)}
+.ask-exhausted-icon{display:grid;place-items:center;width:72px;height:72px;margin-bottom:14px;border:1px solid #ffc84c;border-radius:22px;background:#fff7dd;color:#e89100}
+.ask-exhausted-card>em{padding:5px 12px;border-radius:999px;background:#fff0c7;color:#c97700;font-size:11px;font-style:normal;font-weight:700}
+.ask-exhausted-card h1{margin:18px 0 8px;color:#102744;font-size:24px}
+.ask-exhausted-card p{max-width:330px;margin:0;color:#71859e;font-size:13px;line-height:1.8}
+.ask-exhausted-card small{margin:22px 0 14px;color:#91a1b5}
+.ask-exhausted-card button{width:100%;padding:12px;border-radius:10px;background:#1764e8;color:#fff;font-weight:700;box-shadow:0 7px 16px rgba(23,100,232,.2)}
+.ask-claim-summary{margin:12px 16px;padding:14px 16px;border:1px solid #b9d4ff;border-radius:14px;background:linear-gradient(135deg,#f5f9ff,#eef5ff);color:#194b91}.ask-claim-summary>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px}.ask-claim-summary strong{font-size:13px}.ask-claim-summary span,.ask-claim-summary small{color:#7188a5;font-size:11px}.ask-claim-summary>b{display:block;margin-top:8px;color:#1764e8;font-size:18px}.ask-claim-progress{height:7px;margin:8px 0;border-radius:9px;background:#dce8f8;overflow:hidden}.ask-claim-progress i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#2166ef,#5b46ee)}
+.ask-question-list>button.used{border-color:#d7e1ec;background:#f3f6fa;color:#7d8fa5;cursor:not-allowed;box-shadow:none}.ask-question-list>button.used i{border-color:#d0dbe7;background:#e6edf5;color:#8ca0b6}.ask-question-list>button em{margin-left:auto;padding:4px 7px;border-radius:8px;background:#e6edf5;color:#7288a2;font-size:10px;font-style:normal;white-space:nowrap}
+.ask-status-page{padding:14px 16px 80px}.ask-status-page .ask-history-tabs{margin-top:0}.ask-status-page .ask-history-item{box-shadow:0 2px 7px rgba(32,62,102,.05)}
+.ask-tab-button{display:flex;align-items:center;justify-content:center;gap:7px;flex:1;padding:9px;border:0;border-radius:9px;background:transparent;color:#5e7592;font-size:12px}.ask-tab-button.active{background:#fff;color:#1764e8;font-weight:700;box-shadow:0 2px 7px rgba(34,65,105,.08)}.ask-tab-button b{padding:2px 6px;border-radius:9px;background:#e6eefb;color:#1764e8;font-size:9px}.ask-audit-card .ask-back-home{margin-top:9px;background:#0d1932}.ask-history-backdrop{position:fixed;z-index:70;inset:0;display:grid;place-items:center;padding:14px;background:rgba(8,18,37,.58)}.ask-history-panel{width:min(620px,100%);max-height:calc(100vh - 28px);overflow:auto;padding:16px;border-radius:17px;background:#f5f8fc;color:#173251}.ask-history-panel>header{display:flex;align-items:center;justify-content:space-between}.ask-history-panel>header h2{margin:0}.ask-history-panel>header button{font-size:23px;background:transparent;color:#8194ab}.ask-history-tabs{display:flex;gap:6px;margin:14px 0;padding:4px;border-radius:10px;background:#e9eef5}.ask-history-tabs>*{flex:1;padding:9px;border-radius:8px;text-align:center;color:#637a96;font-size:11px}.ask-history-tabs b{background:#fff;color:#1764e8}.ask-history-item{margin-top:10px;padding:14px;border:1px solid #d8e3ef;border-radius:13px;background:#fff}.ask-history-item>header{display:flex;align-items:center;justify-content:space-between}.ask-history-item>header div{display:flex;align-items:center;gap:8px}.ask-history-item code{padding:4px 7px;border-radius:5px;background:#edf3fb;color:#1764e8;font-size:10px}.ask-history-item small{color:#8a9db4;font-size:10px}.ask-history-item em{padding:4px 8px;border-radius:11px;background:#fff3d6;color:#d28500;font-size:10px;font-style:normal}.ask-history-item.done em{background:#e5faef;color:#07945c}.ask-history-item h3{margin:12px 0 7px;font-size:13px;line-height:1.5}.ask-history-item p{margin:0;color:#8699af;font-size:10px}.ask-history-item>footer{display:flex;align-items:center;justify-content:space-between;margin-top:13px;padding-top:11px;border-top:1px solid #edf1f6}.ask-history-item>footer span{color:#6f849e;font-size:10px}.ask-history-item>footer button{padding:8px 12px;border-radius:8px;background:#5139ec;color:#fff;font-size:10px}
+.empty-state {
+ padding: 60px;
+ text-align: center;
+ color: #7890ad;
+}
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ color: #405a7d;
+}
+.data-table th {
+ padding: 12px 14px;
+ background: #f7f9fc;
+ color: #68809f;
+ text-align: left;
+ font-size: 12px;
+ font-weight: 600;
+}
+.data-table td {
+ padding: 14px;
+ border-bottom: 1px solid #edf1f6;
+ font-size: 13px;
+}
+.role-pill,
+.status-pill {
+ display: inline-block;
+ padding: 4px 9px;
+ border-radius: 5px;
+ background: #edf3ff;
+ color: #2864e8;
+ font-size: 12px;
+}
+.status-pill {
+ background: #fff0f2;
+ color: #c53f54;
+}
+.status-pill.success {
+ background: #e5faf3;
+ color: #008f72;
+}
+.settings-list {
+ max-width: 760px;
+}
+.settings-row {
+ display: flex;
+ padding: 15px 8px;
+ border-bottom: 1px solid #edf1f6;
+}
+.settings-row strong {
+ width: 190px;
+ color: #405a7d;
+ font-size: 13px;
+}
+.settings-row span {
+ color: #1d385c;
+ font-size: 13px;
+}
+.text-button {
+ display: block;
+ margin: 18px auto 0;
+ padding: 0;
+ color: #2864e8;
+ background: transparent;
+ font-size: 13px;
+}
+.user-table-actions {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 14px;
+}
+.table-action {
+ margin-right: 12px;
+ padding: 4px 0;
+ color: #2864e8;
+ background: transparent;
+}
+.table-action.danger {
+ color: #c53f54;
+}
+.user-modal-backdrop {
+ position: fixed;
+ z-index: 20;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ background: rgba(13, 23, 44, 0.38);
+}
+.user-modal {
+ width: 420px;
+ padding: 24px;
+ display: grid;
+ gap: 14px;
+ box-shadow: 0 18px 60px rgba(12, 34, 70, 0.25);
+}
+.user-modal h3 {
+ margin: 0 0 5px;
+ color: #18355c;
+ font-size: 18px;
+}
+.user-modal label {
+ display: grid;
+ gap: 7px;
+ color: #435d80;
+ font-size: 13px;
+}
+.user-modal input,
+.user-modal select {
+ border: 1px solid #ccd9ea;
+ border-radius: 8px;
+ padding: 10px 12px;
+ color: #173258;
+ outline: none;
+ background: #fff;
+}
+.checkbox-row {
+ display: flex !important;
+ align-items: center;
+ grid-template-columns: none !important;
+ gap: 8px !important;
+}
+.checkbox-row input {
+ width: 16px;
+ height: 16px;
+}
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 5px;
+}
+.welcome-panel {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ padding: 28px;
+ color: #2864e8;
+}
+.welcome-panel h3 {
+ margin: 0 0 8px;
+ color: #1d385c;
+}
+.welcome-panel p {
+ margin: 0;
+ color: #68809f;
+}
+.qa-workspace {
+ display: grid;
+ grid-template-columns: minmax(460px, 1fr) minmax(560px, 1.08fr);
+ gap: 24px;
+ align-items: start;
+}
+.qa-workspace .config-card {
+ padding: 20px;
+}
+.qa-workspace .results-area {
+ max-height: calc(100vh - 170px);
+ overflow: auto;
+ padding-right: 4px;
+}
+.qa-workspace .results-area::-webkit-scrollbar {
+ width: 7px;
+}
+.qa-workspace .results-area::-webkit-scrollbar-thumb {
+ background: #c5d3e5;
+ border-radius: 8px;
+}
+.empty-results {
+ min-height: 180px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ color: #7890ad;
+}
+.empty-results svg {
+ color: #2864e8;
+}
+.qa-meta button {
+ padding: 0;
+ background: transparent;
+ color: #466486;
+}
+.qa-meta .accept-link {
+ margin-left: 12px;
+ color: #00a07c;
+}
+.qa-meta strong.accepted {
+ color: #009b7a;
+ background: #dff9f0;
+}
+.generate-btn {
+ border: 0;
+}
+.qa-workspace input[type="number"] {
+ appearance: textfield;
+}
+@media (max-width: 1250px) {
+ .qa-workspace {
+ grid-template-columns: 1fr;
+ }
+ .qa-workspace .results-area {
+ max-height: none;
+ }
+}
+.qa-meta .delete-link {
+ margin-left: 12px;
+ color: #c53f54;
+}
+.qa-card.editing {
+ border-color: #7aa6f5;
+ box-shadow: 0 0 0 3px rgba(40, 100, 232, 0.1);
+}
+.inline-edit-fields {
+ display: grid;
+ gap: 10px;
+ margin-top: 14px;
+}
+.inline-edit-fields label {
+ display: grid;
+ gap: 6px;
+ color: #466486;
+ font-size: 12px;
+}
+.inline-edit-fields input,
+.inline-edit-fields textarea {
+ width: 100%;
+ border: 1px solid #b9cce5;
+ border-radius: 7px;
+ padding: 9px 10px;
+ outline: none;
+ color: #173258;
+ background: #fff;
+}
+.inline-edit-fields textarea {
+ min-height: 55px;
+ resize: vertical;
+}
+.inline-edit-fields input:focus,
+.inline-edit-fields textarea:focus {
+ border-color: #2864e8;
+ box-shadow: 0 0 0 3px rgba(40, 100, 232, 0.1);
+}
+.answer-edit-row,
+.manual-answer-row {
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+}
+.answer-edit-row > label,
+.manual-answer-row > label {
+ flex: 1;
+}
+.remove-answer {
+ padding: 8px 0;
+ color: #c53f54;
+ background: transparent;
+ font-size: 12px;
+ white-space: nowrap;
+}
+.add-answer-button {
+ justify-self: start;
+ padding: 6px 0;
+ color: #2864e8;
+ background: transparent;
+ font-size: 12px;
+}
+.manual-modal-backdrop {
+ position: fixed;
+ z-index: 30;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ background: rgba(12, 25, 48, 0.42);
+}
+.manual-modal {
+ width: min(520px, calc(100vw - 32px));
+ max-height: calc(100vh - 40px);
+ overflow: auto;
+ padding: 22px;
+ background: #fff;
+ border-radius: 12px;
+ box-shadow: 0 20px 70px rgba(15, 38, 75, 0.3);
+}
+.manual-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.manual-modal-header h3 {
+ margin: 0;
+ color: #1d3557;
+ font-size: 18px;
+}
+.manual-modal-header button {
+ padding: 0;
+ color: #8ca0bb;
+ background: transparent;
+ font-size: 24px;
+ line-height: 1;
+}
+.manual-modal-rule {
+ height: 1px;
+ margin: 14px 0 17px;
+ background: #e7edf5;
+}
+.manual-modal > label,
+.manual-answer-row label {
+ display: grid;
+ gap: 7px;
+ color: #405a7d;
+ font-size: 12px;
+}
+.manual-modal input,
+.manual-modal textarea {
+ border: 1px solid #c9d7e8;
+ border-radius: 8px;
+ padding: 10px 12px;
+ outline: none;
+ color: #173258;
+ background: #fff;
+}
+.manual-modal textarea {
+ min-height: 58px;
+ resize: vertical;
+}
+.manual-modal input:focus,
+.manual-modal textarea:focus {
+ border-color: #2864e8;
+ box-shadow: 0 0 0 3px rgba(40, 100, 232, 0.1);
+}
+.manual-modal .manual-answer-row {
+ margin-top: 14px;
+}
+.modal-add-answer {
+ margin-top: 12px;
+}
+.manual-modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 22px;
+}
+.answer-page {
+ min-height: 100vh;
+ background: #f4f7fb;
+ padding: 24px 16px;
+ color: #173258;
+}
+.answer-card {
+ max-width: 760px;
+ margin: 0 auto;
+ background: #fff;
+ border: 1px solid #dce6f2;
+ border-radius: 16px;
+ padding: 24px;
+ box-shadow: 0 12px 30px rgba(31, 69, 120, 0.08);
+}
+.answer-brand {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #2864e8;
+ font-weight: 700;
+}
+.answer-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-top: 22px;
+}
+.answer-head h1 {
+ margin: 0;
+ font-size: 24px;
+}
+.answer-task-id {
+ color: #7187a4;
+ font-size: 13px;
+}
+.answer-section {
+ display: grid;
+ gap: 14px;
+ border-top: 1px solid #e6edf5;
+ padding-top: 20px;
+ margin-top: 20px;
+}
+.answer-section h2 {
+ margin: 0;
+ font-size: 18px;
+}
+.answer-section p {
+ margin: 0;
+ color: #7187a4;
+ line-height: 1.6;
+}
+.answer-section label {
+ display: grid;
+ gap: 7px;
+ color: #405a7d;
+ font-size: 13px;
+}
+.answer-section input:not([type="file"]) {
+ border: 1px solid #c9d7e8;
+ border-radius: 8px;
+ padding: 11px 12px;
+}
+.upload-box {
+ border: 1px dashed #9db6d7 !important;
+ border-radius: 10px;
+ padding: 24px;
+ text-align: center;
+ background: #f8fbff;
+ cursor: pointer;
+}
+.upload-box input {
+ display: none;
+}
+.file-list {
+ font-size: 13px;
+ color: #2864e8;
+}
+.answer-submit {
+ width: 100%;
+ justify-content: center;
+}
+.answer-question-list {
+ display: grid;
+ gap: 8px;
+}
+.answer-question-list label {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 12px;
+ border: 1px solid #e1eaf4;
+ border-radius: 8px;
+ background: #fbfdff;
+}
+.answer-question-list b {
+ margin-right: 8px;
+ color: #2864e8;
+}
+.assigned-list {
+ display: grid;
+ gap: 10px;
+}
+.assigned-list article {
+ padding: 14px;
+ border-radius: 9px;
+ background: #f7faff;
+ border: 1px solid #e2eaf4;
+}
+.assigned-list h3 {
+ margin: 0 0 8px;
+ font-size: 15px;
+}
+.assigned-list p {
+ color: #2864e8;
+}
+.answer-success {
+ margin-top: 20px;
+ padding: 14px;
+ border-radius: 8px;
+ background: #edf9f2;
+ color: #16834a;
+}
+.review-list {
+ display: grid;
+ gap: 16px;
+}
+.review-card {
+ padding: 18px;
+}
+.review-card-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: flex-start;
+}
+.review-card-head div {
+ display: grid;
+ gap: 5px;
+}
+.review-card-head span {
+ color: #7187a4;
+ font-size: 13px;
+}
+.review-columns {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 16px;
+ margin-top: 16px;
+}
+.review-columns h4 {
+ margin: 0 0 8px;
+ font-size: 13px;
+}
+.review-images {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.review-images img {
+ width: 96px;
+ height: 96px;
+ object-fit: cover;
+ border-radius: 6px;
+ border: 1px solid #dce6f2;
+}
+.review-question {
+ padding: 8px 0;
+ border-bottom: 1px solid #edf1f6;
+ font-size: 13px;
+}
+.review-question b {
+ color: #2864e8;
+ margin-right: 8px;
+}
+.review-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 16px;
+}
+.danger-button {
+ border: 1px solid #e8a9b4;
+ border-radius: 8px;
+ padding: 9px 16px;
+ background: #fff3f5;
+ color: #c53f54;
+}
+@media (max-width: 700px) {
+ .answer-card {
+ padding: 18px;
+ }
+ .review-columns {
+ grid-template-columns: 1fr;
+ }
+ .answer-head {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+}
+.review-images img {
+ cursor: zoom-in;
+}
+.review-queues {
+ display: flex;
+ justify-content: flex-end;
+ gap: 4px;
+ margin-bottom: 14px;
+ padding: 4px;
+ background: #e8eef7;
+ border-radius: 12px;
+ width: max-content;
+ margin-left: auto;
+}
+.review-queues button {
+ border: 0;
+ border-radius: 9px;
+ padding: 8px 14px;
+ background: transparent;
+ color: #31527b;
+}
+.review-queues button.active {
+ background: #fff;
+ color: #173258;
+ box-shadow: 0 1px 5px rgba(27, 56, 95, 0.12);
+}
+.review-tip {
+ display: flex;
+ gap: 10px;
+ flex-direction: column;
+ padding: 14px 16px;
+ margin-bottom: 16px;
+ border: 1px solid #b7d5ff;
+ border-radius: 12px;
+ background: #f0f6ff;
+ color: #2056ae;
+ font-size: 13px;
+}
+.review-tip span {
+ color: #31527b;
+}
+.review-images img {
+ cursor: zoom-in;
+}
+.answer-back-floating {
+ position: fixed;
+ top: 20px;
+ left: 20px;
+ z-index: 20;
+ border: 1px solid #c9d7e8;
+ border-radius: 8px;
+ padding: 8px 14px;
+ background: #fff;
+ color: #2864e8;
+ box-shadow: 0 4px 12px rgba(31, 69, 120, 0.12);
+ cursor: pointer;
+}
+.answer-public-shell {
+ min-height: 100vh;
+ padding: 24px;
+ background: #050b1d;
+ color: #183354;
+}
+.answer-mobile-app {
+ width: min(100%, 760px);
+ min-height: calc(100vh - 48px);
+ margin: 0 auto;
+ overflow: hidden;
+ border: 8px solid #1b2943;
+ border-radius: 34px;
+ background: #f2f6fb;
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
+}
+.answer-public-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 20px;
+ background: #0e1a34;
+ color: #fff;
+ font-size: 13px;
+}
+.answer-public-header > div {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.answer-public-header svg {
+ color: #50a7ff;
+}
+.answer-public-header em {
+ padding: 2px 7px;
+ border-radius: 4px;
+ background: #1e355f;
+ color: #dceaff;
+ font-size: 10px;
+ font-style: normal;
+}
+.answer-public-header button {
+ background: transparent;
+ color: #c4d0e3;
+}
+.answer-user-card {
+ margin: 14px 18px;
+ padding: 15px 16px;
+ border: 1px solid #d9e3f0;
+ border-radius: 12px;
+ background: #fff;
+}
+.answer-user-card > div {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.answer-user-card span {
+ padding: 3px 9px;
+ border: 1px solid #f2bd43;
+ border-radius: 16px;
+ background: #fff8e7;
+ color: #da8b00;
+ font-size: 11px;
+}
+.answer-user-card span.finished {
+ border-color: #7cd9ae;
+ background: #effbf5;
+ color: #15945c;
+}
+.answer-user-card p {
+ margin: 7px 0 0;
+ color: #768ba7;
+ font-size: 12px;
+}
+.answer-tabs {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ margin-top: 4px;
+ padding: 8px 18px;
+ border-top: 5px solid #fff;
+ border-bottom: 1px solid #dce5f0;
+}
+.answer-tabs strong,
+.answer-tabs span {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ border-radius: 8px;
+ padding: 9px;
+ background: #fff;
+ font-size: 12px;
+}
+.answer-tabs strong {
+ color: #1764e8;
+ box-shadow: 0 2px 8px rgba(32, 65, 110, 0.08);
+}
+.answer-tabs i {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #ff9f1c;
+}
+.answer-tabs span {
+ color: #506783;
+}
+.answer-progress {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ padding: 12px 18px;
+ background: #fff;
+}
+.answer-progress > div {
+ position: relative;
+ display: grid;
+ justify-items: center;
+ gap: 5px;
+ color: #8193aa;
+ font-size: 11px;
+}
+.answer-progress > div:not(:last-child):after {
+ content: "";
+ position: absolute;
+ top: 13px;
+ left: 64%;
+ width: 72%;
+ height: 1px;
+ background: #dbe4ef;
+}
+.answer-progress b {
+ z-index: 1;
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border: 1px solid #ccd8e8;
+ border-radius: 50%;
+ background: #fff;
+ font-weight: 500;
+}
+.answer-progress .active,
+.answer-progress .complete {
+ color: #1764e8;
+ font-weight: 700;
+}
+.answer-progress .active b {
+ border: 2px solid #1764e8;
+}
+.answer-progress .complete b {
+ border-color: #35bd7d;
+ background: #35bd7d;
+ color: #fff;
+}
+.answer-public-body {
+ padding: 20px 18px 36px;
+}
+.answer-guide {
+ padding: 22px;
+ border-radius: 16px;
+ background: linear-gradient(135deg, #5476eb, #8068d9);
+ color: #fff;
+ box-shadow: 0 12px 26px rgba(75, 94, 218, 0.22);
+}
+.answer-guide small {
+ font-weight: 700;
+ color: #f1ecff;
+}
+.answer-guide h1 {
+ margin: 14px 0 7px;
+ font-size: 22px;
+}
+.answer-guide p {
+ margin: 0;
+ color: #e7eaff;
+ line-height: 1.7;
+ font-size: 13px;
+}
+.answer-upload-card,
+.answer-submit-card,
+.answer-done-card,
+.answer-help {
+ margin-top: 16px;
+ padding: 20px;
+ border: 1px solid #dce6f1;
+ border-radius: 16px;
+ background: #fff;
+}
+.answer-upload-title {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+}
+.answer-upload-title h2 {
+ margin: 0 0 5px;
+ font-size: 18px;
+}
+.answer-upload-title p {
+ margin: 0;
+ color: #8294aa;
+ font-size: 12px;
+}
+.answer-upload-title > span {
+ padding: 3px 8px;
+ border-radius: 12px;
+ background: #edf4ff;
+ color: #4a7ee7;
+ font-size: 11px;
+}
+.answer-upload-card > label:not(.answer-dropzone) {
+ display: grid;
+ gap: 7px;
+ margin-top: 18px;
+ color: #45617f;
+ font-size: 12px;
+}
+.answer-upload-card input:not([type="file"]) {
+ border: 1px solid #cbd8e8;
+ border-radius: 9px;
+ padding: 11px 12px;
+}
+.answer-dropzone {
+ display: grid;
+ place-items: center;
+ gap: 7px;
+ min-height: 180px;
+ margin-top: 15px;
+ border: 2px dashed #cbd9ea;
+ border-radius: 14px;
+ background: #f8fbff;
+ text-align: center;
+ cursor: pointer;
+ transition: 0.2s;
+}
+.answer-dropzone.dragging,
+.answer-dropzone:hover {
+ border-color: #3978ee;
+ background: #f0f6ff;
+}
+.answer-dropzone input {
+ display: none;
+}
+.answer-upload-plus {
+ display: grid;
+ place-items: center;
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: #edf4ff;
+ color: #3978ee;
+ font-size: 28px;
+}
+.answer-dropzone strong {
+ color: #526d8d;
+}
+.answer-dropzone small {
+ color: #9aabc0;
+ line-height: 1.6;
+}
+.answer-preview-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 10px;
+ margin-top: 14px;
+}
+.answer-preview-grid figure {
+ position: relative;
+ min-width: 0;
+ margin: 0;
+}
+.answer-preview-grid img {
+ width: 100%;
+ height: 105px;
+ border: 1px solid #d8e3f0;
+ border-radius: 9px;
+ object-fit: cover;
+}
+.answer-preview-grid button {
+ position: absolute;
+ top: -6px;
+ right: -6px;
+ display: grid;
+ place-items: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background: #d9465f;
+ color: #fff;
+}
+.answer-preview-grid figcaption {
+ overflow: hidden;
+ margin-top: 4px;
+ color: #7b8fa8;
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.answer-upload-note {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ margin-top: 14px;
+ color: #8ba0b9;
+ font-size: 11px;
+}
+.answer-upload-note b {
+ color: #4e7cda;
+}
+.answer-public-error {
+ margin-top: 14px;
+}
+.answer-submit-card > div {
+ display: flex;
+ justify-content: space-between;
+ color: #d49a35;
+ font-size: 12px;
+}
+.answer-submit-card > div b {
+ color: #9bacc0;
+}
+.answer-submit-card button {
+ width: 100%;
+ justify-content: center;
+ margin-top: 14px;
+}
+.answer-submit-card button:disabled {
+ background: #dbe4ed;
+ color: #9dadbf;
+}
+.answer-help strong {
+ font-size: 14px;
+}
+.answer-help ul {
+ margin: 10px 0 0;
+ padding-left: 20px;
+ color: #7187a1;
+ font-size: 12px;
+ line-height: 1.9;
+}
+.answer-done-card {
+ text-align: center;
+}
+.answer-done-icon {
+ display: grid;
+ place-items: center;
+ width: 64px;
+ height: 64px;
+ margin: 5px auto 14px;
+ border-radius: 50%;
+ background: #def7e9;
+ color: #14945c;
+ font-size: 30px;
+}
+.answer-done-card h2 {
+ margin: 0 0 8px;
+}
+.answer-done-card p {
+ color: #7187a1;
+}
+.answer-done-card button {
+ margin-top: 12px;
+}
+@media (max-width: 700px) {
+ .answer-public-shell {
+ padding: 0;
+ background: #f2f6fb;
+ }
+ .answer-mobile-app {
+ min-height: 100vh;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ }
+ .answer-public-header {
+ padding: 12px;
+ }
+ .answer-public-header button {
+ font-size: 11px;
+ }
+ .answer-public-body {
+ padding: 16px 12px 28px;
+ }
+ .answer-user-card {
+ margin: 12px;
+ }
+ .answer-tabs {
+ padding: 7px 12px;
+ }
+ .answer-progress {
+ padding: 11px 7px;
+ }
+ .answer-guide {
+ padding: 19px;
+ }
+ .answer-preview-grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+ .answer-upload-card,
+ .answer-submit-card,
+ .answer-done-card,
+ .answer-help {
+ padding: 16px;
+ }
+}
+.answer-status-card,
+.answer-material-card {
+ margin-top: 16px;
+ padding: 20px;
+ border: 1px solid #cbdcec;
+ border-radius: 16px;
+ background: #fff;
+ box-shadow: 0 3px 10px rgba(36, 69, 108, 0.08);
+}
+.answer-status-heading {
+ display: flex;
+ gap: 14px;
+}
+.answer-status-heading > span {
+ display: grid;
+ place-items: center;
+ flex: 0 0 48px;
+ height: 48px;
+ border: 1px solid #96bfff;
+ border-radius: 13px;
+ background: #eff6ff;
+ color: #1764e8;
+ font-size: 25px;
+}
+.answer-status-heading h2 {
+ margin: 0 0 7px;
+ font-size: 19px;
+}
+.answer-status-heading h2 em {
+ padding: 3px 8px;
+ border-radius: 10px;
+ background: #eaf3ff;
+ color: #4380e8;
+ font-size: 10px;
+ font-style: normal;
+}
+.answer-status-heading p {
+ margin: 0;
+ color: #667e9a;
+ font-size: 12px;
+ line-height: 1.6;
+}
+.answer-status-meta {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ margin-top: 17px;
+ padding: 13px;
+ border-radius: 10px;
+ background: #f8fafc;
+}
+.answer-status-meta div {
+ display: grid;
+ gap: 4px;
+}
+.answer-status-meta span {
+ color: #8598ae;
+ font-size: 10px;
+}
+.answer-status-meta b {
+ font-size: 12px;
+}
+.answer-status-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 15px;
+ color: #8498b2;
+ font-size: 11px;
+}
+.answer-status-actions button {
+ padding: 7px 12px;
+}
+.answer-material-card > div:first-child {
+ display: flex;
+ justify-content: space-between;
+}
+.answer-material-card h3 {
+ margin: 0;
+ font-size: 15px;
+}
+.answer-material-card > div:first-child span {
+ color: #91a4bb;
+ font-size: 11px;
+}
+.answer-material-grid {
+ display: grid !important;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+ margin-top: 15px;
+}
+.answer-material-grid figure {
+ position: relative;
+ margin: 0;
+ overflow: hidden;
+ border-radius: 10px;
+ background: #333;
+ cursor: zoom-in;
+}
+.answer-material-grid img {
+ width: 100%;
+ height: 150px;
+ object-fit: cover;
+ opacity: 0.8;
+}
+.answer-material-grid figcaption {
+ position: absolute;
+ left: 10px;
+ bottom: 10px;
+ padding: 5px 9px;
+ border-radius: 12px;
+ background: rgba(23, 31, 45, 0.72);
+ color: #fff;
+ font-size: 10px;
+}
+.answer-history-actions {
+ display: flex;
+ gap: 10px;
+ margin-top: 14px;
+}
+.answer-history-actions button {
+ flex: 1;
+ justify-content: center;
+}
+.answer-history-page {
+ min-height: 100%;
+ padding: 20px;
+ background: #f2f6fb;
+}
+.answer-history-head {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+}
+.answer-history-head button {
+ background: transparent;
+ color: #1764e8;
+}
+.answer-history-page article {
+ margin-top: 13px;
+ padding: 16px;
+ border: 1px solid #dce6f1;
+ border-radius: 13px;
+ background: #fff;
+}
+.answer-history-page article > div:first-child {
+ display: flex;
+ justify-content: space-between;
+}
+.answer-history-page article p {
+ color: #7890aa;
+ font-size: 11px;
+}
+.answer-history-thumbs {
+ display: flex;
+ gap: 8px;
+}
+.answer-history-thumbs img {
+ width: 70px;
+ height: 70px;
+ border-radius: 7px;
+ object-fit: cover;
+ cursor: zoom-in;
+}
+@media (max-width: 520px) {
+ .answer-material-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ .answer-material-grid img {
+ height: 130px;
+ }
+ .answer-status-meta {
+ grid-template-columns: 1fr;
+ }
+ .answer-status-actions {
+ align-items: flex-start;
+ gap: 10px;
+ }
+ .answer-history-actions {
+ flex-direction: column;
+ }
+}
+.answer-status-card.passed {
+ border-color: #92dfb9;
+ background: #f7fffa;
+}
+.answer-status-card.passed .answer-status-heading > span {
+ border-color: #75d5a4;
+ background: #e7faef;
+ color: #119459;
+}
+.answer-status-card.passed .answer-status-heading h2 em {
+ background: #e5f8ed;
+ color: #148b56;
+}
+.answer-status-card.rejected {
+ border-color: #f2a9b4;
+ background: #fffafb;
+}
+.answer-status-card.rejected .answer-status-heading > span {
+ border-color: #ef9aab;
+ background: #fff0f3;
+ color: #cf3853;
+}
+.answer-status-card.rejected .answer-status-heading h2 em {
+ background: #ffedf1;
+ color: #c8324d;
+}
+.answer-phone-card {
+ display: grid;
+ gap: 16px;
+ margin: 80px auto;
+ padding: 28px;
+ width: min(420px, calc(100% - 32px));
+ border: 1px solid #d9e3f0;
+ border-radius: 18px;
+ background: #fff;
+ text-align: center;
+}
+.answer-login-icon {
+ display: grid;
+ place-items: center;
+ width: 58px;
+ height: 58px;
+ margin: auto;
+ border-radius: 16px;
+ background: #2864e8;
+ color: #fff;
+}
+.answer-phone-card h1 {
+ margin: 0;
+}
+.answer-phone-card p,
+.answer-phone-card small {
+ color: #7890aa;
+}
+.answer-phone-card label {
+ display: grid;
+ gap: 8px;
+ text-align: left;
+ color: #48627f;
+ font-size: 13px;
+}
+.answer-phone-card input {
+ border: 1px solid #c8d7e9;
+ border-radius: 9px;
+ padding: 12px;
+}
+.answer-phone-card button {
+ justify-content: center;
+}
+.answer-v2-code-row,
+.ask-code-row {
+ display: flex;
+ gap: 8px;
+}
+.answer-v2-code-row input,
+.ask-code-row input {
+ min-width: 0;
+ flex: 1;
+}
+.answer-v2-code-row button,
+.ask-code-row button {
+ flex: 0 0 auto;
+ padding: 9px 10px;
+ border: 1px solid #c8d8ec;
+ border-radius: 8px;
+ background: #f5f9ff;
+ color: #2864e8;
+ font-size: 11px;
+ white-space: nowrap;
+}
+.answer-v2-code-row button:disabled,
+.ask-code-row button:disabled {
+ color: #8da0b7;
+ cursor: wait;
+}
+.answer-v2-dev-code,
+.ask-dev-code {
+ display: block;
+ margin-top: -5px;
+ color: #b7791f;
+ font-size: 11px;
+}
+.answer-personal-task {
+ display: grid;
+ gap: 14px;
+}
+.answer-personal-task .answer-upload-card {
+ display: grid;
+ gap: 12px;
+}
+.answer-personal-task .answer-upload-card input {
+ padding: 10px;
+ border: 1px solid #d2deeb;
+ border-radius: 8px;
+}
+.answer-personal-task .answer-upload-card button {
+ justify-content: center;
+}
+.answer-draft-list {
+ display: grid;
+ gap: 16px;
+}
+.answer-draft-group {
+ overflow: hidden;
+ padding: 0;
+}
+.answer-draft-group > header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 15px 18px;
+ background: #f7faff;
+ border-bottom: 1px solid #e1e9f3;
+}
+.answer-draft-group > header > div {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.answer-draft-group header em {
+ padding: 4px 9px;
+ border-radius: 12px;
+ background: #e7f1ff;
+ color: #2466d8;
+ font-size: 11px;
+ font-style: normal;
+}
+.answer-draft-question {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 16px 18px;
+ border-bottom: 1px solid #edf1f6;
+}
+.answer-draft-content {
+ flex: 1;
+ min-width: 0;
+}
+.answer-draft-content h3 {
+ margin: 0 0 10px;
+ font-size: 15px;
+}
+.answer-product-line {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ margin-bottom: 10px;
+}
+.answer-product-line img {
+ width: 44px;
+ height: 44px;
+ border-radius: 5px;
+ object-fit: cover;
+}
+.answer-product-line .source-shot {
+ margin-left: 10px;
+ cursor: zoom-in;
+}
+.answer-product-line span {
+ display: grid;
+}
+.answer-product-line small {
+ color: #8498b0;
+}
+.answer-draft-content textarea {
+ width: 100%;
+ min-height: 58px;
+ padding: 10px;
+ border: 1px solid #cbd9e9;
+ border-radius: 8px;
+ resize: vertical;
+}
+@media (max-width: 700px) {
+ .answer-draft-group > header,
+ .answer-draft-group > header > div {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .answer-draft-question {
+ padding: 13px;
+ }
+ .answer-product-line {
+ flex-wrap: wrap;
+ }
+}
+.create-page-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ margin-bottom: 20px;
+}
+.create-page-heading h2 {
+ margin: 0 0 5px;
+ font-size: 22px;
+ color: #10284c;
+}
+.create-page-heading p {
+ margin: 0;
+ color: #68809f;
+}
+.create-mode-tabs {
+ display: flex;
+ padding: 4px;
+ border-radius: 12px;
+ background: #e7edf6;
+}
+.create-mode-tabs button {
+ padding: 8px 14px;
+ border-radius: 9px;
+ background: transparent;
+ color: #506783;
+}
+.create-mode-tabs button.active {
+ background: #fff;
+ color: #173258;
+ box-shadow: 0 2px 7px rgba(31, 62, 104, 0.12);
+}
+.create-guide {
+ display: grid;
+ gap: 5px;
+ margin-bottom: 16px;
+ padding: 14px 16px;
+ border: 1px solid #f2cb56;
+ border-radius: 12px;
+ background: #fffbeb;
+ color: #b96b08;
+ font-size: 12px;
+}
+.create-task-error {
+ margin-bottom: 14px;
+}
+.create-select-all {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 16px;
+ padding: 14px 16px;
+ border: 1px solid #dfe8f3;
+ border-radius: 12px;
+ background: #fff;
+}
+.create-select-all label {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.create-select-all em {
+ color: #7187a1;
+ font-size: 12px;
+ font-style: normal;
+}
+.task-checkbox {
+ appearance: none !important;
+ -webkit-appearance: none !important;
+ display: inline-grid !important;
+ place-content: center !important;
+ flex: 0 0 17px !important;
+ width: 17px !important;
+ height: 17px !important;
+ margin: 0 !important;
+ border: 1.5px solid #8da0b8 !important;
+ border-radius: 3px !important;
+ background: #fff !important;
+ cursor: pointer !important;
+}
+.task-checkbox:checked {
+ border-color: #2864e8 !important;
+ background: #2864e8 !important;
+}
+.task-checkbox:checked:after {
+ content: "✓";
+ color: #fff;
+ font-size: 12px;
+ font-weight: 800;
+ line-height: 1;
+}
+.task-checkbox:disabled {
+ cursor: not-allowed !important;
+ opacity: 0.5 !important;
+}
+.draft-group-header {
+ gap: 8px !important;
+}
+.draft-expand,
+.draft-fold {
+ padding: 5px;
+ background: transparent;
+ color: #7d92ae;
+}
+.draft-expand {
+ font-size: 24px;
+ line-height: 1;
+}
+.draft-group-select,
+.question-check {
+ display: flex;
+ align-items: center;
+}
+.draft-user-meta {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex: 1;
+}
+.draft-user-meta em {
+ font-style: normal;
+}
+.draft-identifying {
+ display: inline-flex;
+ align-items: center;
+ margin-left: auto;
+ padding: 6px 10px;
+ border: 1px solid #f2d28a;
+ border-radius: 8px;
+ background: #fff8e7;
+ color: #bd7900;
+ font-size: 11px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+.draft-identifying::before {
+ width: 7px;
+ height: 7px;
+ margin-right: 6px;
+ border-radius: 50%;
+ background: #efad22;
+ content: "";
+ animation: draft-identifying-pulse 1.2s ease-in-out infinite;
+}
+@keyframes draft-identifying-pulse {
+ 0%, 100% { opacity: .35; }
+ 50% { opacity: 1; }
+}
+.draft-question-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+.draft-question-title h3 {
+ color: #10284c !important;
+}
+.draft-question-title span {
+ padding: 4px 9px;
+ border-radius: 14px;
+ background: #dff8eb;
+ color: #0b9a62;
+ font-size: 11px;
+ white-space: nowrap;
+}
+.answer-draft-question.selected {
+ box-shadow: inset 3px 0 #2864e8;
+ background: #fbfdff;
+}
+.question-check {
+ padding-top: 3px;
+}
+.source-image-wrap {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-left: 10px;
+}
+.source-image-wrap small {
+ white-space: nowrap;
+}
+.draft-icon-button {
+ flex: 0 0 38px;
+ height: 38px;
+ border: 1px solid #cbd9e8;
+ border-radius: 8px;
+ background: #fff;
+ color: #537397;
+ font-size: 20px;
+}
+.draft-icon-button:hover {
+ border-color: #2864e8;
+ color: #2864e8;
+}
+@media (max-width: 800px) {
+ .create-page-heading {
+ flex-direction: column;
+ gap: 13px;
+ }
+ .draft-user-meta {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .draft-group-header {
+ align-items: flex-start !important;
+ }
+ .draft-fold {
+ display: none;
+ }
+ .draft-question-title {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .source-image-wrap {
+ margin-left: 0;
+ }
+}
+
+/* 公共回答用户中心 V2 */
+.answer-v2-loading {
+ text-align: center;
+ color: #7890aa;
+}
+
+/* 运营任务看板 */
+.board-heading h2 {
+ margin: 0 0 5px;
+ color: #10284c;
+ font-size: 23px;
+}
+.board-heading p {
+ margin: 0;
+ color: #6c82a0;
+}
+.board-type-row,
+.board-status-row {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ margin-top: 20px;
+}
+.board-type-row span {
+ color: #617895;
+ font-size: 13px;
+}
+.board-type-row button,
+.board-status-row button {
+ padding: 8px 15px;
+ border: 1px solid #d9e3ee;
+ border-radius: 8px;
+ background: #fff;
+ color: #294766;
+}
+.board-type-row button.active {
+ border-color: #172844;
+ background: #172844;
+ color: #fff;
+ font-weight: 700;
+}
+.board-status-row {
+ padding-bottom: 8px;
+ border-bottom: 1px solid #dce5ef;
+}
+.board-status-row button.active {
+ border-color: #2864e8;
+ background: #2864e8;
+ color: #fff;
+ font-weight: 700;
+}
+.board-search {
+ display: flex;
+ align-items: center;
+ width: 320px;
+ margin: 16px 0;
+ overflow: hidden;
+ border: 1px solid #cbd9e9;
+ border-radius: 8px;
+ background: #fff;
+ color: #91a3b9;
+}
+.board-search svg {
+ margin-left: 11px;
+}
+.board-search input {
+ width: 100%;
+ border: 0;
+ padding: 11px 10px;
+ outline: 0;
+}
+.board-tools {
+ display: flex;
+ align-items: flex-end;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin: 0 0 16px;
+}
+.board-tools label {
+ display: grid;
+ gap: 5px;
+ color: #617895;
+ font-size: 12px;
+}
+.board-tools input[type="date"] {
+ min-width: 142px;
+ border: 1px solid #cbd9e9;
+ border-radius: 8px;
+ background: #fff;
+ color: #294766;
+ padding: 9px 10px;
+ font: inherit;
+}
+.board-tools > button {
+ height: 36px;
+ padding: 0 14px;
+ border: 1px solid #cbd9e9;
+ border-radius: 8px;
+ background: #fff;
+ color: #1764e8;
+}
+.board-tools > button.danger {
+ border-color: #f2b8c3;
+ color: #d93450;
+}
+.board-tools > button:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+.board-confirm-backdrop {
+ position: fixed;
+ z-index: 80;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 20px;
+ background: rgba(9, 23, 45, 0.58);
+}
+.board-confirm-modal {
+ width: min(440px, 100%);
+ padding: 26px 28px 22px;
+ border: 1px solid #dbe5f1;
+ border-radius: 16px;
+ background: #fff;
+ box-shadow: 0 20px 65px rgba(12, 33, 67, 0.24);
+}
+.board-confirm-icon {
+ display: grid;
+ place-items: center;
+ width: 42px;
+ height: 42px;
+ margin-bottom: 14px;
+ border-radius: 50%;
+ background: #fff1f3;
+ color: #d93450;
+ font-size: 23px;
+ font-weight: 800;
+}
+.board-confirm-modal h2 {
+ margin: 0 0 9px;
+ color: #142d4d;
+ font-size: 19px;
+}
+.board-confirm-modal p {
+ margin: 0;
+ color: #6d829d;
+ font-size: 13px;
+ line-height: 1.75;
+}
+.board-confirm-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 22px;
+}
+.board-confirm-actions button {
+ min-width: 88px;
+ padding: 9px 15px;
+ border: 1px solid #cbd9e9;
+ border-radius: 8px;
+ background: #fff;
+ color: #526d8d;
+ font-weight: 700;
+}
+.board-confirm-actions button.danger {
+ border-color: #d93450;
+ background: #d93450;
+ color: #fff;
+}
+.board-confirm-actions button:disabled {
+ cursor: wait;
+ opacity: 0.6;
+}
+.board-table {
+ overflow: hidden;
+ padding: 0;
+}
+.board-table-head,
+.board-table-row {
+ display: grid;
+ grid-template-columns: 34px 1.05fr 1.05fr 1.9fr 0.75fr 0.55fr 0.75fr 0.75fr 1fr 0.7fr;
+ align-items: center;
+ gap: 10px;
+ padding: 13px 14px;
+}
+.board-table-head {
+ background: #f7f9fc;
+ border-bottom: 1px solid #dce5ef;
+ color: #607794;
+ font-size: 12px;
+ font-weight: 700;
+}
+.board-table-row {
+ min-height: 66px;
+ border-bottom: 1px solid #edf1f6;
+ color: #203c5e;
+ font-size: 12px;
+}
+.board-table-row:last-child {
+ border-bottom: 0;
+}
+.board-table-row > span {
+ min-width: 0;
+}
+.board-select-cell {
+ display: grid;
+ place-items: center;
+}
+.board-select-cell input {
+ appearance: none;
+ -webkit-appearance: none;
+ display: inline-grid;
+ place-content: center;
+ width: 17px;
+ height: 17px;
+ margin: 0;
+ border: 1.5px solid #8da0b8;
+ border-radius: 4px;
+ background: #fff;
+ cursor: pointer;
+}
+.board-select-cell input:checked {
+ border-color: #2864e8;
+ background: #2864e8;
+}
+.board-select-cell input:checked::after {
+ content: "";
+ width: 8px;
+ height: 4px;
+ margin-top: -1px;
+ border-bottom: 2px solid #fff;
+ border-left: 2px solid #fff;
+ transform: rotate(-45deg);
+}
+.board-select-cell input:focus-visible {
+ outline: 3px solid rgba(40, 100, 232, 0.2);
+ outline-offset: 1px;
+}
+.board-table-row code {
+ padding: 5px 7px;
+ border-radius: 5px;
+ background: #edf2f8;
+ color: #264563;
+ font-size: 10px;
+}
+.board-table-row > span:nth-child(3) b {
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 11px;
+}
+.board-table-row > span:nth-child(4),
+.board-table-row > span:nth-child(9) {
+ display: grid;
+ gap: 4px;
+}
+.board-table-row strong {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.board-table-row small {
+ color: #8599b1;
+ font-size: 10px;
+}
+.board-type,
+.board-status {
+ display: inline-block;
+ width: max-content;
+ padding: 4px 8px;
+ border-radius: 5px;
+ font-size: 10px;
+ font-style: normal;
+}
+.board-type.answer {
+ background: #e5faf0;
+ color: #07945c;
+}
+.board-type.ask {
+ background: #e8f2ff;
+ color: #1764e8;
+}
+.board-status {
+ background: #e8f1ff;
+ color: #1764e8;
+}
+.board-status.待审核 {
+ background: #fff3dd;
+ color: #dc8500;
+}
+.board-status.已完成 {
+ background: #e8faef;
+ color: #12965b;
+}
+.board-status.已撤销 {
+ background: #e9eef4;
+ color: #62758c;
+}
+.board-status.待返款 {
+ background: #fff0e5;
+ color: #d66c10;
+}
+.board-actions {
+ display: flex;
+ gap: 9px;
+}
+.board-actions button {
+ padding: 4px 0;
+ background: transparent;
+ color: #1764e8;
+}
+.board-actions button.cancel {
+ color: #d93450;
+}
+.board-actions button.delete {
+ color: #d93450;
+}
+.board-actions button:disabled {
+ opacity: 0.5;
+}
+.board-empty {
+ padding: 54px;
+ text-align: center;
+ color: #8195ad;
+}
+.task-detail-backdrop {
+ position: fixed;
+ z-index: 50;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 20px;
+ background: rgba(10, 22, 40, 0.55);
+ backdrop-filter: blur(2px);
+}
+.task-detail-modal {
+ width: min(790px, 100%);
+ max-height: calc(100vh - 40px);
+ overflow: auto;
+ padding: 24px;
+ border-radius: 15px;
+ background: #fff;
+ box-shadow: 0 25px 80px rgba(9, 22, 41, 0.35);
+ color: #172c49;
+}
+.task-detail-modal > header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-bottom: 15px;
+ border-bottom: 1px solid #dce4ee;
+}
+.task-detail-modal > header > div {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.task-detail-modal > header strong {
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 16px;
+}
+.task-detail-modal > header > button {
+ padding: 4px;
+ background: transparent;
+ color: #8fa0b6;
+}
+.task-detail-meta {
+ display: grid;
+ grid-template-columns: 1.5fr 1fr 1fr 1.1fr;
+ gap: 12px;
+ margin-top: 19px;
+ padding: 16px;
+ border: 1px solid #dce5ef;
+ border-radius: 12px;
+ background: #f8fafc;
+}
+.task-detail-meta span {
+ display: grid;
+ gap: 6px;
+ color: #8396ad;
+ font-size: 11px;
+}
+.task-detail-meta b {
+ overflow: hidden;
+ color: #243c5b;
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.task-detail-modal > h3 {
+ margin: 20px 0 10px;
+ font-size: 13px;
+}
+.task-detail-table {
+ overflow: hidden;
+ border: 1px solid #dce5ef;
+ border-radius: 11px;
+}
+.task-detail-table .head,
+.task-detail-table .row {
+ display: grid;
+ grid-template-columns: 45px 2fr 1fr 0.7fr;
+ gap: 10px;
+ align-items: center;
+ padding: 11px;
+}
+.task-detail-table .head {
+ background: #f7f9fc;
+ color: #647b98;
+ font-size: 11px;
+}
+.task-detail-table .row {
+ border-top: 1px solid #edf1f6;
+ color: #536b88;
+ font-size: 11px;
+}
+.task-detail-table .row > span {
+ display: grid;
+ gap: 5px;
+}
+.task-detail-table .row b {
+ color: #203c5e;
+}
+.task-detail-table .row small {
+ color: #8094ac;
+ line-height: 1.5;
+}
+.task-detail-empty {
+ padding: 30px;
+ text-align: center;
+ color: #8a9cb1;
+}
+.task-detail-evidence {
+ display: grid;
+ grid-template-columns: 1.5fr 1fr;
+ gap: 14px;
+ margin-top: 18px;
+}
+.task-detail-evidence > section {
+ padding: 14px;
+ border: 1px solid #dce5ef;
+ border-radius: 12px;
+ background: #f8fafc;
+}
+.task-detail-evidence > section.payment {
+ border-color: #bfe8d2;
+ background: #f4fcf8;
+}
+.task-detail-evidence h3 {
+ margin: 0 0 11px;
+ font-size: 12px;
+}
+.task-detail-evidence section > div {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 9px;
+}
+.task-detail-evidence .payment > div {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+.task-detail-evidence button {
+ overflow: hidden;
+ padding: 0;
+ border: 1px solid #d7e2ee;
+ border-radius: 9px;
+ background: #fff;
+ color: #55708e;
+}
+.task-detail-evidence img {
+ display: block;
+ width: 100%;
+ height: 105px;
+ object-fit: cover;
+}
+.task-detail-evidence button span {
+ display: block;
+ padding: 7px 5px;
+ font-size: 10px;
+}
+.task-detail-evidence p {
+ margin: 0;
+ padding: 24px 8px;
+ color: #8a9cb1;
+ font-size: 11px;
+ text-align: center;
+}
+.task-detail-modal > footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 20px;
+ padding-top: 12px;
+ border-top: 1px solid #e1e8f1;
+}
+.task-detail-modal > footer button {
+ padding: 9px 20px;
+ border-radius: 8px;
+ background: #f0f4f8;
+ color: #243d5d;
+ font-weight: 700;
+}
+.task-detail-modal > footer button.confirm-refund {
+ background: #1764e8;
+ color: #fff;
+ box-shadow: 0 5px 12px rgba(23, 100, 232, 0.2);
+}
+.refund-confirm-backdrop {
+ position: fixed;
+ z-index: 70;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 18px;
+ background: rgba(8, 18, 37, 0.62);
+}
+.refund-confirm-modal {
+ width: min(390px, 100%);
+ padding: 26px;
+ border-radius: 17px;
+ background: #fff;
+ text-align: center;
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
+}
+.refund-confirm-modal > span {
+ display: grid;
+ place-items: center;
+ width: 54px;
+ height: 54px;
+ margin: 0 auto 14px;
+ border-radius: 17px;
+ background: #e8f8ef;
+ color: #07945c;
+ font-size: 25px;
+ font-weight: 800;
+}
+.refund-confirm-modal h2 {
+ margin: 0 0 10px;
+ color: #142d4d;
+ font-size: 20px;
+}
+.refund-confirm-modal p {
+ margin: 0;
+ color: #70869f;
+ font-size: 12px;
+ line-height: 1.75;
+}
+.refund-confirm-modal > div {
+ display: grid;
+ grid-template-columns: 1fr 1.35fr;
+ gap: 10px;
+ margin-top: 22px;
+}
+.refund-confirm-modal button {
+ padding: 11px;
+ border-radius: 9px;
+ background: #eef3f8;
+ color: #536d8b;
+ font-weight: 700;
+}
+.refund-confirm-modal button.confirm {
+ background: #1764e8;
+ color: #fff;
+}
+.refund-confirm-modal button:disabled {
+ cursor: wait;
+ opacity: 0.65;
+}
+@media (max-width: 1100px) {
+ .board-table {
+ overflow-x: auto;
+ }
+ .board-table-head,
+ .board-table-row {
+ min-width: 1120px;
+ }
+}
+@media (max-width: 700px) {
+ .board-type-row,
+ .board-status-row {
+ overflow-x: auto;
+ padding-bottom: 8px;
+ }
+ .board-type-row button,
+ .board-status-row button {
+ white-space: nowrap;
+ }
+ .board-search {
+ width: 100%;
+ }
+ .task-detail-modal {
+ padding: 16px;
+ }
+ .task-detail-meta {
+ grid-template-columns: 1fr 1fr;
+ }
+ .task-detail-table {
+ overflow-x: auto;
+ }
+ .task-detail-table .head,
+ .task-detail-table .row {
+ min-width: 620px;
+ }
+ .task-detail-evidence {
+ grid-template-columns: 1fr;
+ }
+}
+.answer-v2-shell {
+ min-height: 100vh;
+ padding: 20px;
+ background: #071126;
+ color: #10284c;
+}
+.answer-v2-app {
+ width: min(100%, 720px);
+ min-height: calc(100vh - 40px);
+ margin: auto;
+ overflow: hidden;
+ border-radius: 28px;
+ background: #f3f7fc;
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
+}
+.answer-v2-app button {
+ font-family: inherit;
+}
+.answer-v2-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 13px 20px;
+ background: #0d1932;
+ color: #f5f8ff;
+ font-size: 12px;
+}
+.answer-v2-top div {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+.answer-v2-top svg {
+ color: #4ea1ff;
+}
+.answer-v2-top em {
+ padding: 2px 7px;
+ border-radius: 4px;
+ background: #263858;
+ color: #e4efff;
+ font-size: 10px;
+ font-style: normal;
+}
+.answer-v2-top > span {
+ color: #c8d4e5;
+}
+.answer-v2-user {
+ position: relative;
+ margin: 12px 16px;
+ padding: 14px;
+ border: 1px solid #d9e3ef;
+ border-radius: 12px;
+ background: #fff;
+}
+.answer-v2-user > div {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.answer-v2-user span {
+ padding: 3px 9px;
+ border-radius: 14px;
+ background: #fff5da;
+ color: #d98b00;
+ font-size: 11px;
+}
+.answer-v2-user p {
+ margin: 7px 0 0;
+ color: #8093aa;
+ font-size: 11px;
+}
+.answer-v2-user p strong {
+ color: #1764e8;
+}
+.answer-v2-user > button {
+ position: absolute;
+ top: 14px;
+ right: 14px;
+ padding: 5px;
+ background: transparent;
+ color: #7187a3;
+}
+.answer-v2-tabs {
+ display: flex;
+ gap: 9px;
+ padding: 8px 16px;
+ border-top: 1px solid #e1e8f1;
+ background: #edf2f8;
+}
+.answer-v2-tabs button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ flex: 1;
+ padding: 9px;
+ border-radius: 9px;
+ background: transparent;
+ color: #617893;
+}
+.answer-v2-tabs button.active {
+ background: #fff;
+ color: #1764e8;
+ box-shadow: 0 2px 8px rgba(34, 68, 112, 0.09);
+}
+.answer-v2-tabs small {
+ padding: 2px 6px;
+ border-radius: 8px;
+ background: #e4ebf4;
+ color: #55708e;
+}
+.answer-v2-progress {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ padding: 13px 12px;
+ background: #fff;
+}
+.answer-v2-progress > div {
+ position: relative;
+ display: grid;
+ justify-items: center;
+ gap: 6px;
+ color: #8698af;
+ font-size: 10px;
+}
+.answer-v2-progress > div:not(:last-child):after {
+ content: "";
+ position: absolute;
+ top: 13px;
+ left: 63%;
+ width: 75%;
+ height: 1px;
+ background: #d6e0ec;
+}
+.answer-v2-progress b {
+ z-index: 1;
+ display: grid;
+ place-items: center;
+ width: 27px;
+ height: 27px;
+ border: 1px solid #cbd7e6;
+ border-radius: 50%;
+ background: #fff;
+}
+.answer-v2-progress .active {
+ color: #1764e8;
+ font-weight: 700;
+}
+.answer-v2-progress .active b {
+ border: 2px solid #1764e8;
+}
+.answer-v2-progress .done {
+ color: #1764e8;
+}
+.answer-v2-progress .done b {
+ border-color: #1469ef;
+ background: #1469ef;
+ color: #fff;
+}
+.answer-v2-progress .done:after {
+ background: #1469ef !important;
+}
+.answer-v2-body {
+ padding: 17px 15px 80px;
+}
+.answer-v2-error {
+ margin-bottom: 12px;
+ padding: 11px 13px;
+ border-radius: 9px;
+ background: #fff0f2;
+ color: #cb3951;
+ font-size: 12px;
+}
+.answer-v2-hero {
+ padding: 20px;
+ border-radius: 16px;
+ color: #fff;
+ box-shadow: 0 8px 20px rgba(41, 62, 151, 0.18);
+}
+.answer-v2-hero.purple {
+ background: linear-gradient(135deg, #163d94, #3b2f92);
+}
+.answer-v2-hero.green {
+ background: linear-gradient(135deg, #067665, #006a60);
+}
+.answer-v2-hero small {
+ display: inline-block;
+ padding: 4px 9px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.14);
+ font-weight: 700;
+}
+.answer-v2-hero h1 {
+ margin: 11px 0 6px;
+ font-size: 20px;
+}
+.answer-v2-hero p {
+ margin: 0;
+ color: #e7efff;
+ font-size: 12px;
+ line-height: 1.65;
+}
+.answer-v2-card {
+ margin-top: 15px;
+ padding: 17px;
+ border: 1px solid #d8e4f0;
+ border-radius: 15px;
+ background: #fff;
+ box-shadow: 0 2px 7px rgba(32, 63, 105, 0.06);
+}
+.upload-card > header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+}
+.upload-card h2 {
+ margin: 0 0 5px;
+ font-size: 16px;
+}
+.upload-card h2 em {
+ padding: 3px 7px;
+ border-radius: 10px;
+ background: #eaf3ff;
+ color: #3976df;
+ font-size: 10px;
+ font-style: normal;
+}
+.upload-card header p {
+ margin: 0;
+ color: #7f91a8;
+ font-size: 11px;
+}
+.upload-card > header > b {
+ color: #7188a5;
+ font-size: 11px;
+}
+.answer-v2-upload-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+ margin-top: 14px;
+}
+.answer-v2-upload-grid figure {
+ position: relative;
+ min-width: 0;
+ height: 135px;
+ margin: 0;
+ overflow: hidden;
+ border-radius: 10px;
+ background: #555;
+}
+.answer-v2-upload-grid img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.answer-v2-upload-grid figcaption {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ overflow: hidden;
+ padding: 22px 8px 8px;
+ background: linear-gradient(transparent, rgba(5, 10, 24, 0.85));
+ color: #fff;
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.answer-v2-upload-grid figure button {
+ position: absolute;
+ right: 7px;
+ top: 7px;
+ padding: 4px 7px;
+ border-radius: 6px;
+ background: #dc3155;
+ color: #fff;
+ font-size: 10px;
+}
+.answer-v2-add {
+ display: grid;
+ place-items: center;
+ align-content: center;
+ gap: 7px;
+ min-height: 135px;
+ border: 2px dashed #b8cce5;
+ border-radius: 10px;
+ background: #fbfdff;
+ color: #1764e8;
+}
+.answer-v2-add small {
+ color: #93a4b9;
+}
+.answer-v2-photo-note {
+ margin: 14px 0 0;
+ color: #8195ad;
+ font-size: 11px;
+}
+.answer-v2-primary {
+ border-radius: 9px;
+ padding: 11px 16px;
+ background: #2864e8;
+ color: #fff;
+ font-weight: 700;
+ box-shadow: 0 5px 12px rgba(40, 100, 232, 0.18);
+}
+.answer-v2-primary.wide {
+ width: 100%;
+ justify-content: center;
+ margin-top: 14px;
+}
+.answer-v2-primary:disabled {
+ background: #cbd7e5;
+ box-shadow: none;
+}
+.status-card .status-title {
+ display: flex;
+ gap: 13px;
+}
+.status-title > span {
+ display: grid;
+ place-items: center;
+ flex: 0 0 48px;
+ height: 48px;
+ border: 1px solid #8ebaff;
+ border-radius: 13px;
+ background: #edf5ff;
+ color: #1764e8;
+}
+.status-title h1 {
+ margin: 0 0 5px;
+ font-size: 19px;
+}
+.status-title h1 em {
+ padding: 3px 7px;
+ border-radius: 10px;
+ background: #e8f2ff;
+ color: #3978e8;
+ font-size: 10px;
+ font-style: normal;
+}
+.status-title p {
+ margin: 0;
+ color: #607894;
+ font-size: 11px;
+ line-height: 1.6;
+}
+.status-meta {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ margin-top: 16px;
+ padding: 13px;
+ border-radius: 10px;
+ background: #f7f9fc;
+}
+.status-meta span {
+ display: grid;
+ gap: 4px;
+ color: #8a9cb2;
+ font-size: 10px;
+}
+.status-meta b {
+ color: #173252;
+ font-size: 11px;
+}
+.status-card footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 14px;
+}
+.status-card footer small {
+ color: #8295ad;
+}
+.status-card footer button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 11px;
+ border: 1px solid #d3deeb;
+ border-radius: 8px;
+ background: #fff;
+ color: #35577d;
+}
+.spin {
+ animation: answer-spin 0.8s linear infinite;
+}
+@keyframes answer-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+.material-card > header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.material-card h2 {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ margin: 0;
+ font-size: 14px;
+}
+.material-card header > span {
+ color: #8ba0b8;
+ font-size: 10px;
+}
+.material-card > div {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 9px;
+ margin-top: 13px;
+}
+.material-card figure {
+ position: relative;
+ height: 125px;
+ margin: 0;
+ overflow: hidden;
+ border-radius: 9px;
+ background: #737781;
+ cursor: zoom-in;
+}
+.material-card img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ opacity: 0.84;
+}
+.material-card figcaption {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ padding: 5px 8px;
+ border-radius: 11px;
+ background: rgba(30, 35, 45, 0.72);
+ color: #fff;
+ font-size: 10px;
+ white-space: nowrap;
+}
+.answer-v2-wait {
+ display: flex;
+ gap: 10px;
+ margin-top: 15px;
+ padding: 15px;
+ border: 1px solid #f2cf63;
+ border-radius: 12px;
+ background: #fffbed;
+ color: #d98600;
+}
+.answer-v2-wait strong {
+ font-size: 12px;
+}
+.answer-v2-wait p {
+ margin: 5px 0 0;
+ color: #b9822d;
+ font-size: 10px;
+ line-height: 1.6;
+}
+.answer-v2-inner-tabs {
+ display: flex;
+ margin-top: 14px;
+ padding: 4px;
+ border-radius: 11px;
+ background: #e8eef5;
+}
+.answer-v2-inner-tabs button {
+ flex: 1;
+ padding: 9px;
+ border-radius: 8px;
+ background: transparent;
+ color: #607894;
+}
+.answer-v2-inner-tabs button.active {
+ background: #fff;
+ color: #1764e8;
+ box-shadow: 0 2px 6px rgba(35, 66, 108, 0.1);
+}
+.answer-v2-inner-tabs small {
+ padding: 2px 6px;
+ border-radius: 8px;
+ background: #e7eef8;
+}
+.assignment-list > header {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+}
+.assignment-list h2 {
+ margin: 0 0 4px;
+ font-size: 16px;
+}
+.assignment-list header p {
+ margin: 0;
+ color: #8093aa;
+ font-size: 10px;
+}
+.assignment-list > header > span {
+ display: grid;
+ gap: 4px;
+ min-width: 95px;
+ color: #536e8d;
+ font-size: 10px;
+}
+.assignment-list > header i {
+ display: block;
+ height: 5px;
+ overflow: hidden;
+ border-radius: 5px;
+ background: #edf1f6;
+}
+.assignment-list > header u {
+ display: block;
+ height: 100%;
+ background: #2864e8;
+ text-decoration: none;
+}
+.answer-v2-list-tip {
+ margin: 13px 0;
+ padding: 9px;
+ border-radius: 9px;
+ background: #f7f9fc;
+ color: #758ba5;
+ font-size: 10px;
+}
+.answer-v2-list-tip button {
+ float: right;
+ background: transparent;
+ color: #1764e8;
+}
+.assignment-list > article {
+ margin-top: 11px;
+ padding: 13px;
+ border: 1px solid #bed7ff;
+ border-radius: 11px;
+}
+.assignment-list > article.complete {
+ border-color: #8edab5;
+ background: #f9fffb;
+}
+.assignment-list article > header {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+}
+.assignment-list article > header > b {
+ padding: 5px 8px;
+ border-radius: 5px;
+ background: #1764e8;
+ color: #fff;
+ font-size: 11px;
+}
+.assignment-list h3 {
+ flex: 1;
+ margin: 2px 0;
+ font-size: 14px;
+ line-height: 1.5;
+}
+.assignment-list article > header > button {
+ display: grid;
+ place-items: center;
+ flex: 0 0 19px;
+ height: 19px;
+ border: 2px solid #d0ddec;
+ border-radius: 4px;
+ background: #fff;
+ color: #14945c;
+}
+.assignment-list article.complete > header > button {
+ border-color: #31b879;
+}
+.assignment-list article > small {
+ display: inline-block;
+ margin: 7px 0;
+ padding: 3px 7px;
+ border-radius: 5px;
+ background: #f0f3f7;
+ color: #8092aa;
+}
+.assignment-list article > div {
+ padding: 11px;
+ border-radius: 9px;
+ background: #f2f7ff;
+ color: #315477;
+}
+.assignment-list article > div strong {
+ font-size: 11px;
+}
+.assignment-list article > div p {
+ margin: 6px 0 0;
+ color: #253f60;
+ font-size: 12px;
+ line-height: 1.6;
+}
+.assignment-list article > footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 9px;
+ color: #8599b1;
+ font-size: 10px;
+}
+.assignment-list article > footer button {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 7px 10px;
+ border-radius: 7px;
+ background: #1764e8;
+ color: #fff;
+}
+.completion-form h2 {
+ margin: 0;
+ font-size: 17px;
+}
+.completion-form > p {
+ color: #7188a4;
+ font-size: 11px;
+}
+.completion-form label {
+ display: grid;
+ gap: 7px;
+ margin-top: 13px;
+ color: #405d7e;
+ font-size: 12px;
+}
+.completion-form input {
+ padding: 11px;
+ border: 1px dashed #a9bfd9;
+ border-radius: 9px;
+ background: #f8fbff;
+}
+.completion-form label small {
+ color: #8296ae;
+}
+.status-card.passed {
+ border-color: #91d9b5;
+}
+.status-card.passed .status-title > span {
+ border-color: #7ed4a9;
+ background: #e8faef;
+ color: #15945d;
+}
+.answer-v2-payment-wait {
+ display: grid;
+ gap: 4px;
+ margin-top: 16px;
+ padding: 13px 15px;
+ border: 1px solid #a8e8c5;
+ border-radius: 11px;
+ background: #effcf4;
+ color: #16814f;
+}
+.answer-v2-payment-wait strong {
+ font-size: 14px;
+}
+.answer-v2-payment-wait span {
+ color: #4f9876;
+ font-size: 11px;
+}
+.status-card.rejected {
+ border-color: #f0a5b2;
+}
+.status-card.rejected .status-title > span {
+ border-color: #ec98a8;
+ background: #fff0f3;
+ color: #cc3851;
+}
+.answer-v2-history > header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.answer-v2-history > header button {
+ background: transparent;
+ color: #1764e8;
+}
+.answer-v2-history > header h2 {
+ font-size: 17px;
+}
+.answer-v2-history article > header {
+ display: flex;
+ justify-content: space-between;
+}
+.answer-v2-history article > header span {
+ color: #1764e8;
+}
+.answer-v2-history article > p {
+ color: #7890aa;
+ font-size: 11px;
+}
+.answer-v2-history article > div {
+ display: flex;
+ gap: 7px;
+}
+.answer-v2-history img {
+ width: 62px;
+ height: 62px;
+ border-radius: 7px;
+ object-fit: cover;
+ cursor: zoom-in;
+}
+.answer-v2-footer {
+ position: sticky;
+ bottom: 0;
+ display: flex;
+ gap: 8px;
+ padding: 10px 15px;
+ background: rgba(245, 248, 252, 0.96);
+ border-top: 1px solid #dce5ef;
+}
+.answer-v2-footer button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ flex: 1;
+ padding: 10px;
+ border: 1px solid #d3dfec;
+ border-radius: 9px;
+ background: #fff;
+ color: #355879;
+}
+.answer-v2-footer button.primary {
+ border-color: #2864e8;
+ background: #2864e8;
+ color: #fff;
+}
+.answer-v2-login {
+ display: grid;
+ gap: 14px;
+ width: min(390px, calc(100% - 32px));
+ margin: 70px auto;
+ padding: 28px;
+ border: 1px solid #dce5f0;
+ border-radius: 18px;
+ background: #fff;
+ text-align: center;
+}
+.answer-v2-login > span {
+ display: grid;
+ place-items: center;
+ width: 56px;
+ height: 56px;
+ margin: auto;
+ border-radius: 15px;
+ background: #2864e8;
+ color: #fff;
+}
+.answer-v2-login h1,
+.answer-v2-login p {
+ margin: 0;
+}
+.answer-v2-login p {
+ color: #7890aa;
+ font-size: 12px;
+}
+.answer-v2-login label {
+ display: grid;
+ gap: 7px;
+ text-align: left;
+ color: #466181;
+ font-size: 12px;
+}
+.answer-v2-login input {
+ padding: 12px;
+ border: 1px solid #c5d5e8;
+ border-radius: 9px;
+}
+.answer-v2-login > button {
+ padding: 12px;
+ border-radius: 9px;
+ background: #2864e8;
+ color: #fff;
+ font-weight: 700;
+}
+.answer-v2-login > button:disabled {
+ background: #b9c9dc;
+}
+@media (max-width: 700px) {
+ .answer-v2-shell {
+ padding: 0;
+ background: #f3f7fc;
+ }
+ .answer-v2-app {
+ min-height: 100vh;
+ border-radius: 0;
+ box-shadow: none;
+ }
+ .answer-v2-body {
+ padding: 14px 10px 76px;
+ }
+ .answer-v2-top {
+ padding: 12px;
+ }
+ .answer-v2-user {
+ margin: 10px;
+ }
+ .answer-v2-tabs {
+ padding: 7px 10px;
+ }
+ .answer-v2-progress {
+ padding: 12px 3px;
+ }
+ .answer-v2-upload-grid,
+ .material-card > div {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ .answer-v2-upload-grid figure,
+ .answer-v2-add {
+ height: 128px;
+ }
+ .assignment-list {
+ padding: 13px;
+ }
+ .assignment-list > header {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .status-meta {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+@media (max-width: 380px) {
+ .answer-v2-top > span {
+ display: none;
+ }
+ .answer-v2-inner-tabs button {
+ font-size: 10px;
+ }
+ .status-meta {
+ grid-template-columns: 1fr;
+ }
+}
+.review-v2-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+}
+.review-v2-heading h2 {
+ margin: 0 0 5px;
+ color: #10284c;
+ font-size: 23px;
+}
+.review-v2-heading p {
+ margin: 0;
+ color: #6b82a0;
+}
+.review-v2-heading > div:last-child {
+ display: flex;
+ padding: 4px;
+ border-radius: 11px;
+ background: #e5ebf4;
+}
+.review-v2-heading button {
+ padding: 8px 14px;
+ border-radius: 8px;
+ background: transparent;
+ color: #405c7c;
+}
+.review-v2-heading button.active {
+ background: #fff;
+ color: #132c4d;
+ font-weight: 700;
+ box-shadow: 0 2px 7px rgba(31, 60, 99, 0.12);
+}
+.review-v2-heading button.review-v2-refresh {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ margin-left: 8px;
+ background: #2867df;
+ color: #fff;
+}
+.review-v2-heading button.review-v2-refresh:disabled {
+ opacity: 0.7;
+ cursor: wait;
+}
+.review-v2-guide {
+ display: flex;
+ gap: 10px;
+ margin: 20px 0 16px;
+ padding: 14px 16px;
+ border: 1px solid #d8b9ff;
+ border-radius: 12px;
+ background: #faf5ff;
+ color: #7e31d5;
+}
+.review-v2-guide.ask {
+ border-color: #b7d5ff;
+ background: #f1f6ff;
+ color: #1764e8;
+}
+.review-v2-guide strong {
+ font-size: 12px;
+}
+.review-v2-guide p {
+ margin: 4px 0 0;
+ font-size: 11px;
+ line-height: 1.55;
+}
+.review-v2-table {
+ overflow: hidden;
+ padding: 0;
+}
+.review-v2-table > .head,
+.review-v2-table > .row {
+ display: grid;
+ grid-template-columns: 1.55fr 1.45fr 1.1fr 0.65fr 0.7fr 0.75fr;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+}
+.review-v2-table > .head {
+ background: #f7f9fc;
+ border-bottom: 1px solid #dce5ef;
+ color: #5e7694;
+ font-size: 11px;
+ font-weight: 700;
+}
+.review-v2-table > .row {
+ min-height: 64px;
+ border-bottom: 1px solid #edf1f6;
+ color: #294665;
+ font-size: 11px;
+}
+.review-v2-table > .row:last-child {
+ border: 0;
+}
+.review-v2-table > .row > span:first-child {
+ display: grid;
+ gap: 5px;
+}
+.review-v2-table > .row > span > b {
+ display: flex;
+ gap: 7px;
+}
+.review-v2-table code {
+ padding: 4px 7px;
+ border-radius: 4px;
+ background: #edf2f8;
+ color: #1e466e;
+ font-size: 10px;
+}
+.review-v2-table small {
+ color: #8094ac;
+}
+.review-v2-table em {
+ padding: 4px 8px;
+ border-radius: 6px;
+ background: #f4e9ff;
+ color: #8c35d5;
+ font-style: normal;
+}
+.review-v2-table em.已通过 {
+ background: #e5f8ed;
+ color: #158d57;
+}
+.review-v2-table em.未通过 {
+ background: #fff0f3;
+ color: #cc3853;
+}
+.review-v2-table > .row > span:last-child {
+ text-align: right;
+}
+.review-v2-table > .row > span:last-child button {
+ padding: 8px 13px;
+ border-radius: 7px;
+ background: #2864e8;
+ color: #fff;
+}
+.review-v2-empty {
+ padding: 60px;
+ text-align: center;
+ color: #8195ad;
+}
+.review-v2-backdrop {
+ position: fixed;
+ z-index: 55;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 18px;
+ background: rgba(9, 20, 38, 0.58);
+ backdrop-filter: blur(2px);
+}
+.review-v2-modal {
+ width: min(900px, 100%);
+ max-height: calc(100vh - 36px);
+ overflow: auto;
+ padding: 22px;
+ border-radius: 14px;
+ background: #fff;
+ color: #173251;
+ box-shadow: 0 25px 85px rgba(7, 19, 37, 0.4);
+}
+.review-v2-modal > header {
+ display: flex;
+ justify-content: space-between;
+ padding-bottom: 13px;
+ border-bottom: 1px solid #dce5ef;
+}
+.review-v2-modal > header h2 {
+ margin: 0;
+ font-size: 18px;
+}
+.review-v2-modal > header p {
+ margin: 5px 0 0;
+ color: #607998;
+ font-size: 11px;
+}
+.review-v2-modal > header button {
+ padding: 2px;
+ background: transparent;
+ color: #91a1b5;
+}
+.review-v2-summary {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 14px;
+ color: #526c8b;
+ font-size: 11px;
+ flex-wrap: wrap;
+}
+.review-v2-summary code {
+ padding: 4px 8px;
+ border-radius: 5px;
+ background: #e8f1ff;
+ color: #1764e8;
+}
+.review-v2-modal > h3,
+.review-v2-media h3 {
+ margin: 18px 0 9px;
+ font-size: 12px;
+}
+.review-v2-qa {
+ display: grid;
+ gap: 8px;
+}
+.review-v2-qa article {
+ padding: 12px;
+ border: 1px solid #dce5ef;
+ border-radius: 9px;
+ background: #f8fafc;
+}
+.review-v2-qa strong {
+ color: #792ee4;
+ font-size: 12px;
+}
+.review-v2-qa p {
+ margin: 7px 0 0;
+ color: #31506f;
+ font-size: 12px;
+}
+.review-v2-media {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 18px;
+}
+.review-v2-media section > div {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 8px;
+}
+.review-v2-media figure {
+ margin: 0;
+}
+.review-v2-media img {
+ width: 100%;
+ height: 170px;
+ border: 1px solid #dce5ef;
+ border-radius: 8px;
+ object-fit: cover;
+ cursor: zoom-in;
+}
+.review-v2-media figcaption {
+ margin-top: 4px;
+ color: #7f92aa;
+ font-size: 9px;
+}
+.review-v2-no-payment {
+ display: grid !important;
+ place-items: center;
+ min-height: 170px;
+ border: 1px dashed #c7d5e5;
+ border-radius: 8px;
+ color: #8ca0b8;
+ font-size: 11px;
+}
+.review-v2-assist {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 18px;
+ padding: 12px;
+ border: 1px solid #8de3bb;
+ border-radius: 9px;
+ background: #edfff6;
+ color: #098852;
+ font-size: 11px;
+}
+.review-v2-assist span {
+ margin-left: auto;
+}
+.review-v2-result {
+ margin-top: 15px;
+ padding: 12px;
+ border-radius: 8px;
+ background: #fff0f3;
+ color: #c73952;
+}
+.review-v2-result.approved {
+ background: #eafaf1;
+ color: #118b55;
+}
+.review-v2-result p {
+ margin: 5px 0 0;
+ font-size: 11px;
+}
+.review-v2-modal > footer {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 16px;
+ padding-top: 13px;
+ border-top: 1px solid #dce5ef;
+}
+.review-v2-modal > footer > div {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ flex: 1;
+}
+.review-v2-modal select,
+.review-v2-modal footer input {
+ border: 1px solid #cbd9e8;
+ border-radius: 7px;
+ padding: 9px;
+}
+.review-v2-modal footer input {
+ min-width: 0;
+ flex: 1;
+}
+.review-v2-modal footer > button {
+ padding: 10px 14px;
+ border-radius: 8px;
+ font-weight: 700;
+}
+.review-v2-modal footer .reject {
+ background: #ffedf1;
+ color: #c9314e;
+}
+.review-v2-modal footer .approve {
+ background: #07945c;
+ color: #fff;
+}
+.review-v2-modal footer button:disabled {
+ opacity: 0.5;
+}
+.answer-v2-rejected {
+ border: 2px solid #ffc1ca !important;
+ background: #fff !important;
+}
+.reject-title {
+ display: flex;
+ gap: 13px;
+}
+.reject-title > span {
+ display: grid;
+ place-items: center;
+ flex: 0 0 43px;
+ height: 43px;
+ border: 1px solid #ff98aa;
+ border-radius: 12px;
+ background: #fff0f3;
+ color: #ed1741;
+ font-size: 22px;
+ font-weight: 800;
+}
+.reject-title h1 {
+ margin: 0 0 7px;
+ font-size: 18px;
+}
+.reject-title h1 em {
+ padding: 3px 7px;
+ border-radius: 10px;
+ background: #ffebef;
+ color: #e32649;
+ font-size: 10px;
+ font-style: normal;
+}
+.reject-title p {
+ margin: 0;
+ color: #687f9b;
+ font-size: 11px;
+ line-height: 1.6;
+}
+.reject-reason {
+ margin-top: 16px;
+ padding: 13px;
+ border: 1px solid #ffb5c1;
+ border-radius: 10px;
+ background: #fff1f3;
+ color: #dd2043;
+}
+.reject-reason strong {
+ font-size: 11px;
+}
+.reject-reason p {
+ margin: 7px 0 0;
+ padding-top: 7px;
+ border-top: 1px solid #ffcbd3;
+ font-size: 11px;
+ line-height: 1.55;
+}
+.answer-v2-rejected > button {
+ width: 100%;
+ margin-top: 16px;
+ padding: 12px;
+ border-radius: 9px;
+ background: #f50042;
+ color: #fff;
+ font-weight: 700;
+}
+@media (max-width: 900px) {
+ .review-v2-table {
+ overflow-x: auto;
+ }
+ .review-v2-table > .head,
+ .review-v2-table > .row {
+ min-width: 900px;
+ }
+ .review-v2-modal > footer,
+ .review-v2-modal > footer > div {
+ align-items: stretch;
+ flex-direction: column;
+ }
+ .review-v2-assist {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .review-v2-assist span {
+ margin: 0;
+ }
+}
+
+.ask-selected-card article span {
+ min-width: 0;
+ flex: 1;
+ font-weight: 600;
+}
+.ask-selected-card article button {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex: 0 0 auto;
+ padding: 6px 9px;
+ border: 1px solid #b9d3fa;
+ border-radius: 7px;
+ background: #fff;
+ color: #1764e8;
+ font-size: 10px;
+}
+.ask-selected-card article button:hover {
+ border-color: #1764e8;
+ background: #eaf2ff;
+}
+.ask-payment-card header>span{padding:4px 8px;border-radius:10px;background:#e6faef;color:#07945c;font-size:10px}.ask-payment-file{display:flex;align-items:center;gap:8px;margin-top:14px;padding:10px;border:1px solid #d9e3ee;border-radius:10px;background:#f8fafc}.ask-payment-file span{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ask-payment-file button{background:transparent;color:#1764e8}.ask-payment-file button.delete{color:#dc3151}.ask-payment-empty{display:flex;align-items:center;justify-content:center;gap:7px;width:100%;margin-top:14px;padding:18px;border:2px dashed #b8cde5;border-radius:10px;background:#fbfdff;color:#1764e8}.ask-payment-card label{display:grid;gap:7px;margin-top:13px;color:#496584;font-size:11px}.ask-payment-card input{padding:10px;border:1px solid #ccd9e8;border-radius:9px}.ask-submit-review{width:100%;padding:14px;border-radius:12px;background:linear-gradient(90deg,#1764e8,#5639f5);color:#fff;font-weight:800;box-shadow:0 6px 14px rgba(40,77,220,.25)}.ask-submit-review:disabled{background:#b7c5d7;box-shadow:none}.ask-submit-backdrop{position:fixed;z-index:80;inset:0;display:grid;place-items:center;padding:14px;background:rgba(8,18,37,.58)}.ask-submit-modal{width:min(390px,100%);overflow:hidden;border-radius:18px;background:#fff;box-shadow:0 24px 80px rgba(0,0,0,.35)}.ask-submit-modal>header{position:relative;padding:20px;background:linear-gradient(135deg,#2351d5,#3d2bbf);color:#fff}.ask-submit-modal>header>svg{padding:8px;width:48px;height:48px;border-radius:13px;background:rgba(255,255,255,.14)}.ask-submit-modal>header button{position:absolute;right:15px;top:14px;width:28px;height:28px;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:20px}.ask-submit-modal h2{margin:12px 0 5px}.ask-submit-modal header p{margin:0;color:#dce5ff;font-size:11px}.ask-submit-summary{margin:18px;padding:12px;border:1px solid #dce5ef;border-radius:12px;background:#f8fafc}.ask-submit-summary p{display:flex;justify-content:space-between;margin:7px 0;color:#7187a1;font-size:11px}.ask-submit-summary b,.ask-submit-summary code{color:#1764e8}.ask-submit-summary article{margin:7px 0;padding:8px;border-radius:7px;background:#fff;color:#173453;font-size:11px}.ask-submit-modal aside{margin:0 18px;padding:12px;border:1px solid #f1d172;border-radius:10px;background:#fffbed;color:#bb7700}.ask-submit-modal aside p{margin:5px 0 0;font-size:10px;line-height:1.5}.ask-submit-modal>footer{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:18px}.ask-submit-modal>footer button{padding:11px;border-radius:9px;background:#eef3f8;color:#536d8b}.ask-submit-modal>footer button.confirm{background:#1764e8;color:#fff}.ask-audit-body{padding:28px 16px}.ask-audit-card{display:grid;justify-items:center;padding:25px 20px;border:1px solid #f1cf68;border-radius:17px;background:#fff;text-align:center}.ask-audit-card>span{display:grid;place-items:center;width:60px;height:60px;border:1px solid #f4c64c;border-radius:20px;background:#fff9e8;color:#e68b00;font-size:28px}.ask-audit-card>em{margin-top:13px;padding:5px 11px;border-radius:13px;background:#fff0c7;color:#cb7c00;font-style:normal}.ask-audit-card h1{margin:12px 0 7px}.ask-audit-card>p{margin:0;color:#6b819c;line-height:1.6}.ask-audit-card>div{width:100%;margin-top:20px;padding:14px;border:1px solid #dce5ef;border-radius:12px;background:#f8fafc;text-align:left}.ask-audit-card>div strong{display:block;padding-bottom:10px;border-bottom:1px solid #dfe6ee}.ask-audit-card dl{display:grid;grid-template-columns:90px 1fr;gap:9px;margin:12px 0 0;font-size:11px}.ask-audit-card dt{color:#8295ad}.ask-audit-card dd{margin:0;text-align:right}.ask-audit-card>button{width:100%;margin-top:18px;padding:11px;border-radius:9px;background:#0d1932;color:#fff;font-weight:700}.ask-audit-card.passed{border-color:#86d8ad}.ask-audit-card.passed>span{border-color:#67ca98;background:#e9faef;color:#0a955b}.ask-audit-card.rejected{border-color:#f1a1b0}.ask-audit-card.rejected>span{border-color:#f19aab;background:#fff0f3;color:#d62b4c}.ask-audit-card>aside{width:100%;margin-top:14px;padding:12px;border:1px solid #f3a9b5;border-radius:10px;background:#fff0f3;color:#ce3150;text-align:left}.ask-audit-card>aside p{margin:6px 0 0;font-size:11px}
+
+/* Public ask-task portal */
+.ask-user-shell{min-height:100vh;padding:22px;background:#0c162b}.ask-user-app{position:relative;width:min(100%,680px);min-height:calc(100vh - 44px);margin:auto;overflow:hidden;border-radius:25px;background:#f5f8fc;color:#102744;box-shadow:0 24px 70px rgba(0,0,0,.32)}.ask-user-topbar{display:flex;align-items:center;justify-content:center;gap:7px;padding:13px 18px;background:#0d1932;color:#fff;font-size:12px}.ask-user-topbar span{display:flex;align-items:center;gap:6px}.ask-user-topbar svg{color:#59a2ff}.ask-user-topbar em{padding:2px 7px;border-radius:4px;background:#273858;color:#e8f1ff;font-size:10px;font-style:normal}.ask-user-profile{position:relative;display:flex;align-items:center;gap:11px;margin:16px;padding:14px;border:1px solid #dae4ef;border-radius:14px;background:#fff;box-shadow:0 2px 7px rgba(32,62,102,.05)}.ask-user-avatar{display:grid;place-items:center;flex:0 0 42px;height:42px;border-radius:50%;background:linear-gradient(145deg,#5a84dd,#9b72d6);color:#fff;font-weight:800}.ask-user-profile strong{font-size:13px}.ask-user-profile i{margin-left:7px;padding:3px 6px;border-radius:4px;background:#e7fbf1;color:#0a9b63;font-size:9px;font-style:normal}.ask-user-profile small{display:block;margin-top:7px;color:#8296ad;font-size:10px}.ask-user-profile>button{position:absolute;right:11px;top:11px;padding:5px;background:transparent;color:#8297b0}.ask-user-tabs{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:7px 16px;background:#e9eef5}.ask-user-tabs span{display:flex;align-items:center;justify-content:center;gap:7px;padding:9px;border-radius:9px;color:#5e7592;font-size:12px}.ask-user-tabs span.active{background:#fff;color:#1764e8;font-weight:700;box-shadow:0 2px 7px rgba(34,65,105,.08)}.ask-user-tabs b{padding:2px 6px;border-radius:9px;background:#e6eefb;color:#1764e8;font-size:9px}.ask-user-task-id{display:flex;align-items:center;gap:9px;margin:16px;padding:13px;border:1px solid #d9e4ef;border-radius:11px;background:#fff}.ask-user-task-id span{display:flex;align-items:center;gap:5px;font-weight:700}.ask-user-task-id span svg{color:#1764e8}.ask-user-task-id code{padding:4px 8px;border-radius:5px;background:#eaf2ff;color:#1764e8}.ask-user-task-id button{display:flex;align-items:center;gap:4px;margin-left:auto;padding:7px 10px;border-radius:7px;background:#f0f4f9;color:#54708f}.ask-question-section{padding:0 16px 90px}.ask-question-section>header{display:flex;align-items:center;justify-content:space-between;margin:12px 2px}.ask-question-section>header strong{display:flex;align-items:center;gap:6px}.ask-question-section>header svg,.ask-question-section>header button{color:#1764e8}.ask-question-section>header button{background:transparent}.ask-question-list{display:grid;gap:10px}.ask-question-list>button{display:flex;align-items:center;gap:12px;width:100%;min-height:69px;padding:14px;border:1px solid #d7e1ec;border-radius:14px;background:#fff;color:#152d4c;text-align:left;line-height:1.55}.ask-question-list>button.active{border:1.5px solid #1764e8;background:#f2f7ff;box-shadow:0 2px 6px rgba(23,100,232,.08)}.ask-question-list>button i{display:grid;place-items:center;flex:0 0 20px;height:20px;border:1px solid #c7d5e6;border-radius:5px;background:#fff;color:#fff;font-style:normal}.ask-question-list>button.active i{border-color:#1764e8;background:#1764e8}.ask-question-list>button span{font-weight:600}.ask-user-sticky{position:absolute;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-top:1px solid #dae4ef;background:rgba(255,255,255,.97);box-shadow:0 -8px 20px rgba(28,54,88,.07)}.ask-user-sticky span{color:#647b98;font-size:11px}.ask-user-sticky b{color:#1764e8}.ask-user-sticky button{width:180px;padding:11px;border-radius:10px;background:#1764e8;color:#fff;font-weight:700;box-shadow:0 5px 12px rgba(23,100,232,.23)}.ask-user-sticky button:disabled{background:#afbed0;box-shadow:none}.ask-user-error{padding:10px;border-radius:8px;background:#fff0f3;color:#cc3853;font-size:11px}.ask-user-error.page{margin:12px 16px}.ask-user-loading{padding:70px;text-align:center;color:#7e92aa}.ask-login-app{display:grid;align-content:start}.ask-user-login{display:grid;gap:15px;width:min(390px,calc(100% - 32px));margin:70px auto;padding:28px;border:1px solid #dce5f0;border-radius:18px;background:#fff;text-align:center}.ask-login-icon{display:grid;place-items:center;width:55px;height:55px;margin:auto;border-radius:15px;background:#2864e8;color:#fff}.ask-user-login h1,.ask-user-login p{margin:0}.ask-user-login p{color:#7890aa;font-size:12px}.ask-user-login label{display:grid;gap:7px;text-align:left;color:#466181;font-size:12px}.ask-user-login input{padding:12px;border:1px solid #c5d5e8;border-radius:9px;outline:0}.ask-user-login input:focus{border-color:#2864e8;box-shadow:0 0 0 3px rgba(40,100,232,.1)}.ask-user-login>button{padding:12px;border-radius:9px;background:#2864e8;color:#fff;font-weight:700}.ask-confirm-body{display:grid;gap:14px;padding:16px}.ask-product-card,.ask-selected-card,.ask-upload-card{padding:15px;border:1px solid #d9e4ef;border-radius:15px;background:#fff}.ask-product-card>header{display:flex;align-items:center;gap:6px;margin:-15px -15px 13px;padding:12px 15px;border-radius:15px 15px 0 0;background:#0e1930;color:#fff}.ask-product-card>header svg{color:#4fa0ff}.ask-product-card>header code{margin-left:auto;padding:4px 7px;border-radius:8px;background:#27364f;font-size:9px}.ask-product-card>div{display:flex;align-items:center;gap:11px}.ask-product-placeholder{display:grid;place-items:center;width:74px;height:74px;border-radius:10px;background:linear-gradient(145deg,#e9e1ff,#ae9ad7);color:#5c3e9d}.ask-product-card small{color:#1764e8}.ask-product-card h2{margin:7px 0;font-size:15px}.ask-product-card>footer{display:flex;align-items:center;gap:7px;margin-top:13px;padding:8px;border:1px solid #e2e8f0;border-radius:9px;background:#f8fafc}.ask-product-card>footer code{min-width:0;flex:1;overflow:hidden;color:#7590ad;font-size:9px;text-overflow:ellipsis;white-space:nowrap}.ask-product-card>footer button{display:flex;gap:3px;background:transparent;color:#1764e8;font-size:10px}.ask-product-card>footer a{display:flex;align-items:center;gap:4px;padding:8px;border-radius:8px;background:#f05223;color:#fff;font-size:10px;text-decoration:none}.ask-selected-card{border-color:#9fc5ff}.ask-selected-card>header{display:flex;justify-content:space-between;margin-bottom:11px}.ask-selected-card>header span{color:#98a8ba;font-size:10px}.ask-selected-card article{display:flex;align-items:flex-start;gap:9px;margin-top:8px;padding:12px;border:1px solid #c8ddfb;border-radius:10px;background:#f3f8ff;line-height:1.55}.ask-selected-card article b{padding:4px 7px;border-radius:5px;background:#dbeaff;color:#1764e8;font-size:10px}.ask-selected-card article span{font-weight:600}.ask-upload-card>header{display:flex;justify-content:space-between}.ask-upload-card>header>div{display:grid;grid-template-columns:auto 1fr;gap:1px 8px}.ask-upload-card>header b{grid-row:1/3;display:grid;place-items:center;width:26px;height:26px;border-radius:8px;background:#e7f0ff;color:#1764e8}.ask-upload-card>header small{color:#8296ad}.ask-upload-card>header button{background:transparent;color:#1764e8}.ask-upload-summary{display:flex;justify-content:space-between;margin:13px 0;padding:10px;border-radius:9px;background:#f6f9fd;color:#768da7;font-size:10px}.ask-upload-summary b{color:#1764e8}.ask-upload-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}.ask-upload-grid figure{position:relative;height:125px;margin:0;overflow:hidden;border-radius:10px;background:#555}.ask-upload-grid img{width:100%;height:100%;object-fit:cover}.ask-upload-grid figure button{position:absolute;right:6px;top:6px;display:grid;place-items:center;width:25px;height:25px;border-radius:50%;background:#e5294e;color:#fff}.ask-upload-grid figcaption{position:absolute;bottom:5px;left:7px;color:#fff;font-size:9px}.ask-upload-add{display:grid;place-items:center;align-content:center;gap:5px;min-height:125px;border:2px dashed #b9cde5;border-radius:10px;background:#fbfdff;color:#1764e8}.ask-upload-add small{color:#8ca0b8;font-size:9px}@media(max-width:700px){.ask-user-shell{padding:0;background:#f5f8fc}.ask-user-app{width:100%;min-height:100vh;border-radius:0;box-shadow:none}.ask-user-topbar{justify-content:flex-start}.ask-user-sticky{position:fixed}.ask-upload-grid{grid-template-columns:repeat(2,1fr)}.ask-product-card>footer{flex-wrap:wrap}.ask-product-card>footer code{flex-basis:100%}}@media(max-width:380px){.ask-user-sticky button{width:145px}.ask-user-tabs{padding-inline:8px}.ask-question-section,.ask-confirm-body{padding-inline:10px}}
+@media (max-width: 600px) {
+ .review-v2-heading {
+ gap: 14px;
+ flex-direction: column;
+ }
+ .review-v2-media {
+ grid-template-columns: 1fr;
+ }
+ .review-v2-modal {
+ padding: 14px;
+ }
+ .review-v2-summary {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .review-v2-media img {
+ height: 145px;
+ }
+}
+.review-v2-status-filter {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0 0 14px;
+}
+.review-v2-status-filter strong {
+ color: #526b89;
+ font-size: 12px;
+}
+.review-v2-status-filter button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 13px;
+ border: 1px solid #d6e1ed;
+ border-radius: 8px;
+ background: #fff;
+ color: #405c7b;
+}
+.review-v2-status-filter button.active {
+ border-color: #2864e8;
+ background: #2864e8;
+ color: #fff;
+}
+.review-v2-status-filter small {
+ display: grid;
+ place-items: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: 9px;
+ background: rgba(23, 50, 81, 0.08);
+ font-size: 9px;
+}
+.review-v2-status-filter button.active small {
+ background: rgba(255, 255, 255, 0.2);
+}
+.review-v2-status {
+ display: inline-block;
+ padding: 4px 8px;
+ border-radius: 6px;
+ background: #f4e9ff;
+ color: #8c35d5;
+ font-size: 10px;
+ font-style: normal;
+ white-space: nowrap;
+}
+.review-v2-status.已通过 {
+ background: #e5f8ed;
+ color: #158d57;
+}
+.review-v2-status.未通过 {
+ background: #fff0f3;
+ color: #cc3853;
+}
+
+.form-success {
+ margin-top: 10px;
+ padding: 10px 12px;
+ border: 1px solid #9fdfbd;
+ border-radius: 8px;
+ background: #ecfbf3;
+ color: #118453;
+ font-size: 12px;
+}
+
+.qa-library-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 16px;
+ padding: 14px;
+}
+.qa-library-toolbar > div {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ gap: 8px;
+ padding: 0 12px;
+ border: 1px solid #d5e0ec;
+ border-radius: 9px;
+ background: #fff;
+ color: #7890aa;
+}
+.qa-library-toolbar input {
+ width: 100%;
+ padding: 11px 0;
+ border: 0;
+ outline: 0;
+}
+.qa-library-toolbar > span {
+ color: #7187a1;
+ font-size: 12px;
+}
+.qa-library-list {
+ display: grid;
+ gap: 12px;
+}
+.qa-library-card {
+ padding: 18px;
+}
+.qa-library-card > header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid #e4ebf3;
+}
+.qa-library-card header strong {
+ color: #132e50;
+}
+.qa-library-card header span {
+ margin-left: 8px;
+ padding: 3px 7px;
+ border-radius: 5px;
+ background: #eaf2ff;
+ color: #1764e8;
+ font-size: 10px;
+}
+.qa-library-card header small {
+ color: #8296ad;
+}
+.qa-library-card-tools {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.qa-library-card-tools button {
+ padding: 6px 11px;
+ border: 1px solid #b9d1f7;
+ border-radius: 7px;
+ background: #f4f8ff;
+ color: #1764e8;
+ font-size: 11px;
+}
+.qa-library-card-tools button:hover {
+ background: #e8f1ff;
+}
+.qa-library-edit-modal {
+ max-height: min(720px, calc(100vh - 32px));
+ overflow-y: auto;
+}
+.qa-library-card h3 {
+ margin: 15px 0 10px;
+ color: #102b4b;
+ font-size: 14px;
+}
+.qa-library-answers {
+ display: grid;
+ gap: 8px;
+}
+.qa-library-answers p {
+ display: flex;
+ gap: 9px;
+ margin: 0;
+ padding: 10px 12px;
+ border-radius: 8px;
+ background: #f6f9fd;
+ color: #405d7e;
+ line-height: 1.6;
+}
+.qa-library-answers b {
+ color: #1764e8;
+}
+.qa-library-card footer {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 20px;
+ margin-top: 13px;
+ padding-top: 12px;
+ border-top: 1px dashed #dce5ef;
+ color: #748aa3;
+ font-size: 11px;
+}
+@media (max-width: 700px) {
+ .qa-library-toolbar,
+ .qa-library-card > header {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
+.review-v2-modal > header h2 {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+}
+.review-v2-modal {
+ display: flex !important;
+ flex-direction: column;
+ overflow: hidden !important;
+}
+.review-v2-modal-body {
+ overflow-y: auto;
+ padding-right: 5px;
+ flex: 1;
+}
+.review-v2-modal-body > h3 {
+ margin: 18px 0 9px;
+ font-size: 12px;
+}
+.review-v2-sticky-actions,
+.review-v2-processed-actions {
+ position: sticky;
+ z-index: 3;
+ bottom: 0;
+ flex: 0 0 auto;
+ margin: 14px -22px -22px !important;
+ padding: 14px 22px !important;
+ border-top: 1px solid #d8e2ed !important;
+ background: #fff;
+ box-shadow: 0 -8px 20px rgba(19, 44, 77, 0.08);
+}
+.review-v2-processed-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.review-v2-processed-actions span {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: #607895;
+ font-size: 11px;
+}
+.review-v2-processed-actions button {
+ padding: 9px 18px;
+ border-radius: 8px;
+ background: #eff3f8;
+ color: #294563;
+}
+@media (max-width: 900px) {
+ .review-v2-status-filter {
+ overflow-x: auto;
+ padding-bottom: 4px;
+ }
+ .review-v2-status-filter button {
+ white-space: nowrap;
+ }
+ .review-v2-sticky-actions,
+ .review-v2-processed-actions {
+ margin: 14px -14px -14px !important;
+ padding: 12px 14px !important;
+ }
+}
+.answer-v2-new-task {
+ margin-top: 15px;
+ padding: 18px;
+ border: 1px solid #b8d1fa;
+ border-radius: 14px;
+ background: #f4f8ff;
+ text-align: center;
+}
+.answer-v2-new-task strong {
+ color: #173e77;
+}
+.answer-v2-new-task p {
+ margin: 7px 0 14px;
+ color: #7188a5;
+ font-size: 11px;
+ line-height: 1.6;
+}
+.answer-v2-new-task button {
+ width: 100%;
+ padding: 12px;
+ border-radius: 9px;
+ background: #2864e8;
+ color: #fff;
+ font-weight: 700;
+ box-shadow: 0 5px 13px rgba(40, 100, 232, 0.2);
+}
+.answer-v2-new-task-light {
+ background: #fffbed;
+ border-color: #f2cf63;
+ text-align: left;
+}
+.answer-v2-new-task-light strong {
+ color: #d98600;
+}
+.answer-v2-new-task-light p {
+ color: #b9822d;
+}
+.answer-v2-history article {
+ position: relative;
+ padding-bottom: 58px;
+}
+.answer-v2-history-continue {
+ position: absolute;
+ right: 16px;
+ bottom: 15px;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 10px 18px;
+ border: 0;
+ border-radius: 8px;
+ background: #2864e8;
+ color: #fff;
+ font-size: 12px;
+ font-weight: 700;
+ box-shadow: 0 5px 12px rgba(40, 100, 232, 0.25);
+ transition:
+ transform 0.15s,
+ box-shadow 0.15s;
+}
+.answer-v2-history-continue:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 7px 16px rgba(40, 100, 232, 0.32);
+}
+.ask-task-dashboard {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ padding-bottom: 24px;
+}
+.ask-task-dashboard > .create-page-heading {
+ flex: 0 0 auto;
+}
+.ask-stepper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ flex: 0 0 auto;
+ margin: 20px 0;
+ padding: 16px;
+ border-radius: 13px;
+ background: #fff;
+ color: #8a9cb2;
+}
+.ask-stepper b {
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: #2864e8;
+ color: #fff;
+}
+.ask-stepper strong {
+ color: #2864e8;
+}
+.ask-stepper i {
+ width: 60px;
+ height: 1px;
+ background: #d8e2ed;
+}
+.ask-workspace {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 24px;
+ padding: 24px;
+}
+.ask-task-dashboard > .ask-workspace {
+ min-height: 0;
+ flex: 1;
+ overflow: hidden;
+}
+.ask-config {
+ display: grid;
+ gap: 10px;
+ padding-right: 24px;
+ border-right: 1px solid #e0e7f0;
+}
+.ask-task-dashboard .ask-config,
+.ask-task-dashboard .ask-results {
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+}
+.ask-task-dashboard .ask-config {
+ align-content: start;
+ padding-bottom: 20px;
+}
+.ask-task-dashboard .ask-results {
+ padding-right: 5px;
+}
+.ask-config .panel-title {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding-bottom: 13px;
+ border-bottom: 1px solid #e0e7f0;
+ color: #1764e8;
+ font-weight: 700;
+}
+.ask-config .panel-title small {
+ color: #ef4360;
+ font-size: 11px;
+ font-weight: 400;
+}
+.ask-config label {
+ display: grid;
+ gap: 6px;
+ color: #486482;
+ font-size: 12px;
+}
+.ask-config input,
+.ask-config textarea,
+.ask-config select {
+ border: 1px solid #c7d6e8;
+ border-radius: 8px;
+ padding: 10px;
+ background: #fff;
+ color: #23415f;
+}
+.ask-config textarea {
+ min-height: 64px;
+ resize: vertical;
+}
+.ask-config h4 {
+ margin: 8px 0 0;
+ color: #23415f;
+ font-size: 12px;
+}
+.ask-results {
+ min-width: 0;
+}
+.ask-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px;
+ border-radius: 9px;
+ background: #f8fafc;
+}
+.ask-toolbar label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: 1;
+ color: #405d7d;
+ font-size: 12px;
+}
+.ask-toolbar button {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 10px;
+}
+.ask-empty {
+ display: grid;
+ place-items: center;
+ min-height: 260px;
+ margin-top: 14px;
+ border: 1px dashed #c9d8e9;
+ border-radius: 11px;
+ color: #8da0b8;
+ font-size: 12px;
+ text-align: center;
+}
+.ask-results article {
+ display: flex;
+ gap: 9px;
+ margin-top: 10px;
+ padding: 13px;
+ border: 1px solid #d8e3ef;
+ border-radius: 10px;
+ background: #fff;
+}
+.ask-results article.selected {
+ border-color: #9dbff5;
+ background: #f8fbff;
+}
+.ask-results article > input {
+ margin-top: 3px;
+}
+.ask-results article > div {
+ flex: 1;
+ min-width: 0;
+}
+.ask-results article header {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+.ask-results article header b {
+ padding: 4px 7px;
+ border-radius: 5px;
+ background: #eaf2ff;
+ color: #1764e8;
+ font-size: 10px;
+}
+.ask-results article header strong {
+ flex: 1;
+ color: #21405f;
+ font-size: 12px;
+}
+.ask-results article header input {
+ flex: 1;
+ min-width: 0;
+ border: 1px solid #97bbf2;
+ border-radius: 6px;
+ padding: 6px;
+}
+.ask-results article header button {
+ padding: 3px 7px;
+ background: transparent;
+ color: #1764e8;
+ font-size: 11px;
+}
+.ask-results article small {
+ display: block;
+ margin-top: 7px;
+ color: #8a9db4;
+ font-size: 10px;
+}
+.ask-created-card {
+ display: grid;
+ justify-items: center;
+ gap: 10px;
+ padding: 60px 25px;
+ text-align: center;
+}
+.ask-created-card h2 {
+ margin: 0;
+}
+.ask-created-card p {
+ color: #6d84a2;
+}
+.ask-created-icon {
+ display: grid;
+ place-items: center;
+ width: 64px;
+ height: 64px;
+ border-radius: 50%;
+ background: #d8fae9;
+ color: #07945c;
+}
+.ask-link-box {
+ display: grid;
+ gap: 8px;
+ width: min(510px, 100%);
+ padding: 14px;
+ text-align: left;
+ border: 1px solid #d8e3ee;
+ border-radius: 10px;
+ background: #f8fafc;
+}
+.ask-link-box span {
+ display: flex;
+ gap: 6px;
+ color: #57718f;
+ font-size: 11px;
+}
+.ask-link-box code {
+ overflow: hidden;
+ color: #1764e8;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.ask-link-box small {
+ color: #899bb0;
+}
+.ask-created-card > div:last-child {
+ display: flex;
+ gap: 10px;
+ margin-top: 12px;
+}
+.ask-created-card > div:last-child button {
+ padding: 10px 18px;
+}
+@media (max-width: 900px) {
+ .ask-workspace {
+ grid-template-columns: 1fr;
+ }
+ .ask-config {
+ padding-right: 0;
+ border-right: 0;
+ }
+ .ask-stepper {
+ justify-content: flex-start;
+ overflow: auto;
+ white-space: nowrap;
+ }
+ .ask-created-card > div:last-child {
+ flex-direction: column;
+ }
+}
+.ask-history-tabs button{border:0;background:transparent;cursor:pointer}.ask-history-tabs button.active{background:#fff;color:#1764e8;font-weight:700}
+.ask-status-page .ask-history-tabs>b,.ask-status-page .ask-history-tabs>span{display:none}
+body img {
+ cursor: zoom-in;
+}
+
+.global-image-viewer {
+ position: fixed;
+ z-index: 9999;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 32px;
+ background: rgba(4, 12, 28, .82);
+ cursor: zoom-out;
+}
+
+.global-image-viewer img {
+ display: block;
+ max-width: min(94vw, 1600px);
+ max-height: 92vh;
+ width: auto;
+ height: auto;
+ object-fit: contain;
+ border-radius: 10px;
+ box-shadow: 0 20px 80px rgba(0, 0, 0, .45);
+ cursor: default;
+}
+
+.global-image-viewer-close {
+ position: fixed;
+ top: 18px;
+ right: 22px;
+ z-index: 1;
+ width: 40px;
+ height: 40px;
+ border: 1px solid rgba(255, 255, 255, .35);
+ border-radius: 50%;
+ background: rgba(255, 255, 255, .12);
+ color: #fff;
+ font-size: 28px;
+ line-height: 1;
+ cursor: pointer;
+}
+
+@media (max-width: 640px) {
+ .global-image-viewer {
+ padding: 16px;
+ }
+
+ .global-image-viewer-close {
+ top: 12px;
+ right: 12px;
+ }
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..7cd5515
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -0,0 +1,3720 @@
+import React, { useEffect, useState } from "react";
+import { createRoot } from "react-dom/client";
+import {
+ Bell,
+ ChevronDown,
+ ClipboardList,
+ Database,
+ FileText,
+ LogOut,
+ MessageCircle,
+ Plus,
+ Search,
+ Settings,
+ ShieldCheck,
+ Sparkles,
+ Users,
+} from "lucide-react";
+import "./styles.css";
+import "./admin.css";
+import CreateAnswerTasksPageV2 from "./CreateAnswerTasksPage.jsx";
+import AnswerUserPortalV2 from "./AnswerUserPortal.jsx";
+import TaskBoardPage from "./TaskBoardPage.jsx";
+import ReviewCenterPage from "./ReviewCenterPage.jsx";
+import CreateAskTasksPage from "./CreateAskTasksPage.jsx";
+import AskUserPortal from "./AskUserPortal.jsx";
+
+// 兼容公共回答链接末尾有无斜杠,避免 /answer 被识别为运营后台路由。
+if (window.location.pathname === "/answer")
+ window.history.replaceState(null, "", "/answer/");
+
+const API = import.meta.env.VITE_API_BASE || "";
+const Z = {
+ brand: "\u3010\u95ee\u5927\u5bb6\u3011",
+ backend: "\u8fd0\u8425\u7ba1\u7406\u540e\u53f0",
+ subtitle:
+ "\u667a\u80fd\u95ee\u7b54\u5e93\u4e0e\u4efb\u52a1\u7ba1\u7406\u7cfb\u7edf",
+ username: "\u7528\u6237\u540d",
+ password: "\u5bc6\u7801",
+ login: "\u767b\u5f55\u540e\u53f0",
+ register: "\u6ce8\u518c\u7528\u6237",
+ displayName: "\u663e\u793a\u540d\u79f0",
+};
+
+async function api(path, options = {}) {
+ const isForm = options.body instanceof FormData;
+ const response = await fetch(`${API}${path}`, {
+ credentials: "include",
+ headers: isForm
+ ? { ...(options.headers || {}) }
+ : { "Content-Type": "application/json", ...(options.headers || {}) },
+ ...options,
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(data.detail || "\u8bf7\u6c42\u5931\u8d25");
+ return data;
+}
+
+async function readReferenceFile(file) {
+ if (!file) return "";
+ const text = await file.text();
+ if (file.name.toLowerCase().endsWith(".json")) {
+ try {
+ const data = JSON.parse(text);
+ if (Array.isArray(data)) return data.map((item) => `问题:${item.question || item.q || ""}\n回答:${(item.answers || item.answer || item.a || []).toString()}`).join("\n");
+ if (Array.isArray(data.items)) return data.items.map((item) => `问题:${item.question || ""}\n回答:${(item.answers || []).join(";")}`).join("\n");
+ } catch {
+ // Keep the original text so the backend/model can still interpret it.
+ }
+ }
+ return text.slice(0, 20000);
+}
+
+function LoginPage({ onLogin }) {
+ const [registering, setRegistering] = useState(false);
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [displayName, setDisplayName] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [dingtalkLoading, setDingtalkLoading] = useState(false);
+ async function loginWithDingTalk() {
+ setDingtalkLoading(true);
+ setError("");
+ try {
+ const result = await api("/api/admin/auth/dingtalk/start");
+ window.location.assign(result.data.url);
+ } catch (err) {
+ setError(err.message);
+ setDingtalkLoading(false);
+ }
+ }
+ async function submit(event) {
+ event.preventDefault();
+ setError("");
+ if (!username.trim() || !password || (registering && !displayName.trim()))
+ return setError("\u8bf7\u5b8c\u6574\u586b\u5199\u4fe1\u606f");
+ setLoading(true);
+ try {
+ if (registering) {
+ await api("/api/admin/auth/register", {
+ method: "POST",
+ body: JSON.stringify({
+ username,
+ password,
+ display_name: displayName,
+ }),
+ });
+ setRegistering(false);
+ setPassword("");
+ setError(
+ "\u6ce8\u518c\u6210\u529f\uff0c\u8bf7\u4f7f\u7528\u65b0\u8d26\u53f7\u767b\u5f55",
+ );
+ } else {
+ const result = await api("/api/admin/auth/login", {
+ method: "POST",
+ body: JSON.stringify({ username, password }),
+ });
+ onLogin(result.data.user);
+ }
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+ return (
+
+
+
+
+
+
+ {Z.brand}
+ {registering ? Z.register : Z.backend}
+ 从钉钉工作台进入,使用钉钉身份安全登录
+
+ 或使用后台账号
+
+
+
+ {
+ "\u8fd0\u8425\u8d26\u53f7\u767b\u5f55 \u00b7 \u7528\u6237 A/B \u8bf7\u4f7f\u7528\u72ec\u7acb\u4efb\u52a1\u94fe\u63a5"
+ }
+
+
+
+ );
+}
+
+const BASE_ITEMS = [
+ {
+ id: "assistant",
+ icon: Sparkles,
+ label: "\u95ee\u7b54\u52a9\u624b",
+ group: "\u95ee\u7b54\u7ba1\u7406",
+ tag: "AI",
+ },
+ {
+ id: "library",
+ icon: Database,
+ label: "\u95ee\u7b54\u5e93",
+ group: "\u95ee\u7b54\u7ba1\u7406",
+ },
+ {
+ id: "create",
+ icon: Plus,
+ label: "\u521b\u5efa\u4efb\u52a1",
+ group: "\u4efb\u52a1\u7ba1\u7406",
+ },
+ {
+ id: "board",
+ icon: ClipboardList,
+ label: "\u4efb\u52a1\u770b\u677f",
+ group: "\u4efb\u52a1\u7ba1\u7406",
+ },
+ {
+ id: "review",
+ icon: ShieldCheck,
+ label: "\u5ba1\u6838\u4e2d\u5fc3",
+ group: "\u4efb\u52a1\u7ba1\u7406",
+ },
+];
+const ADMIN_ITEMS = [
+ {
+ id: "users",
+ icon: Users,
+ label: "\u7528\u6237\u7ba1\u7406",
+ group: "\u7cfb\u7edf\u7ba1\u7406",
+ },
+ {
+ id: "logs",
+ icon: FileText,
+ label: "\u767b\u5f55\u65e5\u5fd7",
+ group: "\u7cfb\u7edf\u7ba1\u7406",
+ },
+ {
+ id: "settings",
+ icon: Settings,
+ label: "\u7cfb\u7edf\u914d\u7f6e",
+ group: "\u7cfb\u7edf\u7ba1\u7406",
+ },
+];
+
+function Sidebar({ user, active, setActive, reviewBadge }) {
+ const baseItems = BASE_ITEMS.map((item) =>
+ item.id === "review" ? { ...item, badge: reviewBadge } : item,
+ );
+ const items =
+ user.role_code === "super_admin"
+ ? [...baseItems, ...ADMIN_ITEMS]
+ : baseItems;
+ const groups = [...new Set(items.map((item) => item.group))];
+ return (
+
+ );
+}
+function NavItem({ item, active, setActive }) {
+ const Icon = item.icon;
+ return (
+
+ );
+}
+function Topbar({ user, onLogout }) {
+ return (
+
+
+
+
+
+
+
{user.display_name.slice(0, 1)}
+
+
+ {user.display_name}({user.role_label})
+
+ {"\u7f8e\u5986\u6570\u636e\u7ec4"}
+
+
+
+
+
+
+ );
+}
+function PageHeading({ title, description, actions }) {
+ return (
+
+
+
{title}
+
{description}
+
+ {actions &&
{actions}
}
+
+ );
+}
+function AssistantPage() {
+ const [form, setForm] = useState({
+ product_name: "泊泉雅三色修护凝精华",
+ category: "",
+ benefits: "",
+ selling_points: "",
+ language_style: "口语(亲切随性)",
+ target_group: "女性",
+ usage_scene: "居家",
+ purchase_channel: "电商平台",
+ question_count: 5,
+ answers_per_question: 2,
+ answer_requirement: "",
+ seed_style: true,
+ });
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [manualOpen, setManualOpen] = useState(false);
+ const [referenceFile, setReferenceFile] = useState(null);
+ const [referenceQa, setReferenceQa] = useState("");
+ const update = (key, value) =>
+ setForm((current) => ({ ...current, [key]: value }));
+ async function generate() {
+ setError("");
+ setLoading(true);
+ try {
+ const reference_qa = referenceFile ? await readReferenceFile(referenceFile) : referenceQa;
+ const result = await api("/api/admin/qa/generate", {
+ method: "POST",
+ body: JSON.stringify({
+ ...form,
+ question_count: Number(form.question_count),
+ answers_per_question: Number(form.answers_per_question),
+ reference_qa,
+ }),
+ });
+ setItems(result.data.items);
+ } catch (err) {
+ setError(err.message);
+ setItems([]);
+ } finally {
+ setLoading(false);
+ }
+ }
+ function toggle(id) {
+ setItems((current) =>
+ current.map((item) =>
+ item.id === id ? { ...item, accepted: !item.accepted } : item,
+ ),
+ );
+ }
+ function acceptAll() {
+ setItems((current) =>
+ current.map((item) => ({ ...item, accepted: true, status: "已采纳" })),
+ );
+ }
+ function edit(item) {
+ const question = window.prompt("编辑问题", item.question);
+ if (question === null) return;
+ const answer = window.prompt(
+ "编辑回答(多个回答用换行分隔)",
+ item.answers.join("\n"),
+ );
+ if (answer === null) return;
+ setItems((current) =>
+ current.map((row) =>
+ row.id === item.id
+ ? {
+ ...row,
+ question: question.trim() || row.question,
+ answers: answer
+ .split("\n")
+ .map((value) => value.trim())
+ .filter(Boolean),
+ status: "待采纳",
+ }
+ : row,
+ ),
+ );
+ }
+ const addManual = (question, answers) => {
+ setItems((current) => [
+ ...current,
+ {
+ id: `Q${String(current.length + 1).padStart(3, "0")}`,
+ question,
+ answers,
+ source: "手动添加",
+ status: "待采纳",
+ accepted: false,
+ },
+ ]);
+ setManualOpen(false);
+ };
+ const selected = items.filter((item) => item.accepted).length;
+ return (
+ <>
+
+
+
+
+
+ 生成条件配置 * 商品信息至少填写一项
+
+
+ 商品信息
+
+
+
+
+
+
+
+
+ 生成偏好与数量
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 种草式回答
+
+
+ {error && {error}
}
+
+
+
+
+
+
+
+
+
+
+
+
+ {items.length === 0 && (
+
+
+ 填写左侧商品条件后,点击“生成 Q&A”
+
+ )}
+ {items.map((item) => (
+
+
+
toggle(item.id)}
+ />
+
{item.id}
+
来源:{item.source}
+
+ {item.accepted ? "已采纳" : "待采纳"}
+
+
+
+
+
+
+ 问题:{item.question}
+ {item.answers.map((answer, index) => (
+
+ 回答{index + 1}:
+ {answer}
+
+ ))}
+
+ ))}
+
+
+ >
+ );
+}
+
+function InlineQAItem({ item, onToggle, onDelete, onSave }) {
+ const [editing, setEditing] = useState(false);
+ const [question, setQuestion] = useState(item.question);
+ const [answers, setAnswers] = useState(item.answers);
+ useEffect(() => {
+ setQuestion(item.question);
+ setAnswers(item.answers);
+ }, [item.question, item.answers]);
+ function save() {
+ const cleanAnswers = answers.map((value) => value.trim()).filter(Boolean);
+ if (!question.trim() || !cleanAnswers.length) return;
+ onSave(item.id, question.trim(), cleanAnswers);
+ setEditing(false);
+ }
+ function addAnswer() {
+ setAnswers((current) => [...current, ""]);
+ }
+ function removeAnswer(index) {
+ if (answers.length <= 1) return;
+ setAnswers((current) =>
+ current.filter((_, answerIndex) => answerIndex !== index),
+ );
+ }
+ return (
+
+
+
onToggle(item.id)}
+ />
+
{item.id}
+
来源:{item.source}
+
+ {item.accepted ? "已采纳" : "待采纳"}
+
+
+ {editing ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+
+ {editing ? (
+
+
+ {answers.map((answer, index) => (
+
+
+ {answers.length > 1 && (
+
+ )}
+
+ ))}
+
+
+ ) : (
+ <>
+ 问题:{item.question}
+ {item.answers.map((answer, index) => (
+
+ 回答{index + 1}:
+ {answer}
+
+ ))}
+ >
+ )}
+
+ );
+}
+
+function ManualQAForm({ onClose, onSave }) {
+ const [question, setQuestion] = useState("");
+ const [answers, setAnswers] = useState([""]);
+ function addAnswer() {
+ setAnswers((current) => [...current, ""]);
+ }
+ function save(event) {
+ event.preventDefault();
+ const clean = answers.map((value) => value.trim()).filter(Boolean);
+ if (!question.trim() || !clean.length) return;
+ onSave(question.trim(), clean);
+ }
+ return (
+
+ );
+}
+
+function AssistantPageV2() {
+ const [form, setForm] = useState({
+ product_name: "",
+ category: "",
+ benefits: "",
+ selling_points: "",
+ language_style: "口语(亲切随性)",
+ target_group: "女性",
+ usage_scene: "居家",
+ purchase_channel: "电商平台",
+ question_count: 5,
+ answers_per_question: 2,
+ answer_requirement: "",
+ seed_style: true,
+ });
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const update = (key, value) =>
+ setForm((current) => ({ ...current, [key]: value }));
+ async function generate() {
+ setError("");
+ setLoading(true);
+ try {
+ const result = await api("/api/admin/qa/generate", {
+ method: "POST",
+ body: JSON.stringify({
+ ...form,
+ question_count: Number(form.question_count),
+ answers_per_question: Number(form.answers_per_question),
+ }),
+ });
+ setItems(result.data.items);
+ } catch (err) {
+ setError(err.message);
+ setItems([]);
+ } finally {
+ setLoading(false);
+ }
+ }
+ const toggle = (id) =>
+ setItems((current) =>
+ current.map((item) =>
+ item.id === id
+ ? {
+ ...item,
+ accepted: !item.accepted,
+ status: !item.accepted ? "已采纳" : "待采纳",
+ }
+ : item,
+ ),
+ );
+ const save = (id, question, answers) =>
+ setItems((current) =>
+ current.map((item) =>
+ item.id === id
+ ? { ...item, question, answers, accepted: false, status: "待采纳" }
+ : item,
+ ),
+ );
+ const remove = (id) =>
+ setItems((current) => current.filter((item) => item.id !== id));
+ const addManual = () =>
+ setItems((current) => [
+ ...current,
+ {
+ id: `Q${String(current.length + 1).padStart(3, "0")}`,
+ question: "手动输入问题",
+ answers: ["手动输入参考回答"],
+ source: "手动添加",
+ status: "待采纳",
+ accepted: false,
+ },
+ ]);
+ const selected = items.filter((item) => item.accepted).length;
+ return (
+ <>
+
+
+
+
+
+ 生成条件配置 * 商品信息至少填写一项
+
+
+ 商品信息
+
+
+
+
+
+
+
+ 生成偏好与数量
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 种草式回答
+
+
+ {error && {error}
}
+
+
+
+
+
+
+
+
+
+
+
+
+ {items.length === 0 && (
+
+
+ 填写左侧商品条件后,点击“生成 Q&A”
+
+ )}
+ {items.map((item) => (
+
+ ))}
+
+
+ >
+ );
+}
+
+function QALibraryPage() {
+ const [records, setRecords] = useState([]);
+ const [search, setSearch] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [editing, setEditing] = useState(null);
+ async function load(keyword = search) {
+ setLoading(true);
+ try {
+ const result = await api(
+ `/api/admin/qa-library?search=${encodeURIComponent(keyword.trim())}`,
+ );
+ setRecords(result.data);
+ setError("");
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+ useEffect(() => {
+ load("");
+ const refresh = () => load(search);
+ window.addEventListener("qa-library-changed", refresh);
+ return () => window.removeEventListener("qa-library-changed", refresh);
+ }, []);
+ return (
+ <>
+
+
+ {error && {error}
}
+
+ {loading ? (
+ 正在加载问答库…
+ ) : records.length === 0 ? (
+
+ 暂无已采纳问答,请先在问答助手中采纳内容。
+
+ ) : (
+ records.map((record) => (
+
+
+
+ {record.product_name || "未填写商品名称"}
+ {record.category && {record.category}}
+
+
+
+ {record.source} · {record.created_by} ·{" "}
+ {new Date(record.created_at).toLocaleString("zh-CN", {
+ hour12: false,
+ })}
+
+
+
+
+ Q:{record.question}
+
+ {record.answers.map((answer, index) => (
+
+ A{index + 1}
+ {answer}
+
+ ))}
+
+ {(record.benefits || record.selling_points) && (
+
+ )}
+
+ ))
+ )}
+
+ {editing && (
+ setEditing(null)}
+ onSaved={(updated) => {
+ setRecords((current) =>
+ current.map((item) => (item.id === updated.id ? updated : item)),
+ );
+ setEditing(null);
+ }}
+ />
+ )}
+ >
+ );
+}
+
+function QALibraryEditModal({ record, onClose, onSaved }) {
+ const [form, setForm] = useState({
+ product_name: record.product_name || "",
+ category: record.category || "",
+ benefits: record.benefits || "",
+ selling_points: record.selling_points || "",
+ question: record.question || "",
+ answers: (record.answers || []).join("\n"),
+ });
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const update = (key, value) => setForm((current) => ({ ...current, [key]: value }));
+ async function save(event) {
+ event.preventDefault();
+ const answers = form.answers.split("\n").map((value) => value.trim()).filter(Boolean);
+ if (!form.question.trim() || !answers.length) {
+ setError("问题和回答不能为空");
+ return;
+ }
+ setSaving(true);
+ setError("");
+ try {
+ const result = await api(`/api/admin/qa-library/${record.id}`, {
+ method: "PATCH",
+ body: JSON.stringify({ ...form, question: form.question.trim(), answers }),
+ });
+ onSaved(result.data);
+ window.dispatchEvent(new Event("qa-library-changed"));
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
+ }
+ }
+ return (
+
+ );
+}
+
+function AssistantPageFinal() {
+ const [form, setForm] = useState({
+ product_name: "",
+ category: "",
+ benefits: "",
+ selling_points: "",
+ language_style: "口语(亲切随性)",
+ target_group: "女性",
+ usage_scene: "居家",
+ purchase_channel: "电商平台",
+ question_count: 5,
+ answers_per_question: 2,
+ answer_requirement: "",
+ seed_style: true,
+ });
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [savedMessage, setSavedMessage] = useState("");
+ const [manualOpen, setManualOpen] = useState(false);
+ const [referenceFile, setReferenceFile] = useState(null);
+ const [referenceQa, setReferenceQa] = useState("");
+ const update = (key, value) =>
+ setForm((current) => ({ ...current, [key]: value }));
+ async function generate() {
+ setError("");
+ setLoading(true);
+ try {
+ const reference_qa = referenceFile ? await readReferenceFile(referenceFile) : referenceQa;
+ const result = await api("/api/admin/qa/generate", {
+ method: "POST",
+ body: JSON.stringify({
+ ...form,
+ question_count: Number(form.question_count),
+ answers_per_question: Number(form.answers_per_question),
+ reference_qa,
+ }),
+ });
+ setItems(result.data.items);
+ } catch (err) {
+ setError(err.message);
+ setItems([]);
+ } finally {
+ setLoading(false);
+ }
+ }
+ async function adopt(targetItems) {
+ if (!targetItems.length || saving) return;
+ setSaving(true);
+ setSavedMessage("");
+ try {
+ const result = await api("/api/admin/qa-library", {
+ method: "POST",
+ body: JSON.stringify({
+ product_name: form.product_name,
+ category: form.category,
+ benefits: form.benefits,
+ selling_points: form.selling_points,
+ items: targetItems.map(({ question, answers, source }) => ({
+ question,
+ answers,
+ source,
+ })),
+ }),
+ });
+ const ids = new Set(targetItems.map((item) => item.id));
+ setItems((current) =>
+ current.map((item) =>
+ ids.has(item.id)
+ ? { ...item, accepted: true, status: "已采纳" }
+ : item,
+ ),
+ );
+ const { added_count: added, skipped_count: skipped } = result.data;
+ setSavedMessage(
+ added > 0
+ ? `已将 ${added} 条问答存入问答库${skipped ? `,${skipped} 条重复内容已跳过` : ""}`
+ : "这些问答已经存在于问答库中",
+ );
+ window.dispatchEvent(new Event("qa-library-changed"));
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
+ }
+ }
+ function toggle(id) {
+ const target = items.find((item) => item.id === id);
+ if (!target) return;
+ if (target.accepted) return;
+ adopt([target]);
+ }
+ const acceptAll = () => adopt(items);
+ const save = (id, question, answers) =>
+ setItems((current) =>
+ current.map((item) =>
+ item.id === id
+ ? { ...item, question, answers, accepted: false, status: "待采纳" }
+ : item,
+ ),
+ );
+ const remove = (id) =>
+ setItems((current) => current.filter((item) => item.id !== id));
+ const addManual = (question, answers) => {
+ setItems((current) => [
+ ...current,
+ {
+ id: `Q${String(current.length + 1).padStart(3, "0")}`,
+ question,
+ answers,
+ source: "手动添加",
+ status: "待采纳",
+ accepted: false,
+ },
+ ]);
+ setManualOpen(false);
+ };
+ const selected = items.filter((item) => item.accepted).length;
+ return (
+ <>
+
+
+
+
+
+ 生成条件配置 * 商品信息至少填写一项
+
+
+ 商品信息
+
+
+
+
+
+
+
+
+ 生成偏好与数量
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 种草式回答
+
+
+ {error && {error}
}
+ {savedMessage && {savedMessage}
}
+
+
+
+
+
+
+
+
+
+
+
+
+ {items.length === 0 && (
+
+
+ 填写左侧商品条件后,点击“生成 Q&A”
+
+ )}
+ {items.map((item) => (
+
+ ))}
+
+
+ {manualOpen && (
+ setManualOpen(false)} onSave={addManual} />
+ )}
+ >
+ );
+}
+
+function UserTable({ users, onChanged }) {
+ const [editing, setEditing] = useState(null);
+ const [form, setForm] = useState({
+ username: "",
+ password: "",
+ display_name: "",
+ role_code: "operator",
+ is_active: true,
+ });
+ function open(user) {
+ setForm(
+ user
+ ? {
+ username: user.username,
+ password: "",
+ display_name: user.display_name,
+ role_code: user.role_code,
+ is_active: user.is_active,
+ }
+ : {
+ username: "",
+ password: "",
+ display_name: "",
+ role_code: "operator",
+ is_active: true,
+ },
+ );
+ setEditing(user || "new");
+ }
+ async function save(event) {
+ event.preventDefault();
+ try {
+ if (editing === "new")
+ await api("/api/admin/users", {
+ method: "POST",
+ body: JSON.stringify(form),
+ });
+ else
+ await api(`/api/admin/users/${editing.id}`, {
+ method: "PATCH",
+ body: JSON.stringify({
+ password: form.password || undefined,
+ display_name: form.display_name,
+ role_code: form.role_code,
+ is_active: form.is_active,
+ }),
+ });
+ setEditing(null);
+ onChanged();
+ } catch (error) {
+ window.alert(error.message);
+ }
+ }
+ async function remove(user) {
+ if (
+ !window.confirm(
+ `\u786e\u8ba4\u5220\u9664\u7528\u6237 ${user.username}\uff1f`,
+ )
+ )
+ return;
+ try {
+ await api(`/api/admin/users/${user.id}`, { method: "DELETE" });
+ onChanged();
+ } catch (error) {
+ window.alert(error.message);
+ }
+ }
+ return (
+ <>
+
+
+
+ {editing && (
+
+ )}
+
+
+
+ | {"\u8d26\u53f7"} |
+ {"\u663e\u793a\u540d\u79f0"} |
+ {"\u89d2\u8272"} |
+ {"\u72b6\u6001"} |
+ {"\u6700\u8fd1\u767b\u5f55"} |
+ {"\u64cd\u4f5c"} |
+
+
+
+ {users.map((user) => (
+
+ | {user.username} |
+ {user.display_name} |
+
+ {user.role_label}
+ |
+
+
+ {user.is_active ? "\u6b63\u5e38" : "\u505c\u7528"}
+
+ |
+
+ {user.last_login_at
+ ? new Date(user.last_login_at).toLocaleString()
+ : "\u672a\u767b\u5f55"}
+ |
+
+
+
+ |
+
+ ))}
+
+
+ >
+ );
+}
+function LogTable({ logs }) {
+ return (
+
+
+
+ | {"\u8d26\u53f7"} |
+ {"\u7ed3\u679c"} |
+ IP \u5730\u5740 |
+ {"\u65f6\u95f4"} |
+
+
+
+ {logs.map((log, index) => (
+
+ | {log.username} |
+
+
+ {log.success ? "\u6210\u529f" : "\u5931\u8d25"}
+
+ |
+ {log.ip_address || "-"} |
+ {new Date(log.created_at).toLocaleString()} |
+
+ ))}
+
+
+ );
+}
+function SettingsTable({ settings }) {
+ return (
+
+ {Object.entries(settings).map(([key, value]) => (
+
+ {key}
+
+ {Array.isArray(value)
+ ? value.map((item) => item.label).join("\u3001")
+ : String(value)}
+
+
+ ))}
+
+ );
+}
+function ReviewTable({ records, onRefresh }) {
+ const [busy, setBusy] = useState("");
+ async function decide(record, approved) {
+ setBusy(record.task_id);
+ try {
+ await api(`/api/admin/reviews/${record.task_id}/decision`, {
+ method: "POST",
+ body: JSON.stringify({ approved }),
+ });
+ onRefresh();
+ } catch (error) {
+ window.alert(error.message);
+ } finally {
+ setBusy("");
+ }
+ }
+ return (
+
+ {records.length === 0 && (
+
暂无待审核记录
+ )}
+ {records.map((record) => (
+
+
+
+ {record.task_id}
+
+ {record.user_name} · {record.product_name}
+
+
+
{record.status}
+
+
+
+
邀答截图({record.invited_screenshots.length})
+
+ {record.invited_screenshots.map((image) => (
+

+ ))}
+
+
+
+
识别问题({record.recognized_questions.length})
+ {record.recognized_questions.map((question) => (
+
+ {question.id}
+ {question.question}
+
+ ))}
+
+
+
完成截图({record.final_screenshots.length})
+
+ {record.final_screenshots.map((image) => (
+

+ ))}
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
+function ReviewPanel({ records, onRefresh }) {
+ const [queue, setQueue] = useState("answer");
+ return (
+ <>
+
+
+
+
+
+ {queue === "answer" ? "回答审核说明" : "提问审核说明"}
+
+ {queue === "answer"
+ ? "查看用户上传的邀答截图、识别问题及完成回答截图,确认问题与回答内容是否符合任务要求。点击截图可放大查看。"
+ : "对照已发布的问题文字与用户提交截图,审核提问是否合规、相关、无重复。"}
+
+
+ {queue === "answer" ? (
+
+ ) : (
+ 暂无提问审核记录
+ )}
+ >
+ );
+}
+function AdminPage({ type }) {
+ const [data, setData] = useState(null);
+ const [error, setError] = useState("");
+ const [reload, setReload] = useState(0);
+ const config = {
+ users: {
+ title: "\u7528\u6237\u7ba1\u7406",
+ description:
+ "\u7ba1\u7406\u8fd0\u8425\u540e\u53f0\u8d26\u53f7\u53ca\u89d2\u8272\u72b6\u6001\u3002",
+ endpoint: "/api/admin/users",
+ },
+ logs: {
+ title: "\u767b\u5f55\u65e5\u5fd7",
+ description:
+ "\u67e5\u770b\u8fd0\u8425\u540e\u53f0\u8d26\u53f7\u7684\u767b\u5f55\u8bb0\u5f55\u3002",
+ endpoint: "/api/admin/auth/login-logs",
+ },
+ settings: {
+ title: "\u7cfb\u7edf\u914d\u7f6e",
+ description:
+ "\u67e5\u770b\u5f53\u524d\u540e\u53f0\u8fd0\u884c\u53c2\u6570\u4e0e\u53ef\u7528\u89d2\u8272\u3002",
+ endpoint: "/api/admin/settings",
+ },
+ review: {
+ title: "\u5ba1\u6838\u4e2d\u5fc3",
+ description:
+ "\u67e5\u770b\u56de\u7b54\u7528\u6237\u4e0a\u4f20\u7684\u622a\u56fe\u4e0e\u8bc6\u522b\u95ee\u9898\u3002",
+ endpoint: "/api/admin/reviews",
+ },
+ }[type];
+ useEffect(() => {
+ setData(null);
+ setError("");
+ api(config.endpoint)
+ .then((result) => setData(result.data))
+ .catch((err) => setError(err.message));
+ }, [type, reload]);
+ return (
+ <>
+
+
+ {error ? (
+ {error}
+ ) : data === null ? (
+ {"\u52a0\u8f7d\u4e2d\u2026"}
+ ) : type === "users" ? (
+ setReload((value) => value + 1)}
+ />
+ ) : type === "logs" ? (
+
+ ) : type === "review" ? (
+ setReload((value) => value + 1)}
+ />
+ ) : (
+
+ )}
+
+ >
+ );
+}
+
+function AnswerTaskPage() {
+ return ;
+ /* Legacy task-specific flow retained temporarily for migration. */
+ useEffect(() => {
+ const button = document.createElement("button");
+ button.textContent = "← 返回";
+ button.className = "answer-back-floating";
+ button.onclick = () => {
+ if (window.history.length > 1) window.history.back();
+ else window.location.href = "/";
+ };
+ document.body.appendChild(button);
+ return () => button.remove();
+ }, []);
+ const taskId = "";
+ const token = "";
+ const [task, setTask] = useState(null);
+ const [files, setFiles] = useState([]);
+ const [selected, setSelected] = useState([]);
+ const [userName, setUserName] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+ const [done, setDone] = useState(false);
+ useEffect(() => {
+ setLoading(false);
+ setTask({
+ task_id: "公共回答链接",
+ product_name: "淘宝问大家",
+ status: "待上传",
+ recognized_questions: [],
+ selected_questions: [],
+ });
+ }, []);
+ function choose(event) {
+ const next = Array.from(event.target.files || []);
+ if (next.length > 10) return setError("最多上传10张截图");
+ setError("");
+ setFiles(next);
+ }
+ async function uploadInvited(event) {
+ event.preventDefault();
+ if (!files.length) return setError("请先选择截图");
+ setSubmitting(true);
+ setError("");
+ const body = new FormData();
+ body.append("token", token);
+ body.append("user_name", userName);
+ files.forEach((file) => body.append("files", file));
+ try {
+ await api(
+ `/api/answer/tasks/${encodeURIComponent(taskId)}/invited-screenshots`,
+ { method: "POST", body, headers: {} },
+ );
+ const result = await api(
+ `/api/answer/tasks/${encodeURIComponent(taskId)}?token=${encodeURIComponent(token)}`,
+ );
+ setTask(result.data);
+ setFiles([]);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+ async function submitSelection() {
+ if (!selected.length) return setError("请至少选择一个要回答的问题");
+ setSubmitting(true);
+ try {
+ const result = await api(
+ `/api/answer/tasks/${encodeURIComponent(taskId)}/select`,
+ {
+ method: "POST",
+ body: JSON.stringify({ token, question_ids: selected }),
+ },
+ );
+ setTask(result.data);
+ setError("");
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+ async function uploadFinal(event) {
+ event.preventDefault();
+ if (!files.length) return setError("请先选择完成截图");
+ setSubmitting(true);
+ const body = new FormData();
+ body.append("token", token);
+ files.forEach((file) => body.append("files", file));
+ try {
+ await api(
+ `/api/answer/tasks/${encodeURIComponent(taskId)}/final-screenshots`,
+ { method: "POST", body, headers: {} },
+ );
+ setDone(true);
+ setTask((current) => ({ ...current, status: "待审核" }));
+ setFiles([]);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+ if (loading)
+ return (
+
+ );
+ if (error && !task)
+ return (
+
+ );
+ const recognized = task?.recognized_questions || [];
+ const assigned = task?.selected_questions || [];
+ const canSelect = task?.status === "待筛选" && recognized.length > 0;
+ const canFinish = task?.status === "待完成" && assigned.length > 0;
+ return (
+
+
+
+
+ 【问大家】回答任务
+
+
+
{task.product_name}
+ {done ? "待审核" : task.status}
+
+
任务ID:{task.task_id}
+ {error &&
{error}
}
+ {!recognized.length && (
+
+ )}
+ {canSelect && (
+
+ )}
+ {canFinish && (
+
+ 完成回答
+ 请复制参考回答到淘宝完成回答,然后上传完成截图。
+
+ {assigned.map((question) => (
+
+
+ {question.id} {question.question}
+
+ 参考回答:{question.answer}
+
+ ))}
+
+
+
+ )}
+ {done && (
+
已提交运营审核,请等待处理。
+ )}
+ {task.status === "待审核" && !done && (
+
已提交运营审核,请等待处理。
+ )}
+
+
+ );
+}
+function SubmissionStatusDynamic({
+ record,
+ history,
+ onRefresh,
+ onNew,
+ onHistory,
+}) {
+ if (!record) return null;
+ const passed = record.status === "已通过";
+ const rejected = record.status === "未通过";
+ return (
+ <>
+
+
+
{passed ? "✓" : rejected ? "!" : "◷"}
+
+
+ {passed ? "审核已通过" : rejected ? "审核未通过" : "正在审核中"}{" "}
+
+ {passed ? "审核通过" : rejected ? "需要重传" : "截图初审"}
+
+
+
+ {passed
+ ? "您提交的截图已通过运营审核,后续问题派发功能接入后将在这里展示。"
+ : rejected
+ ? "本次截图未通过审核,请重新上传清晰、完整的截图。"
+ : "您提交的被邀回答截图正在审核,运营正在核验问题,请耐心等待。"}
+
+
+
+
+
+ {passed || rejected ? "处理时间" : "提交时间"}
+ {new Date(record.updated_at).toLocaleString()}
+
+
+ 当前状态
+ {record.status}
+
+
+
+
+ {passed
+ ? "审核结果已更新"
+ : rejected
+ ? "请重新上传材料"
+ : "页面可刷新查看最新审核结果"}
+
+
+
+
+
+
+
▧ 已提交核验材料(只读)
+ ♙ 已锁定审核
+
+
+ {record.invited_screenshots.map((image, index) => (
+
+
+ ◉ 被邀截图{index + 1}
+
+ ))}
+
+
+
+
+
+
+ >
+ );
+}
+
+function AnswerUserPortal() {
+ const [session, setSession] = useState(() =>
+ JSON.parse(localStorage.getItem("answer_user_session") || "null"),
+ );
+ const [phone, setPhone] = useState("");
+ const [records, setRecords] = useState([]);
+ const [files, setFiles] = useState([]);
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+ async function login(event) {
+ event.preventDefault();
+ setBusy(true);
+ setError("");
+ try {
+ const result = await api("/api/answer/auth/phone-login", {
+ method: "POST",
+ body: JSON.stringify({ phone }),
+ });
+ localStorage.setItem("answer_user_session", JSON.stringify(result.data));
+ setSession(result.data);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setBusy(false);
+ }
+ }
+ async function load() {
+ if (!session) return;
+ try {
+ const result = await api(
+ `/api/answer/me/submissions?token=${encodeURIComponent(session.token)}`,
+ );
+ setRecords(result.data);
+ } catch (err) {
+ setError(err.message);
+ }
+ }
+ useEffect(() => {
+ load();
+ }, [session]);
+ useEffect(() => {
+ if (!session) return;
+ const timer = setInterval(load, 15000);
+ return () => clearInterval(timer);
+ }, [session]);
+ async function upload(event) {
+ event.preventDefault();
+ if (!files.length) return setError("请先选择截图");
+ setBusy(true);
+ 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 });
+ setFiles([]);
+ await load();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setBusy(false);
+ }
+ }
+ async function complete(record, event) {
+ event.preventDefault();
+ const selected = Array.from(event.currentTarget.elements.files.files || []);
+ if (!selected.length) return setError("请上传完成截图");
+ setBusy(true);
+ const body = new FormData();
+ body.append("token", session.token);
+ selected.forEach((file) => body.append("files", file));
+ try {
+ await api(`/api/answer/me/submissions/${record.id}/completion`, {
+ method: "POST",
+ body,
+ });
+ await load();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setBusy(false);
+ }
+ }
+ if (!session)
+ return (
+
+
+
+
+
+
+ );
+ const latest = records[0];
+ return (
+
+
+
+
+
+ 用户ID:{session.user_id}
+ {latest?.status || "待上传"}
+
+
{session.phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")}
+
+
+ {error && {error}
}
+ {(!latest || ["已通过", "未通过"].includes(latest.status)) && (
+
+ )}
+ {latest && (
+
+
+
{latest.status}
+
提交编号:{latest.submission_no}
+
+ {latest.status === "待运营派发"
+ ? "问题和回答已同步至运营后台“创建任务”。"
+ : latest.status === "待用户完成"
+ ? "运营已派发回答任务,请按参考回答完成淘宝回答。"
+ : latest.status === "待审核"
+ ? "完成截图已提交,等待运营审核。"
+ : latest.status === "已通过"
+ ? "任务审核已通过。"
+ : "请关注任务进度。"}
+
+
+ {latest.status === "待用户完成" && (
+ <>
+
+ {latest.questions
+ .filter((q) => q.selected)
+ .map((q) => (
+
+ {q.question}
+ 商品:{q.product_title}
+ 参考回答:{q.answer}
+
+ ))}
+
+
+ >
+ )}
+
+ )}
+
+
上传历史
+ {records.map((item) => (
+
+ {item.submission_no} · {item.status} ·{" "}
+ {new Date(item.created_at).toLocaleString()}
+
+ ))}
+
+
+
+
+ );
+}
+
+function PublicAnswerPageWithStatus() {
+ const [records, setRecords] = useState([]);
+ const [showHistory, setShowHistory] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [poll, setPoll] = useState(0);
+ async function load() {
+ try {
+ const result = await api(
+ "/api/answer/public/submissions?client_id=anonymous-browser",
+ );
+ setRecords(result.data);
+ } catch {}
+ }
+ useEffect(() => {
+ load();
+ }, [poll]);
+ useEffect(() => {
+ const timer = setInterval(() => setPoll((value) => value + 1), 15000);
+ return () => clearInterval(timer);
+ }, []);
+ if (showHistory)
+ return (
+
+
+ setShowHistory(false)}
+ />
+
+
+ );
+ if (records.length && !uploading) {
+ const status = records[0].status;
+ const passed = status === "已通过";
+ const rejected = status === "未通过";
+ return (
+
+
+
+
+
+
+ 任务认证
+
+ ⌁ 任务状态 {status}
+
+
+
+ ✓
+ 上传截图
+
+
+ {passed ? "✓" : "2"}
+ 运营审核
+
+
+ 3
+ 问题派发
+
+
+ 4
+ 完成任务
+
+
+
+ setUploading(true)}
+ onHistory={() => setShowHistory(true)}
+ />
+ {rejected && (
+
+ 审核未通过,请重新上传符合要求的截图。
+
+ )}
+
+
+
+ );
+ }
+ return (
+ {
+ setUploading(false);
+ setPoll((value) => value + 1);
+ }}
+ />
+ );
+}
+
+function PublicAnswerPageEnhanced({ onSubmitted }) {
+ const [files, setFiles] = useState([]);
+ const [userName, setUserName] = useState("");
+ const [error, setError] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [done, setDone] = useState(false);
+ const [dragging, setDragging] = useState(false);
+ const [history, setHistory] = useState([]);
+ const [showHistory, setShowHistory] = useState(false);
+ const [current, setCurrent] = useState(null);
+ const clientId = (() => {
+ let value = localStorage.getItem("answer_client_id");
+ if (!value) {
+ value = `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ localStorage.setItem("answer_client_id", value);
+ }
+ return value;
+ })();
+ async function refreshHistory() {
+ try {
+ const result = await api(
+ `/api/answer/public/submissions?client_id=${encodeURIComponent(clientId)}`,
+ );
+ setHistory(result.data);
+ if (result.data.length) {
+ setCurrent(result.data[0]);
+ setDone(true);
+ }
+ } catch {}
+ }
+ useEffect(() => {
+ refreshHistory();
+ }, []);
+ useEffect(
+ () => () => files.forEach((item) => URL.revokeObjectURL(item.preview)),
+ [files],
+ );
+ function addFiles(list) {
+ const incoming = Array.from(list || []).filter((file) =>
+ ["image/jpeg", "image/png", "image/webp"].includes(file.type),
+ );
+ if (files.length + incoming.length > 10)
+ return setError("每次最多上传10张截图");
+ if (incoming.length !== Array.from(list || []).length)
+ setError("仅支持 JPG、PNG、WEBP 图片");
+ else setError("");
+ setFiles((current) => [
+ ...current,
+ ...incoming.map((file) => ({ file, preview: URL.createObjectURL(file) })),
+ ]);
+ }
+ function removeFile(index) {
+ setFiles((current) => {
+ URL.revokeObjectURL(current[index].preview);
+ return current.filter((_, fileIndex) => fileIndex !== index);
+ });
+ }
+ async function submit(event) {
+ event.preventDefault();
+ if (!files.length) return setError("请先上传至少一张受邀回答截图");
+ setSubmitting(true);
+ setError("");
+ const body = new FormData();
+ body.append("client_id", "anonymous-browser");
+ body.append("user_name", userName);
+ files.forEach((item) => body.append("files", item.file));
+ try {
+ await api("/api/answer/public/submissions", { method: "POST", body });
+ setDone(true);
+ files.forEach((item) => URL.revokeObjectURL(item.preview));
+ setFiles([]);
+ onSubmitted?.();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+ return (
+
+
+
+
+
+ 淘宝问答承接
+
+ {done ? "已提交" : "待提交"}
+
+
+
公共回答截图识别通道
+
+
+
+
+ 任务认证
+
+ ⌁ 任务状态 {done ? "已提交审核" : "第1步·上传截图"}
+
+
+
+ {done ? "✓" : "1"}
+ 上传截图
+
+
+ 2
+ 运营审核
+
+
+ 3
+ 问题派发
+
+
+ 4
+ 完成任务
+
+
+
+
+
✣ 任务认证与核验
+
{done ? "截图已提交审核" : "上传被邀请回答截图"}
+
+ {done
+ ? "运营将在审核中心查看原始截图和系统识别的问题,请留意后续通知。"
+ : "请上传淘宝“被邀请回答”页面的真实截图。提交后系统将识别截图中的问题,并连同原始截图提交运营审核。"}
+
+
+ {done ? (
+
+
✓
+
已提交运营审核
+
本次截图已成功接收,请等待运营处理。
+
+
+ ) : (
+
+ )}
+
+
上传说明
+
+ - 请上传淘宝“被邀请回答”页面截图。
+ - 截图需要完整显示问题文字,不要裁剪关键内容。
+ - 提交后用户端不展示识别结果,由运营统一审核。
+
+
+
+
+
+ );
+}
+
+function SubmissionStatus({ record, history, onRefresh, onNew, onHistory }) {
+ if (!record) return null;
+ return (
+ <>
+
+
+
◷
+
+
+ 正在审核中 截图初审
+
+
您提交的被邀回答截图正在审核,运营正在核验问题,请耐心等待。
+
+
+
+
+ 提交时间
+ {new Date(record.updated_at).toLocaleString()}
+
+
+ 预计审核耗时
+ 通常 3~10 分钟
+
+
+
+ 页面可刷新查看最新审核结果
+
+
+
+
+
+
▧ 已提交核验材料(只读)
+ ♙ 已锁定审核
+
+
+ {record.invited_screenshots.map((image, index) => (
+
+
+ ◉ 被邀截图{index + 1}
+
+ ))}
+
+
+
+
+
+
+ >
+ );
+}
+function SubmissionHistory({ records, onBack }) {
+ return (
+
+
+
+
上传历史
+
+ {records.length === 0 ? (
+
暂无上传记录
+ ) : (
+ records.map((record) => (
+
+
+ {record.task_id}
+ {record.status}
+
+
+ {new Date(record.updated_at).toLocaleString()} ·{" "}
+ {record.invited_screenshots.length} 张截图
+
+
+ {record.invited_screenshots.map((image, index) => (
+

+ ))}
+
+
+ ))
+ )}
+
+ );
+}
+
+function PublicAnswerPageEnhancedBridge(props) {
+ const [finished, setFinished] = useState(false);
+ if (finished) {
+ props.onSubmitted?.();
+ return null;
+ }
+ return ;
+}
+function PublicAnswerPage() {
+ const [files, setFiles] = useState([]);
+ const [userName, setUserName] = useState("");
+ const [error, setError] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [done, setDone] = useState(false);
+ function choose(event) {
+ const next = Array.from(event.target.files || []);
+ if (next.length > 10) {
+ setError("最多上传10张截图");
+ return;
+ }
+ setFiles(next);
+ setError("");
+ }
+ async function submit(event) {
+ event.preventDefault();
+ if (!files.length) return setError("请先选择受邀回答截图");
+ setSubmitting(true);
+ const body = new FormData();
+ body.append("user_name", userName);
+ files.forEach((file) => body.append("files", file));
+ try {
+ await api("/api/answer/public/submissions", { method: "POST", body });
+ setDone(true);
+ setFiles([]);
+ setError("");
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+ return (
+
+
+
+
+ 【问大家】回答任务中心
+
+
+
上传受邀回答截图
+
+
+ 所有回答用户使用同一个链接,无需登录或输入任务ID
+
+ {error &&
{error}
}
+ {done ? (
+
+
已提交运营审核
+
系统已接收截图并识别问题,请等待运营处理。
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
+function CreateAnswerTasksPage() {
+ const [records, setRecords] = useState([]);
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState("");
+ async function load() {
+ try {
+ const result = await api("/api/admin/answer-task-drafts");
+ setRecords(result.data);
+ } catch (err) {
+ setError(err.message);
+ }
+ }
+ useEffect(() => {
+ load();
+ }, []);
+ async function update(question, payload) {
+ setBusy(question.id);
+ try {
+ await api(`/api/admin/answer-task-drafts/questions/${question.id}`, {
+ method: "PATCH",
+ body: JSON.stringify(payload),
+ });
+ await load();
+ } catch (err) {
+ window.alert(err.message);
+ } finally {
+ setBusy("");
+ }
+ }
+ async function publish(record) {
+ setBusy(record.id);
+ try {
+ await api(`/api/admin/answer-task-drafts/${record.id}/publish`, {
+ method: "POST",
+ });
+ await load();
+ } catch (err) {
+ window.alert(err.message);
+ } finally {
+ setBusy("");
+ }
+ }
+ return (
+ <>
+
+
+ 选择任务说明
+
+ 每个用户上传批次独立分组;勾选需要派发的问题,可编辑或重新生成回答,确认后生成任务。
+
+
+ {error && {error}
}
+
+ {records.length === 0 && (
+
暂无待创建的回答任务
+ )}
+ {records.map((record) => (
+
+
+ {record.questions.map((question) => (
+
+
+ update(question, { selected: event.target.checked })
+ }
+ />
+
+
+ Q{question.sequence_no}:{question.question}
+
+
+ {question.product_image && (
+

+ )}
+
+ {question.product_title}
+ 来源:模拟OCR识别
+
+ {question.source_image && (
+

+ )}
+
+
+
+
+ ))}
+
+ ))}
+
+ >
+ );
+}
+function CompletionReviewPage() {
+ const [records, setRecords] = useState([]);
+ async function load() {
+ const result = await api("/api/admin/answer-completion-reviews");
+ setRecords(result.data);
+ }
+ useEffect(() => {
+ load();
+ }, []);
+ async function decide(record, approved) {
+ await api(`/api/admin/answer-completion-reviews/${record.id}/decision`, {
+ method: "POST",
+ body: JSON.stringify({ approved }),
+ });
+ load();
+ }
+ return (
+ <>
+
+
+ {records.length === 0 && (
+
暂无完成截图待审核
+ )}
+ {records.map((record) => (
+
+
+
+
+
派发问答
+ {record.questions
+ .filter((q) => q.selected)
+ .map((q) => (
+
+ {q.question}
+
+ {q.answer}
+
+ ))}
+
+
+
完成截图
+
+ {record.completion_screenshots.map((image) => (
+

+ ))}
+
+
+
+
+
+
+
+
+ ))}
+
+ >
+ );
+}
+function GlobalImageViewer() {
+ const [src, setSrc] = useState("");
+ useEffect(() => {
+ const handleClick = (event) => {
+ const image = event.target?.closest?.("img");
+ if (!image || !image.src) return;
+ setSrc(image.src);
+ };
+ const handleKeyDown = (event) => {
+ if (event.key === "Escape") setSrc("");
+ };
+ document.addEventListener("click", handleClick);
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("click", handleClick);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, []);
+ if (!src) return null;
+ return (
+ setSrc("")}>
+
+

event.stopPropagation()} />
+
+ );
+}
+
+function Dashboard({ user, onLogout }) {
+ const [active, setActive] = useState("assistant");
+ const [createMode, setCreateMode] = useState("answer");
+ const [reviewBadge, setReviewBadge] = useState(0);
+ async function loadReviewBadge() {
+ try {
+ const result = await api("/api/admin/review-pending-count");
+ setReviewBadge(result.data.total || 0);
+ } catch {
+ // Keep navigation usable if the counter request temporarily fails.
+ }
+ }
+ useEffect(() => {
+ const handler = (event) => {
+ if (event.type === "open-task-board") setActive("board");
+ if (event.type === "switch-create-answer") {
+ setActive("create");
+ setCreateMode("answer");
+ }
+ if (event.type === "switch-create-ask") {
+ setActive("create");
+ setCreateMode("ask");
+ }
+ };
+ document.addEventListener("click", handler);
+ window.addEventListener("open-task-board", handler);
+ window.addEventListener("switch-create-answer", handler);
+ window.addEventListener("switch-create-ask", handler);
+ window.addEventListener("review-count-changed", loadReviewBadge);
+ loadReviewBadge();
+ const reviewCountTimer = window.setInterval(loadReviewBadge, 30000);
+ return () => {
+ document.removeEventListener("click", handler);
+ window.removeEventListener("open-task-board", handler);
+ window.removeEventListener("switch-create-answer", handler);
+ window.removeEventListener("switch-create-ask", handler);
+ window.removeEventListener("review-count-changed", loadReviewBadge);
+ window.clearInterval(reviewCountTimer);
+ };
+ }, []);
+ const isAdminPage = ["users", "logs", "settings"].includes(active);
+ const selectPage = (next) => {
+ if (user.permissions.includes(next)) {
+ setActive(next);
+ if (next === "create") setCreateMode("answer");
+ if (next === "review") loadReviewBadge();
+ }
+ };
+ const askTaskOpen = active === "create" && createMode === "ask";
+ return (
+
+
+
+
+
+ {active === "create" ? (
+ createMode === "ask" ? (
+
+ ) : (
+
+ )
+ ) : active === "board" ? (
+
+ ) : active === "review" ? (
+
+ ) : active === "library" ? (
+
+ ) : isAdminPage ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+function TextDecoder() {
+ useEffect(() => {
+ const decode = (value) =>
+ value.replace(/\\u([0-9a-fA-F]{4})/g, (_, code) =>
+ String.fromCharCode(parseInt(code, 16)),
+ );
+ const scan = (root) => {
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ let node;
+ while ((node = walker.nextNode())) {
+ const decoded = decode(node.nodeValue);
+ if (decoded !== node.nodeValue) node.nodeValue = decoded;
+ }
+ root.querySelectorAll?.("[placeholder],[title]").forEach((element) => {
+ ["placeholder", "title"].forEach((attribute) => {
+ const value = element.getAttribute(attribute);
+ if (value) element.setAttribute(attribute, decode(value));
+ });
+ });
+ };
+ const observer = new MutationObserver((records) =>
+ records.forEach((record) =>
+ record.addedNodes.forEach((node) => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const decoded = decode(node.nodeValue);
+ if (decoded !== node.nodeValue) node.nodeValue = decoded;
+ } else if (node.nodeType === Node.ELEMENT_NODE) scan(node);
+ }),
+ ),
+ );
+ scan(document.body);
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ });
+ return () => observer.disconnect();
+ }, []);
+ return null;
+}
+function App() {
+ if (window.location.pathname === "/ask" || window.location.pathname.startsWith("/ask/"))
+ return ;
+ if (window.location.pathname.startsWith("/answer/"))
+ return ;
+ const [user, setUser] = useState(null);
+ const [checking, setChecking] = useState(true);
+ useEffect(() => {
+ api("/api/admin/auth/me")
+ .then((result) => setUser(result.data.user))
+ .catch(() => {})
+ .finally(() => setChecking(false));
+ }, []);
+ async function logout() {
+ await api("/api/admin/auth/logout", { method: "POST" }).catch(() => {});
+ setUser(null);
+ }
+ if (checking)
+ return (
+ {"\u6b63\u5728\u52a0\u8f7d\u2026"}
+ );
+ return user ? (
+
+ ) : (
+
+ );
+}
+createRoot(document.getElementById("root")).render(
+
+
+
+
+ ,
+);
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
new file mode 100644
index 0000000..4c13e9a
--- /dev/null
+++ b/frontend/src/styles.css
@@ -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;
+ }
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..b833e9c
--- /dev/null
+++ b/frontend/vite.config.js
@@ -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 },
+ },
+ },
+})
diff --git a/【问大家】问答与任务管理系统PRD.md b/【问大家】问答与任务管理系统PRD.md
new file mode 100644
index 0000000..f6c7dc2
--- /dev/null
+++ b/【问大家】问答与任务管理系统PRD.md
@@ -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 | 发布任务链接 | 两处入口:问答库问题与回答生成时可发布提问任务,审核中心发布回答任务链接
输入品名+条件+商品链接,生成任务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:评审待确认清单
\ No newline at end of file