671 lines
25 KiB
JavaScript
671 lines
25 KiB
JavaScript
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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|