修复卡顿bug,新增问题批量导入,释放机制调整
This commit is contained in:
parent
66aca010f1
commit
73ac3e8163
@ -1,9 +1,14 @@
|
|||||||
import json
|
import json
|
||||||
import base64
|
import base64
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
@ -179,7 +184,7 @@ def generate_questions(payload: dict, count: int, reference_qa: str = "") -> lis
|
|||||||
|
|
||||||
|
|
||||||
def generate_answer_for_question(product_title: str, question: str) -> str:
|
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."""
|
"""Generate one concise answer for an OCR-extracted question."""
|
||||||
if not settings.llm_api_key:
|
if not settings.llm_api_key:
|
||||||
return f"结合{product_title}的使用体验来看,建议按自身肤质先少量试用,确认合适后再正常使用。"
|
return f"结合{product_title}的使用体验来看,建议按自身肤质先少量试用,确认合适后再正常使用。"
|
||||||
url = settings.llm_base_url.rstrip("/") + "/chat/completions"
|
url = settings.llm_base_url.rstrip("/") + "/chat/completions"
|
||||||
@ -192,7 +197,7 @@ def generate_answer_for_question(product_title: str, question: str) -> str:
|
|||||||
answer = str(result["choices"][0]["message"]["content"]).strip()
|
answer = str(result["choices"][0]["message"]["content"]).strip()
|
||||||
return answer[:300]
|
return answer[:300]
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Answer generation failed for simulated OCR question")
|
logger.exception("Answer generation failed for OCR question")
|
||||||
return f"结合{product_title}的使用体验来看,建议按自身肤质先少量试用,确认合适后再正常使用。"
|
return f"结合{product_title}的使用体验来看,建议按自身肤质先少量试用,确认合适后再正常使用。"
|
||||||
|
|
||||||
|
|
||||||
@ -233,31 +238,54 @@ def _parse_ocr_content(content: str) -> list[dict]:
|
|||||||
return items[:100]
|
return items[:100]
|
||||||
|
|
||||||
|
|
||||||
def extract_questions_from_images(images: list[tuple[bytes, str]]) -> list[dict]:
|
def _compress_ocr_image(image_data: bytes, content_type: str) -> tuple[bytes, str]:
|
||||||
"""Extract consumer questions from one or more Taobao Q&A screenshots.
|
"""Normalize screenshots and keep each vision request reasonably small."""
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(image_data)) as source:
|
||||||
|
image = ImageOps.exif_transpose(source)
|
||||||
|
image.thumbnail((2200, 2200), Image.Resampling.LANCZOS)
|
||||||
|
if image.mode in {"RGBA", "LA"} or "transparency" in image.info:
|
||||||
|
rgba = image.convert("RGBA")
|
||||||
|
background = Image.new("RGB", rgba.size, "white")
|
||||||
|
background.paste(rgba, mask=rgba.getchannel("A"))
|
||||||
|
image = background
|
||||||
|
else:
|
||||||
|
image = image.convert("RGB")
|
||||||
|
|
||||||
The configured OpenAI-compatible model must support image input. The
|
for quality in (86, 76, 66):
|
||||||
caller keeps a deterministic fallback when no vision model/API key is
|
output = io.BytesIO()
|
||||||
configured, so uploading screenshots never blocks the task workflow.
|
image.save(output, format="JPEG", quality=quality, optimize=True)
|
||||||
"""
|
compressed = output.getvalue()
|
||||||
if not settings.llm_api_key or not images:
|
if len(compressed) <= 1_500_000:
|
||||||
return []
|
return compressed, "image/jpeg"
|
||||||
content = [{
|
|
||||||
|
# Very long/high-detail screenshots get one final size reduction.
|
||||||
|
image.thumbnail((1700, 1700), Image.Resampling.LANCZOS)
|
||||||
|
output = io.BytesIO()
|
||||||
|
image.save(output, format="JPEG", quality=72, optimize=True)
|
||||||
|
return output.getvalue(), "image/jpeg"
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not compress OCR image; using original payload", exc_info=True)
|
||||||
|
return image_data, content_type
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_questions_from_image(image_data: bytes, content_type: str) -> list[dict]:
|
||||||
|
encoded = base64.b64encode(image_data).decode("ascii")
|
||||||
|
content = [
|
||||||
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": (
|
"text": (
|
||||||
"请识别这些淘宝‘问答/待回答’页面截图中的消费者提问。只提取真正的问题句子,"
|
"请识别这张淘宝‘问答/待回答’页面截图中的所有消费者提问。只提取真正的问题句子,"
|
||||||
"不要提取导航、按钮、商品名、用户昵称、规则文字或页面标题。去除序号并保持原文。"
|
"不要提取导航、按钮、商品名、用户昵称、规则文字或页面标题。去除序号并保持原文。"
|
||||||
"多张截图合并去重。只返回严格 JSON:"
|
"只返回严格 JSON:"
|
||||||
'{"items":[{"product_title":"商品名或待识别商品","question":"问题原文"}]}'
|
'{"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",
|
"type": "image_url",
|
||||||
"image_url": {"url": f"data:{content_type};base64,{encoded}"},
|
"image_url": {"url": f"data:{content_type};base64,{encoded}"},
|
||||||
})
|
},
|
||||||
|
]
|
||||||
request_body = {
|
request_body = {
|
||||||
"model": settings.llm_vision_model,
|
"model": settings.llm_vision_model,
|
||||||
"temperature": 0,
|
"temperature": 0,
|
||||||
@ -273,11 +301,57 @@ def extract_questions_from_images(images: list[tuple[bytes, str]]) -> list[dict]
|
|||||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.llm_api_key}"},
|
headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.llm_api_key}"},
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response:
|
with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response:
|
||||||
result = json.loads(response.read().decode("utf-8"))
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
content = result["choices"][0]["message"]["content"]
|
return _parse_ocr_content(result["choices"][0]["message"]["content"])
|
||||||
return _parse_ocr_content(content)
|
|
||||||
|
|
||||||
|
def _recognize_one_image(index: int, image: tuple[bytes, str]) -> tuple[int, list[dict]]:
|
||||||
|
image_data, content_type = _compress_ocr_image(*image)
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
items = _extract_questions_from_image(image_data, content_type)
|
||||||
|
if items:
|
||||||
|
return index, items
|
||||||
|
raise ValueError("vision model returned no valid questions")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Question OCR failed")
|
logger.warning(
|
||||||
|
"Question OCR failed for image %s on attempt %s/2",
|
||||||
|
index + 1,
|
||||||
|
attempt + 1,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
if attempt == 0:
|
||||||
|
time.sleep(0.5)
|
||||||
|
return index, []
|
||||||
|
|
||||||
|
|
||||||
|
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. Each
|
||||||
|
screenshot is compressed and recognized independently. Failed image
|
||||||
|
requests are retried once, then successful results are merged and deduped.
|
||||||
|
"""
|
||||||
|
if not settings.llm_api_key or not images:
|
||||||
return []
|
return []
|
||||||
|
recognized: dict[int, list[dict]] = {}
|
||||||
|
with ThreadPoolExecutor(max_workers=min(3, len(images))) as executor:
|
||||||
|
futures = {
|
||||||
|
executor.submit(_recognize_one_image, index, image): index
|
||||||
|
for index, image in enumerate(images)
|
||||||
|
}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
index, items = future.result()
|
||||||
|
recognized[index] = items
|
||||||
|
|
||||||
|
merged = []
|
||||||
|
seen = set()
|
||||||
|
for index in range(len(images)):
|
||||||
|
for item in recognized.get(index, []):
|
||||||
|
key = re.sub(r"[\s,。!?、,.!?]+", "", item["question"]).lower()
|
||||||
|
if not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
merged.append({**item, "source_index": index})
|
||||||
|
return merged[:100]
|
||||||
|
|||||||
1277
backend/app/main.py
1277
backend/app/main.py
File diff suppressed because it is too large
Load Diff
@ -27,6 +27,7 @@ class AdminUser(Base):
|
|||||||
# operator. The token is intentionally separate from the admin session
|
# operator. The token is intentionally separate from the admin session
|
||||||
# cookie and is safe to embed in a long-lived URL.
|
# cookie and is safe to embed in a long-lived URL.
|
||||||
answer_public_token: Mapped[str | None] = mapped_column(String(96), unique=True, index=True, nullable=True)
|
answer_public_token: Mapped[str | None] = mapped_column(String(96), unique=True, index=True, nullable=True)
|
||||||
|
last_answer_shop_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
@ -72,6 +73,7 @@ class AnswerUser(Base):
|
|||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
public_id: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
public_id: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||||
phone: Mapped[str] = mapped_column(String(20), unique=True, index=True)
|
phone: Mapped[str] = mapped_column(String(20), unique=True, index=True)
|
||||||
|
taobao_id: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True)
|
||||||
auth_token: Mapped[str] = mapped_column(String(64), 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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
last_login_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
last_login_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
@ -212,6 +214,10 @@ class AskAssignment(Base):
|
|||||||
task_id: Mapped[int] = mapped_column(ForeignKey("ask_tasks.id"), index=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)
|
user_id: Mapped[int] = mapped_column(ForeignKey("answer_users.id"), index=True)
|
||||||
status: Mapped[str] = mapped_column(String(32), default="待选择", index=True)
|
status: Mapped[str] = mapped_column(String(32), default="待选择", index=True)
|
||||||
|
# The current reservation window is independent from the assignment's
|
||||||
|
# lifetime. One assignment can retain several submitted batches while a
|
||||||
|
# later, unsubmitted selection expires and is released safely.
|
||||||
|
reservation_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, 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)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|||||||
@ -5,3 +5,5 @@ pymysql>=1.1,<2.0
|
|||||||
python-multipart>=0.0.20,<1.0
|
python-multipart>=0.0.20,<1.0
|
||||||
httpx2>=2.12,<3.0
|
httpx2>=2.12,<3.0
|
||||||
python-dotenv>=1.0,<2.0
|
python-dotenv>=1.0,<2.0
|
||||||
|
openpyxl>=3.1,<4.0
|
||||||
|
pillow>=11.0,<13.0
|
||||||
|
|||||||
14
backend/tests/conftest.py
Normal file
14
backend/tests/conftest.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# Never let the integration tests inherit a development or production MySQL
|
||||||
|
# URL. The application is imported during test collection, so the isolated
|
||||||
|
# database must be selected before any test module imports app.main.
|
||||||
|
_test_dir = Path(tempfile.mkdtemp(prefix="wendajia-tests-"))
|
||||||
|
os.environ["DATABASE_URL"] = f"sqlite:///{(_test_dir / 'app.db').as_posix()}"
|
||||||
|
os.environ["ADMIN_USERNAME"] = "admin"
|
||||||
|
os.environ["ADMIN_PASSWORD"] = "admin123456"
|
||||||
|
os.environ["OPERATOR_USERNAME"] = "operator"
|
||||||
|
os.environ["OPERATOR_PASSWORD"] = "operator123456"
|
||||||
@ -1,6 +1,9 @@
|
|||||||
import base64
|
import base64
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from openpyxl import Workbook
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import app.main as main_module
|
import app.main as main_module
|
||||||
@ -13,6 +16,25 @@ def fake_ask_questions(payload, count, reference_text):
|
|||||||
return [f"本地测试问题{i + 1}?" for i in range(count)]
|
return [f"本地测试问题{i + 1}?" for i in range(count)]
|
||||||
|
|
||||||
|
|
||||||
|
def answer_owner_token(client: TestClient, username: str = "operator") -> str:
|
||||||
|
password = "operator123456" if username == "operator" else "admin123456"
|
||||||
|
assert client.post("/api/admin/auth/login", json={"username": username, "password": password}).status_code == 200
|
||||||
|
response = client.get("/api/admin/auth/answer-link")
|
||||||
|
assert response.status_code == 200
|
||||||
|
return response.json()["data"]["token"]
|
||||||
|
|
||||||
|
|
||||||
|
def task_board_items(client: TestClient, **params):
|
||||||
|
response = client.get("/api/admin/task-board", params=params)
|
||||||
|
assert response.status_code == 200
|
||||||
|
parents = response.json()["data"]["items"]
|
||||||
|
return [
|
||||||
|
row
|
||||||
|
for parent in parents
|
||||||
|
for row in [parent, *(parent.get("claimed_tasks") or [])]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_admin_login_me_and_logout():
|
def test_admin_login_me_and_logout():
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
login = client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"})
|
login = client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"})
|
||||||
@ -43,9 +65,13 @@ def test_register_and_super_admin_user_crud():
|
|||||||
username = f"tester_{uuid4().hex[:8]}"
|
username = f"tester_{uuid4().hex[:8]}"
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
registration = client.post("/api/admin/auth/register", json={"username": username, "password": "password123", "display_name": "测试账号"})
|
registration = client.post("/api/admin/auth/register", json={"username": username, "password": "password123", "display_name": "测试账号"})
|
||||||
assert registration.status_code == 201
|
assert registration.status_code == 403
|
||||||
assert registration.json()["data"]["user"]["role_code"] == "operator"
|
|
||||||
assert client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"}).status_code == 200
|
assert client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"}).status_code == 200
|
||||||
|
created_response = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"username": username, "password": "password123", "display_name": "测试账号", "role_code": "operator"},
|
||||||
|
)
|
||||||
|
assert created_response.status_code == 201
|
||||||
records = client.get("/api/admin/users").json()["data"]
|
records = client.get("/api/admin/users").json()["data"]
|
||||||
created = next(item for item in records if item["username"] == username)
|
created = next(item for item in records if item["username"] == username)
|
||||||
assert client.get(f"/api/admin/users/{created['id']}").status_code == 200
|
assert client.get(f"/api/admin/users/{created['id']}").status_code == 200
|
||||||
@ -116,13 +142,98 @@ def test_task_board_is_available_to_operator():
|
|||||||
assert login.status_code == 200
|
assert login.status_code == 200
|
||||||
response = client.get("/api/admin/task-board")
|
response = client.get("/api/admin/task-board")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert isinstance(response.json()["data"], list)
|
assert isinstance(response.json()["data"]["items"], list)
|
||||||
|
assert response.json()["data"]["page"] == 1
|
||||||
counts = client.get("/api/admin/review-pending-count")
|
counts = client.get("/api/admin/review-pending-count")
|
||||||
assert counts.status_code == 200
|
assert counts.status_code == 200
|
||||||
count_data = counts.json()["data"]
|
count_data = counts.json()["data"]
|
||||||
assert count_data["total"] == count_data["ask"] + count_data["answer"]
|
assert count_data["total"] == count_data["ask"] + count_data["answer"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_taobao_id_login_is_shared_by_both_user_portals():
|
||||||
|
taobao_id = f"tb{uuid4().hex[:14]}"
|
||||||
|
with TestClient(app) as client:
|
||||||
|
owner_token = answer_owner_token(client)
|
||||||
|
answer_login = client.post(
|
||||||
|
"/api/answer/auth/taobao-login",
|
||||||
|
json={"taobao_id": taobao_id, "owner_token": owner_token},
|
||||||
|
)
|
||||||
|
assert answer_login.status_code == 200
|
||||||
|
answer_session = answer_login.json()["data"]
|
||||||
|
assert answer_session["user_id"] == taobao_id
|
||||||
|
assert answer_session["taobao_id"] == taobao_id
|
||||||
|
assert answer_session["account"] == taobao_id
|
||||||
|
assert answer_session["phone"] == ""
|
||||||
|
|
||||||
|
ask_login = client.post(
|
||||||
|
"/api/ask/auth/taobao-login",
|
||||||
|
json={"taobao_id": taobao_id},
|
||||||
|
)
|
||||||
|
assert ask_login.status_code == 200
|
||||||
|
assert ask_login.json()["data"]["token"] == answer_session["token"]
|
||||||
|
|
||||||
|
invalid = client.post(
|
||||||
|
"/api/ask/auth/taobao-login",
|
||||||
|
json={"taobao_id": "不合法 ID"},
|
||||||
|
)
|
||||||
|
assert invalid.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_task_publish_remembers_operator_shop_name():
|
||||||
|
unique = uuid4().hex[:10]
|
||||||
|
remembered_shop = f"记忆店铺-{unique}"
|
||||||
|
with TestClient(app) as client:
|
||||||
|
assert client.post(
|
||||||
|
"/api/admin/auth/login",
|
||||||
|
json={"username": "operator", "password": "operator123456"},
|
||||||
|
).status_code == 200
|
||||||
|
with main_module.SessionLocal() as db:
|
||||||
|
owner = db.scalar(
|
||||||
|
main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator")
|
||||||
|
)
|
||||||
|
previous_shop_name = owner.last_answer_shop_name
|
||||||
|
public_user = main_module.AnswerUser(
|
||||||
|
public_id=f"U{unique[:7]}",
|
||||||
|
phone=f"136{uuid4().int % 100_000_000:08d}",
|
||||||
|
auth_token=uuid4().hex,
|
||||||
|
)
|
||||||
|
db.add(public_user)
|
||||||
|
db.flush()
|
||||||
|
submission = main_module.AnswerSubmission(
|
||||||
|
submission_no=f"ANS{unique.upper()}",
|
||||||
|
user_id=public_user.id,
|
||||||
|
owner_id=owner.id,
|
||||||
|
status="待筛选",
|
||||||
|
)
|
||||||
|
db.add(submission)
|
||||||
|
db.flush()
|
||||||
|
db.add(main_module.AnswerRecognizedQuestion(
|
||||||
|
submission_id=submission.id,
|
||||||
|
sequence_no=1,
|
||||||
|
product_title="记忆测试商品",
|
||||||
|
question_text="店铺名会被记住吗?",
|
||||||
|
answer_text="会。",
|
||||||
|
selected=True,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
submission_id = submission.id
|
||||||
|
|
||||||
|
published = client.post(
|
||||||
|
f"/api/admin/answer-task-drafts/{submission_id}/publish",
|
||||||
|
json={"shop_name": remembered_shop},
|
||||||
|
)
|
||||||
|
assert published.status_code == 200
|
||||||
|
link_settings = client.get("/api/admin/auth/answer-link")
|
||||||
|
assert link_settings.status_code == 200
|
||||||
|
assert link_settings.json()["data"]["last_shop_name"] == remembered_shop
|
||||||
|
with main_module.SessionLocal() as db:
|
||||||
|
owner = db.scalar(
|
||||||
|
main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator")
|
||||||
|
)
|
||||||
|
owner.last_answer_shop_name = previous_shop_name
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def test_answer_review_rejection_requires_reason():
|
def test_answer_review_rejection_requires_reason():
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
login = client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"})
|
login = client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"})
|
||||||
@ -131,38 +242,261 @@ def test_answer_review_rejection_requires_reason():
|
|||||||
assert missing.status_code == 404
|
assert missing.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_answer_rejection_reason_syncs_to_public_user():
|
def test_answer_review_detail_loads_screenshots_from_protected_endpoint():
|
||||||
|
unique = uuid4().hex[:10]
|
||||||
|
with TestClient(app) as client:
|
||||||
|
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
|
||||||
|
with main_module.SessionLocal() as db:
|
||||||
|
owner = db.scalar(
|
||||||
|
main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator")
|
||||||
|
)
|
||||||
|
public_user = main_module.AnswerUser(
|
||||||
|
public_id=f"U{unique[:7]}",
|
||||||
|
phone=f"139{uuid4().int % 100_000_000:08d}",
|
||||||
|
auth_token=uuid4().hex,
|
||||||
|
)
|
||||||
|
db.add(public_user)
|
||||||
|
db.flush()
|
||||||
|
submission = main_module.AnswerSubmission(
|
||||||
|
submission_no=f"ANS{unique.upper()}",
|
||||||
|
user_id=public_user.id,
|
||||||
|
owner_id=owner.id,
|
||||||
|
shop_name="审核性能测试店铺",
|
||||||
|
status="待审核",
|
||||||
|
)
|
||||||
|
db.add(submission)
|
||||||
|
db.flush()
|
||||||
|
screenshot = main_module.AnswerScreenshot(
|
||||||
|
submission_id=submission.id,
|
||||||
|
stage="completion",
|
||||||
|
filename="completion.png",
|
||||||
|
content_type="image/png",
|
||||||
|
image_data=b"review-image-content",
|
||||||
|
)
|
||||||
|
db.add_all([
|
||||||
|
screenshot,
|
||||||
|
main_module.AnswerRecognizedQuestion(
|
||||||
|
submission_id=submission.id,
|
||||||
|
sequence_no=1,
|
||||||
|
product_title="审核性能测试商品",
|
||||||
|
question_text="测试问题?",
|
||||||
|
answer_text="测试回答。",
|
||||||
|
selected=True,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
submission_id = submission.id
|
||||||
|
|
||||||
|
detail = client.get(f"/api/admin/answer-completion-reviews/{submission_id}")
|
||||||
|
assert detail.status_code == 200
|
||||||
|
screenshot_url = detail.json()["data"]["completion_screenshots"][0]["data_url"]
|
||||||
|
assert screenshot_url.startswith("/api/admin/answer-screenshots/")
|
||||||
|
assert "base64" not in detail.text
|
||||||
|
image = client.get(screenshot_url)
|
||||||
|
assert image.status_code == 200
|
||||||
|
assert image.headers["content-type"] == "image/png"
|
||||||
|
assert image.content == b"review-image-content"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_review_detail_loads_screenshots_from_protected_endpoint():
|
||||||
|
unique = uuid4().hex[:10]
|
||||||
|
with TestClient(app) as client:
|
||||||
|
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
|
||||||
|
with main_module.SessionLocal() as db:
|
||||||
|
owner = db.scalar(
|
||||||
|
main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator")
|
||||||
|
)
|
||||||
|
public_user = main_module.AnswerUser(
|
||||||
|
public_id=f"U{unique[:7]}",
|
||||||
|
phone=f"tb_{unique}",
|
||||||
|
taobao_id=f"tb{unique}",
|
||||||
|
auth_token=uuid4().hex,
|
||||||
|
)
|
||||||
|
task = main_module.AskTask(
|
||||||
|
task_no=f"TASK{unique.upper()}",
|
||||||
|
public_token=uuid4().hex,
|
||||||
|
owner_id=owner.id,
|
||||||
|
product_link="TEST-TOKEN",
|
||||||
|
shop_name="审核性能测试店铺",
|
||||||
|
product_name="审核性能测试商品",
|
||||||
|
status="已发布",
|
||||||
|
)
|
||||||
|
db.add_all([public_user, task])
|
||||||
|
db.flush()
|
||||||
|
question = main_module.AskQuestion(
|
||||||
|
task_id=task.id,
|
||||||
|
sequence_no=1,
|
||||||
|
question_text="提问详情是否轻量?",
|
||||||
|
selected=True,
|
||||||
|
)
|
||||||
|
assignment = main_module.AskAssignment(
|
||||||
|
task_id=task.id,
|
||||||
|
user_id=public_user.id,
|
||||||
|
status="待审核",
|
||||||
|
)
|
||||||
|
db.add_all([question, assignment])
|
||||||
|
db.flush()
|
||||||
|
db.add(main_module.AskAssignmentQuestion(
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
question_id=question.id,
|
||||||
|
selected=True,
|
||||||
|
))
|
||||||
|
batch = main_module.AskAssignmentBatch(
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
status="pending",
|
||||||
|
selected_question_ids=str(question.id),
|
||||||
|
)
|
||||||
|
db.add(batch)
|
||||||
|
db.flush()
|
||||||
|
db.add_all([
|
||||||
|
main_module.AskAssignmentBatchScreenshot(
|
||||||
|
batch_id=batch.id,
|
||||||
|
stage="question",
|
||||||
|
filename="question.png",
|
||||||
|
content_type="image/png",
|
||||||
|
image_data=b"ask-question-image",
|
||||||
|
),
|
||||||
|
main_module.AskAssignmentBatchScreenshot(
|
||||||
|
batch_id=batch.id,
|
||||||
|
stage="payment",
|
||||||
|
filename="payment.png",
|
||||||
|
content_type="image/png",
|
||||||
|
image_data=b"ask-payment-image",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
batch_id = batch.id
|
||||||
|
|
||||||
|
detail = client.get(f"/api/admin/ask-batch-reviews/{batch_id}")
|
||||||
|
assert detail.status_code == 200
|
||||||
|
data = detail.json()["data"]
|
||||||
|
assert "base64" not in detail.text
|
||||||
|
question_url = data["question_screenshots"][0]["data_url"]
|
||||||
|
payment_url = data["payment_screenshots"][0]["data_url"]
|
||||||
|
assert question_url.startswith("/api/admin/ask-batch-screenshots/")
|
||||||
|
assert client.get(question_url).content == b"ask-question-image"
|
||||||
|
assert client.get(payment_url).content == b"ask-payment-image"
|
||||||
|
|
||||||
|
decision = client.post(
|
||||||
|
f"/api/admin/ask-batch-reviews/{batch_id}/decision",
|
||||||
|
json={"approved": True},
|
||||||
|
)
|
||||||
|
assert decision.status_code == 200
|
||||||
|
assert "base64" not in decision.text
|
||||||
|
assert decision.json()["data"]["latest_review"]["approved"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_answer_recognition_has_no_fake_questions_and_can_retry(monkeypatch):
|
||||||
|
unique = uuid4().hex[:10]
|
||||||
|
with TestClient(app) as client:
|
||||||
|
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
|
||||||
|
with main_module.SessionLocal() as db:
|
||||||
|
owner = db.scalar(
|
||||||
|
main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator")
|
||||||
|
)
|
||||||
|
public_user = main_module.AnswerUser(
|
||||||
|
public_id=f"U{unique[:7]}",
|
||||||
|
phone=f"137{uuid4().int % 100_000_000:08d}",
|
||||||
|
auth_token=uuid4().hex,
|
||||||
|
)
|
||||||
|
db.add(public_user)
|
||||||
|
db.flush()
|
||||||
|
submission = main_module.AnswerSubmission(
|
||||||
|
submission_no=f"ANS{unique.upper()}",
|
||||||
|
user_id=public_user.id,
|
||||||
|
owner_id=owner.id,
|
||||||
|
status="待筛选",
|
||||||
|
)
|
||||||
|
db.add(submission)
|
||||||
|
db.flush()
|
||||||
|
db.add(main_module.AnswerScreenshot(
|
||||||
|
submission_id=submission.id,
|
||||||
|
stage="invited",
|
||||||
|
filename="invited.png",
|
||||||
|
content_type="image/png",
|
||||||
|
image_data=b"image-content",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
submission_id = submission.id
|
||||||
|
|
||||||
|
monkeypatch.setattr(main_module, "extract_questions_from_images", lambda images: [])
|
||||||
|
main_module._process_answer_submission(submission_id)
|
||||||
|
with main_module.SessionLocal() as db:
|
||||||
|
failed = db.get(main_module.AnswerSubmission, submission_id)
|
||||||
|
assert failed.status == "识别失败"
|
||||||
|
assert failed.questions == []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main_module,
|
||||||
|
"extract_questions_from_images",
|
||||||
|
lambda images: [{"product_title": "识别商品", "question": "重新识别成功了吗?", "source_index": 0}],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(main_module, "generate_answer_for_question", lambda product, question: "重新识别成功。")
|
||||||
|
retried = client.post(f"/api/admin/answer-task-drafts/{submission_id}/retry-recognition")
|
||||||
|
assert retried.status_code == 200
|
||||||
|
with main_module.SessionLocal() as db:
|
||||||
|
completed = db.get(main_module.AnswerSubmission, submission_id)
|
||||||
|
assert completed.status == "待筛选"
|
||||||
|
assert [question.question_text for question in completed.questions] == ["重新识别成功了吗?"]
|
||||||
|
question_id = completed.questions[0].id
|
||||||
|
|
||||||
|
cleared = client.patch(
|
||||||
|
f"/api/admin/answer-task-drafts/questions/{question_id}",
|
||||||
|
json={"answer": ""},
|
||||||
|
)
|
||||||
|
assert cleared.status_code == 200
|
||||||
|
assert cleared.json()["data"]["answer"] == ""
|
||||||
|
with main_module.SessionLocal() as db:
|
||||||
|
assert db.get(main_module.AnswerRecognizedQuestion, question_id).answer_text == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_rejection_reason_syncs_to_public_user(monkeypatch):
|
||||||
previous_key = settings.llm_api_key
|
previous_key = settings.llm_api_key
|
||||||
settings.llm_api_key = ""
|
settings.llm_api_key = ""
|
||||||
phone = f"19{uuid4().int % 1_000_000_000:09d}"
|
monkeypatch.setattr(
|
||||||
|
main_module,
|
||||||
|
"extract_questions_from_images",
|
||||||
|
lambda images: [{"product_title": "测试商品", "question": "测试问题?", "source_index": 0}],
|
||||||
|
)
|
||||||
|
taobao_id = f"tb{uuid4().hex[:14]}"
|
||||||
try:
|
try:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
login = client.post("/api/answer/auth/phone-login", json={"phone": phone})
|
owner_token = answer_owner_token(client)
|
||||||
|
login = client.post("/api/answer/auth/taobao-login", json={"taobao_id": taobao_id, "owner_token": owner_token})
|
||||||
assert login.status_code == 200
|
assert login.status_code == 200
|
||||||
token = login.json()["data"]["token"]
|
token = login.json()["data"]["token"]
|
||||||
created = client.post(
|
created = client.post(
|
||||||
"/api/answer/me/submissions",
|
"/api/answer/me/submissions",
|
||||||
data={"token": token},
|
data={"token": token, "owner_token": owner_token},
|
||||||
files=[("files", ("invited.png", b"test-image", "image/png"))],
|
files=[("files", ("invited.png", b"test-image", "image/png"))],
|
||||||
)
|
)
|
||||||
assert created.status_code == 200
|
assert created.status_code == 200
|
||||||
submission = created.json()["data"]
|
submission = created.json()["data"]
|
||||||
|
submission = client.get(
|
||||||
|
f"/api/answer/me/submissions/{submission['id']}?token={token}&owner_token={owner_token}"
|
||||||
|
).json()["data"]
|
||||||
question_id = submission["questions"][0]["id"]
|
question_id = submission["questions"][0]["id"]
|
||||||
|
|
||||||
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
|
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.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
|
assert client.post(
|
||||||
|
f"/api/admin/answer-task-drafts/{submission['id']}/publish",
|
||||||
|
json={"shop_name": "测试店铺"},
|
||||||
|
).status_code == 200
|
||||||
completed = client.post(
|
completed = client.post(
|
||||||
f"/api/answer/me/submissions/{submission['id']}/completion",
|
f"/api/answer/me/submissions/{submission['id']}/completion",
|
||||||
data={"token": token},
|
data={"token": token, "owner_token": owner_token},
|
||||||
files=[("files", ("done.png", b"done-image", "image/png"))],
|
files=[
|
||||||
|
("files", ("done.png", b"done-image", "image/png")),
|
||||||
|
("payment_files", ("payment.png", b"payment-image", "image/png")),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
assert completed.status_code == 200
|
assert completed.status_code == 200
|
||||||
no_reason = client.post(f"/api/admin/answer-completion-reviews/{submission['id']}/decision", json={"approved": False})
|
no_reason = client.post(f"/api/admin/answer-completion-reviews/{submission['id']}/decision", json={"approved": False})
|
||||||
assert no_reason.status_code == 400
|
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条回答,截图未完整展示"})
|
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
|
assert rejected.status_code == 200
|
||||||
current = client.get(f"/api/answer/me/submissions?token={token}").json()["data"][0]
|
current = client.get(f"/api/answer/me/submissions?token={token}&owner_token={owner_token}").json()["data"][0]
|
||||||
assert current["status"] == "未通过"
|
assert current["status"] == "未通过"
|
||||||
assert current["latest_review"]["reason_code"] == "数量不足"
|
assert current["latest_review"]["reason_code"] == "数量不足"
|
||||||
assert "截图未完整展示" in current["latest_review"]["reason_text"]
|
assert "截图未完整展示" in current["latest_review"]["reason_text"]
|
||||||
@ -194,7 +528,7 @@ def test_ask_task_publish_appears_on_task_board(monkeypatch):
|
|||||||
assert public_token
|
assert public_token
|
||||||
assert public_token != task["task_id"]
|
assert public_token != task["task_id"]
|
||||||
assert len(public_token) >= 24
|
assert len(public_token) >= 24
|
||||||
board = client.get("/api/admin/task-board").json()["data"]
|
board = task_board_items(client, page_size=100)
|
||||||
created = next(item for item in board if item["task_id"] == task["task_id"])
|
created = next(item for item in board if item["task_id"] == task["task_id"])
|
||||||
assert created["task_type"] == "ask"
|
assert created["task_type"] == "ask"
|
||||||
assert created["task_type_label"] == "提问任务"
|
assert created["task_type_label"] == "提问任务"
|
||||||
@ -250,7 +584,7 @@ def test_ask_task_accepts_qr_image_without_token_and_excludes_it_from_ai(monkeyp
|
|||||||
published = client.post(f"/api/admin/ask-tasks/{task['id']}/publish")
|
published = client.post(f"/api/admin/ask-tasks/{task['id']}/publish")
|
||||||
assert published.status_code == 200
|
assert published.status_code == 200
|
||||||
assert published.json()["data"]["status"] == "已发布"
|
assert published.json()["data"]["status"] == "已发布"
|
||||||
board = client.get("/api/admin/task-board").json()["data"]
|
board = task_board_items(client, page_size=100)
|
||||||
created = next(item for item in board if item["task_id"] == task["task_id"])
|
created = next(item for item in board if item["task_id"] == task["task_id"])
|
||||||
detail = client.get(created["detail_endpoint"])
|
detail = client.get(created["detail_endpoint"])
|
||||||
assert detail.status_code == 200
|
assert detail.status_code == 200
|
||||||
@ -274,6 +608,62 @@ def test_ask_task_regeneration_appends_to_existing_questions(monkeypatch):
|
|||||||
assert [item["sequence_no"] for item in questions] == [1, 2, 3, 4]
|
assert [item["sequence_no"] for item in questions] == [1, 2, 3, 4]
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_ask_assignment_expires_after_selection_timeout(monkeypatch):
|
||||||
|
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
|
||||||
|
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={"shop_name": "超时测试店铺", "product_link": "TOKEN-TIMEOUT-1", "product_name": "超时测试商品", "question_count": 2},
|
||||||
|
).json()["data"]
|
||||||
|
assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200
|
||||||
|
taobao_id = f"tb{uuid4().hex[:14]}"
|
||||||
|
token = client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id}).json()["data"]["token"]
|
||||||
|
task_url = f"/api/ask/tasks/{generated['task_id']}?token={token}"
|
||||||
|
first = client.get(task_url)
|
||||||
|
assert first.status_code == 200
|
||||||
|
first_data = first.json()["data"]
|
||||||
|
assignment_id = first_data["assignment_id"]
|
||||||
|
with main_module.SessionLocal() as db:
|
||||||
|
assignment = db.get(main_module.AskAssignment, assignment_id)
|
||||||
|
assignment.reservation_started_at = datetime.utcnow() - timedelta(minutes=2)
|
||||||
|
db.commit()
|
||||||
|
expired = client.get(task_url)
|
||||||
|
assert expired.status_code == 409
|
||||||
|
assert expired.json()["detail"] == "提问任务已失效,请点击刷新重新分配任务"
|
||||||
|
refreshed = client.get(task_url)
|
||||||
|
assert refreshed.status_code == 200
|
||||||
|
assert refreshed.json()["data"]["created_at"] != first_data["created_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_ask_assignment_expires_after_upload_timeout(monkeypatch):
|
||||||
|
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
|
||||||
|
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={"shop_name": "超时测试店铺二", "product_link": "TOKEN-TIMEOUT-2", "product_name": "超时测试商品二", "question_count": 2},
|
||||||
|
).json()["data"]
|
||||||
|
assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200
|
||||||
|
taobao_id = f"tb{uuid4().hex[:14]}"
|
||||||
|
token = client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id}).json()["data"]["token"]
|
||||||
|
task_url = f"/api/ask/tasks/{generated['task_id']}?token={token}"
|
||||||
|
assignment = client.get(task_url).json()["data"]
|
||||||
|
selected = assignment["questions"][0]["id"]
|
||||||
|
confirmed = client.post(f"/api/ask/tasks/{generated['task_id']}/selection", json={"token": token, "question_ids": [selected]})
|
||||||
|
assert confirmed.status_code == 200
|
||||||
|
with main_module.SessionLocal() as db:
|
||||||
|
stored = db.get(main_module.AskAssignment, assignment["assignment_id"])
|
||||||
|
stored.reservation_started_at = datetime.utcnow() - timedelta(minutes=31)
|
||||||
|
db.commit()
|
||||||
|
expired = client.get(task_url)
|
||||||
|
assert expired.status_code == 409
|
||||||
|
assert expired.json()["detail"] == "提问任务已失效,请点击刷新重新分配任务"
|
||||||
|
refreshed = client.get(task_url)
|
||||||
|
assert refreshed.status_code == 200
|
||||||
|
assert refreshed.json()["data"]["status"] == "待选择"
|
||||||
|
|
||||||
|
|
||||||
def test_ask_task_added_question_is_idempotent_and_deletable(monkeypatch):
|
def test_ask_task_added_question_is_idempotent_and_deletable(monkeypatch):
|
||||||
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
|
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
@ -311,6 +701,74 @@ def test_ask_task_added_question_is_idempotent_and_deletable(monkeypatch):
|
|||||||
assert all(item["question"] != "问答库重复问题?" for item in refreshed.json()["data"]["selected_questions"])
|
assert all(item["question"] != "问答库重复问题?" for item in refreshed.json()["data"]["selected_questions"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_question_file_import_parses_supported_text_formats():
|
||||||
|
with TestClient(app) as client:
|
||||||
|
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
|
||||||
|
|
||||||
|
text_import = client.post(
|
||||||
|
"/api/admin/ask-questions/import",
|
||||||
|
files={"file": ("questions.txt", "Q1:第一条问题?\nA1:参考回答\nQ2:第二条问题?\nQ1:第一条问题?".encode("utf-8"), "text/plain")},
|
||||||
|
)
|
||||||
|
assert text_import.status_code == 200
|
||||||
|
assert text_import.json()["data"]["questions"] == ["第一条问题?", "第二条问题?"]
|
||||||
|
|
||||||
|
csv_import = client.post(
|
||||||
|
"/api/admin/ask-questions/import",
|
||||||
|
files={"file": ("questions.csv", "问题,答案\nCSV问题一?,参考回答\nCSV问题二?,参考回答".encode("utf-8"), "text/csv")},
|
||||||
|
)
|
||||||
|
assert csv_import.status_code == 200
|
||||||
|
assert csv_import.json()["data"]["questions"] == ["CSV问题一?", "CSV问题二?"]
|
||||||
|
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.append(["问题", "答案"])
|
||||||
|
sheet.append(["表格问题一?", "参考回答"])
|
||||||
|
sheet.append(["表格问题二?", "参考回答"])
|
||||||
|
workbook_file = BytesIO()
|
||||||
|
workbook.save(workbook_file)
|
||||||
|
workbook.close()
|
||||||
|
xlsx_import = client.post(
|
||||||
|
"/api/admin/ask-questions/import",
|
||||||
|
files={"file": ("questions.xlsx", workbook_file.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||||
|
)
|
||||||
|
assert xlsx_import.status_code == 200
|
||||||
|
assert xlsx_import.json()["data"]["questions"] == ["表格问题一?", "表格问题二?"]
|
||||||
|
|
||||||
|
json_import = client.post(
|
||||||
|
"/api/admin/ask-questions/import",
|
||||||
|
files={"file": ("questions.json", '[{"question":"JSON问题一?"},{"问题":"JSON问题二?"}]'.encode("utf-8"), "application/json")},
|
||||||
|
)
|
||||||
|
assert json_import.status_code == 200
|
||||||
|
assert json_import.json()["data"]["questions"] == ["JSON问题一?", "JSON问题二?"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_question_bulk_import_appends_and_deduplicates(monkeypatch):
|
||||||
|
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
|
||||||
|
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/ask-tasks/generate",
|
||||||
|
json={
|
||||||
|
"shop_name": f"批量导入测试店铺-{uuid4().hex[:8]}",
|
||||||
|
"product_link": "TOKEN-BULK-IMPORT",
|
||||||
|
"product_name": "批量导入测试商品",
|
||||||
|
"question_count": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert created.status_code == 200
|
||||||
|
task_id = created.json()["data"]["id"]
|
||||||
|
imported = client.post(
|
||||||
|
f"/api/admin/ask-tasks/{task_id}/questions/bulk",
|
||||||
|
json={"questions": ["批量问题一?", "批量问题二?", "批量问题一?", "本地测试问题1?"]},
|
||||||
|
)
|
||||||
|
assert imported.status_code == 200
|
||||||
|
result = imported.json()["data"]
|
||||||
|
assert result["added_count"] == 2
|
||||||
|
assert result["skipped_count"] == 2
|
||||||
|
assert [item["question"] for item in result["items"]] == ["批量问题一?", "批量问题二?"]
|
||||||
|
assert all(item["source"] == "批量导入" for item in result["items"])
|
||||||
|
|
||||||
|
|
||||||
def test_public_ask_user_gets_stable_unique_five_question_assignment():
|
def test_public_ask_user_gets_stable_unique_five_question_assignment():
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
|
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
|
||||||
@ -325,8 +783,8 @@ def test_public_ask_user_gets_stable_unique_five_question_assignment():
|
|||||||
).json()["data"]
|
).json()["data"]
|
||||||
assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200
|
assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200
|
||||||
|
|
||||||
phone = f"139{uuid4().int % 100_000_000:08d}"
|
taobao_id = f"tb{uuid4().hex[:14]}"
|
||||||
session = client.post("/api/ask/auth/phone-login", json={"phone": phone}).json()["data"]
|
session = client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id}).json()["data"]
|
||||||
url = f"/api/ask/tasks/{generated['task_id']}?token={session['token']}"
|
url = f"/api/ask/tasks/{generated['task_id']}?token={session['token']}"
|
||||||
first = client.get(url)
|
first = client.get(url)
|
||||||
second = client.get(url)
|
second = client.get(url)
|
||||||
@ -362,25 +820,30 @@ def test_public_ask_user_gets_stable_unique_five_question_assignment():
|
|||||||
)
|
)
|
||||||
assert submitted.status_code == 200
|
assert submitted.status_code == 200
|
||||||
assert submitted.json()["data"]["status"] == "待审核"
|
assert submitted.json()["data"]["status"] == "待审核"
|
||||||
assert len(submitted.json()["data"]["question_screenshots"]) == 1
|
batch_detail = client.get(
|
||||||
assert len(submitted.json()["data"]["payment_screenshots"]) == 1
|
f"/api/admin/ask-batch-reviews/{submitted.json()['data']['batch_id']}"
|
||||||
|
)
|
||||||
|
assert batch_detail.status_code == 200
|
||||||
|
assert len(batch_detail.json()["data"]["question_screenshots"]) == 1
|
||||||
|
assert len(batch_detail.json()["data"]["payment_screenshots"]) == 1
|
||||||
|
|
||||||
reviews = client.get("/api/admin/ask-completion-reviews")
|
reviews = client.get("/api/admin/ask-completion-reviews")
|
||||||
assert reviews.status_code == 200
|
assert reviews.status_code == 200
|
||||||
review = next(item for item in reviews.json()["data"] if item["assignment_id"] == submitted.json()["data"]["assignment_id"])
|
review = next(item for item in reviews.json()["data"] if item["assignment_id"] == submitted.json()["data"]["assignment_id"])
|
||||||
assert review["status"] == "待审核"
|
assert review["status"] == "待审核"
|
||||||
decision = client.post(
|
decision = client.post(
|
||||||
f"/api/admin/ask-completion-reviews/{review['assignment_id']}/decision",
|
f"/api/admin/ask-batch-reviews/{review['batch_id']}/decision",
|
||||||
json={"approved": True},
|
json={"approved": True},
|
||||||
)
|
)
|
||||||
assert decision.status_code == 200
|
assert decision.status_code == 200
|
||||||
assert decision.json()["data"]["status"] == "已通过"
|
assert decision.json()["data"]["status"] == "已通过"
|
||||||
board = client.get("/api/admin/task-board").json()["data"]
|
assert "base64" not in decision.text
|
||||||
|
board = task_board_items(client, page_size=100)
|
||||||
payout = next(
|
payout = next(
|
||||||
item
|
item
|
||||||
for item in board
|
for item in board
|
||||||
if item.get("record_kind") == "ask_batch"
|
if item.get("record_kind") == "ask_batch"
|
||||||
and item["id"] == review["assignment_id"]
|
and item["id"] == review["batch_id"]
|
||||||
)
|
)
|
||||||
assert payout["status_label"] == "待返款"
|
assert payout["status_label"] == "待返款"
|
||||||
assert payout["task_type"] == "ask"
|
assert payout["task_type"] == "ask"
|
||||||
@ -390,46 +853,68 @@ def test_public_ask_user_gets_stable_unique_five_question_assignment():
|
|||||||
assert payout_detail.status_code == 200
|
assert payout_detail.status_code == 200
|
||||||
assert len(payout_detail.json()["data"]["question_screenshots"]) == 1
|
assert len(payout_detail.json()["data"]["question_screenshots"]) == 1
|
||||||
assert len(payout_detail.json()["data"]["payment_screenshots"]) == 1
|
assert len(payout_detail.json()["data"]["payment_screenshots"]) == 1
|
||||||
refunded = client.post(payout["refund_endpoint"])
|
question_image_url = payout_detail.json()["data"]["question_screenshots"][0]["data_url"]
|
||||||
|
payment_image_url = payout_detail.json()["data"]["payment_screenshots"][0]["data_url"]
|
||||||
|
assert question_image_url.startswith("/api/admin/ask-")
|
||||||
|
assert payment_image_url.startswith("/api/admin/ask-")
|
||||||
|
assert "base64" not in payout_detail.text
|
||||||
|
assert client.get(question_image_url).content == b"question-image"
|
||||||
|
assert client.get(payment_image_url).content == b"payment-image"
|
||||||
|
refunded = client.post(
|
||||||
|
payout["refund_endpoint"],
|
||||||
|
files={"refund_file": ("refund.png", b"refund-image", "image/png")},
|
||||||
|
)
|
||||||
assert refunded.status_code == 200
|
assert refunded.status_code == 200
|
||||||
assert refunded.json()["data"]["status_label"] == "已完成"
|
assert refunded.json()["data"]["status_label"] == "已完成"
|
||||||
assert refunded.json()["data"]["refund_endpoint"] is None
|
assert refunded.json()["data"]["refund_endpoint"] is None
|
||||||
board_after_refund = client.get("/api/admin/task-board").json()["data"]
|
board_after_refund = task_board_items(client, page_size=100)
|
||||||
completed_payout = next(
|
completed_payout = next(
|
||||||
item
|
item
|
||||||
for item in board_after_refund
|
for item in board_after_refund
|
||||||
if item.get("record_kind") == "ask_batch"
|
if item.get("record_kind") == "ask_batch"
|
||||||
and item["id"] == review["assignment_id"]
|
and item["id"] == review["batch_id"]
|
||||||
)
|
)
|
||||||
assert completed_payout["status_label"] == "已完成"
|
assert completed_payout["status_label"] == "已完成"
|
||||||
assert completed_payout["completed_count"] == 1
|
assert completed_payout["completed_count"] == 1
|
||||||
audit_history = client.get("/api/admin/ask-completion-reviews").json()["data"]
|
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)
|
assert not any(item["assignment_id"] == review["assignment_id"] for item in audit_history)
|
||||||
refreshed = client.get(url)
|
refreshed = client.get(url)
|
||||||
assert refreshed.json()["data"]["status"] == "已通过"
|
assert refreshed.json()["data"]["status"] == "已完成"
|
||||||
|
|
||||||
|
|
||||||
def test_approved_answer_appears_as_pending_refund_with_detail():
|
def test_approved_answer_appears_as_pending_refund_with_detail(monkeypatch):
|
||||||
previous_key = settings.llm_api_key
|
previous_key = settings.llm_api_key
|
||||||
settings.llm_api_key = ""
|
settings.llm_api_key = ""
|
||||||
phone = f"19{uuid4().int % 1_000_000_000:09d}"
|
monkeypatch.setattr(
|
||||||
|
main_module,
|
||||||
|
"extract_questions_from_images",
|
||||||
|
lambda images: [{"product_title": "测试商品", "question": "测试问题?", "source_index": 0}],
|
||||||
|
)
|
||||||
|
taobao_id = f"tb{uuid4().hex[:14]}"
|
||||||
try:
|
try:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
login = client.post("/api/answer/auth/phone-login", json={"phone": phone})
|
owner_token = answer_owner_token(client)
|
||||||
|
login = client.post("/api/answer/auth/taobao-login", json={"taobao_id": taobao_id, "owner_token": owner_token})
|
||||||
token = login.json()["data"]["token"]
|
token = login.json()["data"]["token"]
|
||||||
created = client.post(
|
created = client.post(
|
||||||
"/api/answer/me/submissions",
|
"/api/answer/me/submissions",
|
||||||
data={"token": token},
|
data={"token": token, "owner_token": owner_token},
|
||||||
files=[("files", ("invited.png", b"invited-image", "image/png"))],
|
files=[("files", ("invited.png", b"invited-image", "image/png"))],
|
||||||
).json()["data"]
|
).json()["data"]
|
||||||
|
created = client.get(
|
||||||
|
f"/api/answer/me/submissions/{created['id']}?token={token}&owner_token={owner_token}"
|
||||||
|
).json()["data"]
|
||||||
question_id = created["questions"][0]["id"]
|
question_id = created["questions"][0]["id"]
|
||||||
|
|
||||||
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
|
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.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
|
assert client.post(
|
||||||
|
f"/api/admin/answer-task-drafts/{created['id']}/publish",
|
||||||
|
json={"shop_name": "测试店铺"},
|
||||||
|
).status_code == 200
|
||||||
completed = client.post(
|
completed = client.post(
|
||||||
f"/api/answer/me/submissions/{created['id']}/completion",
|
f"/api/answer/me/submissions/{created['id']}/completion",
|
||||||
data={"token": token},
|
data={"token": token, "owner_token": owner_token},
|
||||||
files=[
|
files=[
|
||||||
("files", ("done.png", b"done-image", "image/png")),
|
("files", ("done.png", b"done-image", "image/png")),
|
||||||
("payment_files", ("payment.png", b"payment-image", "image/png")),
|
("payment_files", ("payment.png", b"payment-image", "image/png")),
|
||||||
@ -442,7 +927,7 @@ def test_approved_answer_appears_as_pending_refund_with_detail():
|
|||||||
)
|
)
|
||||||
assert approved.status_code == 200
|
assert approved.status_code == 200
|
||||||
|
|
||||||
board = client.get("/api/admin/task-board").json()["data"]
|
board = task_board_items(client, page_size=100)
|
||||||
payout = next(
|
payout = next(
|
||||||
item
|
item
|
||||||
for item in board
|
for item in board
|
||||||
@ -454,11 +939,14 @@ def test_approved_answer_appears_as_pending_refund_with_detail():
|
|||||||
assert detail.status_code == 200
|
assert detail.status_code == 200
|
||||||
assert len(detail.json()["data"]["completion_screenshots"]) == 1
|
assert len(detail.json()["data"]["completion_screenshots"]) == 1
|
||||||
assert len(detail.json()["data"]["payment_screenshots"]) == 1
|
assert len(detail.json()["data"]["payment_screenshots"]) == 1
|
||||||
refunded = client.post(payout["refund_endpoint"])
|
refunded = client.post(
|
||||||
|
payout["refund_endpoint"],
|
||||||
|
files={"refund_file": ("refund.png", b"refund-image", "image/png")},
|
||||||
|
)
|
||||||
assert refunded.status_code == 200
|
assert refunded.status_code == 200
|
||||||
assert refunded.json()["data"]["status_label"] == "已完成"
|
assert refunded.json()["data"]["status_label"] == "已完成"
|
||||||
assert refunded.json()["data"]["refund_endpoint"] is None
|
assert refunded.json()["data"]["refund_endpoint"] is None
|
||||||
board_after_refund = client.get("/api/admin/task-board").json()["data"]
|
board_after_refund = task_board_items(client, page_size=100)
|
||||||
completed_payout = next(
|
completed_payout = next(
|
||||||
item
|
item
|
||||||
for item in board_after_refund
|
for item in board_after_refund
|
||||||
@ -488,8 +976,8 @@ def test_public_ask_questions_are_unique_across_users_and_eventually_exhausted()
|
|||||||
|
|
||||||
sessions = []
|
sessions = []
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
phone = f"138{uuid4().int % 100_000_000:08d}"
|
taobao_id = f"tb{uuid4().hex[:14]}"
|
||||||
sessions.append(client.post("/api/ask/auth/phone-login", json={"phone": phone}).json()["data"])
|
sessions.append(client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id}).json()["data"])
|
||||||
|
|
||||||
first_url = f"/api/ask/tasks/{generated['task_id']}?token={sessions[0]['token']}"
|
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']}"
|
second_url = f"/api/ask/tasks/{generated['task_id']}?token={sessions[1]['token']}"
|
||||||
@ -512,3 +1000,131 @@ def test_public_ask_questions_are_unique_across_users_and_eventually_exhausted()
|
|||||||
exhausted = client.get(third_url)
|
exhausted = client.get(third_url)
|
||||||
assert exhausted.status_code == 409
|
assert exhausted.status_code == 409
|
||||||
assert exhausted.json()["detail"] == "本次任务已被领取完,请关注下一个任务!"
|
assert exhausted.json()["detail"] == "本次任务已被领取完,请关注下一个任务!"
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_submission_requires_owner_link_and_allows_at_most_two_images():
|
||||||
|
with TestClient(app) as client:
|
||||||
|
missing_owner = client.post(
|
||||||
|
"/api/answer/auth/taobao-login",
|
||||||
|
json={"taobao_id": f"tb{uuid4().hex[:14]}"},
|
||||||
|
)
|
||||||
|
assert missing_owner.status_code == 400
|
||||||
|
|
||||||
|
owner_token = answer_owner_token(client)
|
||||||
|
login = client.post(
|
||||||
|
"/api/answer/auth/taobao-login",
|
||||||
|
json={"taobao_id": f"tb{uuid4().hex[:14]}", "owner_token": owner_token},
|
||||||
|
)
|
||||||
|
assert login.status_code == 200
|
||||||
|
token = login.json()["data"]["token"]
|
||||||
|
too_many = client.post(
|
||||||
|
"/api/answer/me/submissions",
|
||||||
|
data={"token": token, "owner_token": owner_token},
|
||||||
|
files=[
|
||||||
|
("files", (f"shot-{index}.png", b"image", "image/png"))
|
||||||
|
for index in range(3)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert too_many.status_code == 400
|
||||||
|
assert too_many.json()["detail"] == "一次最多上传2张截图"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_batches_survive_reservation_timeout_and_parent_completes_only_after_all_paid(monkeypatch):
|
||||||
|
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
|
||||||
|
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={
|
||||||
|
"shop_name": "分批保留测试店铺",
|
||||||
|
"product_link": "https://item.example.com/batch-history",
|
||||||
|
"product_name": "分批保留测试商品",
|
||||||
|
"question_count": 2,
|
||||||
|
"claim_questions_per_user": 2,
|
||||||
|
},
|
||||||
|
).json()["data"]
|
||||||
|
assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200
|
||||||
|
|
||||||
|
session = client.post(
|
||||||
|
"/api/ask/auth/taobao-login",
|
||||||
|
json={"taobao_id": f"tb{uuid4().hex[:14]}"},
|
||||||
|
).json()["data"]
|
||||||
|
task_url = f"/api/ask/tasks/{generated['task_id']}?token={session['token']}"
|
||||||
|
assignment = client.get(task_url).json()["data"]
|
||||||
|
first_question_id = assignment["questions"][0]["id"]
|
||||||
|
assert client.post(
|
||||||
|
f"/api/ask/tasks/{generated['task_id']}/selection",
|
||||||
|
json={"token": session["token"], "question_ids": [first_question_id]},
|
||||||
|
).status_code == 200
|
||||||
|
first_batch = client.post(
|
||||||
|
f"/api/ask/tasks/{generated['task_id']}/submission",
|
||||||
|
data={"token": session["token"], "payee_name": "测试用户"},
|
||||||
|
files=[
|
||||||
|
("files", ("question-1.png", b"question-1", "image/png")),
|
||||||
|
("payment_file", ("payment-1.png", b"payment-1", "image/png")),
|
||||||
|
],
|
||||||
|
).json()["data"]
|
||||||
|
assert client.post(
|
||||||
|
f"/api/admin/ask-batch-reviews/{first_batch['batch_id']}/decision",
|
||||||
|
json={"approved": True},
|
||||||
|
).status_code == 200
|
||||||
|
assert client.post(
|
||||||
|
f"/api/admin/task-board/ask-batches/{first_batch['batch_id']}/confirm-refund",
|
||||||
|
files={"refund_file": ("refund-1.png", b"refund-1", "image/png")},
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
board = task_board_items(client, task_type="ask", page_size=100)
|
||||||
|
parent = next(item for item in board if item.get("record_kind") == "ask_task" and item["id"] == generated["id"])
|
||||||
|
assert parent["status_label"] == "进行中"
|
||||||
|
assert parent["completed_count"] == 1
|
||||||
|
|
||||||
|
next_assignment = client.get(f"{task_url}&refresh=true").json()["data"]
|
||||||
|
assert next_assignment["status"] == "待选择"
|
||||||
|
with main_module.SessionLocal() as db:
|
||||||
|
stored = db.get(main_module.AskAssignment, next_assignment["assignment_id"])
|
||||||
|
stored.reservation_started_at = datetime.utcnow() - timedelta(minutes=2)
|
||||||
|
db.commit()
|
||||||
|
expired = client.get(task_url)
|
||||||
|
assert expired.status_code == 409
|
||||||
|
|
||||||
|
with main_module.SessionLocal() as db:
|
||||||
|
stored = db.get(main_module.AskAssignment, next_assignment["assignment_id"])
|
||||||
|
assert len(stored.batches) == 1
|
||||||
|
assert stored.batches[0].status == "paid"
|
||||||
|
|
||||||
|
final_assignment = client.get(f"{task_url}&refresh=true").json()["data"]
|
||||||
|
remaining_question_id = next(
|
||||||
|
item["id"] for item in final_assignment["questions"] if item["id"] != first_question_id
|
||||||
|
)
|
||||||
|
assert client.post(
|
||||||
|
f"/api/ask/tasks/{generated['task_id']}/selection",
|
||||||
|
json={"token": session["token"], "question_ids": [remaining_question_id]},
|
||||||
|
).status_code == 200
|
||||||
|
second_batch = client.post(
|
||||||
|
f"/api/ask/tasks/{generated['task_id']}/submission",
|
||||||
|
data={"token": session["token"], "payee_name": "测试用户"},
|
||||||
|
files=[
|
||||||
|
("files", ("question-2.png", b"question-2", "image/png")),
|
||||||
|
("payment_file", ("payment-2.png", b"payment-2", "image/png")),
|
||||||
|
],
|
||||||
|
).json()["data"]
|
||||||
|
assert client.post(
|
||||||
|
f"/api/admin/ask-batch-reviews/{second_batch['batch_id']}/decision",
|
||||||
|
json={"approved": True},
|
||||||
|
).status_code == 200
|
||||||
|
assert client.post(
|
||||||
|
f"/api/admin/task-board/ask-batches/{second_batch['batch_id']}/confirm-refund",
|
||||||
|
files={"refund_file": ("refund-2.png", b"refund-2", "image/png")},
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
completed_board = task_board_items(client, task_type="ask", page_size=100)
|
||||||
|
completed_parent = next(
|
||||||
|
item for item in completed_board
|
||||||
|
if item.get("record_kind") == "ask_task" and item["id"] == generated["id"]
|
||||||
|
)
|
||||||
|
assert completed_parent["status_label"] == "已完成"
|
||||||
|
assert completed_parent["completed_count"] == 2
|
||||||
|
assert len(completed_parent["claimed_tasks"]) == 2
|
||||||
|
|||||||
53
backend/tests/test_llm_ocr.py
Normal file
53
backend/tests/test_llm_ocr.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
from collections import Counter
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app import llm
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_image_ocr_retries_per_image_and_deduplicates(monkeypatch):
|
||||||
|
attempts = Counter()
|
||||||
|
|
||||||
|
def recognize(image_data, content_type):
|
||||||
|
attempts[image_data] += 1
|
||||||
|
if image_data == b"first" and attempts[image_data] == 1:
|
||||||
|
raise TimeoutError("temporary timeout")
|
||||||
|
if image_data == b"first":
|
||||||
|
return [
|
||||||
|
{"product_title": "商品A", "question": "共同问题?"},
|
||||||
|
{"product_title": "商品A", "question": "第一张的问题?"},
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
{"product_title": "商品A", "question": "共同问题?"},
|
||||||
|
{"product_title": "商品A", "question": "第二张的问题?"},
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm.settings, "llm_api_key", "test-key")
|
||||||
|
monkeypatch.setattr(llm, "_compress_ocr_image", lambda data, content_type: (data, content_type))
|
||||||
|
monkeypatch.setattr(llm, "_extract_questions_from_image", recognize)
|
||||||
|
monkeypatch.setattr(llm.time, "sleep", lambda seconds: None)
|
||||||
|
|
||||||
|
result = llm.extract_questions_from_images([
|
||||||
|
(b"first", "image/png"),
|
||||||
|
(b"second", "image/png"),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert attempts == Counter({b"first": 2, b"second": 1})
|
||||||
|
assert [item["question"] for item in result] == ["共同问题?", "第一张的问题?", "第二张的问题?"]
|
||||||
|
assert [item["source_index"] for item in result] == [0, 0, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ocr_image_compression_limits_dimensions_and_converts_to_jpeg():
|
||||||
|
image = Image.new("RGBA", (3200, 2600), (20, 100, 220, 180))
|
||||||
|
source = BytesIO()
|
||||||
|
image.save(source, format="PNG")
|
||||||
|
|
||||||
|
compressed, content_type = llm._compress_ocr_image(source.getvalue(), "image/png")
|
||||||
|
|
||||||
|
with Image.open(BytesIO(compressed)) as result:
|
||||||
|
assert max(result.size) <= 2200
|
||||||
|
assert result.mode == "RGB"
|
||||||
|
assert result.format == "JPEG"
|
||||||
|
assert content_type == "image/jpeg"
|
||||||
|
assert len(compressed) < len(source.getvalue())
|
||||||
@ -1,57 +1,58 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Check, ClipboardCheck, Clock3, Copy, FileCheck2, History, ImagePlus, LogOut, MessageCircle, RefreshCw, ShieldCheck } from 'lucide-react'
|
import { Check, ClipboardCheck, Clock3, Copy, FileCheck2, History, ImagePlus, LogOut, MessageCircle, RefreshCw, ShieldCheck } from 'lucide-react'
|
||||||
|
|
||||||
const SESSION_KEY = 'answer_user_session'
|
const SESSION_KEY = 'answer_user_taobao_session'
|
||||||
const STATUS_TEXT = { '待筛选': '截图审核中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '已完成': '已返款', '未通过': '审核不通过', '已撤销': '任务已撤销' }
|
const STATUS_TEXT = { '待筛选': '截图审核中', '识别失败': '运营处理中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '已完成': '已返款', '未通过': '审核不通过', '已撤销': '任务已撤销' }
|
||||||
const maskPhone = (phone = '') => phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
const accountName = session => session?.taobao_id || session?.account || session?.user_id || ''
|
||||||
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
|
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
|
||||||
const fileKey = file => `${file.name}-${file.size}-${file.lastModified}`
|
const fileKey = file => `${file.name}-${file.size}-${file.lastModified}`
|
||||||
|
|
||||||
function Top({ session, latest, logout }) {
|
function Top({ session, latest, logout }) {
|
||||||
return <>
|
return <>
|
||||||
<header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div><span>ⓘ 规则说明</span></header>
|
<header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div><span>ⓘ 规则说明</span></header>
|
||||||
<section className="answer-v2-user"><div><b>淘宝问答承接</b><span>● {latest ? STATUS_TEXT[latest.status] || latest.status : '待认领'}</span></div><p>用户ID:{session.user_id} · {maskPhone(session.phone)}{latest?.status === '待用户完成' && <> 派发任务编号:<strong>{latest.submission_no}</strong></>}</p><button title="退出当前用户" onClick={logout}><LogOut size={15}/></button></section>
|
<section className="answer-v2-user"><div><b>淘宝问答承接</b><span>● {latest ? STATUS_TEXT[latest.status] || latest.status : '待认领'}</span></div><p>淘宝ID:{accountName(session)}{latest?.status === '待用户完成' && <> 派发任务编号:<strong>{latest.submission_no}</strong></>}</p><button title="退出当前用户" onClick={logout}><LogOut size={15}/></button></section>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function Progress({ status }) {
|
function Progress({ status }) {
|
||||||
const active = !status ? 1 : ['待筛选', '待运营派发', '已派发'].includes(status) ? 2 : ['待用户完成', '未通过'].includes(status) ? 3 : 4
|
const active = !status ? 1 : ['待筛选', '识别失败', '待运营派发', '已派发'].includes(status) ? 2 : ['待用户完成', '未通过'].includes(status) ? 3 : 4
|
||||||
const labels = ['被邀截图', '任务派发', '完成任务', '终审确认']
|
const labels = ['被邀截图', '任务派发', '完成任务', '终审确认']
|
||||||
return <div className="answer-v2-progress">{labels.map((label, index) => { const step = index + 1; const done = step < active || (active === 4 && ['已通过', '已完成'].includes(status)); return <div key={label} className={step === active ? 'active' : done ? 'done' : ''}><b>{done ? <Check size={15}/> : step}</b><span>{label}</span></div> })}</div>
|
return <div className="answer-v2-progress">{labels.map((label, index) => { const step = index + 1; const done = step < active || (active === 4 && ['已通过', '已完成'].includes(status)); return <div key={label} className={step === active ? 'active' : done ? 'done' : ''}><b>{done ? <Check size={15}/> : step}</b><span>{label}</span></div> })}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function Login({ api, onLogin, linkToken }) {
|
function Login({ api, onLogin, linkToken }) {
|
||||||
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('')
|
const [taobaoId, setTaobaoId] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = 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, owner_token: linkToken || undefined }) }); setCooldown(result.data.retry_after || 60); setDevCode(result.data.dev_code || '') }
|
|
||||||
catch (err) { setError(err.message) } finally { setSending(false) }
|
|
||||||
}
|
|
||||||
async function submit(event) {
|
async function submit(event) {
|
||||||
event.preventDefault(); setError('')
|
event.preventDefault(); setError('')
|
||||||
if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码')
|
if (!/^[A-Za-z0-9_.-]{3,64}$/.test(taobaoId.trim())) return setError('请输入正确的淘宝ID')
|
||||||
if (!/^\d{6}$/.test(code)) return setError('请输入6位验证码')
|
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try { const result = await api('/api/answer/auth/phone-login', { method: 'POST', body: JSON.stringify({ phone, code, owner_token: linkToken || undefined }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) }
|
try { const result = await api('/api/answer/auth/taobao-login', { method: 'POST', body: JSON.stringify({ taobao_id: taobaoId.trim(), owner_token: linkToken || undefined }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) }
|
||||||
catch (err) { setError(err.message) } finally { setBusy(false) }
|
catch (err) { setError(err.message) } finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
return <main className="answer-v2-shell"><section className="answer-v2-app login-only"><header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div></header><form className="answer-v2-login" onSubmit={submit}><span><MessageCircle size={28}/></span><h1>手机号登录</h1><p>使用短信验证码安全登录</p><label>手机号码<input value={phone} onChange={event => setPhone(event.target.value.replace(/\D/g, '').slice(0, 11))} inputMode="numeric" placeholder="请输入 11 位手机号码" autoFocus/></label><label>短信验证码<div className="answer-v2-code-row"><input value={code} onChange={event => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} inputMode="numeric" placeholder="请输入6位验证码"/><button type="button" onClick={sendCode} disabled={sending || cooldown > 0}>{cooldown ? `${cooldown}s后重发` : sending ? '发送中…' : '获取验证码'}</button></div></label>{devCode && <small className="answer-v2-dev-code">开发环境验证码:{devCode}</small>}{error && <div className="answer-v2-error">{error}</div>}<button disabled={busy}>{busy ? '登录中…' : '登录并进入任务中心'}</button></form></section></main>
|
return <main className="answer-v2-shell"><section className="answer-v2-app login-only"><header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div></header><form className="answer-v2-login" onSubmit={submit}><span><MessageCircle size={28}/></span><h1>淘宝账号登录</h1><p>输入淘宝ID即可进入任务中心</p><label>淘宝ID<input value={taobaoId} onChange={event => setTaobaoId(event.target.value.replace(/\s/g, '').slice(0, 64))} autoCapitalize="none" autoCorrect="off" placeholder="例如:tb1008611" autoFocus/></label>{error && <div className="answer-v2-error">{error}</div>}<button disabled={busy}>{busy ? '登录中…' : '登录并进入任务中心'}</button></form></section></main>
|
||||||
}
|
}
|
||||||
|
|
||||||
function UploadStage({ api, session, onUploaded, setError, linkToken }) {
|
function UploadStage({ api, session, onUploaded, setError, linkToken }) {
|
||||||
const [files, setFiles] = useState([]); const [busy, setBusy] = useState(false); const inputRef = useRef(null)
|
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])
|
const previews = useMemo(() => files.map(file => ({ file, url: URL.createObjectURL(file) })), [files])
|
||||||
useEffect(() => () => previews.forEach(item => URL.revokeObjectURL(item.url)), [previews])
|
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)) }
|
function addFiles(list) {
|
||||||
|
const images = Array.from(list).filter(file => /^image\/(jpeg|png|webp)$/.test(file.type))
|
||||||
|
if (images.some(file => file.size > 10 * 1024 * 1024)) setError('单张图片不能超过10MB')
|
||||||
|
const valid = images.filter(file => file.size <= 10 * 1024 * 1024)
|
||||||
|
setFiles(current => {
|
||||||
|
const next = [...current, ...valid].filter((file, index, all) => all.findIndex(row => fileKey(row) === fileKey(file)) === index)
|
||||||
|
if (next.length > 2) setError('一次最多上传2张截图')
|
||||||
|
return next.slice(0, 2)
|
||||||
|
})
|
||||||
|
}
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!files.length) return setError('请至少上传一张被邀请回答截图')
|
if (!files.length) return setError('请至少上传一张被邀请回答截图')
|
||||||
setBusy(true); setError(''); const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file))
|
setBusy(true); setError(''); const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file))
|
||||||
if (linkToken) body.append('owner_token', linkToken)
|
if (linkToken) body.append('owner_token', linkToken)
|
||||||
try { await api('/api/answer/me/submissions', { method: 'POST', body }); await onUploaded() } catch (err) { setError(err.message) } finally { setBusy(false) }
|
try { await api('/api/answer/me/submissions', { method: 'POST', body }); await onUploaded() } catch (err) { setError(err.message) } finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
return <><section className="answer-v2-hero purple"><small>✨ 任务认领与核验</small><h1>上传被邀请回答截图</h1><p>提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。</p></section><section className="answer-v2-card upload-card"><header><div><h2>上传被邀请回答截图 <em>截图 · 必传</em></h2><p>用于确认您的账号可回答指定问题(支持上传多张)。</p></div><b>{files.length}/10</b></header><div className="answer-v2-upload-grid">{previews.map(({ file, url }) => <figure key={fileKey(file)}><img src={url} alt={file.name}/><figcaption>{file.name}</figcaption><button onClick={() => setFiles(current => current.filter(row => fileKey(row) !== fileKey(file)))}>删除</button></figure>)}{files.length < 10 && <button type="button" className="answer-v2-add" onClick={() => inputRef.current?.click()}><ImagePlus size={28}/><strong>+ 上传被邀截图</strong><small>支持 JPG、PNG、WEBP</small></button>}</div><input ref={inputRef} hidden type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => addFiles(event.target.files)}/><p className="answer-v2-photo-note">▧ 请确保凭证清晰、信息完整、无遮挡</p></section><button className="answer-v2-primary wide" disabled={busy || !files.length} onClick={submit}>{busy ? '正在提交…' : '提交截图并进入审核'}</button></>
|
return <><section className="answer-v2-hero purple"><small>✨ 任务认领与核验</small><h1>上传被邀请回答截图</h1><p>提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。</p></section><section className="answer-v2-card upload-card"><header><div><h2>上传被邀请回答截图 <em>截图 · 必传</em></h2><p>一次最多上传2张。为了提高问题识别的准确率,请每张图片少于十个问题。</p></div><b>{files.length}/2</b></header><div className="answer-v2-upload-grid">{previews.map(({ file, url }) => <figure key={fileKey(file)}><img src={url} alt={file.name}/><figcaption>{file.name}</figcaption><button onClick={() => setFiles(current => current.filter(row => fileKey(row) !== fileKey(file)))}>删除</button></figure>)}{files.length < 2 && <button type="button" className="answer-v2-add" onClick={() => inputRef.current?.click()}><ImagePlus size={28}/><strong>+ 上传被邀截图</strong><small>支持 JPG、PNG、WEBP</small></button>}</div><input ref={inputRef} hidden type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => addFiles(event.target.files)}/><p className="answer-v2-photo-note">▧ 请确保凭证清晰、信息完整、无遮挡;单张不超过10MB</p></section><button className="answer-v2-primary wide" disabled={busy || !files.length} onClick={submit}>{busy ? '正在提交…' : '提交截图并进入审核'}</button></>
|
||||||
}
|
}
|
||||||
|
|
||||||
function MaterialCard({ latest, completion = false }) {
|
function MaterialCard({ latest, completion = false }) {
|
||||||
@ -121,7 +122,7 @@ function FinalStage({ latest, reload, busy, reopen, startNew }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HistoryPage({ records, close, onContinue }) {
|
function HistoryPage({ records, close, onContinue }) {
|
||||||
const canContinue = status => ['待筛选', '待运营派发', '已派发', '待用户完成', '未通过'].includes(status)
|
const canContinue = status => ['待筛选', '识别失败', '待运营派发', '已派发', '待用户完成', '未通过'].includes(status)
|
||||||
return <section className="answer-v2-history"><header><button onClick={close}>← 返回任务</button><h2>任务状态 / 上传历史({records.length})</h2></header>{records.length === 0 ? <div className="answer-v2-card">暂无历史记录</div> : records.map(record => <article className="answer-v2-card" key={record.id}><header><strong>{record.submission_no}</strong><span>{STATUS_TEXT[record.status] || record.status}</span></header><p>{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张</p><div>{record.invited_screenshots.slice(0, 4).map(image => { const src = image.url || image.data_url; return <img key={image.id || src} src={src}/> })}</div>{canContinue(record.status) && <button className="answer-v2-history-continue" onClick={() => onContinue(record)}>↗ 继续此任务</button>}</article>)}</section>
|
return <section className="answer-v2-history"><header><button onClick={close}>← 返回任务</button><h2>任务状态 / 上传历史({records.length})</h2></header>{records.length === 0 ? <div className="answer-v2-card">暂无历史记录</div> : records.map(record => <article className="answer-v2-card" key={record.id}><header><strong>{record.submission_no}</strong><span>{STATUS_TEXT[record.status] || record.status}</span></header><p>{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张</p><div>{record.invited_screenshots.slice(0, 4).map(image => { const src = image.url || image.data_url; return <img key={image.id || src} src={src}/> })}</div>{canContinue(record.status) && <button className="answer-v2-history-continue" onClick={() => onContinue(record)}>↗ 继续此任务</button>}</article>)}</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,6 +215,6 @@ export default function AnswerUserPortalV2({ api, linkToken = '' }) {
|
|||||||
records.slice(0, 20).forEach(record => loadSubmissionImages(record))
|
records.slice(0, 20).forEach(record => loadSubmissionImages(record))
|
||||||
}, [historyOpen, records.length])
|
}, [historyOpen, records.length])
|
||||||
if (!session) return <Login api={api} onLogin={setSession} linkToken={linkToken}/>
|
if (!session) return <Login api={api} onLogin={setSession} linkToken={linkToken}/>
|
||||||
return <main className="answer-v2-shell"><section className="answer-v2-app"><Top session={session} latest={visibleLatest} logout={logout}/><div className="answer-v2-tabs"><button className={!historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(false)}><ClipboardCheck size={15}/>任务认领</button><button className={historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(true)}><History size={15}/>任务状态 <small>{visibleLatest ? STATUS_TEXT[visibleLatest.status] || visibleLatest.status : '待上传'}</small></button></div>{!historyOpen && <Progress status={visibleLatest?.status}/>}<div className="answer-v2-body">{error && <div className="answer-v2-error">{error}</div>}{!loaded ? <div className="answer-v2-card answer-v2-loading">正在读取任务状态…</div> : historyOpen ? <HistoryPage records={records} close={() => setHistoryOpen(false)} onContinue={record => { setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false) }}/> : !visibleLatest ? <UploadStage api={api} session={session} linkToken={linkToken} onUploaded={async () => { setNewTaskMode(false); setActiveRecordId(null); await load() }} setError={setError}/> : ['待筛选', '待运营派发', '已派发'].includes(visibleLatest.status) ? <ReviewStage latest={visibleLatest} reload={load} busy={busy} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/> : visibleLatest.status === '待用户完成' || (visibleLatest.status === '未通过' && retrying) ? <AssignmentStage api={api} session={session} latest={visibleLatest} linkToken={linkToken} reload={async submitted => { if (submitted) { applySubmittedRecord(submitted); setRetrying(false); load(true); return } await load(); setRetrying(false) }} setError={setError}/> : <FinalStage latest={visibleLatest} reload={load} busy={busy} reopen={() => setRetrying(true)} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/>}</div><footer className="answer-v2-footer"><button onClick={() => setHistoryOpen(true)}>上传历史({records.length})</button>{visibleLatest && <button className="primary" onClick={() => load()}><RefreshCw size={14}/>刷新状态</button>}</footer></section></main>
|
return <main className="answer-v2-shell"><section className="answer-v2-app"><Top session={session} latest={visibleLatest} logout={logout}/><div className="answer-v2-tabs"><button className={!historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(false)}><ClipboardCheck size={15}/>任务认领</button><button className={historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(true)}><History size={15}/>任务状态 <small>{visibleLatest ? STATUS_TEXT[visibleLatest.status] || visibleLatest.status : '待上传'}</small></button></div>{!historyOpen && <Progress status={visibleLatest?.status}/>}<div className="answer-v2-body">{error && <div className="answer-v2-error">{error}</div>}{!loaded ? <div className="answer-v2-card answer-v2-loading">正在读取任务状态…</div> : historyOpen ? <HistoryPage records={records} close={() => setHistoryOpen(false)} onContinue={record => { setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false) }}/> : !visibleLatest ? <UploadStage api={api} session={session} linkToken={linkToken} onUploaded={async () => { setNewTaskMode(false); setActiveRecordId(null); await load() }} setError={setError}/> : ['待筛选', '识别失败', '待运营派发', '已派发'].includes(visibleLatest.status) ? <ReviewStage latest={visibleLatest} reload={load} busy={busy} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/> : visibleLatest.status === '待用户完成' || (visibleLatest.status === '未通过' && retrying) ? <AssignmentStage api={api} session={session} latest={visibleLatest} linkToken={linkToken} reload={async submitted => { if (submitted) { applySubmittedRecord(submitted); setRetrying(false); load(true); return } await load(); setRetrying(false) }} setError={setError}/> : <FinalStage latest={visibleLatest} reload={load} busy={busy} reopen={() => setRetrying(true)} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/>}</div><footer className="answer-v2-footer"><button onClick={() => setHistoryOpen(true)}>上传历史({records.length})</button>{visibleLatest && <button className="primary" onClick={() => load()}><RefreshCw size={14}/>刷新状态</button>}</footer></section></main>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,12 +12,12 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const SESSION_KEY = "ask_user_session";
|
const SESSION_KEY = "ask_user_taobao_session";
|
||||||
const EXHAUSTED_MESSAGE = "本次任务已被领取完,请关注下一个任务!";
|
const EXHAUSTED_MESSAGE = "本次任务已被领取完,请关注下一个任务!";
|
||||||
const INVALID_TASK_MESSAGE = "提问任务链接无效、未发布或已结束";
|
const INVALID_TASK_MESSAGE = "提问任务链接无效、未发布或已结束";
|
||||||
const fileKey = (file) => `${file.name}-${file.size}-${file.lastModified}`;
|
const fileKey = (file) => `${file.name}-${file.size}-${file.lastModified}`;
|
||||||
const maskPhone = (phone = "") =>
|
const accountName = (session) =>
|
||||||
phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
|
session?.taobao_id || session?.account || session?.user_id || "";
|
||||||
|
|
||||||
function getTaskNo() {
|
function getTaskNo() {
|
||||||
const segments = window.location.pathname.split("/").filter(Boolean);
|
const segments = window.location.pathname.split("/").filter(Boolean);
|
||||||
@ -28,41 +28,72 @@ function isInvalidTaskError(message = "") {
|
|||||||
return message.includes("提问任务链接无效") || message.includes("提问任务不存在");
|
return message.includes("提问任务链接无效") || message.includes("提问任务不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
function PhoneLogin({ api, onLogin }) {
|
function isExpiredAssignmentError(message = "") {
|
||||||
const [phone, setPhone] = useState("");
|
return message.includes("提问任务已失效") || message.includes("任务已失效");
|
||||||
const [code, setCode] = useState("");
|
}
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [sending, setSending] = useState(false);
|
function formatCountdown(seconds) {
|
||||||
const [cooldown, setCooldown] = useState(0);
|
const safe = Math.max(0, Number(seconds) || 0);
|
||||||
const [error, setError] = useState("");
|
const minutes = Math.floor(safe / 60).toString().padStart(2, "0");
|
||||||
const [devCode, setDevCode] = useState("");
|
const remainder = Math.floor(safe % 60).toString().padStart(2, "0");
|
||||||
|
return `${minutes}:${remainder}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseServerDate(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const text = String(value);
|
||||||
|
// API datetime values are stored as UTC but older responses omit the
|
||||||
|
// timezone suffix. Explicitly mark those values as UTC before parsing in
|
||||||
|
// the browser, otherwise China Standard Time makes them appear eight hours
|
||||||
|
// older and the countdown expires immediately.
|
||||||
|
return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(text) ? text : `${text}Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssignmentCountdown({ expiresAt, onExpired }) {
|
||||||
|
const [remaining, setRemaining] = useState(() =>
|
||||||
|
expiresAt ? Math.max(0, (parseServerDate(expiresAt)?.getTime() - Date.now()) / 1000) : 0,
|
||||||
|
);
|
||||||
|
const expiredRef = useRef(false);
|
||||||
|
const onExpiredRef = useRef(onExpired);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cooldown) return undefined;
|
onExpiredRef.current = onExpired;
|
||||||
const timer = window.setInterval(() => setCooldown((value) => Math.max(0, value - 1)), 1000);
|
}, [onExpired]);
|
||||||
return () => window.clearInterval(timer);
|
useEffect(() => {
|
||||||
}, [cooldown]);
|
if (!expiresAt) return undefined;
|
||||||
async function sendCode() {
|
expiredRef.current = false;
|
||||||
if (!/^1\d{10}$/.test(phone)) return setError("请输入正确的 11 位手机号码");
|
const update = () => {
|
||||||
setSending(true); setError("");
|
const next = Math.max(0, (parseServerDate(expiresAt)?.getTime() - Date.now()) / 1000);
|
||||||
try {
|
setRemaining(next);
|
||||||
const result = await api("/api/ask/auth/send-code", { method: "POST", body: JSON.stringify({ phone }) });
|
if (next <= 0 && !expiredRef.current) {
|
||||||
setCooldown(result.data.retry_after || 60); setDevCode(result.data.dev_code || "");
|
expiredRef.current = true;
|
||||||
} catch (requestError) { setError(requestError.message); } finally { setSending(false); }
|
onExpiredRef.current?.();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
const timer = window.setInterval(update, 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [expiresAt]);
|
||||||
|
if (!expiresAt) return null;
|
||||||
|
return <span className={`ask-assignment-countdown ${remaining <= 10 ? "urgent" : ""}`}>剩余 {formatCountdown(remaining)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaobaoLogin({ api, onLogin }) {
|
||||||
|
const [taobaoId, setTaobaoId] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
async function submit(event) {
|
async function submit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!/^1\d{10}$/.test(phone)) {
|
if (!/^[A-Za-z0-9_.-]{3,64}$/.test(taobaoId.trim())) {
|
||||||
setError("请输入正确的 11 位手机号码");
|
setError("请输入正确的淘宝ID");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!/^\d{6}$/.test(code)) { setError("请输入6位验证码"); return; }
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const result = await api("/api/ask/auth/phone-login", {
|
const result = await api("/api/ask/auth/taobao-login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ phone, code }),
|
body: JSON.stringify({ taobao_id: taobaoId.trim() }),
|
||||||
});
|
});
|
||||||
localStorage.setItem(SESSION_KEY, JSON.stringify(result.data));
|
localStorage.setItem(SESSION_KEY, JSON.stringify(result.data));
|
||||||
onLogin(result.data);
|
onLogin(result.data);
|
||||||
@ -87,23 +118,20 @@ function PhoneLogin({ api, onLogin }) {
|
|||||||
<span className="ask-login-icon">
|
<span className="ask-login-icon">
|
||||||
<MessageCircle size={28} />
|
<MessageCircle size={28} />
|
||||||
</span>
|
</span>
|
||||||
<h1>手机号登录</h1>
|
<h1>淘宝账号登录</h1>
|
||||||
<p>使用短信验证码安全登录</p>
|
<p>输入淘宝ID即可领取提问任务</p>
|
||||||
<label>
|
<label>
|
||||||
手机号码
|
淘宝ID
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
inputMode="numeric"
|
maxLength="64"
|
||||||
maxLength="11"
|
value={taobaoId}
|
||||||
value={phone}
|
onChange={(event) => setTaobaoId(event.target.value.replace(/\s/g, ""))}
|
||||||
onChange={(event) =>
|
autoCapitalize="none"
|
||||||
setPhone(event.target.value.replace(/\D/g, ""))
|
autoCorrect="off"
|
||||||
}
|
placeholder="例如:tb1008611"
|
||||||
placeholder="请输入 11 位手机号码"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>短信验证码<div className="ask-code-row"><input inputMode="numeric" maxLength="6" value={code} onChange={(event) => setCode(event.target.value.replace(/\D/g, ""))} placeholder="请输入6位验证码"/><button type="button" onClick={sendCode} disabled={sending || cooldown > 0}>{cooldown ? `${cooldown}s后重发` : sending ? "发送中…" : "获取验证码"}</button></div></label>
|
|
||||||
{devCode && <small className="ask-dev-code">开发环境验证码:{devCode}</small>}
|
|
||||||
{error && <div className="ask-user-error">{error}</div>}
|
{error && <div className="ask-user-error">{error}</div>}
|
||||||
<button disabled={busy}>
|
<button disabled={busy}>
|
||||||
{busy ? "登录中…" : "登录并领取提问任务"}
|
{busy ? "登录中…" : "登录并领取提问任务"}
|
||||||
@ -133,7 +161,7 @@ function TaskExhausted({ session, onLogout }) {
|
|||||||
<h1>本次任务已被领取完</h1>
|
<h1>本次任务已被领取完</h1>
|
||||||
<p>当前链接中的问题已全部分配给其他用户,请关注下一个任务!</p>
|
<p>当前链接中的问题已全部分配给其他用户,请关注下一个任务!</p>
|
||||||
<small>
|
<small>
|
||||||
当前账号:{session.user_id} · {maskPhone(session.phone)}
|
当前淘宝ID:{accountName(session)}
|
||||||
</small>
|
</small>
|
||||||
<button type="button" onClick={onLogout}>
|
<button type="button" onClick={onLogout}>
|
||||||
更换登录账号
|
更换登录账号
|
||||||
@ -164,6 +192,27 @@ function InvalidTask({ message, onReload }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ExpiredAssignment({ session, onReload }) {
|
||||||
|
return (
|
||||||
|
<main className="ask-user-shell">
|
||||||
|
<section className="ask-user-app ask-invalid-app">
|
||||||
|
<header className="ask-user-topbar">
|
||||||
|
<span><ShieldCheck size={14} /> 淘宝问答运营官方承接通道</span>
|
||||||
|
<em>官方认证</em>
|
||||||
|
</header>
|
||||||
|
<section className="ask-invalid-card ask-expired-card">
|
||||||
|
<span className="ask-invalid-icon"><PackageCheck size={34} /></span>
|
||||||
|
<em>任务已失效</em>
|
||||||
|
<h1>本次任务已超时</h1>
|
||||||
|
<p>当前分配的问题已自动释放,请点击刷新重新分配任务。</p>
|
||||||
|
<small>当前淘宝ID:{accountName(session)}</small>
|
||||||
|
<button type="button" onClick={onReload}>刷新重新分配</button>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Header({
|
function Header({
|
||||||
session,
|
session,
|
||||||
assignment,
|
assignment,
|
||||||
@ -186,9 +235,9 @@ function Header({
|
|||||||
<div className="ask-user-avatar">问</div>
|
<div className="ask-user-avatar">问</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>问大家提问用户</strong>
|
<strong>问大家提问用户</strong>
|
||||||
<i>手机已绑定</i>
|
<i>淘宝账号已登录</i>
|
||||||
<small>
|
<small>
|
||||||
ID:{session.user_id} · {maskPhone(session.phone)}
|
淘宝ID:{accountName(session)}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<button title="退出登录" onClick={onLogout}>
|
<button title="退出登录" onClick={onLogout}>
|
||||||
@ -221,6 +270,9 @@ function SelectQuestions({
|
|||||||
setSelected,
|
setSelected,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
busy,
|
busy,
|
||||||
|
onExpired,
|
||||||
|
expired = false,
|
||||||
|
onRefresh,
|
||||||
}) {
|
}) {
|
||||||
const isSubmitted = ["待审核", "已通过"].includes(assignment.status);
|
const isSubmitted = ["待审核", "已通过"].includes(assignment.status);
|
||||||
const isUsed = (item) => item.used || (isSubmitted && item.selected);
|
const isUsed = (item) => item.used || (isSubmitted && item.selected);
|
||||||
@ -231,7 +283,7 @@ function SelectQuestions({
|
|||||||
selected.includes(item.id),
|
selected.includes(item.id),
|
||||||
);
|
);
|
||||||
function toggle(id) {
|
function toggle(id) {
|
||||||
if (isUsed(assignment.questions.find((item) => item.id === id))) return;
|
if (expired || isUsed(assignment.questions.find((item) => item.id === id))) return;
|
||||||
setSelected((current) =>
|
setSelected((current) =>
|
||||||
current.includes(id)
|
current.includes(id)
|
||||||
? current.filter((value) => value !== id)
|
? current.filter((value) => value !== id)
|
||||||
@ -281,6 +333,7 @@ function SelectQuestions({
|
|||||||
请选择提问内容(共 {assignment.questions.length} 题)
|
请选择提问内容(共 {assignment.questions.length} 题)
|
||||||
</strong>
|
</strong>
|
||||||
<button
|
<button
|
||||||
|
disabled={expired}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSelected(
|
setSelected(
|
||||||
allSelected
|
allSelected
|
||||||
@ -302,7 +355,7 @@ function SelectQuestions({
|
|||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`${active ? "active" : ""} ${isUsed(item) ? "used" : ""}`}
|
className={`${active ? "active" : ""} ${isUsed(item) ? "used" : ""}`}
|
||||||
disabled={isUsed(item)}
|
disabled={expired || isUsed(item)}
|
||||||
onClick={() => toggle(item.id)}
|
onClick={() => toggle(item.id)}
|
||||||
>
|
>
|
||||||
<i>{active && <Check size={16} />}</i>
|
<i>{active && <Check size={16} />}</i>
|
||||||
@ -313,12 +366,18 @@ function SelectQuestions({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{expired && (
|
||||||
|
<div className="ask-assignment-expired-notice" role="alert">
|
||||||
|
领取超时,问题已自动释放,请点击“刷新重新分配”获取新任务。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<footer className="ask-user-sticky">
|
<footer className="ask-user-sticky">
|
||||||
<span>
|
<span>
|
||||||
已选择 <b>{selected.length}</b> / {assignment.questions.length} 题
|
已选择 <b>{selected.length}</b> / {assignment.questions.length} 题
|
||||||
|
{!expired && <AssignmentCountdown expiresAt={assignment.expires_at} onExpired={onExpired} />}
|
||||||
</span>
|
</span>
|
||||||
<button disabled={!selected.length || busy} onClick={onConfirm}>
|
<button disabled={busy || (!expired && !selected.length)} onClick={expired ? onRefresh : onConfirm}>
|
||||||
{busy ? "确认中…" : "确认"}
|
{busy ? "处理中…" : expired ? "刷新重新分配" : "确认"}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
@ -332,6 +391,7 @@ function ConfirmStage({
|
|||||||
taskNo,
|
taskNo,
|
||||||
onSubmitted,
|
onSubmitted,
|
||||||
setError,
|
setError,
|
||||||
|
onExpired,
|
||||||
}) {
|
}) {
|
||||||
const selectedQuestions = assignment.questions.filter(
|
const selectedQuestions = assignment.questions.filter(
|
||||||
(item) => item.selected,
|
(item) => item.selected,
|
||||||
@ -395,7 +455,8 @@ function ConfirmStage({
|
|||||||
onSubmitted(result.data);
|
onSubmitted(result.data);
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setReviewOpen(false);
|
setReviewOpen(false);
|
||||||
setError(requestError.message);
|
if (isExpiredAssignmentError(requestError.message)) onExpired();
|
||||||
|
else setError(requestError.message);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@ -503,6 +564,10 @@ function ConfirmStage({
|
|||||||
onChange={(event) => addFiles(event.target.files)}
|
onChange={(event) => addFiles(event.target.files)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
<div className="ask-upload-expiry-note">
|
||||||
|
<ShieldCheck size={14} />
|
||||||
|
<span>请在有效期内完成上传并提交<AssignmentCountdown expiresAt={assignment.expires_at} onExpired={onExpired} /></span>
|
||||||
|
</div>
|
||||||
<section className="ask-upload-card ask-payment-card">
|
<section className="ask-upload-card ask-payment-card">
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
@ -798,24 +863,28 @@ export default function AskUserPortal({ api }) {
|
|||||||
const [claimEdit, setClaimEdit] = useState(false);
|
const [claimEdit, setClaimEdit] = useState(false);
|
||||||
const [exhausted, setExhausted] = useState(false);
|
const [exhausted, setExhausted] = useState(false);
|
||||||
const [invalidTask, setInvalidTask] = useState("");
|
const [invalidTask, setInvalidTask] = useState("");
|
||||||
|
const [expiredAssignment, setExpiredAssignment] = useState(false);
|
||||||
|
const [selectionExpired, setSelectionExpired] = useState(false);
|
||||||
|
|
||||||
async function loadAssignment(silent = false) {
|
async function loadAssignment(silent = false, refresh = false) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const previousAssignmentId = assignment?.assignment_id;
|
const previousAssignmentId = assignment?.assignment_id;
|
||||||
if (!silent) setBusy(true);
|
if (!silent) setBusy(true);
|
||||||
try {
|
try {
|
||||||
const result = await api(
|
const result = await api(
|
||||||
`/api/ask/tasks/${encodeURIComponent(taskNo)}?token=${encodeURIComponent(session.token)}`,
|
`/api/ask/tasks/${encodeURIComponent(taskNo)}?token=${encodeURIComponent(session.token)}${refresh ? "&refresh=true" : ""}`,
|
||||||
);
|
);
|
||||||
setAssignment(result.data);
|
setAssignment(result.data);
|
||||||
setExhausted(false);
|
setExhausted(false);
|
||||||
setInvalidTask("");
|
setInvalidTask("");
|
||||||
|
setExpiredAssignment(false);
|
||||||
|
setSelectionExpired(false);
|
||||||
// Keep the current view. In particular, polling a pending review must
|
// 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
|
// not pull the user back to the audit screen after they chose to return
|
||||||
// to the claim page.
|
// to the claim page.
|
||||||
// Keep unsaved checkbox choices while a user is on the claim screen.
|
// Keep unsaved checkbox choices while a user is on the claim screen.
|
||||||
// If the five-hour reservation expires, the assignment id changes and
|
// If an expired reservation is refreshed, the fresh allocation is
|
||||||
// the fresh allocation is selected instead.
|
// selected instead of restoring the stale checkbox choices.
|
||||||
if (!silent || result.data.assignment_id !== previousAssignmentId || assignment?.status !== "待选择") {
|
if (!silent || result.data.assignment_id !== previousAssignmentId || assignment?.status !== "待选择") {
|
||||||
setSelected(
|
setSelected(
|
||||||
result.data.questions
|
result.data.questions
|
||||||
@ -836,6 +905,11 @@ export default function AskUserPortal({ api }) {
|
|||||||
setAssignment(null);
|
setAssignment(null);
|
||||||
setInvalidTask(requestError.message);
|
setInvalidTask(requestError.message);
|
||||||
setError("");
|
setError("");
|
||||||
|
} else if (isExpiredAssignmentError(requestError.message)) {
|
||||||
|
setAssignment(null);
|
||||||
|
setSelected([]);
|
||||||
|
setExpiredAssignment(true);
|
||||||
|
setError("");
|
||||||
} else {
|
} else {
|
||||||
setError(requestError.message);
|
setError(requestError.message);
|
||||||
}
|
}
|
||||||
@ -858,8 +932,8 @@ export default function AskUserPortal({ api }) {
|
|||||||
loadAssignment();
|
loadAssignment();
|
||||||
}, [session?.token, taskNo]);
|
}, [session?.token, taskNo]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!assignment || !["待审核", "待选择", "进行中"].includes(assignment.status)) return undefined;
|
if (!assignment || assignment.status !== "待审核") return undefined;
|
||||||
const interval = assignment.status === "待审核" ? 10000 : 60000;
|
const interval = 10000;
|
||||||
const timer = window.setInterval(() => loadAssignment(true), interval);
|
const timer = window.setInterval(() => loadAssignment(true), interval);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [assignment?.status, session?.token, taskNo]);
|
}, [assignment?.status, session?.token, taskNo]);
|
||||||
@ -870,12 +944,56 @@ export default function AskUserPortal({ api }) {
|
|||||||
setAssignment(null);
|
setAssignment(null);
|
||||||
setExhausted(false);
|
setExhausted(false);
|
||||||
setInvalidTask("");
|
setInvalidTask("");
|
||||||
|
setExpiredAssignment(false);
|
||||||
|
setSelectionExpired(false);
|
||||||
};
|
};
|
||||||
if (!session) return <PhoneLogin api={api} onLogin={setSession} />;
|
if (!session) return <TaobaoLogin api={api} onLogin={setSession} />;
|
||||||
if (exhausted)
|
if (exhausted)
|
||||||
return <TaskExhausted session={session} onLogout={logout} />;
|
return <TaskExhausted session={session} onLogout={logout} />;
|
||||||
if (invalidTask)
|
if (invalidTask)
|
||||||
return <InvalidTask message={invalidTask} onReload={() => loadAssignment()} />;
|
return <InvalidTask message={invalidTask} onReload={() => loadAssignment()} />;
|
||||||
|
if (expiredAssignment)
|
||||||
|
return <ExpiredAssignment session={session} onReload={refreshAssignment} />;
|
||||||
|
async function releaseAssignment() {
|
||||||
|
try {
|
||||||
|
await api(`/api/ask/tasks/${encodeURIComponent(taskNo)}/release`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ token: session.token }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// The server may already have released the reservation during its
|
||||||
|
// expiry sweep; a subsequent refresh remains safe in that case.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function expireSelection() {
|
||||||
|
if (selectionExpired) return;
|
||||||
|
void releaseAssignment();
|
||||||
|
setSelectionExpired(true);
|
||||||
|
setSelected([]);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
function expireUpload() {
|
||||||
|
if (expiredAssignment) return;
|
||||||
|
void releaseAssignment();
|
||||||
|
setExpiredAssignment(true);
|
||||||
|
setAssignment(null);
|
||||||
|
setSelected([]);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
async function refreshAssignment() {
|
||||||
|
await releaseAssignment();
|
||||||
|
setSelectionExpired(false);
|
||||||
|
setExpiredAssignment(false);
|
||||||
|
setAssignment(null);
|
||||||
|
await loadAssignment(false, true);
|
||||||
|
}
|
||||||
|
async function startNextSelection() {
|
||||||
|
setView("claim");
|
||||||
|
setSelected([]);
|
||||||
|
setNotice("");
|
||||||
|
await loadAssignment(false, true);
|
||||||
|
setClaimEdit(true);
|
||||||
|
}
|
||||||
async function confirm() {
|
async function confirm() {
|
||||||
if (!selected.length) return;
|
if (!selected.length) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@ -914,17 +1032,13 @@ export default function AskUserPortal({ api }) {
|
|||||||
view={view}
|
view={view}
|
||||||
onOpenHistory={loadHistory}
|
onOpenHistory={loadHistory}
|
||||||
onOpenClaim={() => {
|
onOpenClaim={() => {
|
||||||
|
if (assignment && (assignment.remaining_count ?? 0) > 0 && ["已通过", "已完成"].includes(assignment.status)) {
|
||||||
|
void startNextSelection();
|
||||||
|
} else {
|
||||||
setView("claim");
|
setView("claim");
|
||||||
// If a previous batch has completed and questions remain, opening
|
setClaimEdit(false);
|
||||||
// the claim tab should show the next selectable batch directly.
|
|
||||||
setClaimEdit(
|
|
||||||
Boolean(
|
|
||||||
assignment &&
|
|
||||||
(assignment.remaining_count ?? 0) > 0 &&
|
|
||||||
assignment.status === "已通过",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setNotice("");
|
setNotice("");
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{error && <div className="ask-user-error page">{error}</div>}
|
{error && <div className="ask-user-error page">{error}</div>}
|
||||||
@ -953,6 +1067,9 @@ export default function AskUserPortal({ api }) {
|
|||||||
setSelected={setSelected}
|
setSelected={setSelected}
|
||||||
onConfirm={confirm}
|
onConfirm={confirm}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
onExpired={expireSelection}
|
||||||
|
expired={selectionExpired}
|
||||||
|
onRefresh={refreshAssignment}
|
||||||
/>
|
/>
|
||||||
) : ["待审核", "已通过", "已完成"].includes(assignment.status) ? (
|
) : ["待审核", "已通过", "已完成"].includes(assignment.status) ? (
|
||||||
<AuditStatus
|
<AuditStatus
|
||||||
@ -972,11 +1089,7 @@ export default function AskUserPortal({ api }) {
|
|||||||
loadHistory();
|
loadHistory();
|
||||||
setNotice("本次任务您的认领额度已达上限,请关注下一次任务");
|
setNotice("本次任务您的认领额度已达上限,请关注下一次任务");
|
||||||
} else {
|
} else {
|
||||||
setView("claim");
|
void startNextSelection();
|
||||||
setClaimEdit(true);
|
|
||||||
setNotice(
|
|
||||||
`本次任务还可以继续选择 ${assignment.questions.length - selectedCount} 个问题`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -988,6 +1101,7 @@ export default function AskUserPortal({ api }) {
|
|||||||
taskNo={taskNo}
|
taskNo={taskNo}
|
||||||
onSubmitted={setAssignment}
|
onSubmitted={setAssignment}
|
||||||
setError={setError}
|
setError={setError}
|
||||||
|
onExpired={expireUpload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
export default function CreateAnswerTasksPageV2({ api, user }) {
|
export default function CreateAnswerTasksPageV2({ api, user }) {
|
||||||
const [records, setRecords] = useState([])
|
const [records, setRecords] = useState([])
|
||||||
@ -10,11 +10,25 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
|
|||||||
const [shopNames, setShopNames] = useState({})
|
const [shopNames, setShopNames] = useState({})
|
||||||
const [answerLink, setAnswerLink] = useState('')
|
const [answerLink, setAnswerLink] = useState('')
|
||||||
const [linkCopied, setLinkCopied] = useState(false)
|
const [linkCopied, setLinkCopied] = useState(false)
|
||||||
|
const editingAnswersRef = useRef(new Set())
|
||||||
|
const editedShopNamesRef = useRef(new Set())
|
||||||
|
const shopMemoryKey = `answer_last_shop_name_${user?.id || 'current'}`
|
||||||
|
const lastShopNameRef = useRef(window.localStorage.getItem(shopMemoryKey) || '')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
api('/api/admin/auth/answer-link').then(result => {
|
api('/api/admin/auth/answer-link').then(result => {
|
||||||
if (active && result.data?.path) setAnswerLink(new URL(result.data.path, window.location.origin).toString())
|
if (!active) return
|
||||||
|
if (result.data?.path) setAnswerLink(new URL(result.data.path, window.location.origin).toString())
|
||||||
|
const remembered = String(result.data?.last_shop_name || '').trim()
|
||||||
|
if (remembered) {
|
||||||
|
lastShopNameRef.current = remembered
|
||||||
|
window.localStorage.setItem(shopMemoryKey, remembered)
|
||||||
|
setShopNames(current => Object.fromEntries(Object.entries(current).map(([recordId, value]) => [
|
||||||
|
recordId,
|
||||||
|
!value && !editedShopNamesRef.current.has(Number(recordId)) ? remembered : value,
|
||||||
|
])))
|
||||||
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [api, user?.id])
|
}, [api, user?.id])
|
||||||
@ -41,6 +55,11 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
|
|||||||
...record,
|
...record,
|
||||||
questions: record.questions.map(question => ({
|
questions: record.questions.map(question => ({
|
||||||
...question,
|
...question,
|
||||||
|
// Keep an in-progress textarea edit while the 5-second polling
|
||||||
|
// request is returning the previous server value.
|
||||||
|
answer: editingAnswersRef.current.has(question.id)
|
||||||
|
? (previousQuestions.get(question.id)?.answer ?? question.answer)
|
||||||
|
: question.answer,
|
||||||
product_image: question.product_image || previousQuestions.get(question.id)?.product_image || null,
|
product_image: question.product_image || previousQuestions.get(question.id)?.product_image || null,
|
||||||
source_image: question.source_image || previousQuestions.get(question.id)?.source_image || null,
|
source_image: question.source_image || previousQuestions.get(question.id)?.source_image || null,
|
||||||
operator_images: question.operator_images?.length ? question.operator_images : (previousQuestions.get(question.id)?.operator_images || []),
|
operator_images: question.operator_images?.length ? question.operator_images : (previousQuestions.get(question.id)?.operator_images || []),
|
||||||
@ -50,7 +69,7 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
|
|||||||
setShopNames(current => {
|
setShopNames(current => {
|
||||||
const next = { ...current }
|
const next = { ...current }
|
||||||
result.data.forEach(record => {
|
result.data.forEach(record => {
|
||||||
if (next[record.id] === undefined) next[record.id] = record.shop_name || ''
|
if (next[record.id] === undefined) next[record.id] = record.shop_name || lastShopNameRef.current || ''
|
||||||
})
|
})
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
@ -79,7 +98,19 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
|
|||||||
setDetailBusy(record.id)
|
setDetailBusy(record.id)
|
||||||
try {
|
try {
|
||||||
const result = await api(`/api/admin/answer-task-drafts/${record.id}`)
|
const result = await api(`/api/admin/answer-task-drafts/${record.id}`)
|
||||||
setRecords(current => current.map(item => item.id === record.id ? result.data : item))
|
setRecords(current => current.map(item => {
|
||||||
|
if (item.id !== record.id) return item
|
||||||
|
const currentQuestions = new Map(item.questions.map(question => [question.id, question]))
|
||||||
|
return {
|
||||||
|
...result.data,
|
||||||
|
questions: result.data.questions.map(question => ({
|
||||||
|
...question,
|
||||||
|
answer: editingAnswersRef.current.has(question.id)
|
||||||
|
? (currentQuestions.get(question.id)?.answer ?? question.answer)
|
||||||
|
: question.answer,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@ -110,8 +141,10 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
|
|||||||
method: 'PATCH', body: JSON.stringify(patch),
|
method: 'PATCH', body: JSON.stringify(patch),
|
||||||
})
|
})
|
||||||
setQuestionLocal(question.id, { answer: result.data.answer, selected: result.data.selected })
|
setQuestionLocal(question.id, { answer: result.data.answer, selected: result.data.selected })
|
||||||
|
if (Object.prototype.hasOwnProperty.call(patch, 'answer')) editingAnswersRef.current.delete(question.id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setQuestionLocal(question.id, previous)
|
setQuestionLocal(question.id, previous)
|
||||||
|
if (Object.prototype.hasOwnProperty.call(patch, 'answer')) editingAnswersRef.current.delete(question.id)
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy('')
|
setBusy('')
|
||||||
@ -151,6 +184,12 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ shop_name: shopName }),
|
body: JSON.stringify({ shop_name: shopName }),
|
||||||
})
|
})
|
||||||
|
lastShopNameRef.current = shopName
|
||||||
|
window.localStorage.setItem(shopMemoryKey, shopName)
|
||||||
|
setShopNames(current => Object.fromEntries(Object.entries(current).map(([recordId, value]) => [
|
||||||
|
recordId,
|
||||||
|
!value && !editedShopNamesRef.current.has(Number(recordId)) ? shopName : value,
|
||||||
|
])))
|
||||||
await load()
|
await load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
@ -159,6 +198,16 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateShopName(recordId, value) {
|
||||||
|
editedShopNamesRef.current.add(recordId)
|
||||||
|
setShopNames(current => ({ ...current, [recordId]: value }))
|
||||||
|
const remembered = value.trim()
|
||||||
|
if (remembered) {
|
||||||
|
lastShopNameRef.current = remembered
|
||||||
|
window.localStorage.setItem(shopMemoryKey, remembered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function reject(record) {
|
async function reject(record) {
|
||||||
if (!record || busy) return
|
if (!record || busy) return
|
||||||
setBusy(record.id)
|
setBusy(record.id)
|
||||||
@ -174,6 +223,20 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function retryRecognition(record) {
|
||||||
|
if (!record || busy) return
|
||||||
|
setBusy(record.id)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await api(`/api/admin/answer-task-drafts/${record.id}/retry-recognition`, { method: 'POST' })
|
||||||
|
setRecords(current => current.map(item => item.id === record.id ? result.data : item))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadOperatorImage(question, event) {
|
async function uploadOperatorImage(question, event) {
|
||||||
const files = Array.from(event.target.files || [])
|
const files = Array.from(event.target.files || [])
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
@ -212,16 +275,18 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
|
|||||||
const selected = record.questions.filter(question => question.selected).length
|
const selected = record.questions.filter(question => question.selected).length
|
||||||
const allSelected = record.questions.length > 0 && selected === record.questions.length
|
const allSelected = record.questions.length > 0 && selected === record.questions.length
|
||||||
const identifying = record.questions.length === 0 && ['待筛选', '待运营派发'].includes(record.status)
|
const identifying = record.questions.length === 0 && ['待筛选', '待运营派发'].includes(record.status)
|
||||||
|
const recognitionFailed = record.status === '识别失败' || record.recognition_failed
|
||||||
const open = expanded[record.id]
|
const open = expanded[record.id]
|
||||||
return <article className="answer-draft-group panel" key={record.id}>
|
return <article className="answer-draft-group panel" key={record.id}>
|
||||||
<header className="draft-group-header">
|
<header className="draft-group-header">
|
||||||
<button className="draft-expand" disabled={identifying || detailBusy === record.id} onClick={() => toggleOpen(record)}>{open ? '⌄' : '›'}</button>
|
<button className="draft-expand" disabled={identifying || detailBusy === record.id} onClick={() => toggleOpen(record)}>{open ? '⌄' : '›'}</button>
|
||||||
<label className="draft-group-select"><input className="task-checkbox" type="checkbox" checked={allSelected} disabled={locked || identifying} onChange={event => toggleMany(record.questions.map(question => ({ ...question, locked })), event.target.checked)}/></label>
|
<label className="draft-group-select"><input className="task-checkbox" type="checkbox" checked={allSelected} disabled={locked || identifying} onChange={event => toggleMany(record.questions.map(question => ({ ...question, locked })), event.target.checked)}/></label>
|
||||||
<div className="draft-user-meta"><strong>{record.user_id}</strong><span>用户_{record.user_id.slice(1)}</span><code>{record.submission_no}</code><em>已选 {selected}/{record.questions.length} 题</em></div>
|
<div className="draft-user-meta"><strong>淘宝ID:{record.user_id}</strong><code>{record.submission_no}</code><em>已选 {selected}/{record.questions.length} 题</em></div>
|
||||||
<label className="draft-shop-field"><span>店铺名<span className="required-mark">*</span></span><input value={shopNames[record.id] ?? record.shop_name ?? ''} disabled={locked || identifying} onChange={event => setShopNames(current => ({ ...current, [record.id]: event.target.value }))} placeholder="请输入店铺名" maxLength={255}/></label>
|
<label className="draft-shop-field"><span>店铺名<span className="required-mark">*</span></span><input value={shopNames[record.id] ?? record.shop_name ?? ''} disabled={locked || identifying || recognitionFailed} onChange={event => updateShopName(record.id, event.target.value)} placeholder="请输入店铺名" maxLength={255}/></label>
|
||||||
{identifying && <span className="draft-identifying" role="status">正在识别,请等待…</span>}
|
{identifying && <span className="draft-identifying" role="status">正在识别,请等待…</span>}
|
||||||
<button className="draft-fold" disabled={identifying || detailBusy === record.id} onClick={() => toggleOpen(record)}>{detailBusy === record.id ? '正在加载图片…' : open ? '点击折叠' : '点击展开'}</button>
|
{recognitionFailed && <span className="draft-recognition-failed" role="alert">识别失败,请重新识别</span>}
|
||||||
<button className="primary-button" disabled={!!busy || locked || identifying} onClick={() => publish(record)}>{locked ? '已派发' : identifying ? '识别中…' : '生成任务'}</button>
|
<button className="draft-fold" disabled={identifying || recognitionFailed || detailBusy === record.id} onClick={() => toggleOpen(record)}>{detailBusy === record.id ? '正在加载图片…' : open ? '点击折叠' : '点击展开'}</button>
|
||||||
|
<button className="primary-button" disabled={!!busy || locked || identifying} onClick={() => recognitionFailed ? retryRecognition(record) : publish(record)}>{busy === record.id && recognitionFailed ? '重新识别中…' : locked ? '已派发' : identifying ? '识别中…' : recognitionFailed ? '重新识别' : '生成任务'}</button>
|
||||||
<button className="draft-reject-button" disabled={!!busy || locked || identifying} onClick={() => setRejecting(record)}>打回</button>
|
<button className="draft-reject-button" disabled={!!busy || locked || identifying} onClick={() => setRejecting(record)}>打回</button>
|
||||||
</header>
|
</header>
|
||||||
{open && record.questions.map(question => <React.Fragment key={question.id}><section className={`answer-draft-question ${question.selected ? 'selected' : ''}`}>
|
{open && record.questions.map(question => <React.Fragment key={question.id}><section className={`answer-draft-question ${question.selected ? 'selected' : ''}`}>
|
||||||
@ -230,10 +295,10 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
|
|||||||
<div className="draft-question-title"><h3>Q{question.sequence_no}:{question.question}</h3></div>
|
<div className="draft-question-title"><h3>Q{question.sequence_no}:{question.question}</h3></div>
|
||||||
<div className="answer-product-line">
|
<div className="answer-product-line">
|
||||||
{question.product_image && <img src={question.product_image} alt="商品主图"/>}
|
{question.product_image && <img src={question.product_image} alt="商品主图"/>}
|
||||||
<span><b>{question.product_title}</b><small>来源:模拟OCR识别</small></span>
|
<span><b>{question.product_title}</b><small>来源:图片OCR识别</small></span>
|
||||||
{question.source_image && <div className="source-image-wrap"><small>识图原始截图:</small><img className="source-shot" src={question.source_image} alt="识别原始截图"/></div>}
|
{question.source_image && <div className="source-image-wrap"><small>识图原始截图:</small><img className="source-shot" src={question.source_image} alt="识别原始截图"/></div>}
|
||||||
</div>
|
</div>
|
||||||
<textarea value={question.answer} disabled={locked} onChange={event => setQuestionLocal(question.id, { answer: event.target.value })} onBlur={event => updateQuestion({ ...question, answer: event.target.value }, { answer: event.target.value })}/>
|
<textarea value={question.answer} disabled={locked} onChange={event => { editingAnswersRef.current.add(question.id); setQuestionLocal(question.id, { answer: event.target.value }) }} onBlur={event => updateQuestion({ ...question, answer: event.target.value }, { answer: event.target.value })}/>
|
||||||
</div>
|
</div>
|
||||||
<button className="draft-icon-button" title="重新生成回答" disabled={!!busy || locked} onClick={() => updateQuestion(question, { regenerate: true })}>↻</button>
|
<button className="draft-icon-button" title="重新生成回答" disabled={!!busy || locked} onClick={() => updateQuestion(question, { regenerate: true })}>↻</button>
|
||||||
</section><div className="operator-image-panel">
|
</section><div className="operator-image-panel">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Check, Link2, Plus, Sparkles, X } from "lucide-react";
|
import { Check, Copy, FileUp, Link2, Plus, Sparkles, X } from "lucide-react";
|
||||||
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
|
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
|
||||||
import { clearPageCache, readPageCache, writePageCache } from "./pageCache.js";
|
import { clearPageCache, readPageCache, writePageCache } from "./pageCache.js";
|
||||||
|
|
||||||
@ -20,7 +20,7 @@ const blankForm = {
|
|||||||
claim_questions_per_user: "",
|
claim_questions_per_user: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ADDED_QUESTION_SOURCES = new Set(["自主添加", "问答库"]);
|
const ADDED_QUESTION_SOURCES = new Set(["自主添加", "问答库", "批量导入"]);
|
||||||
|
|
||||||
function questionKey(value) {
|
function questionKey(value) {
|
||||||
return String(value || "").trim().replace(/\s+/g, " ");
|
return String(value || "").trim().replace(/\s+/g, " ");
|
||||||
@ -71,6 +71,7 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
const [referenceFileName, setReferenceFileName] = useState(cached.referenceFileName || "");
|
const [referenceFileName, setReferenceFileName] = useState(cached.referenceFileName || "");
|
||||||
const [referenceQa, setReferenceQa] = useState(cached.referenceQa || "");
|
const [referenceQa, setReferenceQa] = useState(cached.referenceQa || "");
|
||||||
const [addDialog, setAddDialog] = useState(null);
|
const [addDialog, setAddDialog] = useState(null);
|
||||||
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
const processingLibrarySelection = useRef(false);
|
const processingLibrarySelection = useRef(false);
|
||||||
const consumedLibrarySelectionKey = useRef("");
|
const consumedLibrarySelectionKey = useRef("");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -152,6 +153,22 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
if (productQrInputRef.current) productQrInputRef.current.value = "";
|
if (productQrInputRef.current) productQrInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
if (busy) return;
|
||||||
|
clearPageCache("create-ask-task");
|
||||||
|
setForm({ ...blankForm });
|
||||||
|
setTask(null);
|
||||||
|
setQuestions([]);
|
||||||
|
setEditing(null);
|
||||||
|
setError("");
|
||||||
|
setReferenceFile(null);
|
||||||
|
setReferenceFileName("");
|
||||||
|
setReferenceQa("");
|
||||||
|
setAddDialog(null);
|
||||||
|
if (referenceInputRef.current) referenceInputRef.current.value = "";
|
||||||
|
if (productQrInputRef.current) productQrInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
if (!form.shop_name.trim()) {
|
if (!form.shop_name.trim()) {
|
||||||
setError("请填写店铺名称");
|
setError("请填写店铺名称");
|
||||||
@ -202,11 +219,20 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
setTask(result.data);
|
setTask(result.data);
|
||||||
const generatedQuestions = result.data.selected_questions.map((item) => ({ ...item, selected: true }));
|
const generatedQuestions = result.data.selected_questions.map((item) => ({ ...item, selected: true }));
|
||||||
if (!draftTaskId && pendingQuestions.length) {
|
if (!draftTaskId && pendingQuestions.length) {
|
||||||
|
const importedQuestions = pendingQuestions.filter((item) => item.source === "批量导入");
|
||||||
|
const individualQuestions = pendingQuestions.filter((item) => item.source !== "批量导入");
|
||||||
|
if (importedQuestions.length) {
|
||||||
|
const imported = await api(`/api/admin/ask-tasks/${result.data.id}/questions/bulk`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ questions: importedQuestions.map((item) => item.question) }),
|
||||||
|
});
|
||||||
|
generatedQuestions.push(...imported.data.items);
|
||||||
|
}
|
||||||
const added = [];
|
const added = [];
|
||||||
for (const item of pendingQuestions) {
|
for (const item of individualQuestions) {
|
||||||
added.push(await api(`/api/admin/ask-tasks/${result.data.id}/questions`, { method: "POST", body: JSON.stringify({ question: item.question, source: item.source }) }));
|
added.push(await api(`/api/admin/ask-tasks/${result.data.id}/questions`, { method: "POST", body: JSON.stringify({ question: item.question, source: item.source }) }));
|
||||||
}
|
}
|
||||||
generatedQuestions.push(...added.map((item, index) => ({ ...item.data, source: pendingQuestions[index].source })));
|
generatedQuestions.push(...added.map((item, index) => ({ ...item.data, source: individualQuestions[index].source })));
|
||||||
}
|
}
|
||||||
const priorSourceByQuestion = new Map(questions.map((item) => [item.question, item.source]));
|
const priorSourceByQuestion = new Map(questions.map((item) => [item.question, item.source]));
|
||||||
setQuestions(dedupeQuestions(generatedQuestions.map((item) => ({ ...item, source: item.source || priorSourceByQuestion.get(item.question) || "AI生成" }))));
|
setQuestions(dedupeQuestions(generatedQuestions.map((item) => ({ ...item, source: item.source || priorSourceByQuestion.get(item.question) || "AI生成" }))));
|
||||||
@ -344,6 +370,46 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
setError(err.message);
|
setError(err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function addImportedQuestions(values = []) {
|
||||||
|
const existingTexts = new Set(questions.map((item) => questionKey(item.question)));
|
||||||
|
const selected = dedupeQuestions(
|
||||||
|
values
|
||||||
|
.map((value) => ({ question: String(value || "").trim() }))
|
||||||
|
.filter((item) => item.question && !existingTexts.has(questionKey(item.question))),
|
||||||
|
);
|
||||||
|
if (!selected.length) throw new Error("文件中的问题都已存在,无需重复导入");
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
if (!task) {
|
||||||
|
const importedAt = Date.now();
|
||||||
|
setQuestions((current) => dedupeQuestions([
|
||||||
|
...current,
|
||||||
|
...selected.map((item, index) => ({
|
||||||
|
id: `new-import-${importedAt}-${index}`,
|
||||||
|
question: item.question,
|
||||||
|
selected: true,
|
||||||
|
source: "批量导入",
|
||||||
|
})),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
const result = await api(`/api/admin/ask-tasks/${task.id}/questions/bulk`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ questions: selected.map((item) => item.question) }),
|
||||||
|
});
|
||||||
|
setQuestions((current) => dedupeQuestions([
|
||||||
|
...current,
|
||||||
|
...result.data.items.map((item) => ({ ...item, source: item.source || "批量导入" })),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
setAddDialog(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "批量导入失败");
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
async function publish() {
|
async function publish() {
|
||||||
if (!task) return setError("请先生成提问");
|
if (!task) return setError("请先生成提问");
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@ -364,6 +430,28 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyTaskLink() {
|
||||||
|
const taskLink = `${window.location.origin}/ask/${task.public_token}`;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(taskLink);
|
||||||
|
} else {
|
||||||
|
const input = document.createElement("textarea");
|
||||||
|
input.value = taskLink;
|
||||||
|
input.style.position = "fixed";
|
||||||
|
input.style.opacity = "0";
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
input.remove();
|
||||||
|
}
|
||||||
|
setLinkCopied(true);
|
||||||
|
window.setTimeout(() => setLinkCopied(false), 1600);
|
||||||
|
} catch {
|
||||||
|
setError("复制失败,请手动选择链接复制");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (task?.status === "已发布" || task?.status_label === "已发布")
|
if (task?.status === "已发布" || task?.status_label === "已发布")
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -390,9 +478,15 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
<Link2 size={14} />
|
<Link2 size={14} />
|
||||||
提问任务链接
|
提问任务链接
|
||||||
</span>
|
</span>
|
||||||
|
<div className="ask-link-value">
|
||||||
<code>
|
<code>
|
||||||
{window.location.origin}/ask/{task.public_token}
|
{window.location.origin}/ask/{task.public_token}
|
||||||
</code>
|
</code>
|
||||||
|
<button type="button" onClick={copyTaskLink}>
|
||||||
|
{linkCopied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
{linkCopied ? "已复制" : "复制链接"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<small>该随机链接用于用户领取任务,请勿通过任务ID拼接访问。</small>
|
<small>该随机链接用于用户领取任务,请勿通过任务ID拼接访问。</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -451,6 +545,7 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
<div className="panel-title">
|
<div className="panel-title">
|
||||||
<Sparkles size={17} />
|
<Sparkles size={17} />
|
||||||
生成条件配置 <small>* 店铺名称必填,淘口令和二维码至少填写一种</small>
|
生成条件配置 <small>* 店铺名称必填,淘口令和二维码至少填写一种</small>
|
||||||
|
<button type="button" className="ask-clear-all" disabled={busy} onClick={clearAll}>一键清空</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="ask-product-token-field">
|
<div className="ask-product-token-field">
|
||||||
<label>
|
<label>
|
||||||
@ -734,11 +829,13 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
<div className="ask-add-options">
|
<div className="ask-add-options">
|
||||||
<button type="button" onClick={() => setAddDialog("manual")}>自主添加<span>手动输入问题</span></button>
|
<button type="button" onClick={() => setAddDialog("manual")}>自主添加<span>手动输入问题</span></button>
|
||||||
<button type="button" onClick={openLibrary}>问答库添加<span>进入问答库搜索并勾选</span></button>
|
<button type="button" onClick={openLibrary}>问答库添加<span>进入问答库搜索并勾选</span></button>
|
||||||
|
<button type="button" onClick={() => setAddDialog("import")}>批量导入<span>从本地文件导入问题</span></button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{addDialog === "manual" && <ManualAskQuestionModal onClose={() => setAddDialog(null)} onAdd={addManualQuestion} />}
|
{addDialog === "manual" && <ManualAskQuestionModal onClose={() => setAddDialog(null)} onAdd={addManualQuestion} />}
|
||||||
|
{addDialog === "import" && <ImportAskQuestionsModal api={api} onClose={() => setAddDialog(null)} onImport={addImportedQuestions} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -747,3 +844,78 @@ function ManualAskQuestionModal({ onClose, onAdd }) {
|
|||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
return <div className="manual-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && onClose()}><form className="manual-modal ask-manual-dialog" onSubmit={(event) => { event.preventDefault(); onAdd(value); }}><div className="manual-modal-header"><h3>自主添加提问</h3><button type="button" onClick={onClose}><X size={18} /></button></div><label>问题内容<textarea autoFocus value={value} onChange={(event) => setValue(event.target.value)} placeholder="请输入要加入任务的问题" maxLength={500} /></label><div className="manual-modal-actions"><button type="button" onClick={onClose}>取消</button><button type="submit" className="primary-button" disabled={!value.trim()}>添加问题</button></div></form></div>;
|
return <div className="manual-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && onClose()}><form className="manual-modal ask-manual-dialog" onSubmit={(event) => { event.preventDefault(); onAdd(value); }}><div className="manual-modal-header"><h3>自主添加提问</h3><button type="button" onClick={onClose}><X size={18} /></button></div><label>问题内容<textarea autoFocus value={value} onChange={(event) => setValue(event.target.value)} placeholder="请输入要加入任务的问题" maxLength={500} /></label><div className="manual-modal-actions"><button type="button" onClick={onClose}>取消</button><button type="submit" className="primary-button" disabled={!value.trim()}>添加问题</button></div></form></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ImportAskQuestionsModal({ api, onClose, onImport }) {
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const [file, setFile] = useState(null);
|
||||||
|
const [questions, setQuestions] = useState([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
async function selectFile(nextFile) {
|
||||||
|
setFile(nextFile || null);
|
||||||
|
setQuestions([]);
|
||||||
|
setError("");
|
||||||
|
if (!nextFile) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", nextFile);
|
||||||
|
const result = await api("/api/admin/ask-questions/import", { method: "POST", body });
|
||||||
|
setQuestions(result.data.questions || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "文件解析失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function submit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!questions.length || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await onImport(questions);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "批量导入失败");
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="manual-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && !busy && onClose()}>
|
||||||
|
<form className="manual-modal ask-import-dialog" onSubmit={submit}>
|
||||||
|
<div className="manual-modal-header">
|
||||||
|
<h3>批量导入提问</h3>
|
||||||
|
<button type="button" disabled={busy} onClick={onClose}><X size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<p>上传本地问题文件,识别后将追加到当前待发布列表。</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
hidden
|
||||||
|
type="file"
|
||||||
|
accept=".txt,.csv,.json,.md,.xlsx,text/plain,text/csv,application/json,text/markdown,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
onChange={(event) => selectFile(event.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
<button type="button" className="ask-import-picker" disabled={busy} onClick={() => inputRef.current?.click()}>
|
||||||
|
<FileUp size={22} />
|
||||||
|
<span>{file ? file.name : "选择本地文件"}<small>支持 TXT、CSV、JSON、Markdown、XLSX,最大 5MB</small></span>
|
||||||
|
</button>
|
||||||
|
<small className="ask-import-rule">TXT、Markdown 每行一个问题;表格文件优先读取“问题”列。</small>
|
||||||
|
{busy && !questions.length && <div className="ask-import-status">正在解析文件…</div>}
|
||||||
|
{error && <div className="form-error">{error}</div>}
|
||||||
|
{questions.length > 0 && (
|
||||||
|
<div className="ask-import-preview">
|
||||||
|
<header><strong>识别到 {questions.length} 条问题</strong><span>导入时自动排除重复项</span></header>
|
||||||
|
<ol>
|
||||||
|
{questions.slice(0, 8).map((question, index) => <li key={`${question}-${index}`}>{question}</li>)}
|
||||||
|
</ol>
|
||||||
|
{questions.length > 8 && <small>还有 {questions.length - 8} 条未展开显示</small>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="manual-modal-actions">
|
||||||
|
<button type="button" disabled={busy} onClick={onClose}>取消</button>
|
||||||
|
<button type="submit" className="primary-button" disabled={!questions.length || busy}>{busy ? "导入中…" : `导入 ${questions.length || 0} 条问题`}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { MessageCircleMore, RefreshCw, X } from "lucide-react";
|
import { ChevronRight, LoaderCircle, MessageCircleMore, RefreshCw, X } from "lucide-react";
|
||||||
|
|
||||||
const REASONS = [
|
const REASONS = [
|
||||||
"内容不清晰",
|
"内容不清晰",
|
||||||
@ -22,7 +22,40 @@ const StatusBadge = ({ status }) => (
|
|||||||
<em className={`review-v2-status ${status}`}>{status}</em>
|
<em className={`review-v2-status ${status}`}>{status}</em>
|
||||||
);
|
);
|
||||||
|
|
||||||
function RefundPrompt({ onConfirm, onCancel }) {
|
const reviewKind = (record) => record?.batch_id ? "batch" : "assignment";
|
||||||
|
const reviewId = (record) => record?.batch_id || record?.assignment_id || record?.id;
|
||||||
|
const reviewKey = (record) => `${reviewKind(record)}:${reviewId(record)}`;
|
||||||
|
const reviewEndpoint = (record) => reviewKind(record) === "batch"
|
||||||
|
? `/api/admin/ask-batch-reviews/${reviewId(record)}`
|
||||||
|
: `/api/admin/ask-assignment-reviews/${reviewId(record)}`;
|
||||||
|
|
||||||
|
function DetailLoadingModal({ title }) {
|
||||||
|
return (
|
||||||
|
<div className="review-v2-backdrop review-detail-loading-backdrop">
|
||||||
|
<section className="review-detail-loading" role="status" aria-live="polite">
|
||||||
|
<LoaderCircle size={24} className="spin" />
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<span>请求已收到,图片将在弹窗内按需加载…</span>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NextReviewButton({ hasNext, onNext, busy, label = "下一个审核任务" }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="review-next-button"
|
||||||
|
disabled={!hasNext || busy}
|
||||||
|
onClick={onNext}
|
||||||
|
>
|
||||||
|
{busy ? "正在打开…" : hasNext ? label : "已是最后一个"}
|
||||||
|
{hasNext && !busy && <ChevronRight size={15} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RefundPrompt({ onConfirm, onCancel, onNext, hasNext }) {
|
||||||
return (
|
return (
|
||||||
<div className="review-v2-backdrop refund-prompt-backdrop">
|
<div className="review-v2-backdrop refund-prompt-backdrop">
|
||||||
<section className="refund-prompt" role="dialog" aria-modal="true" aria-labelledby="refund-prompt-title">
|
<section className="refund-prompt" role="dialog" aria-modal="true" aria-labelledby="refund-prompt-title">
|
||||||
@ -33,6 +66,12 @@ function RefundPrompt({ onConfirm, onCancel }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="refund-prompt-actions">
|
<div className="refund-prompt-actions">
|
||||||
<button type="button" className="refund-prompt-cancel" onClick={onCancel}>暂不前往</button>
|
<button type="button" className="refund-prompt-cancel" onClick={onCancel}>暂不前往</button>
|
||||||
|
{hasNext && (
|
||||||
|
<button type="button" className="refund-prompt-next" onClick={onNext}>
|
||||||
|
继续下一个审核
|
||||||
|
<ChevronRight size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" className="refund-prompt-confirm" onClick={onConfirm}>前往待返款</button>
|
<button type="button" className="refund-prompt-confirm" onClick={onConfirm}>前往待返款</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -40,7 +79,7 @@ function RefundPrompt({ onConfirm, onCancel }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReviewDetail({ record, close, decide, busy }) {
|
function ReviewDetail({ record, close, decide, busy, hasNext, onNext, nextBusy }) {
|
||||||
const [reason, setReason] = useState(REASONS[0]);
|
const [reason, setReason] = useState(REASONS[0]);
|
||||||
const [customReason, setCustomReason] = useState("");
|
const [customReason, setCustomReason] = useState("");
|
||||||
const selected = record.questions.filter((q) => q.selected);
|
const selected = record.questions.filter((q) => q.selected);
|
||||||
@ -92,6 +131,8 @@ function ReviewDetail({ record, close, decide, busy }) {
|
|||||||
<img
|
<img
|
||||||
src={image.data_url}
|
src={image.data_url}
|
||||||
alt={`任务截图${index + 1}`}
|
alt={`任务截图${index + 1}`}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<figcaption>任务截图 {index + 1}</figcaption>
|
<figcaption>任务截图 {index + 1}</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
@ -107,6 +148,8 @@ function ReviewDetail({ record, close, decide, busy }) {
|
|||||||
<img
|
<img
|
||||||
src={image.data_url}
|
src={image.data_url}
|
||||||
alt="收款码"
|
alt="收款码"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<figcaption>审核通过后用于后续人工处理</figcaption>
|
<figcaption>审核通过后用于后续人工处理</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
@ -157,6 +200,7 @@ function ReviewDetail({ record, close, decide, busy }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<NextReviewButton hasNext={hasNext} onNext={onNext} busy={nextBusy || busy} />
|
||||||
<button
|
<button
|
||||||
className="reject"
|
className="reject"
|
||||||
disabled={busy || (reason === "其他" && !customReason.trim())}
|
disabled={busy || (reason === "其他" && !customReason.trim())}
|
||||||
@ -178,6 +222,7 @@ function ReviewDetail({ record, close, decide, busy }) {
|
|||||||
该任务已完成审核,当前状态:
|
该任务已完成审核,当前状态:
|
||||||
<StatusBadge status={record.status} />
|
<StatusBadge status={record.status} />
|
||||||
</span>
|
</span>
|
||||||
|
<NextReviewButton hasNext={hasNext} onNext={onNext} busy={nextBusy} />
|
||||||
<button onClick={close}>关闭</button>
|
<button onClick={close}>关闭</button>
|
||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
@ -186,7 +231,7 @@ function ReviewDetail({ record, close, decide, busy }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AskReviewDetail({ record, close, onChanged, api, onApproved }) {
|
function AskReviewDetail({ record, close, api, onDecision, hasNext, onNext, nextBusy }) {
|
||||||
const [reason, setReason] = useState("截图不清晰");
|
const [reason, setReason] = useState("截图不清晰");
|
||||||
const [reasonText, setReasonText] = useState("");
|
const [reasonText, setReasonText] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@ -194,8 +239,8 @@ function AskReviewDetail({ record, close, onChanged, api, onApproved }) {
|
|||||||
async function decide(approved) {
|
async function decide(approved) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api(
|
const result = await api(
|
||||||
`/api/admin/ask-completion-reviews/${record.batch_id || record.assignment_id}/decision`,
|
`${reviewEndpoint(record)}/decision`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -205,10 +250,12 @@ function AskReviewDetail({ record, close, onChanged, api, onApproved }) {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
close();
|
onDecision({
|
||||||
await onChanged();
|
...record,
|
||||||
window.dispatchEvent(new Event("review-count-changed"));
|
...result.data,
|
||||||
if (approved) onApproved();
|
question_screenshots: record.question_screenshots || [],
|
||||||
|
payment_screenshots: record.payment_screenshots || [],
|
||||||
|
}, approved);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(error.message);
|
window.alert(error.message);
|
||||||
} finally {
|
} finally {
|
||||||
@ -258,6 +305,8 @@ function AskReviewDetail({ record, close, onChanged, api, onApproved }) {
|
|||||||
<img
|
<img
|
||||||
src={image.data_url}
|
src={image.data_url}
|
||||||
alt={`提问截图${index + 1}`}
|
alt={`提问截图${index + 1}`}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<figcaption>提问截图 {index + 1}</figcaption>
|
<figcaption>提问截图 {index + 1}</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
@ -272,6 +321,8 @@ function AskReviewDetail({ record, close, onChanged, api, onApproved }) {
|
|||||||
<img
|
<img
|
||||||
src={image.data_url}
|
src={image.data_url}
|
||||||
alt="收款码"
|
alt="收款码"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<figcaption>收款码截图</figcaption>
|
<figcaption>收款码截图</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
@ -318,6 +369,7 @@ function AskReviewDetail({ record, close, onChanged, api, onApproved }) {
|
|||||||
placeholder="补充说明(选填)"
|
placeholder="补充说明(选填)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<NextReviewButton hasNext={hasNext} onNext={onNext} busy={nextBusy || busy} />
|
||||||
<button
|
<button
|
||||||
className="reject"
|
className="reject"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@ -339,6 +391,7 @@ function AskReviewDetail({ record, close, onChanged, api, onApproved }) {
|
|||||||
当前审核状态:
|
当前审核状态:
|
||||||
<StatusBadge status={record.status} />
|
<StatusBadge status={record.status} />
|
||||||
</span>
|
</span>
|
||||||
|
<NextReviewButton hasNext={hasNext} onNext={onNext} busy={nextBusy} />
|
||||||
<button onClick={close}>关闭</button>
|
<button onClick={close}>关闭</button>
|
||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
@ -351,25 +404,61 @@ function AskReviewQueue({ api, onApproved }) {
|
|||||||
const [records, setRecords] = useState([]);
|
const [records, setRecords] = useState([]);
|
||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(null);
|
||||||
|
const [nextRecord, setNextRecord] = useState(null);
|
||||||
|
const detailCache = useRef(new Map());
|
||||||
async function openDetail(record) {
|
async function openDetail(record) {
|
||||||
|
if (!record || detailLoading) return;
|
||||||
|
const key = reviewKey(record);
|
||||||
|
const index = records.findIndex((item) => reviewKey(item) === key);
|
||||||
|
const nextCandidate = index >= 0 ? records[index + 1] || null : null;
|
||||||
|
setNextRecord(nextCandidate);
|
||||||
|
const cached = detailCache.current.get(key);
|
||||||
|
if (cached) {
|
||||||
|
setDetail(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDetailLoading(key);
|
||||||
try {
|
try {
|
||||||
const result = await api(
|
const result = await api(
|
||||||
`/api/admin/ask-completion-reviews/${record.batch_id || record.assignment_id}`,
|
reviewEndpoint(record),
|
||||||
);
|
);
|
||||||
|
detailCache.current.set(key, result.data);
|
||||||
setDetail(result.data);
|
setDetail(result.data);
|
||||||
|
if (nextCandidate && !detailCache.current.has(reviewKey(nextCandidate))) {
|
||||||
|
void api(reviewEndpoint(nextCandidate))
|
||||||
|
.then((nextResult) => detailCache.current.set(reviewKey(nextCandidate), nextResult.data))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(error.message);
|
window.alert(error.message);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function load() {
|
async function load(showLoading = true) {
|
||||||
setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await api("/api/admin/ask-completion-reviews");
|
const result = await api("/api/admin/ask-completion-reviews");
|
||||||
setRecords(result.data);
|
setRecords(result.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(error.message);
|
window.alert(error.message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (showLoading) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleDecision(updated, approved) {
|
||||||
|
const key = reviewKey(updated);
|
||||||
|
detailCache.current.set(key, updated);
|
||||||
|
setDetail(updated);
|
||||||
|
setRecords((current) => current.filter((item) => reviewKey(item) !== key));
|
||||||
|
window.dispatchEvent(new Event("review-count-changed"));
|
||||||
|
void load(false);
|
||||||
|
if (approved) {
|
||||||
|
onApproved({
|
||||||
|
hasNext: Boolean(nextRecord),
|
||||||
|
onNext: () => openDetail(nextRecord),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -398,9 +487,7 @@ function AskReviewQueue({ api, onApproved }) {
|
|||||||
<code>{record.user_id}</code>
|
<code>{record.user_id}</code>
|
||||||
<code>{record.task_id}</code>
|
<code>{record.task_id}</code>
|
||||||
</b>
|
</b>
|
||||||
<small>
|
{(record.taobao_id || record.phone) !== record.user_id && <small>{record.taobao_id || record.phone}</small>}
|
||||||
{record.phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")}
|
|
||||||
</small>
|
|
||||||
</span>
|
</span>
|
||||||
<span>{record.product_name}</span>
|
<span>{record.product_name}</span>
|
||||||
<span>{formatTime(record.updated_at)}</span>
|
<span>{formatTime(record.updated_at)}</span>
|
||||||
@ -414,21 +501,27 @@ function AskReviewQueue({ api, onApproved }) {
|
|||||||
<StatusBadge status={record.status} />
|
<StatusBadge status={record.status} />
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<button onClick={() => openDetail(record)}>
|
<button disabled={Boolean(detailLoading)} onClick={() => openDetail(record)}>
|
||||||
{record.status === "待审核" ? "查看并审核" : "查看详情"}
|
{detailLoading === reviewKey(record)
|
||||||
|
? "正在打开…"
|
||||||
|
: record.status === "待审核" ? "查看并审核" : "查看详情"}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{detailLoading && !detail && <DetailLoadingModal title="正在打开提问审核详情" />}
|
||||||
{detail && (
|
{detail && (
|
||||||
<AskReviewDetail
|
<AskReviewDetail
|
||||||
|
key={reviewKey(detail)}
|
||||||
record={detail}
|
record={detail}
|
||||||
close={() => setDetail(null)}
|
close={() => setDetail(null)}
|
||||||
onChanged={load}
|
|
||||||
api={api}
|
api={api}
|
||||||
onApproved={onApproved}
|
onDecision={handleDecision}
|
||||||
|
hasNext={Boolean(nextRecord)}
|
||||||
|
nextBusy={Boolean(detailLoading)}
|
||||||
|
onNext={() => openDetail(nextRecord)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -443,25 +536,42 @@ export default function ReviewCenterPage({ api }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(null);
|
||||||
const [refundPrompt, setRefundPrompt] = useState(false);
|
const [nextRecord, setNextRecord] = useState(null);
|
||||||
|
const [refundPrompt, setRefundPrompt] = useState(null);
|
||||||
const loadVersion = useRef(0);
|
const loadVersion = useRef(0);
|
||||||
|
const detailCache = useRef(new Map());
|
||||||
async function openAnswerDetail(record) {
|
async function openAnswerDetail(record) {
|
||||||
setDetailLoading(true);
|
if (!record || detailLoading) return;
|
||||||
|
const index = allRows.findIndex((item) => item.id === record.id);
|
||||||
|
const nextCandidate = index >= 0 ? allRows[index + 1] || null : null;
|
||||||
|
setNextRecord(nextCandidate);
|
||||||
|
const cached = detailCache.current.get(record.id);
|
||||||
|
if (cached) {
|
||||||
|
setDetail(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDetailLoading(record.id);
|
||||||
try {
|
try {
|
||||||
const result = await api(
|
const result = await api(
|
||||||
`/api/admin/answer-completion-reviews/${record.id}`,
|
`/api/admin/answer-completion-reviews/${record.id}`,
|
||||||
);
|
);
|
||||||
|
detailCache.current.set(record.id, result.data);
|
||||||
setDetail(result.data);
|
setDetail(result.data);
|
||||||
|
if (nextCandidate && !detailCache.current.has(nextCandidate.id)) {
|
||||||
|
void api(`/api/admin/answer-completion-reviews/${nextCandidate.id}`)
|
||||||
|
.then((nextResult) => detailCache.current.set(nextCandidate.id, nextResult.data))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.alert(err.message);
|
window.alert(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setDetailLoading(false);
|
setDetailLoading(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function load() {
|
async function load(showLoading = true) {
|
||||||
const version = ++loadVersion.current;
|
const version = ++loadVersion.current;
|
||||||
setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await api("/api/admin/answer-completion-reviews");
|
const result = await api("/api/admin/answer-completion-reviews");
|
||||||
if (version !== loadVersion.current) return;
|
if (version !== loadVersion.current) return;
|
||||||
@ -470,17 +580,12 @@ export default function ReviewCenterPage({ api }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (version === loadVersion.current) setError(err.message);
|
if (version === loadVersion.current) setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
if (version === loadVersion.current) setLoading(false);
|
if (showLoading && version === loadVersion.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
|
||||||
if (queue !== "answer") return undefined;
|
|
||||||
const timer = window.setInterval(() => load(), 5000);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [queue]);
|
|
||||||
const allRows = useMemo(
|
const allRows = useMemo(
|
||||||
() => records.filter((record) => record.status === "待审核"),
|
() => records.filter((record) => record.status === "待审核"),
|
||||||
[records],
|
[records],
|
||||||
@ -499,7 +604,7 @@ export default function ReviewCenterPage({ api }) {
|
|||||||
async function decide(record, approved, reasonCode = "", reasonText = "") {
|
async function decide(record, approved, reasonCode = "", reasonText = "") {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api(`/api/admin/answer-completion-reviews/${record.id}/decision`, {
|
const result = await api(`/api/admin/answer-completion-reviews/${record.id}/decision`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
approved,
|
approved,
|
||||||
@ -507,11 +612,25 @@ export default function ReviewCenterPage({ api }) {
|
|||||||
reason_text: reasonText,
|
reason_text: reasonText,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
setDetail(null);
|
const updated = {
|
||||||
await load();
|
...record,
|
||||||
|
...result.data,
|
||||||
|
invited_screenshots: record.invited_screenshots || [],
|
||||||
|
completion_screenshots: record.completion_screenshots || [],
|
||||||
|
payment_screenshots: record.payment_screenshots || [],
|
||||||
|
};
|
||||||
|
detailCache.current.set(record.id, updated);
|
||||||
|
setDetail(updated);
|
||||||
|
setRecords((current) => current.filter((item) => item.id !== record.id));
|
||||||
|
void load(false);
|
||||||
window.dispatchEvent(new Event("review-count-changed"));
|
window.dispatchEvent(new Event("review-count-changed"));
|
||||||
setStatusFilter("待审核");
|
setStatusFilter("待审核");
|
||||||
if (approved) setRefundPrompt(true);
|
if (approved) {
|
||||||
|
setRefundPrompt({
|
||||||
|
hasNext: Boolean(nextRecord),
|
||||||
|
onNext: () => openAnswerDetail(nextRecord),
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.alert(err.message);
|
window.alert(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@ -568,7 +687,7 @@ export default function ReviewCenterPage({ api }) {
|
|||||||
</div>
|
</div>
|
||||||
{error && <div className="form-error">{error}</div>}
|
{error && <div className="form-error">{error}</div>}
|
||||||
{queue === "ask" ? (
|
{queue === "ask" ? (
|
||||||
<AskReviewQueue api={api} onApproved={() => setRefundPrompt(true)} />
|
<AskReviewQueue api={api} onApproved={(navigation) => setRefundPrompt(navigation)} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="review-v2-status-filter">
|
<div className="review-v2-status-filter">
|
||||||
@ -605,9 +724,7 @@ export default function ReviewCenterPage({ api }) {
|
|||||||
<code>{record.user_id}</code>
|
<code>{record.user_id}</code>
|
||||||
<code>{record.submission_no}</code>
|
<code>{record.submission_no}</code>
|
||||||
</b>
|
</b>
|
||||||
<small>
|
{(record.taobao_id || record.phone) !== record.user_id && <small>{record.taobao_id || record.phone}</small>}
|
||||||
{record.phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")}
|
|
||||||
</small>
|
|
||||||
</span>
|
</span>
|
||||||
<span>{productName(record)}</span>
|
<span>{productName(record)}</span>
|
||||||
<span>{formatTime(record.updated_at)}</span>
|
<span>{formatTime(record.updated_at)}</span>
|
||||||
@ -621,8 +738,10 @@ export default function ReviewCenterPage({ api }) {
|
|||||||
<StatusBadge status={record.status} />
|
<StatusBadge status={record.status} />
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<button onClick={() => openAnswerDetail(record)}>
|
<button disabled={Boolean(detailLoading)} onClick={() => openAnswerDetail(record)}>
|
||||||
{record.status === "待审核"
|
{detailLoading === record.id
|
||||||
|
? "正在打开…"
|
||||||
|
: record.status === "待审核"
|
||||||
? "查看并审核"
|
? "查看并审核"
|
||||||
: "查看任务列表"}
|
: "查看任务列表"}
|
||||||
</button>
|
</button>
|
||||||
@ -635,17 +754,27 @@ export default function ReviewCenterPage({ api }) {
|
|||||||
)}
|
)}
|
||||||
{detail && (
|
{detail && (
|
||||||
<ReviewDetail
|
<ReviewDetail
|
||||||
|
key={detail.id}
|
||||||
record={detail}
|
record={detail}
|
||||||
close={() => setDetail(null)}
|
close={() => setDetail(null)}
|
||||||
decide={decide}
|
decide={decide}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
hasNext={Boolean(nextRecord)}
|
||||||
|
nextBusy={Boolean(detailLoading)}
|
||||||
|
onNext={() => openAnswerDetail(nextRecord)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{detailLoading && !detail && <DetailLoadingModal title="正在打开回答审核详情" />}
|
||||||
{refundPrompt && (
|
{refundPrompt && (
|
||||||
<RefundPrompt
|
<RefundPrompt
|
||||||
onCancel={() => setRefundPrompt(false)}
|
hasNext={refundPrompt.hasNext}
|
||||||
|
onNext={() => {
|
||||||
|
setRefundPrompt(null);
|
||||||
|
refundPrompt.onNext();
|
||||||
|
}}
|
||||||
|
onCancel={() => setRefundPrompt(null)}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
setRefundPrompt(false);
|
setRefundPrompt(null);
|
||||||
window.dispatchEvent(new CustomEvent("open-task-board", { detail: { status: "待返款" } }));
|
window.dispatchEvent(new CustomEvent("open-task-board", { detail: { status: "待返款" } }));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Search, X } from "lucide-react";
|
import { ChevronRight, LoaderCircle, Search, X } from "lucide-react";
|
||||||
import { qrDataUrl } from "./qrCode.js";
|
import { qrDataUrl } from "./qrCode.js";
|
||||||
|
|
||||||
const STATUS_FILTERS = [
|
const STATUS_FILTERS = [
|
||||||
@ -16,9 +16,12 @@ const STATUS_ALIASES = {
|
|||||||
"已发布": "进行中",
|
"已发布": "进行中",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep the last lightweight board response while operators switch pages.
|
// Cache only the first unfiltered page and isolate it by administrator.
|
||||||
// Returning to the board renders immediately and refreshes silently.
|
// This keeps navigation fast without ever showing another operator's rows.
|
||||||
let taskBoardCache = null;
|
const taskBoardCache = new Map();
|
||||||
|
export function clearTaskBoardCache() {
|
||||||
|
taskBoardCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
const formatTime = (value) => {
|
const formatTime = (value) => {
|
||||||
if (!value) return "--";
|
if (!value) return "--";
|
||||||
@ -31,17 +34,31 @@ const formatTime = (value) => {
|
|||||||
? "--"
|
? "--"
|
||||||
: date.toLocaleString("zh-CN", { hour12: false, timeZone: "Asia/Shanghai" });
|
: date.toLocaleString("zh-CN", { hour12: false, timeZone: "Asia/Shanghai" });
|
||||||
};
|
};
|
||||||
const maskPhone = (phone = "") =>
|
const maskPhone = (account = "") => account;
|
||||||
phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
|
|
||||||
|
|
||||||
function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPollRefundUpload }) {
|
function TaskDetailLoading() {
|
||||||
|
return (
|
||||||
|
<div className="task-detail-backdrop task-detail-loading-backdrop">
|
||||||
|
<section className="task-detail-loading" role="status" aria-live="polite">
|
||||||
|
<LoaderCircle size={24} className="spin" />
|
||||||
|
<strong>正在打开任务详情</strong>
|
||||||
|
<span>请求已收到,凭证图片将在弹窗内按需加载…</span>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPollRefundUpload, hasNextRefund, onNextRefund, nextBusy }) {
|
||||||
const [refundOpen, setRefundOpen] = useState(false);
|
const [refundOpen, setRefundOpen] = useState(false);
|
||||||
const [refundBusy, setRefundBusy] = useState(false);
|
const [refundBusy, setRefundBusy] = useState(false);
|
||||||
const [refundFile, setRefundFile] = useState(null);
|
const [refundFile, setRefundFile] = useState(null);
|
||||||
|
const [refundFileError, setRefundFileError] = useState("");
|
||||||
|
const [refundFilePreview, setRefundFilePreview] = useState("");
|
||||||
const [refundSession, setRefundSession] = useState(null);
|
const [refundSession, setRefundSession] = useState(null);
|
||||||
const [refundQr, setRefundQr] = useState("");
|
const [refundQr, setRefundQr] = useState("");
|
||||||
const [refundSessionError, setRefundSessionError] = useState("");
|
const [refundSessionError, setRefundSessionError] = useState("");
|
||||||
const [linkCopied, setLinkCopied] = useState(false);
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
|
const refundPasteRef = useRef(null);
|
||||||
const isAskTask =
|
const isAskTask =
|
||||||
task.task_type === "ask" || task.task_type_label === "提问任务";
|
task.task_type === "ask" || task.task_type_label === "提问任务";
|
||||||
const taskLink = isAskTask
|
const taskLink = isAskTask
|
||||||
@ -53,6 +70,20 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
|
|||||||
];
|
];
|
||||||
const paymentScreenshots = task.payment_screenshots || [];
|
const paymentScreenshots = task.payment_screenshots || [];
|
||||||
const refundScreenshots = task.refund_screenshots || [];
|
const refundScreenshots = task.refund_screenshots || [];
|
||||||
|
function setRefundProofFile(file) {
|
||||||
|
if (!file) return false;
|
||||||
|
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
|
||||||
|
setRefundFileError("返款凭证仅支持 JPG、PNG、WEBP 图片");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
setRefundFileError("返款凭证不能超过 10MB");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setRefundFile(file);
|
||||||
|
setRefundFileError("");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!refundOpen || refundSession || !task.refund_endpoint) return undefined;
|
if (!refundOpen || refundSession || !task.refund_endpoint) return undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@ -63,7 +94,7 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
|
|||||||
try {
|
try {
|
||||||
setRefundQr(qrDataUrl(`${window.location.origin}${session.upload_url}`));
|
setRefundQr(qrDataUrl(`${window.location.origin}${session.upload_url}`));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRefundSessionError(error.message || "二维码生成失败,请改用下方直接上传");
|
setRefundSessionError(error.message || "二维码生成失败,请改用下方粘贴上传");
|
||||||
}
|
}
|
||||||
}).catch((error) => !cancelled && setRefundSessionError(error.message));
|
}).catch((error) => !cancelled && setRefundSessionError(error.message));
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
@ -82,6 +113,46 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
|
|||||||
const timer = window.setInterval(poll, 2500);
|
const timer = window.setInterval(poll, 2500);
|
||||||
return () => { cancelled = true; window.clearInterval(timer); };
|
return () => { cancelled = true; window.clearInterval(timer); };
|
||||||
}, [refundOpen, refundSession?.token, refundSession?.uploaded]);
|
}, [refundOpen, refundSession?.token, refundSession?.uploaded]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!refundFile) {
|
||||||
|
setRefundFilePreview("");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const previewUrl = URL.createObjectURL(refundFile);
|
||||||
|
setRefundFilePreview(previewUrl);
|
||||||
|
return () => URL.revokeObjectURL(previewUrl);
|
||||||
|
}, [refundFile]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!refundOpen) return undefined;
|
||||||
|
const pasteImage = (event) => {
|
||||||
|
const imageItem = Array.from(event.clipboardData?.items || []).find((item) =>
|
||||||
|
item.kind === "file" && item.type.startsWith("image/")
|
||||||
|
);
|
||||||
|
if (!imageItem) {
|
||||||
|
setRefundFileError("剪贴板中没有图片,请先复制或截图后再按 Ctrl+V");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = imageItem.getAsFile();
|
||||||
|
if (!blob) {
|
||||||
|
setRefundFileError("读取剪贴板图片失败,请重新复制后再试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const extension = blob.type === "image/jpeg" ? "jpg" : blob.type.split("/")[1];
|
||||||
|
const pastedFile = new File(
|
||||||
|
[blob],
|
||||||
|
`refund-proof-${Date.now()}.${extension}`,
|
||||||
|
{ type: blob.type, lastModified: Date.now() },
|
||||||
|
);
|
||||||
|
event.preventDefault();
|
||||||
|
setRefundProofFile(pastedFile);
|
||||||
|
};
|
||||||
|
window.addEventListener("paste", pasteImage);
|
||||||
|
const focusTimer = window.setTimeout(() => refundPasteRef.current?.focus(), 0);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(focusTimer);
|
||||||
|
window.removeEventListener("paste", pasteImage);
|
||||||
|
};
|
||||||
|
}, [refundOpen]);
|
||||||
async function confirmRefund() {
|
async function confirmRefund() {
|
||||||
setRefundBusy(true);
|
setRefundBusy(true);
|
||||||
try {
|
try {
|
||||||
@ -211,7 +282,7 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
|
|||||||
style={{ gridColumn: 3, gridRow: index + 1 }}
|
style={{ gridColumn: 3, gridRow: index + 1 }}
|
||||||
>
|
>
|
||||||
{task.user_id}
|
{task.user_id}
|
||||||
<small>{maskPhone(task.phone)}</small>
|
{task.phone && task.phone !== task.user_id && <small>{maskPhone(task.phone)}</small>}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`task-detail-cell${index ? " row-border" : ""}`}
|
className={`task-detail-cell${index ? " row-border" : ""}`}
|
||||||
@ -235,6 +306,8 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
|
|||||||
<img
|
<img
|
||||||
src={refundScreenshots[0].data_url || refundScreenshots[0].url}
|
src={refundScreenshots[0].data_url || refundScreenshots[0].url}
|
||||||
alt="返款凭证"
|
alt="返款凭证"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<small>已上传</small>
|
<small>已上传</small>
|
||||||
</button>
|
</button>
|
||||||
@ -255,7 +328,7 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
|
|||||||
type="button"
|
type="button"
|
||||||
key={image.id}
|
key={image.id}
|
||||||
>
|
>
|
||||||
<img src={image.data_url || image.url} alt={image.name || "完成截图"} />
|
<img src={image.data_url || image.url} alt={image.name || "完成截图"} loading="lazy" decoding="async" />
|
||||||
<span>点击查看大图</span>
|
<span>点击查看大图</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -273,7 +346,7 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
|
|||||||
type="button"
|
type="button"
|
||||||
key={image.id}
|
key={image.id}
|
||||||
>
|
>
|
||||||
<img src={image.data_url || image.url} alt={image.name || "收款码"} />
|
<img src={image.data_url || image.url} alt={image.name || "收款码"} loading="lazy" decoding="async" />
|
||||||
<span>点击核对收款码</span>
|
<span>点击核对收款码</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -291,6 +364,7 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRefundSession(null);
|
setRefundSession(null);
|
||||||
setRefundFile(null);
|
setRefundFile(null);
|
||||||
|
setRefundFileError("");
|
||||||
setRefundQr("");
|
setRefundQr("");
|
||||||
setRefundOpen(true);
|
setRefundOpen(true);
|
||||||
}}
|
}}
|
||||||
@ -298,6 +372,17 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
|
|||||||
确认返款
|
确认返款
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{(task.status_label === "待返款" || task.refund_completed) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="next-refund-task"
|
||||||
|
disabled={!hasNextRefund || nextBusy}
|
||||||
|
onClick={onNextRefund}
|
||||||
|
>
|
||||||
|
{nextBusy ? "正在打开…" : hasNextRefund ? "下一个待返款任务" : "已是最后一个"}
|
||||||
|
{hasNextRefund && !nextBusy && <ChevronRight size={15} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={close}>关闭</button>
|
<button onClick={close}>关闭</button>
|
||||||
</footer>
|
</footer>
|
||||||
{refundOpen && (
|
{refundOpen && (
|
||||||
@ -322,14 +407,33 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
|
|||||||
</div>
|
</div>
|
||||||
{refundQr ? <img src={refundQr} alt="扫码上传返款凭证" /> : <i>正在生成二维码…</i>}
|
{refundQr ? <img src={refundQr} alt="扫码上传返款凭证" /> : <i>正在生成二维码…</i>}
|
||||||
</div>
|
</div>
|
||||||
<label className="refund-proof-upload">
|
<div
|
||||||
<span>电脑直接上传(可选)</span>
|
ref={refundPasteRef}
|
||||||
|
className={`refund-proof-upload${refundFile ? " has-image" : ""}`}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label="粘贴剪贴板中的返款凭证图片"
|
||||||
|
>
|
||||||
|
{refundFilePreview ? (
|
||||||
|
<img src={refundFilePreview} alt="已粘贴的返款凭证" />
|
||||||
|
) : (
|
||||||
|
<span className="refund-paste-icon">粘贴</span>
|
||||||
|
)}
|
||||||
|
<span>剪贴板粘贴上传(可选)</span>
|
||||||
|
<strong>{refundFile ? "图片已粘贴,可直接确认" : "复制图片后,按 Ctrl+V 粘贴"}</strong>
|
||||||
|
<small>{refundFile ? `${refundFile.name} · ${(refundFile.size / 1024).toFixed(0)}KB` : "支持 JPG、PNG、WEBP,最大 10MB"}</small>
|
||||||
|
{refundFileError && <em>{refundFileError}</em>}
|
||||||
|
</div>
|
||||||
|
<label className="refund-proof-fallback">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp"
|
accept="image/jpeg,image/png,image/webp"
|
||||||
onChange={(event) => setRefundFile(event.target.files?.[0] || null)}
|
onChange={(event) => {
|
||||||
|
setRefundProofFile(event.target.files?.[0]);
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<small>{refundFile ? refundFile.name : "请选择支付截图"}</small>
|
<span>无法粘贴?选择本地图片</span>
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@ -356,28 +460,54 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TaskBoardPage({ api }) {
|
export default function TaskBoardPage({ api, user }) {
|
||||||
const [tasks, setTasks] = useState(() => taskBoardCache || []);
|
const accountCacheKey = String(user?.id || user?.username || "anonymous");
|
||||||
|
const initialStatus = window.__taskBoardInitialStatus || "all";
|
||||||
|
const initialCache = initialStatus === "all" ? taskBoardCache.get(accountCacheKey) : null;
|
||||||
|
const [tasks, setTasks] = useState(() => initialCache?.items || []);
|
||||||
const [type, setType] = useState("all");
|
const [type, setType] = useState("all");
|
||||||
const [status, setStatus] = useState(() => window.__taskBoardInitialStatus || "all");
|
const [status, setStatus] = useState(initialStatus);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [dateFrom, setDateFrom] = useState("");
|
const [dateFrom, setDateFrom] = useState("");
|
||||||
const [dateTo, setDateTo] = useState("");
|
const [dateTo, setDateTo] = useState("");
|
||||||
|
const [page, setPage] = useState(() => initialCache?.page || 1);
|
||||||
|
const [pageSize, setPageSize] = useState(() => initialCache?.page_size || 20);
|
||||||
|
const [total, setTotal] = useState(() => initialCache?.total || 0);
|
||||||
|
const [totalPages, setTotalPages] = useState(() => initialCache?.total_pages || 1);
|
||||||
const [selectedIds, setSelectedIds] = useState([]);
|
const [selectedIds, setSelectedIds] = useState([]);
|
||||||
const [expandedAskTasks, setExpandedAskTasks] = useState(() => new Set());
|
const [expandedAskTasks, setExpandedAskTasks] = useState(() => new Set());
|
||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(null);
|
||||||
|
const [nextRefundTask, setNextRefundTask] = useState(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(() => taskBoardCache === null);
|
const [loading, setLoading] = useState(() => !initialCache);
|
||||||
const [busy, setBusy] = useState("");
|
const [busy, setBusy] = useState("");
|
||||||
const [batchBusy, setBatchBusy] = useState(false);
|
const [batchBusy, setBatchBusy] = useState(false);
|
||||||
const [batchConfirm, setBatchConfirm] = useState(null);
|
const [batchConfirm, setBatchConfirm] = useState(null);
|
||||||
const [completeConfirm, setCompleteConfirm] = useState(null);
|
const [completeConfirm, setCompleteConfirm] = useState(null);
|
||||||
async function load(showLoading = taskBoardCache === null) {
|
const detailCache = useRef(new Map());
|
||||||
|
async function load(showLoading = !tasks.length) {
|
||||||
if (showLoading) setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await api("/api/admin/task-board");
|
const params = new URLSearchParams({
|
||||||
taskBoardCache = result.data;
|
page: String(page),
|
||||||
setTasks(result.data);
|
page_size: String(pageSize),
|
||||||
|
task_type: type,
|
||||||
|
status_filter: status,
|
||||||
|
});
|
||||||
|
if (keyword.trim()) params.set("keyword", keyword.trim());
|
||||||
|
if (dateFrom) params.set("date_from", dateFrom);
|
||||||
|
if (dateTo) params.set("date_to", dateTo);
|
||||||
|
const result = await api(`/api/admin/task-board?${params.toString()}`);
|
||||||
|
const payload = result.data;
|
||||||
|
setTasks(payload.items);
|
||||||
|
setPage(payload.page);
|
||||||
|
setPageSize(payload.page_size);
|
||||||
|
setTotal(payload.total);
|
||||||
|
setTotalPages(payload.total_pages);
|
||||||
|
if (payload.page === 1 && type === "all" && status === "all" && !keyword.trim() && !dateFrom && !dateTo) {
|
||||||
|
taskBoardCache.set(accountCacheKey, payload);
|
||||||
|
}
|
||||||
setError("");
|
setError("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@ -386,39 +516,24 @@ export default function TaskBoardPage({ api }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load(taskBoardCache === null);
|
const timer = window.setTimeout(() => load(!tasks.length), keyword.trim() ? 250 : 0);
|
||||||
window.__taskBoardInitialStatus = "";
|
window.__taskBoardInitialStatus = "";
|
||||||
}, []);
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [accountCacheKey, page, pageSize, type, status, keyword, dateFrom, dateTo]);
|
||||||
const rowKey = (task) => task.board_key || `${task.task_type}-${task.id}`;
|
const rowKey = (task) => task.board_key || `${task.task_type}-${task.id}`;
|
||||||
|
const detailCacheKey = (task) => task.detail_endpoint || rowKey(task);
|
||||||
const isAskTask = (task) => task.task_type === "ask" || task.task_type_label === "提问任务";
|
const isAskTask = (task) => task.task_type === "ask" || task.task_type_label === "提问任务";
|
||||||
const childRows = (task) => (isAskTask(task) ? task.claimed_tasks || [] : []);
|
const childRows = (task) => (isAskTask(task) ? task.claimed_tasks || [] : []);
|
||||||
// Keep filtering as a direct derivation so changing either filter always
|
// Keep filtering as a direct derivation so changing either filter always
|
||||||
// immediately updates the table (without relying on a stale memoized list).
|
// immediately updates the table (without relying on a stale memoized list).
|
||||||
const rows = tasks.filter((task) => {
|
const rows = tasks;
|
||||||
const taskType = String(task.task_type || "")
|
const refundQueue = useMemo(
|
||||||
.trim()
|
() => tasks.flatMap((task) => [task, ...childRows(task)]).filter((task) => {
|
||||||
.toLowerCase();
|
|
||||||
const taskTypeLabel = String(task.task_type_label || "").trim();
|
|
||||||
const rawStatus = String(task.status_label || task.status || "").trim();
|
const rawStatus = String(task.status_label || task.status || "").trim();
|
||||||
const taskStatus = STATUS_ALIASES[rawStatus] || rawStatus;
|
return (STATUS_ALIASES[rawStatus] || rawStatus) === "待返款" || Boolean(task.refund_endpoint);
|
||||||
const typeMatch =
|
}),
|
||||||
type === "all" ||
|
[tasks],
|
||||||
(type === "ask" &&
|
);
|
||||||
(taskType === "ask" || taskTypeLabel === "提问任务")) ||
|
|
||||||
(type === "answer" &&
|
|
||||||
(taskType === "answer" || taskTypeLabel === "回答任务"));
|
|
||||||
const children = childRows(task);
|
|
||||||
const childStatusMatch = children.some((child) => {
|
|
||||||
const childRawStatus = String(child.status_label || child.status || "").trim();
|
|
||||||
return (STATUS_ALIASES[childRawStatus] || childRawStatus) === status;
|
|
||||||
});
|
|
||||||
const statusMatch = status === "all" || taskStatus === status || childStatusMatch;
|
|
||||||
const text = `${task.user_id || ""} ${task.task_id || ""} ${task.shop_name || ""} ${task.product_name || ""} ${children.map((child) => `${child.user_id || ""} ${child.phone || ""}`).join(" ")}`.toLowerCase();
|
|
||||||
const createdDate = String(task.created_at || task.updated_at || "").slice(0, 10);
|
|
||||||
const fromMatch = !dateFrom || (createdDate && createdDate >= dateFrom);
|
|
||||||
const toMatch = !dateTo || (createdDate && createdDate <= dateTo);
|
|
||||||
return typeMatch && statusMatch && text.includes(keyword.trim().toLowerCase()) && fromMatch && toMatch;
|
|
||||||
});
|
|
||||||
const visibleKeys = rows.map(rowKey);
|
const visibleKeys = rows.map(rowKey);
|
||||||
const allVisibleSelected = visibleKeys.length > 0 && visibleKeys.every((key) => selectedIds.includes(key));
|
const allVisibleSelected = visibleKeys.length > 0 && visibleKeys.every((key) => selectedIds.includes(key));
|
||||||
function toggleSelected(task) {
|
function toggleSelected(task) {
|
||||||
@ -437,6 +552,7 @@ export default function TaskBoardPage({ api }) {
|
|||||||
setDateTo("");
|
setDateTo("");
|
||||||
setType("all");
|
setType("all");
|
||||||
setStatus("all");
|
setStatus("all");
|
||||||
|
setPage(1);
|
||||||
}
|
}
|
||||||
function toggleAskTask(task) {
|
function toggleAskTask(task) {
|
||||||
const key = rowKey(task);
|
const key = rowKey(task);
|
||||||
@ -452,12 +568,12 @@ export default function TaskBoardPage({ api }) {
|
|||||||
window.alert("当前没有可导出的任务");
|
window.alert("当前没有可导出的任务");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const headers = ["用户ID", "任务ID", "店铺/商品信息", "类型", "Q&A数", "完成", "状态", "创建人", "返款凭证", "创建时间"];
|
const headers = ["用户ID", "任务ID", "商品/店铺信息", "类型", "Q&A数", "完成", "状态", "创建人", "返款凭证", "创建时间"];
|
||||||
const csvCell = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`;
|
const csvCell = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`;
|
||||||
const lines = [headers, ...rows.map((task) => [
|
const lines = [headers, ...rows.map((task) => [
|
||||||
task.user_id,
|
task.user_id,
|
||||||
task.task_id,
|
task.task_id,
|
||||||
[task.shop_name, task.product_name].filter(Boolean).join(" / "),
|
[task.product_name, task.shop_name].filter(Boolean).join(" / "),
|
||||||
task.task_type_label,
|
task.task_type_label,
|
||||||
task.qa_count,
|
task.qa_count,
|
||||||
task.completed_count,
|
task.completed_count,
|
||||||
@ -509,19 +625,40 @@ export default function TaskBoardPage({ api }) {
|
|||||||
setBatchBusy(false);
|
setBatchBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function openDetail(task) {
|
async function requestDetail(task) {
|
||||||
try {
|
|
||||||
if (task.detail_endpoint) {
|
if (task.detail_endpoint) {
|
||||||
const result = await api(task.detail_endpoint);
|
const result = await api(task.detail_endpoint);
|
||||||
setDetail(result.data);
|
return result.data;
|
||||||
} else if (task.task_type === "ask") {
|
}
|
||||||
setDetail(task);
|
if (task.task_type === "ask") return task;
|
||||||
} else {
|
|
||||||
const result = await api(`/api/admin/task-board/${task.id}`);
|
const result = await api(`/api/admin/task-board/${task.id}`);
|
||||||
setDetail(result.data);
|
return result.data;
|
||||||
|
}
|
||||||
|
async function openDetail(task) {
|
||||||
|
if (!task || detailLoading) return;
|
||||||
|
const key = detailCacheKey(task);
|
||||||
|
const refundIndex = refundQueue.findIndex((item) => rowKey(item) === rowKey(task));
|
||||||
|
const nextCandidate = refundIndex >= 0 ? refundQueue[refundIndex + 1] || null : null;
|
||||||
|
setNextRefundTask(nextCandidate);
|
||||||
|
const cached = detailCache.current.get(key);
|
||||||
|
if (cached) {
|
||||||
|
setDetail(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDetailLoading(key);
|
||||||
|
try {
|
||||||
|
const loaded = await requestDetail(task);
|
||||||
|
detailCache.current.set(key, loaded);
|
||||||
|
setDetail(loaded);
|
||||||
|
if (nextCandidate && !detailCache.current.has(detailCacheKey(nextCandidate))) {
|
||||||
|
void requestDetail(nextCandidate)
|
||||||
|
.then((nextDetail) => detailCache.current.set(detailCacheKey(nextCandidate), nextDetail))
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.alert(err.message);
|
window.alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function cancel(task) {
|
async function cancel(task) {
|
||||||
@ -574,7 +711,7 @@ export default function TaskBoardPage({ api }) {
|
|||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
async function confirmRefund(task, refundFile, uploadToken) {
|
async function confirmRefund(task, refundFile, uploadToken) {
|
||||||
if (!refundFile && !uploadToken) throw new Error("请先上传返款凭证,或用手机扫码上传");
|
if (!refundFile && !uploadToken) throw new Error("请先粘贴返款凭证,或用手机扫码上传");
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
if (refundFile) body.append("refund_file", refundFile);
|
if (refundFile) body.append("refund_file", refundFile);
|
||||||
if (uploadToken) body.append("upload_token", uploadToken);
|
if (uploadToken) body.append("upload_token", uploadToken);
|
||||||
@ -592,6 +729,7 @@ export default function TaskBoardPage({ api }) {
|
|||||||
result.data.refund_screenshots || task.refund_screenshots || [],
|
result.data.refund_screenshots || task.refund_screenshots || [],
|
||||||
refund_screenshot_count: result.data.refund_screenshots?.length || task.refund_screenshot_count || 0,
|
refund_screenshot_count: result.data.refund_screenshots?.length || task.refund_screenshot_count || 0,
|
||||||
};
|
};
|
||||||
|
detailCache.current.set(detailCacheKey(task), updatedDetail);
|
||||||
setDetail(updatedDetail);
|
setDetail(updatedDetail);
|
||||||
setTasks((current) => {
|
setTasks((current) => {
|
||||||
const next = current.map((item) =>
|
const next = current.map((item) =>
|
||||||
@ -599,7 +737,7 @@ export default function TaskBoardPage({ api }) {
|
|||||||
? { ...item, ...result.data }
|
? { ...item, ...result.data }
|
||||||
: item,
|
: item,
|
||||||
);
|
);
|
||||||
taskBoardCache = next;
|
taskBoardCache.delete(accountCacheKey);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
// A claimed ask row is nested under its system task. Refresh the compact
|
// A claimed ask row is nested under its system task. Refresh the compact
|
||||||
@ -623,7 +761,7 @@ export default function TaskBoardPage({ api }) {
|
|||||||
type="button"
|
type="button"
|
||||||
className={type === value ? "active" : ""}
|
className={type === value ? "active" : ""}
|
||||||
key={value}
|
key={value}
|
||||||
onClick={() => setType(value)}
|
onClick={() => { setType(value); setPage(1); }}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
@ -635,7 +773,7 @@ export default function TaskBoardPage({ api }) {
|
|||||||
type="button"
|
type="button"
|
||||||
className={status === value ? "active" : ""}
|
className={status === value ? "active" : ""}
|
||||||
key={value}
|
key={value}
|
||||||
onClick={() => setStatus(value)}
|
onClick={() => { setStatus(value); setPage(1); }}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
@ -645,18 +783,18 @@ export default function TaskBoardPage({ api }) {
|
|||||||
<Search size={17} />
|
<Search size={17} />
|
||||||
<input
|
<input
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
|
||||||
placeholder="搜索用户ID、任务ID、店铺或商品…"
|
placeholder="搜索用户ID、任务ID、店铺或商品…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="board-tools">
|
<div className="board-tools">
|
||||||
<label>
|
<label>
|
||||||
<span>开始日期</span>
|
<span>开始日期</span>
|
||||||
<input type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} />
|
<input type="date" value={dateFrom} onChange={(event) => { setDateFrom(event.target.value); setPage(1); }} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>结束日期</span>
|
<span>结束日期</span>
|
||||||
<input type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} />
|
<input type="date" value={dateTo} onChange={(event) => { setDateTo(event.target.value); setPage(1); }} />
|
||||||
</label>
|
</label>
|
||||||
<button type="button" onClick={clearFilters}>清空筛选</button>
|
<button type="button" onClick={clearFilters}>清空筛选</button>
|
||||||
<button type="button" onClick={exportRows}>导出表格</button>
|
<button type="button" onClick={exportRows}>导出表格</button>
|
||||||
@ -675,7 +813,7 @@ export default function TaskBoardPage({ api }) {
|
|||||||
</span>
|
</span>
|
||||||
<span>用户ID</span>
|
<span>用户ID</span>
|
||||||
<span>任务ID</span>
|
<span>任务ID</span>
|
||||||
<span>店铺/商品信息</span>
|
<span>商品/店铺信息</span>
|
||||||
<span>类型</span>
|
<span>类型</span>
|
||||||
<span>Q&A数</span>
|
<span>Q&A数</span>
|
||||||
<span>完成</span>
|
<span>完成</span>
|
||||||
@ -713,12 +851,12 @@ export default function TaskBoardPage({ api }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<code>{task.user_id}</code>
|
<code>{task.user_id}</code>
|
||||||
{!isAskTask(task) && <small>{maskPhone(task.phone)}</small>}
|
{!isAskTask(task) && task.phone && task.phone !== task.user_id && <small>{maskPhone(task.phone)}</small>}
|
||||||
</span>
|
</span>
|
||||||
<span><b>{task.task_id}</b></span>
|
<span><b>{task.task_id}</b></span>
|
||||||
<span>
|
<span>
|
||||||
<strong>{isAskTask(task) ? task.shop_name || "--" : task.product_name}</strong>
|
<strong>{task.product_name || "--"}</strong>
|
||||||
<small>{isAskTask(task) ? task.product_name : task.shop_name || "--"}</small>
|
<small>{task.shop_name || "--"}</small>
|
||||||
</span>
|
</span>
|
||||||
<span><em className={`board-type ${task.task_type}`}>{task.task_type_label}</em></span>
|
<span><em className={`board-type ${task.task_type}`}>{task.task_type_label}</em></span>
|
||||||
<span>{task.qa_count}</span>
|
<span>{task.qa_count}</span>
|
||||||
@ -727,7 +865,9 @@ export default function TaskBoardPage({ api }) {
|
|||||||
<span>{task.creator_name}<small>{formatTime(task.created_at)}</small></span>
|
<span>{task.creator_name}<small>{formatTime(task.created_at)}</small></span>
|
||||||
<span className="board-refund-proof">{task.refund_screenshot_count ? "已上传" : "--"}</span>
|
<span className="board-refund-proof">{task.refund_screenshot_count ? "已上传" : "--"}</span>
|
||||||
<span className="board-actions">
|
<span className="board-actions">
|
||||||
<button onClick={() => openDetail(task)}>详情</button>
|
<button disabled={Boolean(detailLoading)} onClick={() => openDetail(task)}>
|
||||||
|
{detailLoading === detailCacheKey(task) ? "打开中…" : "详情"}
|
||||||
|
</button>
|
||||||
{task.can_cancel && (
|
{task.can_cancel && (
|
||||||
<button className="cancel" disabled={busy === task.id} onClick={() => cancel(task)}>
|
<button className="cancel" disabled={busy === task.id} onClick={() => cancel(task)}>
|
||||||
{busy === task.id ? "撤销中" : "撤销"}
|
{busy === task.id ? "撤销中" : "撤销"}
|
||||||
@ -742,16 +882,19 @@ export default function TaskBoardPage({ api }) {
|
|||||||
{expanded && children.map((child) => (
|
{expanded && children.map((child) => (
|
||||||
<div className="board-table-row board-child-row" key={rowKey(child)}>
|
<div className="board-table-row board-child-row" key={rowKey(child)}>
|
||||||
<span className="board-select-cell" />
|
<span className="board-select-cell" />
|
||||||
<span className="board-user-cell board-child-user"><i>↳</i><code>{child.user_id}</code></span>
|
<span className="board-user-cell board-child-user"><i>↳</i><code>{child.user_id}</code>{child.phone && child.phone !== child.user_id && <small>{maskPhone(child.phone)}</small>}</span>
|
||||||
<span><b>{child.task_id}</b></span>
|
<span>
|
||||||
<span><strong>{child.product_name}</strong><small>{maskPhone(child.phone)}</small></span>
|
<b>{child.task_id}</b>
|
||||||
|
{child.batch_label && <small>{child.batch_label}</small>}
|
||||||
|
</span>
|
||||||
|
<span><strong>{child.product_name || "--"}</strong><small>{child.shop_name || "--"}</small></span>
|
||||||
<span><em className="board-type ask">提问任务</em></span>
|
<span><em className="board-type ask">提问任务</em></span>
|
||||||
<span>{child.qa_count}</span>
|
<span>{child.qa_count}</span>
|
||||||
<span>{child.completed_count}</span>
|
<span>{child.completed_count}</span>
|
||||||
<span><em className={`board-status ${child.status_label}`}>{child.status_label}</em></span>
|
<span><em className={`board-status ${child.status_label}`}>{child.status_label}</em></span>
|
||||||
<span>{child.creator_name}<small>{formatTime(child.created_at)}</small></span>
|
<span>{child.creator_name}<small>{formatTime(child.created_at)}</small></span>
|
||||||
<span className="board-refund-proof">{child.refund_proof_uploaded ? "已上传" : child.refund_endpoint ? "待上传" : "--"}</span>
|
<span className="board-refund-proof">{child.refund_proof_uploaded ? "已上传" : child.refund_endpoint ? "待上传" : "--"}</span>
|
||||||
<span className="board-actions"><button onClick={() => openDetail(child)}>详情</button></span>
|
<span className="board-actions"><button disabled={Boolean(detailLoading)} onClick={() => openDetail(child)}>{detailLoading === detailCacheKey(child) ? "打开中…" : "详情"}</button></span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
@ -759,13 +902,30 @@ export default function TaskBoardPage({ api }) {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
<nav className="board-pagination" aria-label="任务看板分页">
|
||||||
|
<span>共 {total} 条</span>
|
||||||
|
<label>
|
||||||
|
每页
|
||||||
|
<select value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPage(1); }}>
|
||||||
|
{[10, 20, 50, 100].map((size) => <option key={size} value={size}>{size} 条</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" disabled={page <= 1 || loading} onClick={() => setPage((value) => Math.max(1, value - 1))}>上一页</button>
|
||||||
|
<strong>{page} / {totalPages}</strong>
|
||||||
|
<button type="button" disabled={page >= totalPages || loading} onClick={() => setPage((value) => Math.min(totalPages, value + 1))}>下一页</button>
|
||||||
|
</nav>
|
||||||
|
{detailLoading && !detail && <TaskDetailLoading />}
|
||||||
{detail && (
|
{detail && (
|
||||||
<TaskDetail
|
<TaskDetail
|
||||||
|
key={rowKey(detail)}
|
||||||
task={detail}
|
task={detail}
|
||||||
close={() => setDetail(null)}
|
close={() => setDetail(null)}
|
||||||
onConfirmRefund={confirmRefund}
|
onConfirmRefund={confirmRefund}
|
||||||
onCreateRefundUpload={createRefundUpload}
|
onCreateRefundUpload={createRefundUpload}
|
||||||
onPollRefundUpload={pollRefundUpload}
|
onPollRefundUpload={pollRefundUpload}
|
||||||
|
hasNextRefund={Boolean(nextRefundTask)}
|
||||||
|
nextBusy={Boolean(detailLoading)}
|
||||||
|
onNextRefund={() => openDetail(nextRefundTask)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{batchConfirm && (
|
{batchConfirm && (
|
||||||
|
|||||||
@ -2,6 +2,46 @@
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
min-height: 260px;
|
min-height: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ask-assignment-countdown {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #c9dcfb;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f1f6ff;
|
||||||
|
color: #2864e8;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ask-assignment-countdown.urgent {
|
||||||
|
border-color: #ffc7bd;
|
||||||
|
background: #fff3f0;
|
||||||
|
color: #d94b35;
|
||||||
|
}
|
||||||
|
.ask-upload-expiry-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid #c9dcfb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f5f8ff;
|
||||||
|
color: #52719c;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.ask-upload-expiry-note > span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.ask-upload-expiry-note .ask-assignment-countdown {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
.ask-user-notice{margin:12px 16px;padding:13px 16px;border:1px solid #ffb9c1;border-radius:12px;background:linear-gradient(135deg,#fff4f5,#fff9ea);color:#c43b4a;font-size:12px;font-weight:700;text-align:left;box-shadow:0 4px 12px rgba(198,66,84,.08)}
|
.ask-user-notice{margin:12px 16px;padding:13px 16px;border:1px solid #ffb9c1;border-radius:12px;background:linear-gradient(135deg,#fff4f5,#fff9ea);color:#c43b4a;font-size:12px;font-weight:700;text-align:left;box-shadow:0 4px 12px rgba(198,66,84,.08)}
|
||||||
.ask-exhausted-app{min-height:calc(100vh - 44px)}
|
.ask-exhausted-app{min-height:calc(100vh - 44px)}
|
||||||
.ask-exhausted-card{display:flex;align-items:center;flex-direction:column;width:min(420px,calc(100% - 32px));margin:90px auto 24px;padding:34px 24px;border:1px solid #f2cd71;border-radius:20px;background:linear-gradient(180deg,#fffdf7,#fff);text-align:center;box-shadow:0 12px 32px rgba(76,57,18,.1)}
|
.ask-exhausted-card{display:flex;align-items:center;flex-direction:column;width:min(420px,calc(100% - 32px));margin:90px auto 24px;padding:34px 24px;border:1px solid #f2cd71;border-radius:20px;background:linear-gradient(180deg,#fffdf7,#fff);text-align:center;box-shadow:0 12px 32px rgba(76,57,18,.1)}
|
||||||
@ -364,7 +404,7 @@
|
|||||||
margin-top: 22px;
|
margin-top: 22px;
|
||||||
}
|
}
|
||||||
.ask-add-dialog {
|
.ask-add-dialog {
|
||||||
width: min(430px, calc(100vw - 32px));
|
width: min(620px, calc(100vw - 32px));
|
||||||
}
|
}
|
||||||
.ask-add-dialog > p {
|
.ask-add-dialog > p {
|
||||||
margin: 12px 0 18px;
|
margin: 12px 0 18px;
|
||||||
@ -373,7 +413,7 @@
|
|||||||
}
|
}
|
||||||
.ask-add-options {
|
.ask-add-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.ask-add-options button {
|
.ask-add-options button {
|
||||||
@ -381,7 +421,7 @@
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
padding: 18px 14px;
|
padding: 18px 14px;
|
||||||
border: 1px solid #d6e2f0;
|
border: 1px solid #d6e2f0;
|
||||||
border-radius: 11px;
|
border-radius: 8px;
|
||||||
background: #f8fbff;
|
background: #f8fbff;
|
||||||
color: #1c3f68;
|
color: #1c3f68;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@ -399,6 +439,93 @@
|
|||||||
.ask-manual-dialog {
|
.ask-manual-dialog {
|
||||||
width: min(500px, calc(100vw - 32px));
|
width: min(500px, calc(100vw - 32px));
|
||||||
}
|
}
|
||||||
|
.ask-import-dialog {
|
||||||
|
width: min(620px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
.ask-import-dialog > p {
|
||||||
|
margin: 12px 0 16px;
|
||||||
|
color: #7187a1;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ask-import-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 78px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px dashed #9ebced;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f7faff;
|
||||||
|
color: #2462d8;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.ask-import-picker:hover {
|
||||||
|
border-color: #2864e8;
|
||||||
|
background: #f1f6ff;
|
||||||
|
}
|
||||||
|
.ask-import-picker > span {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ask-import-picker small,
|
||||||
|
.ask-import-rule {
|
||||||
|
color: #8296ae;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.ask-import-rule {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.ask-import-status {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #f4f7fb;
|
||||||
|
color: #6d84a0;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ask-import-preview {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 13px 14px;
|
||||||
|
border: 1px solid #d8e4f2;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfdff;
|
||||||
|
}
|
||||||
|
.ask-import-preview header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.ask-import-preview header strong {
|
||||||
|
color: #1d3c63;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ask-import-preview header span,
|
||||||
|
.ask-import-preview > small {
|
||||||
|
color: #8296ae;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.ask-import-preview ol {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 220px;
|
||||||
|
margin: 11px 0 8px;
|
||||||
|
padding-left: 23px;
|
||||||
|
overflow: auto;
|
||||||
|
color: #405a7d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ask-import-preview li {
|
||||||
|
padding-left: 3px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
.ask-library-search {
|
.ask-library-search {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
@ -1765,6 +1892,18 @@
|
|||||||
content: "";
|
content: "";
|
||||||
animation: draft-identifying-pulse 1.2s ease-in-out infinite;
|
animation: draft-identifying-pulse 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
.draft-recognition-failed {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 9px;
|
||||||
|
border: 1px solid #ffc4cb;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff2f4;
|
||||||
|
color: #cf3851;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@keyframes draft-identifying-pulse {
|
@keyframes draft-identifying-pulse {
|
||||||
0%, 100% { opacity: .35; }
|
0%, 100% { opacity: .35; }
|
||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
@ -2160,6 +2299,9 @@
|
|||||||
.board-child-user {
|
.board-child-user {
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
}
|
}
|
||||||
|
.board-child-user small {
|
||||||
|
margin-left: 26px;
|
||||||
|
}
|
||||||
.board-child-user i {
|
.board-child-user i {
|
||||||
color: #8ca2bc;
|
color: #8ca2bc;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@ -2618,6 +2760,23 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 5px 12px rgba(23, 100, 232, 0.2);
|
box-shadow: 0 5px 12px rgba(23, 100, 232, 0.2);
|
||||||
}
|
}
|
||||||
|
.task-detail-modal > footer button.next-refund-task {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
border: 1px solid #bad0f2;
|
||||||
|
background: #eef5ff;
|
||||||
|
color: #1764e8;
|
||||||
|
}
|
||||||
|
.task-detail-modal > footer button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: .55;
|
||||||
|
}
|
||||||
|
.task-detail-loading-backdrop {
|
||||||
|
z-index: 65;
|
||||||
|
}
|
||||||
.refund-confirm-backdrop {
|
.refund-confirm-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 70;
|
z-index: 70;
|
||||||
@ -2660,16 +2819,53 @@
|
|||||||
}
|
}
|
||||||
.refund-proof-upload {
|
.refund-proof-upload {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: 54px 1fr;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
|
align-items: center;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px dashed #b8cbe2;
|
border: 1px dashed #b8cbe2;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
outline: none;
|
||||||
|
background: #fbfdff;
|
||||||
color: #294767;
|
color: #294767;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.refund-proof-upload input {
|
.refund-proof-upload:focus {
|
||||||
width: 100%;
|
border-color: #1764e8;
|
||||||
|
box-shadow: 0 0 0 3px rgba(23, 100, 232, .1);
|
||||||
|
}
|
||||||
|
.refund-proof-upload.has-image {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #72cda6;
|
||||||
|
background: #f4fcf8;
|
||||||
|
}
|
||||||
|
.refund-proof-upload > img,
|
||||||
|
.refund-paste-icon {
|
||||||
|
grid-row: 1 / span 4;
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.refund-proof-upload > img {
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid #dce7f5;
|
||||||
|
}
|
||||||
|
.refund-paste-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #eaf2ff;
|
||||||
|
color: #1764e8;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.refund-proof-upload > span:not(.refund-paste-icon) {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.refund-proof-upload strong {
|
||||||
|
color: #1764e8;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.refund-proof-upload small {
|
.refund-proof-upload small {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -2677,6 +2873,27 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.refund-proof-upload em {
|
||||||
|
color: #dc4f5c;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.refund-proof-fallback {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 9px;
|
||||||
|
color: #1764e8;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.refund-proof-fallback input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.refund-proof-fallback span:hover { text-decoration: underline; }
|
||||||
.refund-scan-upload {
|
.refund-scan-upload {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 118px;
|
grid-template-columns: 1fr 118px;
|
||||||
@ -2745,6 +2962,12 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 22px;
|
margin-top: 22px;
|
||||||
}
|
}
|
||||||
|
.refund-confirm-modal > .refund-proof-upload {
|
||||||
|
grid-template-columns: 54px 1fr;
|
||||||
|
gap: 7px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
.refund-confirm-modal button {
|
.refund-confirm-modal button {
|
||||||
padding: 11px;
|
padding: 11px;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
@ -3864,6 +4087,10 @@
|
|||||||
background: #2864e8;
|
background: #2864e8;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
.review-v2-table > .row > span:last-child button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
.review-v2-empty {
|
.review-v2-empty {
|
||||||
padding: 60px;
|
padding: 60px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -3935,6 +4162,15 @@
|
|||||||
background: #f7faff;
|
background: #f7faff;
|
||||||
color: #607998;
|
color: #607998;
|
||||||
}
|
}
|
||||||
|
.refund-prompt-next {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid #bad0f2;
|
||||||
|
background: #eef5ff;
|
||||||
|
color: #1764e8;
|
||||||
|
}
|
||||||
.refund-prompt-confirm {
|
.refund-prompt-confirm {
|
||||||
background: #1764e8;
|
background: #1764e8;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@ -3950,6 +4186,29 @@
|
|||||||
color: #173251;
|
color: #173251;
|
||||||
box-shadow: 0 25px 85px rgba(7, 19, 37, 0.4);
|
box-shadow: 0 25px 85px rgba(7, 19, 37, 0.4);
|
||||||
}
|
}
|
||||||
|
.review-detail-loading,
|
||||||
|
.task-detail-loading {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
width: min(320px, calc(100vw - 36px));
|
||||||
|
padding: 26px 24px;
|
||||||
|
border: 1px solid #dce7f5;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1764e8;
|
||||||
|
box-shadow: 0 20px 60px rgba(7, 25, 55, .28);
|
||||||
|
}
|
||||||
|
.review-detail-loading strong,
|
||||||
|
.task-detail-loading strong {
|
||||||
|
color: #173251;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.review-detail-loading span,
|
||||||
|
.task-detail-loading span {
|
||||||
|
color: #7187a1;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
.review-v2-modal > header {
|
.review-v2-modal > header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -4096,6 +4355,17 @@
|
|||||||
background: #07945c;
|
background: #07945c;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
.review-v2-modal footer .review-next-button,
|
||||||
|
.review-v2-processed-actions .review-next-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid #b9d0f1;
|
||||||
|
background: #eef5ff;
|
||||||
|
color: #1764e8;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.review-v2-modal footer button:disabled {
|
.review-v2-modal footer button:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
@ -4740,6 +5010,7 @@
|
|||||||
}
|
}
|
||||||
.ask-config .panel-title {
|
.ask-config .panel-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
padding-bottom: 13px;
|
padding-bottom: 13px;
|
||||||
@ -4752,6 +5023,25 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
.ask-clear-all {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #ffc2c8;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff5f6;
|
||||||
|
color: #d9364f;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ask-clear-all:hover {
|
||||||
|
background: #ffe8eb;
|
||||||
|
}
|
||||||
|
.ask-clear-all:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: .6;
|
||||||
|
}
|
||||||
.ask-config label {
|
.ask-config label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@ -5009,6 +5299,14 @@
|
|||||||
.ask-created-summary {
|
.ask-created-summary {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.ask-link-value {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.ask-link-value code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.ask-created-icon {
|
.ask-created-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -5036,11 +5334,36 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.ask-link-box code {
|
.ask-link-box code {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #1764e8;
|
color: #1764e8;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.ask-link-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.ask-link-value button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #bfd3f3;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1764e8;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ask-link-value button:hover {
|
||||||
|
border-color: #1764e8;
|
||||||
|
background: #eef5ff;
|
||||||
|
}
|
||||||
.ask-link-box small {
|
.ask-link-box small {
|
||||||
color: #899bb0;
|
color: #899bb0;
|
||||||
}
|
}
|
||||||
@ -5466,3 +5789,201 @@ body img {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ask-user-sticky > span {
|
||||||
|
overflow: visible;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-assignment-expired-notice {
|
||||||
|
margin: 0 16px 94px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #ffc7bd;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fff3f0;
|
||||||
|
color: #d94b35;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.ask-user-sticky > span {
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-assignment-expired-notice {
|
||||||
|
position: fixed;
|
||||||
|
right: 10px;
|
||||||
|
bottom: calc(70px + env(safe-area-inset-bottom));
|
||||||
|
left: 10px;
|
||||||
|
z-index: 9;
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: 0 4px 14px rgba(77, 44, 35, .12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DingTalk may expose a desktop-sized layout viewport on mobile. Apply the
|
||||||
|
public portal layout from its user agent as well as from media queries. */
|
||||||
|
html.dingtalk-mobile {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dingtalk-mobile,
|
||||||
|
html.dingtalk-mobile body,
|
||||||
|
html.dingtalk-mobile #root {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dingtalk-mobile .ask-user-shell,
|
||||||
|
html.dingtalk-mobile .answer-v2-shell {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background: #f5f8fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dingtalk-mobile .ask-user-app,
|
||||||
|
html.dingtalk-mobile .answer-v2-app {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dingtalk-mobile .ask-user-app *,
|
||||||
|
html.dingtalk-mobile .answer-v2-app * {
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dingtalk-mobile .ask-user-topbar {
|
||||||
|
padding-top: calc(13px + env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dingtalk-mobile .ask-user-profile,
|
||||||
|
html.dingtalk-mobile .ask-user-task-id {
|
||||||
|
margin-inline: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-user-task-id {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-user-task-id > span,
|
||||||
|
.ask-user-task-id > button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-user-task-id > code {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-user-task-id > button {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dingtalk-mobile .ask-question-section,
|
||||||
|
html.dingtalk-mobile .ask-confirm-body {
|
||||||
|
padding-inline: 10px;
|
||||||
|
padding-bottom: calc(110px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dingtalk-mobile .ask-user-sticky {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 10px calc(14px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dingtalk-mobile .ask-user-sticky > span {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dingtalk-mobile .ask-user-sticky > button {
|
||||||
|
flex: 0 1 180px;
|
||||||
|
width: min(180px, 46vw);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dingtalk-mobile .answer-v2-body {
|
||||||
|
min-height: 0;
|
||||||
|
padding-bottom: calc(88px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dingtalk-mobile .answer-v2-footer {
|
||||||
|
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.ask-user-task-id {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 7px;
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-user-task-id > code {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-user-task-id > button {
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.board-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
color: #607596;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-pagination label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-pagination select,
|
||||||
|
.board-pagination button {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #cddaf0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #17345e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-pagination button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.board-pagination {
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import "./styles.css";
|
|||||||
import "./admin.css";
|
import "./admin.css";
|
||||||
import CreateAnswerTasksPageV2 from "./CreateAnswerTasksPage.jsx";
|
import CreateAnswerTasksPageV2 from "./CreateAnswerTasksPage.jsx";
|
||||||
import AnswerUserPortalV2 from "./AnswerUserPortal.jsx";
|
import AnswerUserPortalV2 from "./AnswerUserPortal.jsx";
|
||||||
import TaskBoardPage from "./TaskBoardPage.jsx";
|
import TaskBoardPage, { clearTaskBoardCache } from "./TaskBoardPage.jsx";
|
||||||
import ReviewCenterPage from "./ReviewCenterPage.jsx";
|
import ReviewCenterPage from "./ReviewCenterPage.jsx";
|
||||||
import CreateAskTasksPage from "./CreateAskTasksPage.jsx";
|
import CreateAskTasksPage from "./CreateAskTasksPage.jsx";
|
||||||
import AskUserPortal from "./AskUserPortal.jsx";
|
import AskUserPortal from "./AskUserPortal.jsx";
|
||||||
@ -29,6 +29,14 @@ import { readPageCache, writePageCache } from "./pageCache.js";
|
|||||||
if (window.location.pathname === "/answer")
|
if (window.location.pathname === "/answer")
|
||||||
window.history.replaceState(null, "", "/answer/");
|
window.history.replaceState(null, "", "/answer/");
|
||||||
|
|
||||||
|
const publicPortalUserAgent = navigator.userAgent || "";
|
||||||
|
const isDingTalkMobile =
|
||||||
|
(/DingTalk|AliApp\(DT/i.test(publicPortalUserAgent) &&
|
||||||
|
/Android|iPhone|iPad|iPod|Mobile/i.test(publicPortalUserAgent)) ||
|
||||||
|
(new URLSearchParams(window.location.search).get("from") === "dingtalk" &&
|
||||||
|
window.matchMedia("(max-width: 700px)").matches);
|
||||||
|
if (isDingTalkMobile) document.documentElement.classList.add("dingtalk-mobile");
|
||||||
|
|
||||||
const API = import.meta.env.VITE_API_BASE || "";
|
const API = import.meta.env.VITE_API_BASE || "";
|
||||||
const Z = {
|
const Z = {
|
||||||
brand: "\u3010\u95ee\u5927\u5bb6\u3011",
|
brand: "\u3010\u95ee\u5927\u5bb6\u3011",
|
||||||
@ -2402,6 +2410,7 @@ function AdminPage({ type }) {
|
|||||||
function AnswerTaskPage() {
|
function AnswerTaskPage() {
|
||||||
const segments = window.location.pathname.split('/').filter(Boolean)
|
const segments = window.location.pathname.split('/').filter(Boolean)
|
||||||
const linkToken = segments[0] === 'answer' ? (segments[1] || '') : ''
|
const linkToken = segments[0] === 'answer' ? (segments[1] || '') : ''
|
||||||
|
if (!linkToken) return <InvalidAnswerLink />;
|
||||||
return <AnswerUserPortalV2 api={api} linkToken={linkToken} />;
|
return <AnswerUserPortalV2 api={api} linkToken={linkToken} />;
|
||||||
/* Legacy task-specific flow retained temporarily for migration. */
|
/* Legacy task-specific flow retained temporarily for migration. */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -2652,6 +2661,19 @@ function AnswerTaskPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InvalidAnswerLink() {
|
||||||
|
return (
|
||||||
|
<main className="answer-v2-shell">
|
||||||
|
<section className="answer-v2-app login-only">
|
||||||
|
<section className="answer-v2-login">
|
||||||
|
<h1>回答任务链接无效</h1>
|
||||||
|
<p>请联系对应运营获取专属回答任务链接。</p>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
function SubmissionStatusDynamic({
|
function SubmissionStatusDynamic({
|
||||||
record,
|
record,
|
||||||
history,
|
history,
|
||||||
@ -2881,7 +2903,7 @@ function AnswerUserPortal() {
|
|||||||
<strong>用户ID:{session.user_id}</strong>
|
<strong>用户ID:{session.user_id}</strong>
|
||||||
<span>{latest?.status || "待上传"}</span>
|
<span>{latest?.status || "待上传"}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{session.phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")}</p>
|
<p>{session.taobao_id || session.account || session.phone}</p>
|
||||||
</div>
|
</div>
|
||||||
<section className="answer-public-body">
|
<section className="answer-public-body">
|
||||||
{error && <div className="form-error">{error}</div>}
|
{error && <div className="form-error">{error}</div>}
|
||||||
@ -3595,7 +3617,7 @@ function CreateAnswerTasksPage() {
|
|||||||
<div>
|
<div>
|
||||||
<strong>{record.user_id}</strong>
|
<strong>{record.user_id}</strong>
|
||||||
<span>
|
<span>
|
||||||
{record.phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")}
|
{record.taobao_id || record.phone}
|
||||||
</span>
|
</span>
|
||||||
<code>{record.submission_no}</code>
|
<code>{record.submission_no}</code>
|
||||||
<em>{record.status}</em>
|
<em>{record.status}</em>
|
||||||
@ -3846,6 +3868,7 @@ function Dashboard({ user, onLogout }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (event) => {
|
const handler = (event) => {
|
||||||
if (event.type === "open-task-board") {
|
if (event.type === "open-task-board") {
|
||||||
|
clearTaskBoardCache();
|
||||||
if (event.detail?.status) window.__taskBoardInitialStatus = event.detail.status;
|
if (event.detail?.status) window.__taskBoardInitialStatus = event.detail.status;
|
||||||
setActive("board");
|
setActive("board");
|
||||||
}
|
}
|
||||||
@ -3919,7 +3942,7 @@ function Dashboard({ user, onLogout }) {
|
|||||||
<CreateAnswerTasksPageV2 api={api} user={user} />
|
<CreateAnswerTasksPageV2 api={api} user={user} />
|
||||||
)
|
)
|
||||||
) : active === "board" ? (
|
) : active === "board" ? (
|
||||||
<TaskBoardPage api={api} />
|
<TaskBoardPage api={api} user={user} />
|
||||||
) : active === "review" ? (
|
) : active === "review" ? (
|
||||||
<ReviewCenterPage api={api} />
|
<ReviewCenterPage api={api} />
|
||||||
) : active === "library" ? (
|
) : active === "library" ? (
|
||||||
@ -3991,6 +4014,7 @@ function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await api("/api/admin/auth/logout", { method: "POST" }).catch(() => {});
|
await api("/api/admin/auth/logout", { method: "POST" }).catch(() => {});
|
||||||
|
clearTaskBoardCache();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}
|
}
|
||||||
if (checking)
|
if (checking)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user