import React, { useEffect, useState } from "react"; import { Search, X } from "lucide-react"; const STATUS_FILTERS = [ ["all", "全部任务"], ["进行中", "进行中"], ["待审核", "待审核"], ["已完成", "已完成"], ["待返款", "待返款"], ["已撤销", "已撤销"], ]; const STATUS_ALIASES = { "待用户完成": "进行中", "未通过": "进行中", "已发布": "进行中", }; // Keep the last lightweight board response while operators switch pages. // Returning to the board renders immediately and refreshes silently. let taskBoardCache = null; const formatTime = (value) => value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; const maskPhone = (phone = "") => phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2"); function TaskDetail({ task, close, onConfirmRefund }) { const [refundOpen, setRefundOpen] = useState(false); const [refundBusy, setRefundBusy] = useState(false); const [linkCopied, setLinkCopied] = useState(false); const isAskTask = task.task_type === "ask" || task.task_type_label === "提问任务"; const taskLink = isAskTask ? `${window.location.origin}/ask/${task.task_id}` : ""; const proofScreenshots = [ ...(task.question_screenshots || []), ...(task.completion_screenshots || []), ]; const paymentScreenshots = task.payment_screenshots || []; async function confirmRefund() { setRefundBusy(true); try { await onConfirmRefund(task); setRefundOpen(false); } 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.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} {task.user_id} {maskPhone(task.phone)} {task.status_label}
)) )}

{isAskTask ? "提问完成截图" : "回答完成截图"}

{proofScreenshots.length ? (
{proofScreenshots.map((image) => ( ))}
) : (

未上传完成截图

)}

收款码

{paymentScreenshots.length ? (
{paymentScreenshots.map((image) => ( ))}
) : (

用户未单独上传收款码

)}
{task.refund_endpoint && ( )}
{refundOpen && (
event.target === event.currentTarget && setRefundOpen(false) } >
✓

确认已经完成返款?

请确认已按用户上传的收款码完成付款。确认后,该任务将归入“已完成”,此操作不可撤销。

)}
); } export default function TaskBoardPage({ api }) { const [tasks, setTasks] = useState(() => taskBoardCache || []); const [type, setType] = useState("all"); const [status, setStatus] = useState(() => window.__taskBoardInitialStatus || "all"); const [keyword, setKeyword] = useState(""); const [dateFrom, setDateFrom] = useState(""); const [dateTo, setDateTo] = useState(""); const [selectedIds, setSelectedIds] = useState([]); const [expandedAskTasks, setExpandedAskTasks] = useState(() => new Set()); const [detail, setDetail] = useState(null); const [error, setError] = useState(""); const [loading, setLoading] = useState(() => taskBoardCache === null); const [busy, setBusy] = useState(""); const [batchBusy, setBatchBusy] = useState(false); const [batchConfirm, setBatchConfirm] = useState(null); const [completeConfirm, setCompleteConfirm] = useState(null); async function load(showLoading = taskBoardCache === null) { if (showLoading) setLoading(true); try { const result = await api("/api/admin/task-board"); taskBoardCache = result.data; setTasks(result.data); setError(""); } catch (err) { setError(err.message); } finally { setLoading(false); } } useEffect(() => { load(taskBoardCache === null); window.__taskBoardInitialStatus = ""; }, []); const rowKey = (task) => task.board_key || `${task.task_type}-${task.id}`; 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.filter((task) => { const taskType = String(task.task_type || "") .trim() .toLowerCase(); const taskTypeLabel = String(task.task_type_label || "").trim(); const rawStatus = String(task.status_label || task.status || "").trim(); const taskStatus = STATUS_ALIASES[rawStatus] || rawStatus; const typeMatch = type === "all" || (type === "ask" && (taskType === "ask" || taskTypeLabel === "提问任务")) || (type === "answer" && (taskType === "answer" || taskTypeLabel === "回答任务")); const children = childRows(task); const childStatusMatch = children.some((child) => { const childRawStatus = String(child.status_label || child.status || "").trim(); return (STATUS_ALIASES[childRawStatus] || childRawStatus) === status; }); const statusMatch = status === "all" || taskStatus === status || childStatusMatch; const text = `${task.user_id || ""} ${task.task_id || ""} ${task.product_name || ""} ${children.map((child) => `${child.user_id || ""} ${child.phone || ""}`).join(" ")}`.toLowerCase(); const createdDate = String(task.created_at || task.updated_at || "").slice(0, 10); const fromMatch = !dateFrom || (createdDate && createdDate >= dateFrom); const toMatch = !dateTo || (createdDate && createdDate <= dateTo); return typeMatch && statusMatch && text.includes(keyword.trim().toLowerCase()) && fromMatch && toMatch; }); 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"); } 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.task_type_label, task.qa_count, `${task.pending_review_count} / ${task.completed_count}`, task.status_label, task.creator_name, 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 openDetail(task) { try { if (task.detail_endpoint) { const result = await api(task.detail_endpoint); setDetail(result.data); } else if (task.task_type === "ask") { setDetail(task); } else { const result = await api(`/api/admin/task-board/${task.id}`); setDetail(result.data); } } catch (err) { window.alert(err.message); } } 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 confirmRefund(task) { const result = await api(task.refund_endpoint, { method: "POST" }); 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 || [], }; setDetail(updatedDetail); setTasks((current) => { const next = current.map((item) => item.board_key === result.data.board_key ? { ...item, ...result.data } : item, ); taskBoardCache = next; 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)} 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} {task.task_id} {task.product_name}{maskPhone(task.phone)} {task.task_type_label} {task.qa_count} {task.pending_review_count} / {task.completed_count} {task.status_label} {task.creator_name}{formatTime(task.created_at)} {task.can_cancel && ( )} {task.can_complete && task.task_type === "ask" && ( )}
{expanded && children.map((child) => (
↳{child.user_id} {child.task_id} {child.product_name}{maskPhone(child.phone)} 提问任务 {child.qa_count} {child.pending_review_count} / {child.completed_count} {child.status_label} {child.creator_name}{formatTime(child.created_at)}
))}
); }) )}
{detail && ( setDetail(null)} onConfirmRefund={confirmRefund} /> )} {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} 条。确认后将强制归档到“已完成”,用户将不能继续提交。

)} ); }