967 lines
39 KiB
JavaScript
967 lines
39 KiB
JavaScript
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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|