From 4fea0a28fc096222b9b09ca94c6abcaba55b86e3 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Wed, 2 Sep 2026 10:27:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=8A=E4=BC=A0=E9=97=AE=E5=A4=A7?= =?UTF-8?q?=E5=AE=B6=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 26 + .gitignore | 6 + backend/app/__init__.py | 3 + backend/app/config.py | 39 + backend/app/database.py | 27 + backend/app/llm.py | 193 ++ backend/app/main.py | 1768 ++++++++++ backend/app/models.py | 300 ++ backend/app/schemas.py | 54 + backend/app/security.py | 59 + backend/package-lock.json | 6 + backend/requirements.txt | 7 + backend/schema.sql | 33 + backend/tests/test_auth.py | 395 +++ data/app.db | Bin 0 -> 49152 bytes data/async-smoke.db | Bin 0 -> 315392 bytes frontend/index.html | 1 + frontend/package-lock.json | 1213 +++++++ frontend/package.json | 9 + frontend/src/AnswerUserPortal.jsx | 195 ++ frontend/src/AskUserPortal.jsx | 925 ++++++ frontend/src/CreateAnswerTasksPage.jsx | 167 + frontend/src/CreateAskTasksPage.jsx | 406 +++ frontend/src/ReviewCenterPage.jsx | 631 ++++ frontend/src/TaskBoardPage.jsx | 560 ++++ frontend/src/admin.css | 4161 ++++++++++++++++++++++++ frontend/src/main.jsx | 3720 +++++++++++++++++++++ frontend/src/styles.css | 611 ++++ frontend/vite.config.js | 12 + 【问大家】问答与任务管理系统PRD.md | 599 ++++ 30 files changed, 16126 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/llm.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/security.py create mode 100644 backend/package-lock.json create mode 100644 backend/requirements.txt create mode 100644 backend/schema.sql create mode 100644 backend/tests/test_auth.py create mode 100644 data/app.db create mode 100644 data/async-smoke.db create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/AnswerUserPortal.jsx create mode 100644 frontend/src/AskUserPortal.jsx create mode 100644 frontend/src/CreateAnswerTasksPage.jsx create mode 100644 frontend/src/CreateAskTasksPage.jsx create mode 100644 frontend/src/ReviewCenterPage.jsx create mode 100644 frontend/src/TaskBoardPage.jsx create mode 100644 frontend/src/admin.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/vite.config.js create mode 100644 【问大家】问答与任务管理系统PRD.md 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 0000000000000000000000000000000000000000..8a0e5ed7482d6593533da357a8189ab273b681d0 GIT binary patch literal 49152 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 literal 0 HcmV?d00001 diff --git a/data/async-smoke.db b/data/async-smoke.db new file mode 100644 index 0000000000000000000000000000000000000000..2c3d05944bf407258c200d486da888ff5d613765 GIT binary patch literal 315392 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 Z2?7v+00bZa0SG_<0uX=z1R&rM_ 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
淘宝问答运营官方承接通道官方认证

手机号登录

使用短信验证码安全登录

{devCode && 开发环境验证码:{devCode}}{error &&
{error}
}
+} + +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}/
{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
◉ {completion ? '完成截图' : `被邀截图${index + 1}`}
})}
+} + +function ReviewStage({ latest, reload, busy, startNew }) { + return <>

已进入任务派发审核 截图审核中

截图已提交,任务派发审核界面已创建。运营正在核验截图并整理可回答问题,识别完成后会自动显示在后台。

提交时间{formatTime(latest.created_at)}当前状态等待运营审核
页面每 10 秒自动检查最新审核结果
等待运营确认并派发问题

截图已进入审核队列,无需停留等待识别完成;问题整理好后会自动开放任务。

还可以继续提交新的任务

当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。

+} + +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 || '商品问题'}
▱ 参考回答:

{question.answer}

{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}
)}
:

上传任务完成凭证

请上传已发布回答的截图;收款二维码为必传材料,用于后续核验。

} +} + +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.submission_no}
+
+ {latest.completion_screenshots?.length > 0 && } +
还可以开始新的任务

当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。

+ + return <> +
+
{passed ? : cancelled ? '!' : }

{passed ? '审核已完成' : cancelled ? '任务已撤销' : '完成凭证审核中'} {STATUS_TEXT[latest.status]}

{passed ? '运营已审核通过您提交的任务凭证,当前进入打款流程,请耐心等待打款。' : cancelled ? '运营已撤销本次任务,您无需继续提交。' : '您提交的回答完成截图正在审核,请耐心等待运营确认。'}

+ {passed &&
审核已完成,等待打款款项将按运营安排发放,请留意收款账户。
} +
提交编号:{latest.submission_no}
+
+ {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 && }
{error &&
{error}
}{!loaded ?
正在读取任务状态…
: historyOpen ? setHistoryOpen(false)} onContinue={record => { setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false) }}/> : !visibleLatest ? { setNewTaskMode(false); setActiveRecordId(null); await load() }} setError={setError}/> : ['待筛选', '待运营派发', '已派发'].includes(visibleLatest.status) ? { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/> : visibleLatest.status === '待用户完成' || (visibleLatest.status === '未通过' && retrying) ? { if (submitted) { applySubmittedRecord(submitted); setRetrying(false); load(true); return } await load(); setRetrying(false) }} setError={setError}/> : setRetrying(true)} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/>}
{visibleLatest && }
+} 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 ( +
+
+
+ + + 淘宝问答运营官方承接通道 + + 官方认证 +
+
+ + + +

手机号登录

+

使用短信验证码安全登录

+ + + {devCode && 开发环境验证码:{devCode}} + {error &&
{error}
} + +
+
+
+ ); +} + +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 ( + + ); + })} +
+
+
+ + 已选择 {selected.length} / {assignment.questions.length} 题 + + +
+ + ); +} + +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}

+
+
+
+ {assignment.product_link} + + + 去淘宝提问 + +
+
+
+
+ 已选提问内容(共 {selectedQuestions.length} 题) + 请在淘宝提问对应内容 +
+ {selectedQuestions.map((item, index) => ( +
+ #{index + 1} + {item.question} + +
+ ))} +
+
+
+
+ 1 + 上传截图(支持多图上传) + 请上传淘宝“我的提问”记录截图凭证 +
+ +
+
+ 支持多图 · 可多次添加 · JPG/PNG/WEBP + 已上传 {files.length} 张 +
+
+ {previews.map(({ file, url }, index) => ( +
+ {`提问截图${index + +
图 {index + 1}
+
+ ))} + +
+ addFiles(event.target.files)} + /> +
+
+
+
+ 2 + 上传收款码 + 审核通过后将按原路径结算至此账户 +
+ {paymentFile ? "已上传" : "未上传"} +
+ {paymentFile ? ( +
+ {paymentFile.name} + + +
+ ) : ( + + )} + setPaymentFile(event.target.files?.[0] || null)} + /> + +
+ + {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.task_id} + ID:{item.user_id} +
+ {item.status} +
+

+ {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, + })} +

+
+ + {item.status === "待审核" + ? "凭证已锁定,专员核验中" + : item.status === "已通过" + ? "审核已通过" + : item.status === "未通过" + ? "需要重新提交" + : "等待选择问题"} + + +
+
+ )) + )} +
+ ); +} + +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 ( +
+
+
{ + setView("claim"); + // If a previous batch has completed and questions remain, opening + // the claim tab should show the next selectable batch directly. + setClaimEdit( + Boolean( + assignment && + (assignment.remaining_count ?? 0) > 0 && + assignment.status === "已通过", + ), + ); + setNotice(""); + }} + /> + {error &&
{error}
} + {notice &&
{notice}
} + {view === "status" ? ( + { + setAssignment(item); + setSelected( + item.questions + .filter((question) => question.selected) + .map((question) => question.id), + ); + setView("claim"); + }} + /> + ) : !assignment ? ( +
+ {busy ? "正在随机分配问题…" : "暂时无法读取任务"} +
+ ) : claimEdit || assignment.status === "待选择" ? ( + + ) : ["待审核", "已通过"].includes(assignment.status) ? ( + loadAssignment()} + busy={busy} + onBackHome={() => { + const consumed = assignment.questions.filter( + (item) => item.used || (["待审核", "已通过"].includes(assignment.status) && item.selected), + ); + const selectedCount = assignment.used_count ?? consumed.length; + // Start a fresh selection containing only the remaining items; + // consumed questions stay visibly locked in the list. + setSelected([]); + if (selectedCount >= assignment.questions.length) { + setView("status"); + loadHistory(); + setNotice("本次任务您的认领额度已达上限,请关注下一次任务"); + } else { + setView("claim"); + setClaimEdit(true); + setNotice( + `本次任务还可以继续选择 ${assignment.questions.length - selectedCount} 个问题`, + ); + } + }} + /> + ) : ( + + )} +
+
+ ); +} 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
+
+ + +
{record.user_id}用户_{record.user_id.slice(1)}{record.submission_no}已选 {selected}/{record.questions.length} 题
+ {identifying && 正在识别,请等待…} + + +
+ {open && record.questions.map(question =>
+ +
+

Q{question.sequence_no}:{question.question}

+
+ {question.product_image && 商品主图} + {question.product_title}来源:模拟OCR识别 + {question.source_image &&
识图原始截图:识别原始截图
} +
+