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 (
淘宝问答运营官方承接通道 官方认证

淘宝账号登录

输入淘宝ID即可领取提问任务

{error &&
{error}
}
); } 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_link}
)} {assignment.product_qr?.data_url && (
商品二维码
商品二维码
)}
)}
已选提问内容(共 {selectedQuestions.length} 题) 请在淘宝提问对应内容
{selectedQuestions.map((item, index) => (
#{index + 1} {item.question}
))}
1 上传截图(支持多图上传) 请上传淘宝“我的提问”记录截图凭证
支持多图 · 可多次添加 · JPG/PNG/WEBP 已上传 {files.length} 张
{previews.map(({ file, url }, index) => (
{`提问截图${index
图 {index + 1}
))}
addFiles(event.target.files)} />
请在有效期内完成上传并提交
2 填写收款信息 请填写准确的支付宝账号及实名姓名
{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.task_id} ID:{item.user_id}
{item.status}

{item.questions .filter((question) => question.selected) .map((question) => question.question) .join("、") || "尚未选择问题"}

认领问题{" "} {item.questions.filter((question) => question.selected).length} 条 · 更新时间{" "} {formatDateTime(item.updated_at)}

{item.status === "待审核" ? "凭证已锁定,专员核验中" : ["已通过", "已完成"].includes(item.status) ? item.status === "已完成" ? "任务已完成" : "审核已通过" : item.status === "未通过" ? "需要重新提交" : "等待选择问题"}
)) )}
); } 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 (
{ if (assignment && (assignment.remaining_count ?? 0) > 0 && ["已通过", "已完成"].includes(assignment.status)) { void startNextSelection(); } else { setView("claim"); setClaimEdit(false); setNotice(""); } }} /> {error &&
{error}
} {notice &&
{notice}
} {view === "status" ? ( { setAssignment(item); setSelected( item.questions .filter((question) => question.selected) .map((question) => question.id), ); setView("claim"); }} /> ) : !assignment ? (
{busy ? "正在随机分配问题…" : "暂时无法读取任务"}
) : claimEdit || assignment.status === "待选择" ? ( ) : ["待审核", "已通过", "已完成"].includes(assignment.status) ? ( loadAssignment()} busy={busy} onBackHome={() => { const consumed = assignment.questions.filter( (item) => item.used || (["待审核", "已通过"].includes(assignment.status) && item.selected), ); const selectedCount = assignment.used_count ?? consumed.length; // Start a fresh selection containing only the remaining items; // consumed questions stay visibly locked in the list. setSelected([]); if (selectedCount >= assignment.questions.length) { setView("status"); loadHistory(); setNotice("本次任务您的认领额度已达上限,请关注下一次任务"); } else { void startNextSelection(); } }} /> ) : ( )}
); }