import React, { useEffect, useMemo, useRef, useState } from "react";
import {
Check,
ClipboardCheck,
Copy,
ImagePlus,
ListChecks,
LogOut,
MessageCircle,
PackageCheck,
ShieldCheck,
Trash2,
} from "lucide-react";
import { isValidTaobaoId, normalizeTaobaoId, TAOBAO_ID_HINT } from "./taobaoId.js";
import { formatDateTime } from "./time.js";
const SESSION_KEY = "ask_user_taobao_session_v2";
const LEGACY_SESSION_KEYS = ["ask_user_taobao_session", "ask_user_session"];
const EXHAUSTED_MESSAGE = "本次任务已被领取完,请关注下一个任务!";
const COMPLETED_TASK_MESSAGE = "本次提问任务已全部完成,可查看历史状态";
const INVALID_TASK_MESSAGE = "提问任务链接无效、未发布或已结束";
const fileKey = (file) => `${file.name}-${file.size}-${file.lastModified}`;
const accountName = (session) =>
session?.taobao_id || session?.account || session?.user_id || "";
function getTaskNo() {
const segments = window.location.pathname.split("/").filter(Boolean);
return segments[1] || "latest";
}
function isInvalidTaskError(message = "") {
return message.includes("提问任务链接无效") || message.includes("提问任务不存在");
}
function isExpiredAssignmentError(message = "") {
return message.includes("提问任务已失效") || message.includes("任务已失效");
}
function formatCountdown(seconds) {
const safe = Math.max(0, Number(seconds) || 0);
const minutes = Math.floor(safe / 60).toString().padStart(2, "0");
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(() => {
onExpiredRef.current = onExpired;
}, [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);
}, [expiresAt]);
if (!expiresAt) return null;
return 剩余 {formatCountdown(remaining)};
}
function TaobaoLogin({ api, onLogin }) {
const [taobaoId, setTaobaoId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit(event) {
event.preventDefault();
const account = normalizeTaobaoId(taobaoId);
if (!isValidTaobaoId(account)) {
setError(TAOBAO_ID_HINT);
return;
}
setBusy(true);
setError("");
try {
const result = await api("/api/ask/auth/taobao-login", {
method: "POST",
body: JSON.stringify({ taobao_id: account }),
});
localStorage.setItem(SESSION_KEY, JSON.stringify(result.data));
onLogin(result.data);
} catch (requestError) {
setError(requestError.message);
} finally {
setBusy(false);
}
}
return (
);
}
function TaskExhausted({ session, onLogout }) {
return (
任务已领完
本次任务已被领取完
当前链接中的问题已全部分配给其他用户,请关注下一个任务!
当前淘宝ID:{accountName(session)}
);
}
function InvalidTask({ message, onReload }) {
return (
任务链接不可用
暂时无法读取任务
{message || INVALID_TASK_MESSAGE}。请向运营确认是否已成功发布,并重新打开最新任务链接。
);
}
function CompletedTask({ session, onOpenHistory, busy, error }) {
return (
任务已完成
本次提问任务已全部完成
当前任务的所有问题均已完成,您可以前往历史状态查看自己提交过的任务和审核进度。
当前淘宝ID:{accountName(session)}
{error && {error}
}
);
}
function ExpiredAssignment({ session, onReload }) {
return (
任务已失效
本次任务已超时
当前分配的问题已自动释放,请点击刷新重新分配任务。
当前淘宝ID:{accountName(session)}
);
}
function Header({
session,
assignment,
onLogout,
step,
view,
onOpenHistory,
onOpenClaim,
}) {
return (
<>
问
问大家提问用户
淘宝账号已登录
淘宝ID:{accountName(session)}
>
);
}
function SelectQuestions({
assignment,
selected,
setSelected,
onConfirm,
busy,
onExpired,
expired = false,
onRefresh,
}) {
const isSubmitted = ["待审核", "已通过"].includes(assignment.status);
const isUsed = (item) => item.used || (isSubmitted && item.selected);
const usedCount = assignment.used_count ?? assignment.questions.filter(isUsed).length;
const remainingCount = assignment.remaining_count ?? Math.max(assignment.questions.length - usedCount, 0);
const selectableQuestions = assignment.questions.filter((item) => !isUsed(item));
const allSelected = selectableQuestions.length > 0 && selectableQuestions.every((item) =>
selected.includes(item.id),
);
function toggle(id) {
if (expired || isUsed(assignment.questions.find((item) => item.id === id))) return;
setSelected((current) =>
current.includes(id)
? current.filter((value) => value !== id)
: [...current, id],
);
}
return (
<>
任务ID
{assignment.task_id}
本任务认领进度
每个链接最多认领 {assignment.questions.length} 条
已认领 {usedCount} / {assignment.questions.length}
{remainingCount
? `还可继续选择 ${remainingCount} 条问题`
: "本任务认领额度已达上限"}
请选择提问内容(共 {assignment.questions.length} 题)
{assignment.questions.map((item) => {
const active = selected.includes(item.id);
return (
);
})}
{expired && (
领取超时,问题已自动释放,请点击“刷新重新分配”获取新任务。
)}
>
);
}
function ConfirmStage({
assignment,
api,
session,
taskNo,
onSubmitted,
setError,
onExpired,
}) {
const selectedQuestions = assignment.questions.filter(
(item) => item.selected,
);
const [files, setFiles] = useState([]);
const [payeeName, setPayeeName] = useState("");
const [alipayAccount, setAlipayAccount] = useState("");
const [reviewOpen, setReviewOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [copiedId, setCopiedId] = useState(null);
const inputRef = useRef(null);
const previews = useMemo(
() => files.map((file) => ({ file, url: URL.createObjectURL(file) })),
[files],
);
useEffect(
() => () => previews.forEach((item) => URL.revokeObjectURL(item.url)),
[previews],
);
function addFiles(list) {
const images = Array.from(list).filter((file) =>
/^image\/(jpeg|png|webp)$/.test(file.type),
);
setFiles((current) =>
[...current, ...images].filter(
(file, index, all) =>
all.findIndex((row) => fileKey(row) === fileKey(file)) === index,
),
);
}
async function copyQuestion(item) {
try {
await navigator.clipboard.writeText(item.question);
setCopiedId(item.id);
window.setTimeout(
() => setCopiedId((current) => (current === item.id ? null : current)),
1500,
);
} catch {
setCopiedId(null);
}
}
async function submit() {
setBusy(true);
setError("");
const body = new FormData();
body.append("token", session.token);
files.forEach((file) => body.append("files", file));
body.append("payee_name", payeeName);
body.append("alipay_account", alipayAccount.trim());
try {
const result = await api(
`/api/ask/tasks/${encodeURIComponent(taskNo)}/submission`,
{
method: "POST",
body,
},
);
setReviewOpen(false);
onSubmitted(result.data);
} catch (requestError) {
setReviewOpen(false);
if (isExpiredAssignmentError(requestError.message)) onExpired();
else setError(requestError.message);
} finally {
setBusy(false);
}
}
return (
问大家核验商品
{assignment.task_id}
官方任务商品
{assignment.product_name}
{(assignment.product_link || assignment.product_qr) && (
{assignment.product_link && (
)}
{assignment.product_qr?.data_url && (
商品二维码
)}
)}
已选提问内容(共 {selectedQuestions.length} 题)
请在淘宝提问对应内容
{selectedQuestions.map((item, index) => (
#{index + 1}
{item.question}
))}
支持多图 · 可多次添加 · JPG/PNG/WEBP
已上传 {files.length} 张
{previews.map(({ file, url }, index) => (
图 {index + 1}
))}
addFiles(event.target.files)}
/>
{reviewOpen && (
提交审核核对
请确认凭证信息无误,提交后将锁定审核
任务编号(TaskID)
{assignment.task_id}
已完成问题
{selectedQuestions.length} 条(已认领)
{selectedQuestions.map((item) => (
{item.question}
))}
提问截图
已上传({files.length}张)
支付宝收款信息
{payeeName} / {alipayAccount}
)}
);
}
function AuditStatus({ assignment, reload, busy, onBackHome }) {
const pending = assignment.status === "待审核";
const paid = assignment.status === "已完成";
const passed = ["已通过", "已完成"].includes(assignment.status);
const review = assignment.latest_review;
return (
{passed ? "✓" : assignment.status === "未通过" ? "!" : "⌁"}
{assignment.status}
{pending
? "任务正在审核,请耐心等待。"
: paid
? "返款已完成"
: passed
? "运营审核已通过"
: "运营审核未通过"}
{pending
? "审核专员正在核验您的淘宝提问截图与发布有效性,核验通过后将自动更新状态。"
: paid
? "运营已完成返款,以下为返款支付凭证,请留意您的收款账户。"
: passed
? "您提交的提问凭证已经核验通过,本次任务审核完成。"
: "请根据运营反馈修改截图后重新提交审核。"}
提交记录凭据
- 任务ID:
- {assignment.task_id}
- 提交问题:
-
{assignment.questions.filter((item) => item.selected).length} 条
- 提交时间:
-
{formatDateTime(assignment.updated_at)}
- 锁定状态:
- {pending ? "已锁定不可撤销" : assignment.status}
{review && !review.approved && (
)}
);
}
function TaskStatusPage({ history, onView }) {
const [filter, setFilter] = useState("all");
const inProgress = history.filter((item) =>
["待选择", "进行中", "待审核"].includes(item.status),
).length;
const completed = history.filter((item) =>
["已通过", "已完成", "未通过"].includes(item.status),
).length;
const filtered = history.filter(
(item) =>
filter === "all" ||
(filter === "progress"
? ["待选择", "进行中", "待审核"].includes(item.status)
: ["已通过", "已完成", "未通过"].includes(item.status)),
);
return (
全部任务({history.length})
进行中 {inProgress}
已完成 {completed}
{filtered.length === 0 ? (
暂无历史提交任务
) : (
filtered.map((item) => (
{item.questions
.filter((question) => question.selected)
.map((question) => question.question)
.join("、") || "尚未选择问题"}
认领问题{" "}
{item.questions.filter((question) => question.selected).length} 条
· 更新时间{" "}
{formatDateTime(item.updated_at)}
))
)}
);
}
export default function AskUserPortal({ api }) {
const taskNo = getTaskNo();
const [session, setSession] = useState(() => {
try {
LEGACY_SESSION_KEYS.forEach((key) => localStorage.removeItem(key));
return JSON.parse(localStorage.getItem(SESSION_KEY) || "null");
} catch {
return null;
}
});
const [assignment, setAssignment] = useState(null);
const [selected, setSelected] = useState([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [history, setHistory] = useState([]);
const [view, setView] = useState("claim");
const [notice, setNotice] = useState("");
const [claimEdit, setClaimEdit] = useState(false);
const [exhausted, setExhausted] = useState(false);
const [invalidTask, setInvalidTask] = useState("");
const [completedTask, setCompletedTask] = useState(false);
const [expiredAssignment, setExpiredAssignment] = useState(false);
const [selectionExpired, setSelectionExpired] = useState(false);
async function loadAssignment(silent = false, refresh = false) {
if (!session) return;
const previousAssignmentId = assignment?.assignment_id;
if (!silent) setBusy(true);
try {
const result = await api(
`/api/ask/tasks/${encodeURIComponent(taskNo)}?token=${encodeURIComponent(session.token)}${refresh ? "&refresh=true" : ""}`,
);
setAssignment(result.data);
setExhausted(false);
setInvalidTask("");
setCompletedTask(false);
setExpiredAssignment(false);
setSelectionExpired(false);
// Keep the current view. In particular, polling a pending review must
// not pull the user back to the audit screen after they chose to return
// to the claim page.
// Keep unsaved checkbox choices while a user is on the claim screen.
// If an expired reservation is refreshed, the fresh allocation is
// selected instead of restoring the stale checkbox choices.
if (!silent || result.data.assignment_id !== previousAssignmentId || assignment?.status !== "待选择") {
setSelected(
result.data.questions
// Questions already submitted for review are consumed and must not
// be counted as a new selection when the user returns to claim more.
.filter((item) => item.selected && !item.used)
.map((item) => item.id),
);
}
setError("");
} catch (requestError) {
if (requestError.message === EXHAUSTED_MESSAGE) {
setAssignment(null);
setExhausted(true);
setInvalidTask("");
setError("");
} else if (requestError.message === COMPLETED_TASK_MESSAGE) {
setAssignment(null);
setCompletedTask(true);
setInvalidTask("");
setError("");
} else if (isInvalidTaskError(requestError.message)) {
setAssignment(null);
setInvalidTask(requestError.message);
setError("");
} else if (isExpiredAssignmentError(requestError.message)) {
setAssignment(null);
setSelected([]);
setExpiredAssignment(true);
setError("");
} else if (requestError.message.includes("登录已失效")) {
logout();
} else {
setError(requestError.message);
}
} finally {
if (!silent) setBusy(false);
}
}
async function loadHistory() {
setBusy(true);
try {
const result = await api(
`/api/ask/me/assignments?token=${encodeURIComponent(session.token)}`,
);
setHistory(result.data);
setView("status");
setError("");
} catch (requestError) {
setError(requestError.message);
} finally {
setBusy(false);
}
}
useEffect(() => {
loadAssignment();
}, [session?.token, taskNo]);
useEffect(() => {
if (!assignment || assignment.status !== "待审核") return undefined;
const interval = 10000;
const timer = window.setInterval(() => loadAssignment(true), interval);
return () => window.clearInterval(timer);
}, [assignment?.status, session?.token, taskNo]);
const logout = () => {
localStorage.removeItem(SESSION_KEY);
LEGACY_SESSION_KEYS.forEach((key) => localStorage.removeItem(key));
setSession(null);
setAssignment(null);
setExhausted(false);
setInvalidTask("");
setCompletedTask(false);
setExpiredAssignment(false);
setSelectionExpired(false);
};
if (!session) return ;
if (exhausted)
return ;
if (completedTask && view !== "status" && !assignment)
return ;
if (invalidTask)
return loadAssignment()} />;
if (expiredAssignment)
return ;
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() {
if (!selected.length) return;
setBusy(true);
setError("");
try {
const result = await api(
`/api/ask/tasks/${encodeURIComponent(taskNo)}/selection`,
{
method: "POST",
body: JSON.stringify({
token: session.token,
question_ids: selected,
}),
},
);
setAssignment(result.data);
// Selection is complete; continue directly to the upload/submit stage.
setClaimEdit(false);
setSelected([]);
} catch (requestError) {
setError(requestError.message);
} finally {
setBusy(false);
}
}
const step = assignment?.status === "待选择" ? 1 : 2;
return (
);
}