import React, { useEffect, useMemo, useRef, useState } from "react";
import { ChevronRight, LoaderCircle, Search, X } from "lucide-react";
import { qrDataUrl } from "./qrCode.js";
const STATUS_FILTERS = [
["all", "全部任务"],
["进行中", "进行中"],
["待审核", "待审核"],
["已完成", "已完成"],
["待返款", "待返款"],
["已撤销", "已撤销"],
];
const STATUS_ALIASES = {
"待用户完成": "进行中",
"未通过": "进行中",
"已发布": "进行中",
};
// Cache only the first unfiltered page and isolate it by administrator.
// This keeps navigation fast without ever showing another operator's rows.
const taskBoardCache = new Map();
export function clearTaskBoardCache() {
taskBoardCache.clear();
}
const formatTime = (value) => {
if (!value) return "--";
const raw = String(value);
// The API stores legacy UTC datetimes without an offset (datetime.utcnow()).
// Treat those values as UTC before displaying them in the business timezone.
const hasOffset = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw);
const date = new Date(hasOffset ? raw : `${raw}Z`);
return Number.isNaN(date.getTime())
? "--"
: date.toLocaleString("zh-CN", { hour12: false, timeZone: "Asia/Shanghai" });
};
const maskPhone = (account = "") => account;
function TaskDetailLoading() {
return (
正在打开任务详情
请求已收到,凭证图片将在弹窗内按需加载…
);
}
function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPollRefundUpload, hasNextRefund, onNextRefund, nextBusy }) {
const [refundOpen, setRefundOpen] = useState(false);
const [refundBusy, setRefundBusy] = useState(false);
const [refundFile, setRefundFile] = useState(null);
const [refundFileError, setRefundFileError] = useState("");
const [refundFilePreview, setRefundFilePreview] = useState("");
const [refundSession, setRefundSession] = useState(null);
const [refundQr, setRefundQr] = useState("");
const [refundSessionError, setRefundSessionError] = useState("");
const [linkCopied, setLinkCopied] = useState(false);
const refundPasteRef = useRef(null);
const isAskTask =
task.task_type === "ask" || task.task_type_label === "提问任务";
const taskLink = isAskTask
? `${window.location.origin}/ask/${task.public_token}`
: "";
const proofScreenshots = [
...(task.question_screenshots || []),
...(task.completion_screenshots || []),
];
const paymentScreenshots = task.payment_screenshots || [];
const refundScreenshots = task.refund_screenshots || [];
function setRefundProofFile(file) {
if (!file) return false;
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
setRefundFileError("返款凭证仅支持 JPG、PNG、WEBP 图片");
return false;
}
if (file.size > 10 * 1024 * 1024) {
setRefundFileError("返款凭证不能超过 10MB");
return false;
}
setRefundFile(file);
setRefundFileError("");
return true;
}
useEffect(() => {
if (!refundOpen || refundSession || !task.refund_endpoint) return undefined;
let cancelled = false;
setRefundSessionError("");
onCreateRefundUpload(task).then((session) => {
if (cancelled) return;
setRefundSession(session);
try {
setRefundQr(qrDataUrl(`${window.location.origin}${session.upload_url}`));
} catch (error) {
setRefundSessionError(error.message || "二维码生成失败,请改用下方粘贴上传");
}
}).catch((error) => !cancelled && setRefundSessionError(error.message));
return () => { cancelled = true; };
}, [refundOpen, refundSession, task.id, onCreateRefundUpload]);
useEffect(() => {
if (!refundOpen || !refundSession || refundSession.uploaded) return undefined;
let cancelled = false;
const poll = async () => {
try {
const current = await onPollRefundUpload(refundSession.token);
if (!cancelled && current.uploaded) setRefundSession((value) => ({ ...value, ...current }));
} catch (error) {
if (!cancelled) setRefundSessionError(error.message);
}
};
const timer = window.setInterval(poll, 2500);
return () => { cancelled = true; window.clearInterval(timer); };
}, [refundOpen, refundSession?.token, refundSession?.uploaded]);
useEffect(() => {
if (!refundFile) {
setRefundFilePreview("");
return undefined;
}
const previewUrl = URL.createObjectURL(refundFile);
setRefundFilePreview(previewUrl);
return () => URL.revokeObjectURL(previewUrl);
}, [refundFile]);
useEffect(() => {
if (!refundOpen) return undefined;
const pasteImage = (event) => {
const imageItem = Array.from(event.clipboardData?.items || []).find((item) =>
item.kind === "file" && item.type.startsWith("image/")
);
if (!imageItem) {
setRefundFileError("剪贴板中没有图片,请先复制或截图后再按 Ctrl+V");
return;
}
const blob = imageItem.getAsFile();
if (!blob) {
setRefundFileError("读取剪贴板图片失败,请重新复制后再试");
return;
}
const extension = blob.type === "image/jpeg" ? "jpg" : blob.type.split("/")[1];
const pastedFile = new File(
[blob],
`refund-proof-${Date.now()}.${extension}`,
{ type: blob.type, lastModified: Date.now() },
);
event.preventDefault();
setRefundProofFile(pastedFile);
};
window.addEventListener("paste", pasteImage);
const focusTimer = window.setTimeout(() => refundPasteRef.current?.focus(), 0);
return () => {
window.clearTimeout(focusTimer);
window.removeEventListener("paste", pasteImage);
};
}, [refundOpen]);
async function confirmRefund() {
setRefundBusy(true);
try {
await onConfirmRefund(task, refundFile, refundSession?.uploaded ? refundSession.token : null);
setRefundOpen(false);
setRefundSession(null);
setRefundFile(null);
} catch (error) {
window.alert(error.message);
} finally {
setRefundBusy(false);
}
}
async function copyTaskLink() {
if (!taskLink) return;
let copied = false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(taskLink);
copied = true;
}
} catch {
copied = false;
}
if (!copied) {
const textarea = document.createElement("textarea");
textarea.value = taskLink;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
copied = document.execCommand("copy");
} catch {
copied = false;
}
document.body.removeChild(textarea);
}
if (copied) {
setLinkCopied(true);
window.setTimeout(() => setLinkCopied(false), 1800);
}
}
return (
event.target === event.currentTarget && close()}
>
{taskLink && (
提问链接
event.target.select()}
/>
{linkCopied ? "已复制" : "复制链接"}
)}
{task.shop_name &&
店铺名称{task.shop_name || "--"}
}
商品名称{task.product_name}
任务类型{task.task_type_label}
创建人{task.creator_name}
创建时间{formatTime(task.created_at)}
认领用户的问题与回答(共 {task.assigned_users.length} 位)
序号
关联问题与回答
认领用户
状态
返款凭证
{task.selected_questions.length === 0 ? (
暂无问题
) : (
{task.selected_questions.map((question, index) => (
{index + 1}
Q:{question.question}
A:{question.answer}
{question.user_id || "--"}
{question.phone && question.phone !== question.user_id && {maskPhone(question.phone)} }
{task.status_label}
))}
{refundScreenshots.length ? (
已上传
) : (
"未上传"
)}
)}
{isAskTask ? "提问完成截图" : "回答完成截图"}
{proofScreenshots.length ? (
{proofScreenshots.map((image) => (
点击查看大图
))}
) : (
未上传完成截图
)}
收款码
{paymentScreenshots.length ? (
{paymentScreenshots.map((image) => (
点击核对收款码
))}
) : (
用户未单独上传收款码
)}
{task.refund_endpoint && (
{
setRefundSession(null);
setRefundFile(null);
setRefundFileError("");
setRefundQr("");
setRefundOpen(true);
}}
>
确认返款
)}
{(task.status_label === "待返款" || task.refund_completed) && (
{nextBusy ? "正在打开…" : hasNextRefund ? "下一个待返款任务" : "已是最后一个"}
{hasNextRefund && !nextBusy && }
)}
关闭
{refundOpen && (
event.target === event.currentTarget && setRefundOpen(false)
}
>
扫码
确认已经完成返款?
请确认已按用户上传的收款码完成付款。确认后,该任务将归入“已完成”,此操作不可撤销。
扫码上传返款凭证
二维码有效期 10 分钟
{refundSessionError && {refundSessionError} }
{refundSession?.uploaded && ✓ 手机端已上传,运营端可确认返款 }
{refundQr ?
:
正在生成二维码… }
{refundFilePreview ? (
) : (
粘贴
)}
剪贴板粘贴上传(可选)
{refundFile ? "图片已粘贴,可直接确认" : "复制图片后,按 Ctrl+V 粘贴"}
{refundFile ? `${refundFile.name} · ${(refundFile.size / 1024).toFixed(0)}KB` : "支持 JPG、PNG、WEBP,最大 10MB"}
{refundFileError &&
{refundFileError} }
{
setRefundProofFile(event.target.files?.[0]);
event.target.value = "";
}}
/>
无法粘贴?选择本地图片
setRefundOpen(false)}
>
取消
{refundBusy ? "确认中…" : "确定返款完成"}
)}
);
}
export default function TaskBoardPage({ api, user }) {
const accountCacheKey = String(user?.id || user?.username || "anonymous");
const initialStatus = window.__taskBoardInitialStatus || "all";
const initialCache = initialStatus === "all" ? taskBoardCache.get(accountCacheKey) : null;
const [tasks, setTasks] = useState(() => initialCache?.items || []);
const [type, setType] = useState("all");
const [status, setStatus] = useState(initialStatus);
const [keyword, setKeyword] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [page, setPage] = useState(() => initialCache?.page || 1);
const [pageSize, setPageSize] = useState(() => initialCache?.page_size || 20);
const [total, setTotal] = useState(() => initialCache?.total || 0);
const [totalPages, setTotalPages] = useState(() => initialCache?.total_pages || 1);
const [selectedIds, setSelectedIds] = useState([]);
const [expandedAskTasks, setExpandedAskTasks] = useState(() => new Set());
const [detail, setDetail] = useState(null);
const [detailLoading, setDetailLoading] = useState(null);
const [nextRefundTask, setNextRefundTask] = useState(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(() => !initialCache);
const [busy, setBusy] = useState("");
const [batchBusy, setBatchBusy] = useState(false);
const [batchConfirm, setBatchConfirm] = useState(null);
const [completeConfirm, setCompleteConfirm] = useState(null);
const detailCache = useRef(new Map());
async function load(showLoading = !tasks.length) {
if (showLoading) setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
task_type: type,
status_filter: status,
});
if (keyword.trim()) params.set("keyword", keyword.trim());
if (dateFrom) params.set("date_from", dateFrom);
if (dateTo) params.set("date_to", dateTo);
const result = await api(`/api/admin/task-board?${params.toString()}`);
const payload = result.data;
setTasks(payload.items);
setPage(payload.page);
setPageSize(payload.page_size);
setTotal(payload.total);
setTotalPages(payload.total_pages);
if (payload.page === 1 && type === "all" && status === "all" && !keyword.trim() && !dateFrom && !dateTo) {
taskBoardCache.set(accountCacheKey, payload);
}
setError("");
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => {
const timer = window.setTimeout(() => load(!tasks.length), keyword.trim() ? 250 : 0);
window.__taskBoardInitialStatus = "";
return () => window.clearTimeout(timer);
}, [accountCacheKey, page, pageSize, type, status, keyword, dateFrom, dateTo]);
const rowKey = (task) => task.board_key || `${task.task_type}-${task.id}`;
const detailCacheKey = (task) => task.detail_endpoint || rowKey(task);
const isAskTask = (task) => task.task_type === "ask" || task.task_type_label === "提问任务";
const childRows = (task) => (isAskTask(task) ? task.claimed_tasks || [] : []);
// Keep filtering as a direct derivation so changing either filter always
// immediately updates the table (without relying on a stale memoized list).
const rows = tasks;
const refundQueue = useMemo(
() => tasks.flatMap((task) => [task, ...childRows(task)]).filter((task) => {
const rawStatus = String(task.status_label || task.status || "").trim();
return (STATUS_ALIASES[rawStatus] || rawStatus) === "待返款" || Boolean(task.refund_endpoint);
}),
[tasks],
);
const visibleKeys = rows.map(rowKey);
const allVisibleSelected = visibleKeys.length > 0 && visibleKeys.every((key) => selectedIds.includes(key));
function toggleSelected(task) {
const key = rowKey(task);
setSelectedIds((current) => current.includes(key) ? current.filter((item) => item !== key) : [...current, key]);
}
function toggleAllVisible() {
setSelectedIds((current) => {
if (allVisibleSelected) return current.filter((key) => !visibleKeys.includes(key));
return [...new Set([...current, ...visibleKeys])];
});
}
function clearFilters() {
setKeyword("");
setDateFrom("");
setDateTo("");
setType("all");
setStatus("all");
setPage(1);
}
function toggleAskTask(task) {
const key = rowKey(task);
setExpandedAskTasks((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
function exportRows() {
if (!rows.length) {
window.alert("当前没有可导出的任务");
return;
}
const headers = ["用户ID", "任务ID", "商品/店铺信息", "类型", "Q&A数", "完成", "状态", "创建人", "返款凭证", "创建时间"];
const csvCell = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`;
const lines = [headers, ...rows.map((task) => [
task.user_id,
task.task_id,
[task.product_name, task.shop_name].filter(Boolean).join(" / "),
task.task_type_label,
task.qa_count,
task.completed_count,
task.status_label,
task.creator_name,
task.refund_screenshot_count ? "已上传" : "--",
formatTime(task.created_at),
])].map((line) => line.map(csvCell).join(","));
const blob = new Blob([`\uFEFF${lines.join("\r\n")}`], { type: "text/csv;charset=utf-8" });
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `任务看板-${new Date().toISOString().slice(0, 10)}.csv`;
anchor.click();
window.URL.revokeObjectURL(url);
}
async function batchClear() {
const selected = tasks.filter((task) => selectedIds.includes(rowKey(task)));
if (!selected.length) {
window.alert("请先选择要清除的任务");
return;
}
setBatchConfirm({ tasks: selected });
}
async function confirmBatchClear() {
if (!batchConfirm) return;
const { tasks: selected } = batchConfirm;
setBatchConfirm(null);
setBatchBusy(true);
try {
const results = await Promise.allSettled(selected.map((task) => api(
task.delete_endpoint || (task.task_type === "ask"
? `/api/admin/ask-tasks/${task.id}`
: `/api/admin/task-board/${task.id}`),
{ method: "DELETE" },
)));
const failedCount = results.filter((result) => result.status === "rejected").length;
setSelectedIds([]);
if (detail && selected.some((task) => task.id === detail.id && task.task_type === (detail.task_type || task.task_type))) {
setDetail(null);
}
await load();
if (failedCount) {
window.alert(`${selected.length - failedCount} 条任务已删除,${failedCount} 条任务处理失败,请刷新后重试。`);
}
} catch (err) {
window.alert(err.message);
} finally {
setBatchBusy(false);
}
}
async function requestDetail(task) {
if (task.detail_endpoint) {
const result = await api(task.detail_endpoint);
return result.data;
}
if (task.task_type === "ask") return task;
const result = await api(`/api/admin/task-board/${task.id}`);
return result.data;
}
async function openDetail(task) {
if (!task || detailLoading) return;
const key = detailCacheKey(task);
const refundIndex = refundQueue.findIndex((item) => rowKey(item) === rowKey(task));
const nextCandidate = refundIndex >= 0 ? refundQueue[refundIndex + 1] || null : null;
setNextRefundTask(nextCandidate);
const cached = detailCache.current.get(key);
if (cached) {
setDetail(cached);
return;
}
setDetailLoading(key);
try {
const loaded = await requestDetail(task);
detailCache.current.set(key, loaded);
setDetail(loaded);
if (nextCandidate && !detailCache.current.has(detailCacheKey(nextCandidate))) {
void requestDetail(nextCandidate)
.then((nextDetail) => detailCache.current.set(detailCacheKey(nextCandidate), nextDetail))
.catch(() => {});
}
} catch (err) {
window.alert(err.message);
} finally {
setDetailLoading(null);
}
}
async function cancel(task) {
if (
!window.confirm(
`确定撤销任务 ${task.task_id} 吗?撤销后用户将不能继续提交。`,
)
)
return;
setBusy(task.id);
try {
await api(
task.task_type === "ask"
? `/api/admin/ask-tasks/${task.id}/cancel`
: `/api/admin/task-board/${task.id}/cancel`,
{ method: "POST" },
);
await load();
if (detail?.id === task.id) setDetail(null);
} catch (err) {
window.alert(err.message);
} finally {
setBusy("");
}
}
async function complete(task) {
if (!completeConfirm || busy) return;
setBusy(task.id);
try {
await api(`/api/admin/ask-tasks/${task.id}/complete`, { method: "POST" });
setCompleteConfirm(null);
setSelectedIds((current) => current.filter((key) => key !== rowKey(task)));
await load();
if (detail?.id === task.id) setDetail(null);
} catch (err) {
setError(err.message);
} finally {
setBusy("");
}
}
async function createRefundUpload(task) {
const result = await api("/api/admin/refund-upload-sessions", {
method: "POST",
body: JSON.stringify({ target_kind: task.record_kind, target_id: task.id }),
});
return result.data;
}
async function pollRefundUpload(token) {
const result = await api(`/api/refund-upload-sessions/${encodeURIComponent(token)}`);
return result.data;
}
async function confirmRefund(task, refundFile, uploadToken) {
if (!refundFile && !uploadToken) throw new Error("请先粘贴返款凭证,或用手机扫码上传");
const body = new FormData();
if (refundFile) body.append("refund_file", refundFile);
if (uploadToken) body.append("upload_token", uploadToken);
const result = await api(task.refund_endpoint, { method: "POST", body });
const updatedDetail = {
...task,
...result.data,
question_screenshots:
task.question_screenshots || result.data.question_screenshots || [],
completion_screenshots:
task.completion_screenshots || result.data.completion_screenshots || [],
payment_screenshots:
task.payment_screenshots || result.data.payment_screenshots || [],
refund_screenshots:
result.data.refund_screenshots || task.refund_screenshots || [],
refund_screenshot_count: result.data.refund_screenshots?.length || task.refund_screenshot_count || 0,
};
detailCache.current.set(detailCacheKey(task), updatedDetail);
setDetail(updatedDetail);
setTasks((current) => {
const next = current.map((item) =>
item.board_key === result.data.board_key
? { ...item, ...result.data }
: item,
);
taskBoardCache.delete(accountCacheKey);
return next;
});
// A claimed ask row is nested under its system task. Refresh the compact
// tree as well so the child immediately leaves “待返款” after payment.
await load(false);
}
return (
<>
任务看板
监控全量问答任务执行状态、完成进度与审核链路。
任务类型:
{[
["all", "全部类型"],
["ask", "提问任务"],
["answer", "回答任务"],
].map(([value, label]) => (
{ setType(value); setPage(1); }}
>
{label}
))}
{STATUS_FILTERS.map(([value, label]) => (
{ setStatus(value); setPage(1); }}
>
{label}
))}
{ setKeyword(event.target.value); setPage(1); }}
placeholder="搜索用户ID、任务ID、店铺或商品…"
/>
开始日期
{ setDateFrom(event.target.value); setPage(1); }} />
结束日期
{ setDateTo(event.target.value); setPage(1); }} />
清空筛选
导出表格
{batchBusy ? "删除中…" : `批量删除${selectedIds.length ? `(${selectedIds.length})` : ""}`}
{error && {error}
}
共 {total} 条
每页
{ setPageSize(Number(event.target.value)); setPage(1); }}>
{[10, 20, 50, 100].map((size) => {size} 条 )}
setPage((value) => Math.max(1, value - 1))}>上一页
{page} / {totalPages}
= totalPages || loading} onClick={() => setPage((value) => Math.min(totalPages, value + 1))}>下一页
{detailLoading && !detail && }
{detail && (
setDetail(null)}
onConfirmRefund={confirmRefund}
onCreateRefundUpload={createRefundUpload}
onPollRefundUpload={pollRefundUpload}
hasNextRefund={Boolean(nextRefundTask)}
nextBusy={Boolean(detailLoading)}
onNextRefund={() => openDetail(nextRefundTask)}
/>
)}
{batchConfirm && (
event.target === event.currentTarget && setBatchConfirm(null)}
>
!
确认是否删除?
确定删除已选的 {batchConfirm.tasks.length} 条任务吗?删除后将从任务看板和系统记录中永久移除,无法恢复。
setBatchConfirm(null)} disabled={batchBusy}>取消
{batchBusy ? "删除中…" : "确认删除"}
)}
{completeConfirm && (
event.target === event.currentTarget && setCompleteConfirm(null)}>
✓
确认完成任务?
该提问任务当前已完成 {completeConfirm.completed_count || 0} / {completeConfirm.total_count || completeConfirm.qa_count || 0} 条。确认后将强制归档到“已完成”,用户将不能继续提交。
setCompleteConfirm(null)} disabled={busy === completeConfirm.id}>取消
complete(completeConfirm)} disabled={busy === completeConfirm.id}>{busy === completeConfirm.id ? "处理中…" : "确认完成"}
)}
>
);
}