wendajia/frontend/src/TaskBoardPage.jsx

671 lines
25 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, 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 (
<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">
<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>
</div>
{task.selected_questions.length === 0 ? (
<div className="task-detail-empty">暂无问题</div>
) : (
task.selected_questions.map((question, index) => (
<div className="row" key={question.id}>
<span>{index + 1}</span>
<span>
<b>Q:{question.question}</b>
<small>A:{question.answer}</small>
</span>
<span>
{task.user_id}
<small>{maskPhone(task.phone)}</small>
</span>
<span>
<em className={`board-status ${task.status_label}`}>
{task.status_label}
</em>
</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 || "完成截图"} />
<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 || "收款码"} />
<span>点击核对收款码</span>
</button>
))}
</div>
) : (
<p>用户未单独上传收款码</p>
)}
</section>
</div>
<footer>
{task.refund_endpoint && (
<button
type="button"
className="confirm-refund"
onClick={() => setRefundOpen(true)}
>
确认返款
</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>
<button
type="button"
disabled={refundBusy}
onClick={() => setRefundOpen(false)}
>
取消
</button>
<button
type="button"
className="confirm"
disabled={refundBusy}
onClick={confirmRefund}
>
{refundBusy ? "确认中…" : "确定返款完成"}
</button>
</div>
</section>
</div>
)}
</section>
</div>
);
}
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 (
<>
<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)}
>
{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)}
>
{label}
</button>
))}
</div>
<div className="board-search">
<Search size={17} />
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索用户ID或任务ID…"
/>
</div>
<div className="board-tools">
<label>
<span>开始日期</span>
<input type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} />
</label>
<label>
<span>结束日期</span>
<input type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} />
</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>
</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">
{children.length > 0 && (
<button
type="button"
className={`board-expand-button${expanded ? " open" : ""}`}
onClick={() => toggleAskTask(task)}
aria-label={expanded ? "收起认领任务" : "展开认领任务"}
>
▾
</button>
)}
<code>{task.user_id}</code>
</span>
<span><b>{task.task_id}</b></span>
<span><strong>{task.product_name}</strong><small>{maskPhone(task.phone)}</small></span>
<span><em className={`board-type ${task.task_type}`}>{task.task_type_label}</em></span>
<span>{task.qa_count}</span>
<span>{task.pending_review_count} / {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-actions">
<button onClick={() => openDetail(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></span>
<span><b>{child.task_id}</b></span>
<span><strong>{child.product_name}</strong><small>{maskPhone(child.phone)}</small></span>
<span><em className="board-type ask">提问任务</em></span>
<span>{child.qa_count}</span>
<span>{child.pending_review_count} / {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-actions"><button onClick={() => openDetail(child)}>详情</button></span>
</div>
))}
</React.Fragment>
);
})
)}
</section>
{detail && (
<TaskDetail
task={detail}
close={() => setDetail(null)}
onConfirmRefund={confirmRefund}
/>
)}
{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>
)}
</>
);
}