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()}
>
{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) => (
))}
) : (
用户未单独上传收款码
)}
{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}
}
{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} 条。确认后将强制归档到“已完成”,用户将不能继续提交。
)}
>
);
}