wendajia/frontend/src/AskUserPortal.jsx

1119 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <span className={`ask-assignment-countdown ${remaining <= 10 ? "urgent" : ""}`}>剩余 {formatCountdown(remaining)}</span>;
}
function TaobaoLogin({ api, onLogin }) {
const [taobaoId, setTaobaoId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit(event) {
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 (
<main className="ask-user-shell">
<section className="ask-user-app ask-login-app">
<header className="ask-user-topbar">
<span>
<ShieldCheck size={14} />
淘宝问答运营官方承接通道
</span>
<em>官方认证</em>
</header>
<form className="ask-user-login" onSubmit={submit}>
<span className="ask-login-icon">
<MessageCircle size={28} />
</span>
<h1>淘宝账号登录</h1>
<p>输入淘宝ID即可领取提问任务</p>
<label>
淘宝ID
<input
autoFocus
maxLength="64"
value={taobaoId}
onChange={(event) => setTaobaoId(event.target.value.slice(0, 64))}
inputMode="text"
lang="zh-CN"
autoCapitalize="none"
autoCorrect="off"
placeholder="例如:tb1008611 或 淘宝用户"
/>
<small className="user-login-notice">为了运营更好地核对信息,请填写真实的淘宝ID;支持中文、字母和数字</small>
</label>
{error && <div className="ask-user-error">{error}</div>}
<button disabled={busy}>
{busy ? "登录中…" : "登录并领取提问任务"}
</button>
</form>
</section>
</main>
);
}
function TaskExhausted({ session, onLogout }) {
return (
<main className="ask-user-shell">
<section className="ask-user-app ask-exhausted-app">
<header className="ask-user-topbar">
<span>
<ShieldCheck size={14} />
淘宝问答运营官方承接通道
</span>
<em>官方认证</em>
</header>
<section className="ask-exhausted-card">
<span className="ask-exhausted-icon">
<PackageCheck size={34} />
</span>
<em>任务已领完</em>
<h1>本次任务已被领取完</h1>
<p>当前链接中的问题已全部分配给其他用户,请关注下一个任务!</p>
<small>
当前淘宝ID:{accountName(session)}
</small>
<button type="button" onClick={onLogout}>
更换登录账号
</button>
</section>
</section>
</main>
);
}
function InvalidTask({ message, 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">
<span className="ask-invalid-icon"><ShieldCheck size={34} /></span>
<em>任务链接不可用</em>
<h1>暂时无法读取任务</h1>
<p>{message || INVALID_TASK_MESSAGE}。请向运营确认是否已成功发布,并重新打开最新任务链接。</p>
<button type="button" onClick={onReload}>重新读取任务</button>
</section>
</section>
</main>
);
}
function CompletedTask({ session, onOpenHistory, busy, error }) {
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-completed-card">
<span className="ask-invalid-icon"><PackageCheck size={34} /></span>
<em>任务已完成</em>
<h1>本次提问任务已全部完成</h1>
<p>当前任务的所有问题均已完成,您可以前往历史状态查看自己提交过的任务和审核进度。</p>
<small>当前淘宝ID:{accountName(session)}</small>
{error && <div className="ask-user-error">{error}</div>}
<button type="button" onClick={onOpenHistory} disabled={busy}>
{busy ? "正在读取历史…" : "查看历史状态"}
</button>
</section>
</section>
</main>
);
}
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({
session,
assignment,
onLogout,
step,
view,
onOpenHistory,
onOpenClaim,
}) {
return (
<>
<header className="ask-user-topbar">
<span>
<ShieldCheck size={14} />
淘宝问答运营官方承接通道
</span>
<em>官方认证</em>
</header>
<section className="ask-user-profile">
<div className="ask-user-avatar">问</div>
<div>
<strong>问大家提问用户</strong>
<i>淘宝账号已登录</i>
<small>
淘宝ID:{accountName(session)}
</small>
</div>
<button title="退出登录" onClick={onLogout}>
<LogOut size={16} />
</button>
</section>
<nav className="ask-user-tabs">
<button
className={`ask-tab-button ${view === "claim" ? "active" : ""}`}
onClick={onOpenClaim}
>
<ClipboardCheck size={16} />
任务认领 <b>{assignment?.questions.length || 0}</b>
</button>
<button
className={`ask-tab-button ${view === "status" ? "active" : ""}`}
onClick={onOpenHistory}
>
<ListChecks size={16} />
任务状态 <b>{step === 1 ? "待选择" : "进行中"}</b>
</button>
</nav>
</>
);
}
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 (
<>
<section className="ask-user-task-id">
<span>
<ShieldCheck size={16} />
任务ID
</span>
<code>{assignment.task_id}</code>
<button
onClick={() => navigator.clipboard?.writeText(assignment.task_id)}
>
<Copy size={14} />
复制
</button>
</section>
<section className="ask-claim-summary">
<div>
<strong>本任务认领进度</strong>
<span>每个链接最多认领 {assignment.questions.length} 条</span>
</div>
<b>
已认领 {usedCount} / {assignment.questions.length}
</b>
<div className="ask-claim-progress">
<i
style={{
width: `${assignment.questions.length ? (usedCount / assignment.questions.length) * 100 : 0}%`,
}}
/>
</div>
<small>
{remainingCount
? `还可继续选择 ${remainingCount} 条问题`
: "本任务认领额度已达上限"}
</small>
</section>
<section className="ask-question-section">
<header>
<strong>
<ListChecks size={17} />
请选择提问内容(共 {assignment.questions.length} 题)
</strong>
<button
disabled={expired}
onClick={() =>
setSelected(
allSelected
? selected.filter(
(id) =>
isUsed(assignment.questions.find((item) => item.id === id)),
)
: assignment.questions.map((item) => item.id),
)
}
>
{allSelected ? "取消全选" : "全选"}
</button>
</header>
<div className="ask-question-list">
{assignment.questions.map((item) => {
const active = selected.includes(item.id);
return (
<button
key={item.id}
className={`${active ? "active" : ""} ${isUsed(item) ? "used" : ""}`}
disabled={expired || isUsed(item)}
onClick={() => toggle(item.id)}
>
<i>{active && <Check size={16} />}</i>
<span>{item.question}</span>
{isUsed(item) && <em>已认领 / 已使用</em>}
</button>
);
})}
</div>
</section>
{expired && (
<div className="ask-assignment-expired-notice" role="alert">
领取超时,问题已自动释放,请点击“刷新重新分配”获取新任务。
</div>
)}
<footer className="ask-user-sticky">
<span>
已选择 <b>{selected.length}</b> / {assignment.questions.length} 题
{!expired && <AssignmentCountdown expiresAt={assignment.expires_at} onExpired={onExpired} />}
</span>
<button disabled={busy || (!expired && !selected.length)} onClick={expired ? onRefresh : onConfirm}>
{busy ? "处理中…" : expired ? "刷新重新分配" : "确认"}
</button>
</footer>
</>
);
}
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 (
<div className="ask-confirm-body">
<section className="ask-product-card">
<header>
<ShieldCheck size={15} />
<strong>问大家核验商品</strong>
<code>{assignment.task_id}</code>
</header>
<div>
<span className="ask-product-placeholder">
<PackageCheck size={31} />
</span>
<div>
<small>官方任务商品</small>
<h2>{assignment.product_name}</h2>
</div>
</div>
{(assignment.product_link || assignment.product_qr) && (
<div className="ask-product-access">
{assignment.product_link && (
<footer>
<code title={assignment.product_link}>{assignment.product_link}</code>
<button
onClick={() => navigator.clipboard?.writeText(assignment.product_link)}
>
<Copy size={13} />
复制淘口令
</button>
</footer>
)}
{assignment.product_qr?.data_url && (
<figure className="ask-product-qr-card">
<img src={assignment.product_qr.data_url} alt="商品二维码" />
<figcaption>商品二维码</figcaption>
</figure>
)}
</div>
)}
</section>
<section className="ask-selected-card">
<header>
<strong>已选提问内容(共 {selectedQuestions.length} 题)</strong>
<span>请在淘宝提问对应内容</span>
</header>
{selectedQuestions.map((item, index) => (
<article key={item.id}>
<b>#{index + 1}</b>
<span>{item.question}</span>
<button onClick={() => copyQuestion(item)}>
{copiedId === item.id ? <Check size={13} /> : <Copy size={13} />}
{copiedId === item.id ? "已复制" : "复制"}
</button>
</article>
))}
</section>
<section className="ask-upload-card">
<header>
<div>
<b>1</b>
<strong>上传截图(支持多图上传)</strong>
<small>请上传淘宝“我的提问”记录截图凭证</small>
</div>
<button>查看范例</button>
</header>
<div className="ask-upload-summary">
<span>支持多图 · 可多次添加 · JPG/PNG/WEBP</span>
<b>已上传 {files.length} 张</b>
</div>
<div className="ask-upload-grid">
{previews.map(({ file, url }, index) => (
<figure key={fileKey(file)}>
<img src={url} alt={`提问截图${index + 1}`} />
<button
onClick={() =>
setFiles((current) =>
current.filter((row) => fileKey(row) !== fileKey(file)),
)
}
>
<Trash2 size={14} />
</button>
<figcaption>图 {index + 1}</figcaption>
</figure>
))}
<button
className="ask-upload-add"
onClick={() => inputRef.current?.click()}
>
<ImagePlus size={25} />
<strong>上传提问截图</strong>
<small>支持 JPG、PNG、WEBP</small>
</button>
</div>
<input
ref={inputRef}
hidden
multiple
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(event) => addFiles(event.target.files)}
/>
</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">
<header>
<div>
<b>2</b>
<strong>填写收款信息</strong>
<small>请填写准确的支付宝账号及实名姓名</small>
</div>
</header>
<label>
支付宝账号(必填)
<input value={alipayAccount} maxLength={128} onChange={(event) => setAlipayAccount(event.target.value)} placeholder="请输入支付宝账号(手机号或邮箱)" />
</label>
<label>
收款人真实姓名(必填)
<input
value={payeeName}
maxLength={64}
onChange={(event) => setPayeeName(event.target.value)}
placeholder="请输入收款人姓名"
/>
</label>
</section>
<button
className="ask-submit-review"
disabled={!files.length || !alipayAccount.trim() || !payeeName.trim()}
onClick={() => setReviewOpen(true)}
>
下一步:核对并提交审核
</button>
{reviewOpen && (
<div className="ask-submit-backdrop">
<section className="ask-submit-modal">
<header>
<ShieldCheck size={24} />
<button onClick={() => setReviewOpen(false)}>×</button>
<h2>提交审核核对</h2>
<p>请确认凭证信息无误,提交后将锁定审核</p>
</header>
<div className="ask-submit-summary">
<p>
<span>任务编号(TaskID)</span>
<code>{assignment.task_id}</code>
</p>
<p>
<span>已完成问题</span>
<b>{selectedQuestions.length} 条(已认领)</b>
</p>
{selectedQuestions.map((item) => (
<article key={item.id}>{item.question}</article>
))}
<p>
<span>提问截图</span>
<b>已上传({files.length}张)</b>
</p>
<p>
<span>支付宝收款信息</span>
<b>{payeeName} / {alipayAccount}</b>
</p>
</div>
<aside>
<strong>防作弊与审核锁定声明</strong>
<p>
点击提交后,任务状态将变更为“审核中”,凭证不可撤销修改;审核通常在
1~24 小时内完成。
</p>
</aside>
<footer>
<button onClick={() => setReviewOpen(false)}>返回修改</button>
<button className="confirm" disabled={busy} onClick={submit}>
{busy ? "提交中…" : "确认提交审核"}
</button>
</footer>
</section>
</div>
)}
</div>
);
}
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 (
<div className="ask-audit-body">
<section
className={`ask-audit-card ${passed ? "passed" : assignment.status === "未通过" ? "rejected" : ""}`}
>
<span>
{passed ? "✓" : assignment.status === "未通过" ? "!" : "⌁"}
</span>
<em>{assignment.status}</em>
<h1>
{pending
? "任务正在审核,请耐心等待。"
: paid
? "返款已完成"
: passed
? "运营审核已通过"
: "运营审核未通过"}
</h1>
<p>
{pending
? "审核专员正在核验您的淘宝提问截图与发布有效性,核验通过后将自动更新状态。"
: paid
? "运营已完成返款,以下为返款支付凭证,请留意您的收款账户。"
: passed
? "您提交的提问凭证已经核验通过,本次任务审核完成。"
: "请根据运营反馈修改截图后重新提交审核。"}
</p>
<div>
<strong>提交记录凭据</strong>
<dl>
<dt>任务ID:</dt>
<dd>{assignment.task_id}</dd>
<dt>提交问题:</dt>
<dd>
{assignment.questions.filter((item) => item.selected).length} 条
</dd>
<dt>提交时间:</dt>
<dd>
{formatDateTime(assignment.updated_at)}
</dd>
<dt>锁定状态:</dt>
<dd>{pending ? "已锁定不可撤销" : assignment.status}</dd>
</dl>
</div>
{review && !review.approved && (
<aside>
<strong>审核未通过原因</strong>
<p>
{[review.reason_code, review.reason_text]
.filter(Boolean)
.join(":")}
</p>
</aside>
)}
<button onClick={reload} disabled={busy}>
{busy ? "刷新中…" : "刷新审核状态"}
</button>
<button className="ask-back-home" onClick={onBackHome}>
返回任务首页
</button>
</section>
</div>
);
}
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 (
<div className="ask-status-page">
<div className="ask-history-tabs">
<button
className={filter === "all" ? "active" : ""}
onClick={() => setFilter("all")}
>
全部任务 <b>{history.length}</b>
</button>
<button
className={filter === "progress" ? "active" : ""}
onClick={() => setFilter("progress")}
>
进行中 <b>{inProgress}</b>
</button>
<button
className={filter === "done" ? "active" : ""}
onClick={() => setFilter("done")}
>
已完成 <b>{completed}</b>
</button>
<b>全部任务({history.length})</b>
<span>进行中 {inProgress}</span>
<span>已完成 {completed}</span>
</div>
{filtered.length === 0 ? (
<div className="ask-user-loading">暂无历史提交任务</div>
) : (
filtered.map((item) => (
<article
className={`ask-history-item ${item.status === "待审核" ? "pending" : ["已通过", "已完成"].includes(item.status) ? "done" : ""}`}
key={item.assignment_id}
>
<header>
<div>
<code>{item.task_id}</code>
<small>ID:{item.user_id}</small>
</div>
<em>{item.status}</em>
</header>
<h3>
{item.questions
.filter((question) => question.selected)
.map((question) => question.question)
.join("、") || "尚未选择问题"}
</h3>
<p>
认领问题{" "}
{item.questions.filter((question) => question.selected).length} 条
· 更新时间{" "}
{formatDateTime(item.updated_at)}
</p>
<footer>
<span>
{item.status === "待审核"
? "凭证已锁定,专员核验中"
: ["已通过", "已完成"].includes(item.status)
? item.status === "已完成" ? "任务已完成" : "审核已通过"
: item.status === "未通过"
? "需要重新提交"
: "等待选择问题"}
</span>
<button onClick={() => onView(item)}>查看进度</button>
</footer>
</article>
))
)}
</div>
);
}
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 <TaobaoLogin api={api} onLogin={setSession} />;
if (exhausted)
return <TaskExhausted session={session} onLogout={logout} />;
if (completedTask && view !== "status" && !assignment)
return <CompletedTask session={session} onOpenHistory={loadHistory} busy={busy} error={error} />;
if (invalidTask)
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() {
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 (
<main className="ask-user-shell">
<section className="ask-user-app">
<Header
session={session}
assignment={assignment}
onLogout={logout}
step={step}
view={view}
onOpenHistory={loadHistory}
onOpenClaim={() => {
if (assignment && (assignment.remaining_count ?? 0) > 0 && ["已通过", "已完成"].includes(assignment.status)) {
void startNextSelection();
} else {
setView("claim");
setClaimEdit(false);
setNotice("");
}
}}
/>
{error && <div className="ask-user-error page">{error}</div>}
{notice && <div className="ask-user-notice">{notice}</div>}
{view === "status" ? (
<TaskStatusPage
history={history}
onView={(item) => {
setAssignment(item);
setSelected(
item.questions
.filter((question) => question.selected)
.map((question) => question.id),
);
setView("claim");
}}
/>
) : !assignment ? (
<div className="ask-user-loading">
{busy ? "正在随机分配问题…" : "暂时无法读取任务"}
</div>
) : claimEdit || assignment.status === "待选择" ? (
<SelectQuestions
assignment={assignment}
selected={selected}
setSelected={setSelected}
onConfirm={confirm}
busy={busy}
onExpired={expireSelection}
expired={selectionExpired}
onRefresh={refreshAssignment}
/>
) : ["待审核", "已通过", "已完成"].includes(assignment.status) ? (
<AuditStatus
assignment={assignment}
reload={() => 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();
}
}}
/>
) : (
<ConfirmStage
assignment={assignment}
api={api}
session={session}
taskNo={taskNo}
onSubmitted={setAssignment}
setError={setError}
onExpired={expireUpload}
/>
)}
</section>
</main>
);
}