wendajia/frontend/src/TaskBoardPage.jsx

967 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div className="task-detail-backdrop task-detail-loading-backdrop">
<section className="task-detail-loading" role="status" aria-live="polite">
<LoaderCircle size={24} className="spin" />
<strong>正在打开任务详情</strong>
<span>请求已收到,凭证图片将在弹窗内按需加载…</span>
</section>
</div>
);
}
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 (
<div
className="task-detail-backdrop"
onMouseDown={(event) => event.target === event.currentTarget && close()}
>
<section className="task-detail-modal">
<header>
<div>
<strong>{task.task_id}</strong>
<span className={`board-status ${task.status_label}`}>
{task.status_label}
</span>
</div>
<button onClick={close}>
<X size={21} />
</button>
</header>
{taskLink && (
<div className="task-detail-share">
<span>提问链接</span>
<input
readOnly
value={taskLink}
aria-label="提问任务链接"
onFocus={(event) => event.target.select()}
/>
<button
type="button"
className={linkCopied ? "copied" : ""}
onClick={copyTaskLink}
>
{linkCopied ? "已复制" : "复制链接"}
</button>
</div>
)}
<div className={`task-detail-meta${isAskTask ? " ask-task-meta" : task.shop_name ? " shop-task-meta" : ""}`}>
{task.shop_name && <span>
店铺名称<b>{task.shop_name || "--"}</b>
</span>}
<span>
商品名称<b>{task.product_name}</b>
</span>
<span>
任务类型<b>{task.task_type_label}</b>
</span>
<span>
创建人<b>{task.creator_name}</b>
</span>
<span>
创建时间<b>{formatTime(task.created_at)}</b>
</span>
</div>
<h3>认领用户的问题与回答(共 {task.assigned_users.length} 位)</h3>
<div className="task-detail-table">
<div className="head">
<span>序号</span>
<span>关联问题与回答</span>
<span>认领用户</span>
<span>状态</span>
<span>返款凭证</span>
</div>
{task.selected_questions.length === 0 ? (
<div className="task-detail-empty">暂无问题</div>
) : (
<div className="task-detail-table-body">
{task.selected_questions.map((question, index) => (
<React.Fragment key={question.id}>
<span
className={`task-detail-cell${index ? " row-border" : ""}`}
style={{ gridColumn: 1, gridRow: index + 1 }}
>
{index + 1}
</span>
<span
className={`task-detail-cell${index ? " row-border" : ""}`}
style={{ gridColumn: 2, gridRow: index + 1 }}
>
<b>Q:{question.question}</b>
<small>A:{question.answer}</small>
</span>
<span
className={`task-detail-cell${index ? " row-border" : ""}`}
style={{ gridColumn: 3, gridRow: index + 1 }}
>
{question.user_id || "--"}
{question.phone && question.phone !== question.user_id && <small>{maskPhone(question.phone)}</small>}
</span>
<span
className={`task-detail-cell${index ? " row-border" : ""}`}
style={{ gridColumn: 4, gridRow: index + 1 }}
>
<em className={`board-status ${task.status_label}`}>
{task.status_label}
</em>
</span>
</React.Fragment>
))}
<span
className="task-detail-refund-proof task-detail-refund-proof-merged"
style={{
gridColumn: 5,
gridRow: `1 / span ${task.selected_questions.length}`,
}}
>
{refundScreenshots.length ? (
<button type="button">
<img
src={refundScreenshots[0].data_url || refundScreenshots[0].url}
alt="返款凭证"
loading="lazy"
decoding="async"
/>
<small>已上传</small>
</button>
) : (
"未上传"
)}
</span>
</div>
)}
</div>
<div className="task-detail-evidence">
<section>
<h3>{isAskTask ? "提问完成截图" : "回答完成截图"}</h3>
{proofScreenshots.length ? (
<div>
{proofScreenshots.map((image) => (
<button
type="button"
key={image.id}
>
<img src={image.data_url || image.url} alt={image.name || "完成截图"} loading="lazy" decoding="async" />
<span>点击查看大图</span>
</button>
))}
</div>
) : (
<p>未上传完成截图</p>
)}
</section>
<section className="payment">
<h3>收款码</h3>
{paymentScreenshots.length ? (
<div>
{paymentScreenshots.map((image) => (
<button
type="button"
key={image.id}
>
<img src={image.data_url || image.url} alt={image.name || "收款码"} loading="lazy" decoding="async" />
<span>点击核对收款码</span>
</button>
))}
</div>
) : (
<p>用户未单独上传收款码</p>
)}
</section>
</div>
<footer>
{task.refund_endpoint && (
<button
type="button"
className="confirm-refund"
onClick={() => {
setRefundSession(null);
setRefundFile(null);
setRefundFileError("");
setRefundQr("");
setRefundOpen(true);
}}
>
确认返款
</button>
)}
{(task.status_label === "待返款" || task.refund_completed) && (
<button
type="button"
className="next-refund-task"
disabled={!hasNextRefund || nextBusy}
onClick={onNextRefund}
>
{nextBusy ? "正在打开…" : hasNextRefund ? "下一个待返款任务" : "已是最后一个"}
{hasNextRefund && !nextBusy && <ChevronRight size={15} />}
</button>
)}
<button onClick={close}>关闭</button>
</footer>
{refundOpen && (
<div
className="refund-confirm-backdrop"
onMouseDown={(event) =>
event.target === event.currentTarget && setRefundOpen(false)
}
>
<section className="refund-confirm-modal">
<span>扫码</span>
<h2>确认已经完成返款?</h2>
<p>
请确认已按用户上传的收款码完成付款。确认后,该任务将归入“已完成”,此操作不可撤销。
</p>
<div className="refund-scan-upload">
<div>
<strong>扫码上传返款凭证</strong>
<small>二维码有效期 10 分钟</small>
{refundSessionError && <em>{refundSessionError}</em>}
{refundSession?.uploaded && <b>✓ 手机端已上传,运营端可确认返款</b>}
</div>
{refundQr ? <img src={refundQr} alt="扫码上传返款凭证" /> : <i>正在生成二维码…</i>}
</div>
<div
ref={refundPasteRef}
className={`refund-proof-upload${refundFile ? " has-image" : ""}`}
tabIndex={0}
role="button"
aria-label="粘贴剪贴板中的返款凭证图片"
>
{refundFilePreview ? (
<img src={refundFilePreview} alt="已粘贴的返款凭证" />
) : (
<span className="refund-paste-icon">粘贴</span>
)}
<span>剪贴板粘贴上传(可选)</span>
<strong>{refundFile ? "图片已粘贴,可直接确认" : "复制图片后,按 Ctrl+V 粘贴"}</strong>
<small>{refundFile ? `${refundFile.name} · ${(refundFile.size / 1024).toFixed(0)}KB` : "支持 JPG、PNG、WEBP,最大 10MB"}</small>
{refundFileError && <em>{refundFileError}</em>}
</div>
<label className="refund-proof-fallback">
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(event) => {
setRefundProofFile(event.target.files?.[0]);
event.target.value = "";
}}
/>
<span>无法粘贴?选择本地图片</span>
</label>
<div>
<button
type="button"
disabled={refundBusy}
onClick={() => setRefundOpen(false)}
>
取消
</button>
<button
type="button"
className="confirm"
disabled={refundBusy || (!refundFile && !refundSession?.uploaded)}
onClick={confirmRefund}
>
{refundBusy ? "确认中…" : "确定返款完成"}
</button>
</div>
</section>
</div>
)}
</section>
</div>
);
}
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 (
<>
<div className="board-heading">
<h2>任务看板</h2>
<p>监控全量问答任务执行状态、完成进度与审核链路。</p>
</div>
<div className="board-type-row">
<span>任务类型:</span>
{[
["all", "全部类型"],
["ask", "提问任务"],
["answer", "回答任务"],
].map(([value, label]) => (
<button
type="button"
className={type === value ? "active" : ""}
key={value}
onClick={() => { setType(value); setPage(1); }}
>
{label}
</button>
))}
</div>
<div className="board-status-row">
{STATUS_FILTERS.map(([value, label]) => (
<button
type="button"
className={status === value ? "active" : ""}
key={value}
onClick={() => { setStatus(value); setPage(1); }}
>
{label}
</button>
))}
</div>
<div className="board-search">
<Search size={17} />
<input
value={keyword}
onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
placeholder="搜索用户ID、任务ID、店铺或商品…"
/>
</div>
<div className="board-tools">
<label>
<span>开始日期</span>
<input type="date" value={dateFrom} onChange={(event) => { setDateFrom(event.target.value); setPage(1); }} />
</label>
<label>
<span>结束日期</span>
<input type="date" value={dateTo} onChange={(event) => { setDateTo(event.target.value); setPage(1); }} />
</label>
<button type="button" onClick={clearFilters}>清空筛选</button>
<button type="button" onClick={exportRows}>导出表格</button>
<button type="button" className="danger" disabled={batchBusy} onClick={batchClear}>
{batchBusy ? "删除中…" : `批量删除${selectedIds.length ? `(${selectedIds.length})` : ""}`}
</button>
</div>
{error && <div className="form-error">{error}</div>}
<section
className="board-table panel"
key={`${type}|${status}|${keyword.trim().toLowerCase()}|${dateFrom}|${dateTo}`}
>
<div className="board-table-head">
<span className="board-select-cell">
<input type="checkbox" checked={allVisibleSelected} onChange={toggleAllVisible} aria-label="全选当前任务" />
</span>
<span>用户ID</span>
<span>任务ID</span>
<span>商品/店铺信息</span>
<span>类型</span>
<span>Q&A数</span>
<span>完成</span>
<span>状态</span>
<span>创建人</span>
<span>返款凭证</span>
<span>操作</span>
</div>
{loading ? (
<div className="board-empty">正在加载任务…</div>
) : rows.length === 0 ? (
<div className="board-empty">
暂无符合条件的任务
</div>
) : (
rows.map((task) => {
const key = rowKey(task);
const children = childRows(task);
const expanded = expandedAskTasks.has(key);
return (
<React.Fragment key={key}>
<div className="board-table-row">
<span className="board-select-cell">
<input type="checkbox" checked={selectedIds.includes(key)} onChange={() => toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} />
</span>
<span className={`board-user-cell${!isAskTask(task) ? " board-answer-user" : ""}`}>
{children.length > 0 && (
<button
type="button"
className={`board-expand-button${expanded ? " open" : ""}`}
onClick={() => toggleAskTask(task)}
aria-label={expanded ? "收起认领任务" : "展开认领任务"}
>
▾
</button>
)}
<code>{task.user_id}</code>
{!isAskTask(task) && task.phone && task.phone !== task.user_id && <small>{maskPhone(task.phone)}</small>}
</span>
<span><b>{task.task_id}</b></span>
<span>
<strong>{task.product_name || "--"}</strong>
<small>{task.shop_name || "--"}</small>
</span>
<span><em className={`board-type ${task.task_type}`}>{task.task_type_label}</em></span>
<span>{task.qa_count}</span>
<span>{task.completed_count}</span>
<span><em className={`board-status ${task.status_label}`}>{task.status_label}</em></span>
<span>{task.creator_name}<small>{formatTime(task.created_at)}</small></span>
<span className="board-refund-proof">{task.refund_screenshot_count ? "已上传" : "--"}</span>
<span className="board-actions">
<button disabled={Boolean(detailLoading)} onClick={() => openDetail(task)}>
{detailLoading === detailCacheKey(task) ? "打开中…" : "详情"}
</button>
{task.can_cancel && (
<button className="cancel" disabled={busy === task.id} onClick={() => cancel(task)}>
{busy === task.id ? "撤销中" : "撤销"}
</button>
)}
{task.can_complete && task.task_type === "ask" && (
<button className="complete" disabled={busy === task.id} onClick={() => setCompleteConfirm(task)}>完成任务</button>
)}
<button className="delete" onClick={() => setBatchConfirm({ tasks: [task] })}>删除</button>
</span>
</div>
{expanded && children.map((child) => (
<div className="board-table-row board-child-row" key={rowKey(child)}>
<span className="board-select-cell" />
<span className="board-user-cell board-child-user"><i>↳</i><code>{child.user_id}</code>{child.phone && child.phone !== child.user_id && <small>{maskPhone(child.phone)}</small>}</span>
<span>
<b>{child.task_id}</b>
{child.batch_label && <small>{child.batch_label}</small>}
</span>
<span><strong>{child.product_name || "--"}</strong><small>{child.shop_name || "--"}</small></span>
<span><em className="board-type ask">提问任务</em></span>
<span>{child.qa_count}</span>
<span>{child.completed_count}</span>
<span><em className={`board-status ${child.status_label}`}>{child.status_label}</em></span>
<span>{child.creator_name}<small>{formatTime(child.created_at)}</small></span>
<span className="board-refund-proof">{child.refund_proof_uploaded ? "已上传" : child.refund_endpoint ? "待上传" : "--"}</span>
<span className="board-actions"><button disabled={Boolean(detailLoading)} onClick={() => openDetail(child)}>{detailLoading === detailCacheKey(child) ? "打开中…" : "详情"}</button></span>
</div>
))}
</React.Fragment>
);
})
)}
</section>
<nav className="board-pagination" aria-label="任务看板分页">
<span>共 {total} 条</span>
<label>
每页
<select value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPage(1); }}>
{[10, 20, 50, 100].map((size) => <option key={size} value={size}>{size} 条</option>)}
</select>
</label>
<button type="button" disabled={page <= 1 || loading} onClick={() => setPage((value) => Math.max(1, value - 1))}>上一页</button>
<strong>{page} / {totalPages}</strong>
<button type="button" disabled={page >= totalPages || loading} onClick={() => setPage((value) => Math.min(totalPages, value + 1))}>下一页</button>
</nav>
{detailLoading && !detail && <TaskDetailLoading />}
{detail && (
<TaskDetail
key={rowKey(detail)}
task={detail}
close={() => setDetail(null)}
onConfirmRefund={confirmRefund}
onCreateRefundUpload={createRefundUpload}
onPollRefundUpload={pollRefundUpload}
hasNextRefund={Boolean(nextRefundTask)}
nextBusy={Boolean(detailLoading)}
onNextRefund={() => openDetail(nextRefundTask)}
/>
)}
{batchConfirm && (
<div
className="board-confirm-backdrop"
onMouseDown={(event) => event.target === event.currentTarget && setBatchConfirm(null)}
>
<section className="board-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="batch-clear-title">
<div className="board-confirm-icon">!</div>
<h2 id="batch-clear-title">确认是否删除?</h2>
<p>
确定删除已选的 {batchConfirm.tasks.length} 条任务吗?删除后将从任务看板和系统记录中永久移除,无法恢复。
</p>
<div className="board-confirm-actions">
<button type="button" onClick={() => setBatchConfirm(null)} disabled={batchBusy}>取消</button>
<button type="button" className="danger" onClick={confirmBatchClear} disabled={batchBusy}>
{batchBusy ? "删除中…" : "确认删除"}
</button>
</div>
</section>
</div>
)}
{completeConfirm && (
<div className="board-confirm-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setCompleteConfirm(null)}>
<section className="board-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="complete-task-title">
<div className="board-confirm-icon complete-icon">✓</div>
<h2 id="complete-task-title">确认完成任务?</h2>
<p>该提问任务当前已完成 {completeConfirm.completed_count || 0} / {completeConfirm.total_count || completeConfirm.qa_count || 0} 条。确认后将强制归档到“已完成”,用户将不能继续提交。</p>
<div className="board-confirm-actions">
<button type="button" onClick={() => setCompleteConfirm(null)} disabled={busy === completeConfirm.id}>取消</button>
<button type="button" className="confirm-complete" onClick={() => complete(completeConfirm)} disabled={busy === completeConfirm.id}>{busy === completeConfirm.id ? "处理中…" : "确认完成"}</button>
</div>
</section>
</div>
)}
</>
);
}