1119 lines
38 KiB
JavaScript
1119 lines
38 KiB
JavaScript
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>
|
||
);
|
||
}
|