修复卡顿bug,新增问题批量导入,释放机制调整

This commit is contained in:
db_huang 2026-09-16 10:15:20 +08:00
parent 66aca010f1
commit 73ac3e8163
15 changed files with 3373 additions and 495 deletions

View File

@ -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 = [{
"type": "text", # Very long/high-detail screenshots get one final size reduction.
"text": ( image.thumbnail((1700, 1700), Image.Resampling.LANCZOS)
"请识别这些淘宝‘问答/待回答’页面截图中的消费者提问。只提取真正的问题句子," output = io.BytesIO()
"不要提取导航、按钮、商品名、用户昵称、规则文字或页面标题。去除序号并保持原文。" image.save(output, format="JPEG", quality=72, optimize=True)
"多张截图合并去重。只返回严格 JSON:" return output.getvalue(), "image/jpeg"
'{"items":[{"product_title":"商品名或待识别商品","question":"问题原文"}]}' except Exception:
), logger.warning("Could not compress OCR image; using original payload", exc_info=True)
}] return image_data, content_type
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}"}) def _extract_questions_from_image(image_data: bytes, content_type: str) -> list[dict]:
content.append({ encoded = base64.b64encode(image_data).decode("ascii")
content = [
{
"type": "text",
"text": (
"请识别这张淘宝‘问答/待回答’页面截图中的所有消费者提问。只提取真正的问题句子,"
"不要提取导航、按钮、商品名、用户昵称、规则文字或页面标题。去除序号并保持原文。"
"只返回严格 JSON:"
'{"items":[{"product_title":"商品名或待识别商品","question":"问题原文"}]}'
),
},
{
"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")) return _parse_ocr_content(result["choices"][0]["message"]["content"])
content = result["choices"][0]["message"]["content"]
return _parse_ocr_content(content)
except Exception: def _recognize_one_image(index: int, image: tuple[bytes, str]) -> tuple[int, list[dict]]:
logger.exception("Question OCR failed") 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:
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]

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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
View 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"

View File

@ -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

View 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())

View File

@ -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>
} }

View File

@ -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]);
useEffect(() => {
if (!expiresAt) return undefined;
expiredRef.current = false;
const update = () => {
const next = Math.max(0, (parseServerDate(expiresAt)?.getTime() - Date.now()) / 1000);
setRemaining(next);
if (next <= 0 && !expiredRef.current) {
expiredRef.current = true;
onExpiredRef.current?.();
}
};
update();
const timer = window.setInterval(update, 1000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [cooldown]); }, [expiresAt]);
async function sendCode() { if (!expiresAt) return null;
if (!/^1\d{10}$/.test(phone)) return setError("请输入正确的 11 位手机号码"); return <span className={`ask-assignment-countdown ${remaining <= 10 ? "urgent" : ""}`}>剩余 {formatCountdown(remaining)}</span>;
setSending(true); setError(""); }
try {
const result = await api("/api/ask/auth/send-code", { method: "POST", body: JSON.stringify({ phone }) }); function TaobaoLogin({ api, onLogin }) {
setCooldown(result.data.retry_after || 60); setDevCode(result.data.dev_code || ""); const [taobaoId, setTaobaoId] = useState("");
} catch (requestError) { setError(requestError.message); } finally { setSending(false); } 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={() => {
setView("claim"); if (assignment && (assignment.remaining_count ?? 0) > 0 && ["已通过", "已完成"].includes(assignment.status)) {
// If a previous batch has completed and questions remain, opening void startNextSelection();
// the claim tab should show the next selectable batch directly. } else {
setClaimEdit( setView("claim");
Boolean( setClaimEdit(false);
assignment && setNotice("");
(assignment.remaining_count ?? 0) > 0 && }
assignment.status === "已通过",
),
);
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>

View File

@ -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">

View File

@ -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>
<code> <div className="ask-link-value">
{window.location.origin}/ask/{task.public_token} <code>
</code> {window.location.origin}/ask/{task.public_token}
</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>
);
}

View File

@ -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: "待返款" } }));
}} }}
/> />

View File

@ -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 rawStatus = String(task.status_label || task.status || "").trim();
const taskTypeLabel = String(task.task_type_label || "").trim(); return (STATUS_ALIASES[rawStatus] || rawStatus) === "待返款" || Boolean(task.refund_endpoint);
const rawStatus = String(task.status_label || task.status || "").trim(); }),
const taskStatus = STATUS_ALIASES[rawStatus] || rawStatus; [tasks],
const typeMatch = );
type === "all" ||
(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 requestDetail(task) {
if (task.detail_endpoint) {
const result = await api(task.detail_endpoint);
return result.data;
}
if (task.task_type === "ask") return task;
const result = await api(`/api/admin/task-board/${task.id}`);
return result.data;
}
async function openDetail(task) { 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 { try {
if (task.detail_endpoint) { const loaded = await requestDetail(task);
const result = await api(task.detail_endpoint); detailCache.current.set(key, loaded);
setDetail(result.data); setDetail(loaded);
} else if (task.task_type === "ask") { if (nextCandidate && !detailCache.current.has(detailCacheKey(nextCandidate))) {
setDetail(task); void requestDetail(nextCandidate)
} else { .then((nextDetail) => detailCache.current.set(detailCacheKey(nextCandidate), nextDetail))
const result = await api(`/api/admin/task-board/${task.id}`); .catch(() => {});
setDetail(result.data);
} }
} 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 && (

View File

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

View File

@ -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)