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()} >
{task.task_id} {task.status_label}
{taskLink && (
提问链接 event.target.select()} />
)}
{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 && ( )} {(task.status_label === "待返款" || task.refund_completed) && ( )}
{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}}
)}
); } 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]) => ( ))}
{STATUS_FILTERS.map(([value, label]) => ( ))}
{ setKeyword(event.target.value); setPage(1); }} placeholder="搜索用户ID、任务ID、店铺或商品…" />
{error &&
{error}
}
用户ID 任务ID 商品/店铺信息 类型 Q&A数 完成 状态 创建人 返款凭证 操作
{loading ? (
正在加载任务…
) : rows.length === 0 ? (
暂无符合条件的任务
) : ( rows.map((task) => { const key = rowKey(task); const children = childRows(task); const expanded = expandedAskTasks.has(key); return (
toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} /> {children.length > 0 && ( )} {task.user_id} {!isAskTask(task) && task.phone && task.phone !== task.user_id && {maskPhone(task.phone)}} {task.task_id} {task.product_name || "--"} {task.shop_name || "--"} {task.task_type_label} {task.qa_count} {task.completed_count} {task.status_label} {task.creator_name}{formatTime(task.created_at)} {task.refund_screenshot_count ? "已上传" : "--"} {task.can_cancel && ( )} {task.can_complete && task.task_type === "ask" && ( )}
{expanded && children.map((child) => (
↳{child.user_id}{child.phone && child.phone !== child.user_id && {maskPhone(child.phone)}} {child.task_id} {child.batch_label && {child.batch_label}} {child.product_name || "--"}{child.shop_name || "--"} 提问任务 {child.qa_count} {child.completed_count} {child.status_label} {child.creator_name}{formatTime(child.created_at)} {child.refund_proof_uploaded ? "已上传" : child.refund_endpoint ? "待上传" : "--"}
))}
); }) )}
{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} 条任务吗?删除后将从任务看板和系统记录中永久移除,无法恢复。

)} {completeConfirm && (
event.target === event.currentTarget && setCompleteConfirm(null)}>
✓

确认完成任务?

该提问任务当前已完成 {completeConfirm.completed_count || 0} / {completeConfirm.total_count || completeConfirm.qa_count || 0} 条。确认后将强制归档到“已完成”,用户将不能继续提交。

)} ); }