656 lines
22 KiB
JavaScript
656 lines
22 KiB
JavaScript
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||
import { MessageCircleMore, RefreshCw, X } from "lucide-react";
|
||
|
||
const REASONS = [
|
||
"内容不清晰",
|
||
"数量不足",
|
||
"问题不匹配",
|
||
"回答不符合要求",
|
||
"商品不一致",
|
||
"其他",
|
||
];
|
||
const STATUSES = ["全部", "待审核"];
|
||
const formatTime = (value) =>
|
||
value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
|
||
const productName = (record) =>
|
||
[
|
||
...new Set(
|
||
record.questions.filter((q) => q.selected).map((q) => q.product_title),
|
||
),
|
||
].join(" / ") || "待识别商品";
|
||
const StatusBadge = ({ status }) => (
|
||
<em className={`review-v2-status ${status}`}>{status}</em>
|
||
);
|
||
|
||
function RefundPrompt({ onConfirm, onCancel }) {
|
||
return (
|
||
<div className="review-v2-backdrop refund-prompt-backdrop">
|
||
<section className="refund-prompt" role="dialog" aria-modal="true" aria-labelledby="refund-prompt-title">
|
||
<div className="refund-prompt-icon">✓</div>
|
||
<div className="refund-prompt-content">
|
||
<h2 id="refund-prompt-title">审核已通过</h2>
|
||
<p>是否前往任务看板的“待返款”模块进行返款?</p>
|
||
</div>
|
||
<div className="refund-prompt-actions">
|
||
<button type="button" className="refund-prompt-cancel" onClick={onCancel}>暂不前往</button>
|
||
<button type="button" className="refund-prompt-confirm" onClick={onConfirm}>前往待返款</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReviewDetail({ record, close, decide, busy }) {
|
||
const [reason, setReason] = useState(REASONS[0]);
|
||
const [customReason, setCustomReason] = useState("");
|
||
const selected = record.questions.filter((q) => q.selected);
|
||
const pending = record.status === "待审核";
|
||
return (
|
||
<div
|
||
className="review-v2-backdrop"
|
||
onMouseDown={(event) => event.target === event.currentTarget && close()}
|
||
>
|
||
<section className="review-v2-modal">
|
||
<header>
|
||
<div>
|
||
<h2>
|
||
回答审核 · 用户任务列表 <StatusBadge status={record.status} />
|
||
</h2>
|
||
<p>任务ID:{record.submission_no}</p>
|
||
</div>
|
||
<button onClick={close}>
|
||
<X size={22} />
|
||
</button>
|
||
</header>
|
||
<div className="review-v2-modal-body">
|
||
<div className="review-v2-summary">
|
||
<code>{record.user_id}</code>
|
||
<code>{record.submission_no}</code>
|
||
<span>商品:{productName(record)}</span>
|
||
<span>提交时间:{formatTime(record.updated_at)}</span>
|
||
</div>
|
||
<h3>① 问答记录(发布问题 ↔ 用户回答)</h3>
|
||
<div className="review-v2-qa">
|
||
{selected.map((question, index) => (
|
||
<article key={question.id}>
|
||
<strong>
|
||
Q{index + 1}:{question.question}
|
||
</strong>
|
||
<p>
|
||
<b>A:</b>
|
||
{question.answer}
|
||
</p>
|
||
</article>
|
||
))}
|
||
</div>
|
||
<div className="review-v2-media">
|
||
<section>
|
||
<h3>② 用户上传的任务截图</h3>
|
||
<div>
|
||
{record.completion_screenshots.map((image, index) => (
|
||
<figure key={image.id}>
|
||
<img
|
||
src={image.data_url}
|
||
alt={`任务截图${index + 1}`}
|
||
/>
|
||
<figcaption>任务截图 {index + 1}</figcaption>
|
||
</figure>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h3>③ 返款二维码截图</h3>
|
||
<div>
|
||
{record.payment_screenshots?.length ? (
|
||
record.payment_screenshots.map((image) => (
|
||
<figure key={image.id}>
|
||
<img
|
||
src={image.data_url}
|
||
alt="收款码"
|
||
/>
|
||
<figcaption>审核通过后用于后续人工处理</figcaption>
|
||
</figure>
|
||
))
|
||
) : (
|
||
<div className="review-v2-no-payment">未单独上传收款码</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
{record.latest_review && !pending && (
|
||
<div
|
||
className={`review-v2-result ${record.latest_review.approved ? "approved" : ""}`}
|
||
>
|
||
<strong>
|
||
{record.latest_review.approved ? "审核已通过" : "审核未通过"}
|
||
</strong>
|
||
{!record.latest_review.approved && (
|
||
<p>
|
||
原因:{record.latest_review.reason_code}
|
||
{record.latest_review.reason_text
|
||
? `;${record.latest_review.reason_text}`
|
||
: ""}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{pending ? (
|
||
<footer className="review-v2-sticky-actions">
|
||
<div>
|
||
<span>审核不通过原因:</span>
|
||
<select
|
||
value={reason}
|
||
onChange={(event) => setReason(event.target.value)}
|
||
>
|
||
{REASONS.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
value={customReason}
|
||
onChange={(event) => setCustomReason(event.target.value)}
|
||
placeholder={
|
||
reason === "其他"
|
||
? "请输入自定义原因(必填)"
|
||
: "补充说明(选填)"
|
||
}
|
||
/>
|
||
</div>
|
||
<button
|
||
className="reject"
|
||
disabled={busy || (reason === "其他" && !customReason.trim())}
|
||
onClick={() => decide(record, false, reason, customReason.trim())}
|
||
>
|
||
{busy ? "处理中…" : "审核不通过(需重做)"}
|
||
</button>
|
||
<button
|
||
className="approve"
|
||
disabled={busy}
|
||
onClick={() => decide(record, true)}
|
||
>
|
||
{busy ? "处理中…" : "审核通过"}
|
||
</button>
|
||
</footer>
|
||
) : (
|
||
<footer className="review-v2-processed-actions">
|
||
<span>
|
||
该任务已完成审核,当前状态:
|
||
<StatusBadge status={record.status} />
|
||
</span>
|
||
<button onClick={close}>关闭</button>
|
||
</footer>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AskReviewDetail({ record, close, onChanged, api, onApproved }) {
|
||
const [reason, setReason] = useState("截图不清晰");
|
||
const [reasonText, setReasonText] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const selected = record.questions.filter((item) => item.selected);
|
||
async function decide(approved) {
|
||
setBusy(true);
|
||
try {
|
||
await api(
|
||
`/api/admin/ask-completion-reviews/${record.batch_id || record.assignment_id}/decision`,
|
||
{
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
approved,
|
||
reason_code: approved ? "" : reason,
|
||
reason_text: reasonText.trim(),
|
||
}),
|
||
},
|
||
);
|
||
close();
|
||
await onChanged();
|
||
window.dispatchEvent(new Event("review-count-changed"));
|
||
if (approved) onApproved();
|
||
} catch (error) {
|
||
window.alert(error.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
return (
|
||
<div
|
||
className="review-v2-backdrop"
|
||
onMouseDown={(event) => event.target === event.currentTarget && close()}
|
||
>
|
||
<section className="review-v2-modal">
|
||
<header>
|
||
<div>
|
||
<h2>
|
||
提问审核 · 用户任务详情 <StatusBadge status={record.status} />
|
||
</h2>
|
||
<p>任务ID:{record.task_id}</p>
|
||
</div>
|
||
<button onClick={close}>
|
||
<X size={22} />
|
||
</button>
|
||
</header>
|
||
<div className="review-v2-modal-body">
|
||
<div className="review-v2-summary">
|
||
<code>{record.user_id}</code>
|
||
<code>{record.task_id}</code>
|
||
<span>商品:{record.product_name}</span>
|
||
<span>提交时间:{formatTime(record.updated_at)}</span>
|
||
</div>
|
||
<h3>① 用户认领并完成的提问</h3>
|
||
<div className="review-v2-qa">
|
||
{selected.map((item, index) => (
|
||
<article key={item.id}>
|
||
<strong>
|
||
Q{index + 1}:{item.question}
|
||
</strong>
|
||
</article>
|
||
))}
|
||
</div>
|
||
<div className="review-v2-media">
|
||
<section>
|
||
<h3>② 用户上传的提问截图</h3>
|
||
<div>
|
||
{record.question_screenshots.map((image, index) => (
|
||
<figure key={image.id}>
|
||
<img
|
||
src={image.data_url}
|
||
alt={`提问截图${index + 1}`}
|
||
/>
|
||
<figcaption>提问截图 {index + 1}</figcaption>
|
||
</figure>
|
||
))}
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h3>③ 用户上传的收款码</h3>
|
||
<div>
|
||
{record.payment_screenshots.map((image) => (
|
||
<figure key={image.id}>
|
||
<img
|
||
src={image.data_url}
|
||
alt="收款码"
|
||
/>
|
||
<figcaption>收款码截图</figcaption>
|
||
</figure>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
{record.latest_review && record.status !== "待审核" && (
|
||
<div
|
||
className={`review-v2-result ${record.latest_review.approved ? "approved" : ""}`}
|
||
>
|
||
<strong>
|
||
{record.latest_review.approved ? "审核已通过" : "审核未通过"}
|
||
</strong>
|
||
{!record.latest_review.approved && (
|
||
<p>
|
||
原因:{record.latest_review.reason_code}
|
||
{record.latest_review.reason_text
|
||
? `;${record.latest_review.reason_text}`
|
||
: ""}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{record.status === "待审核" ? (
|
||
<footer className="review-v2-sticky-actions">
|
||
<div>
|
||
<span>审核不通过原因:</span>
|
||
<select
|
||
value={reason}
|
||
onChange={(event) => setReason(event.target.value)}
|
||
>
|
||
<option>截图不清晰</option>
|
||
<option>数量不足</option>
|
||
<option>问题不匹配</option>
|
||
<option>商品不一致</option>
|
||
<option>重复提交</option>
|
||
<option>其他</option>
|
||
</select>
|
||
<input
|
||
value={reasonText}
|
||
onChange={(event) => setReasonText(event.target.value)}
|
||
placeholder="补充说明(选填)"
|
||
/>
|
||
</div>
|
||
<button
|
||
className="reject"
|
||
disabled={busy}
|
||
onClick={() => decide(false)}
|
||
>
|
||
审核不通过
|
||
</button>
|
||
<button
|
||
className="approve"
|
||
disabled={busy}
|
||
onClick={() => decide(true)}
|
||
>
|
||
审核通过
|
||
</button>
|
||
</footer>
|
||
) : (
|
||
<footer className="review-v2-processed-actions">
|
||
<span>
|
||
当前审核状态:
|
||
<StatusBadge status={record.status} />
|
||
</span>
|
||
<button onClick={close}>关闭</button>
|
||
</footer>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AskReviewQueue({ api, onApproved }) {
|
||
const [records, setRecords] = useState([]);
|
||
const [detail, setDetail] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
async function openDetail(record) {
|
||
try {
|
||
const result = await api(
|
||
`/api/admin/ask-completion-reviews/${record.batch_id || record.assignment_id}`,
|
||
);
|
||
setDetail(result.data);
|
||
} catch (error) {
|
||
window.alert(error.message);
|
||
}
|
||
}
|
||
async function load() {
|
||
setLoading(true);
|
||
try {
|
||
const result = await api("/api/admin/ask-completion-reviews");
|
||
setRecords(result.data);
|
||
} catch (error) {
|
||
window.alert(error.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
useEffect(() => {
|
||
load();
|
||
}, []);
|
||
return (
|
||
<>
|
||
<section className="review-v2-table panel">
|
||
<div className="head">
|
||
<span>用户 / 任务</span>
|
||
<span>商品</span>
|
||
<span>提交时间</span>
|
||
<span>提问数</span>
|
||
<span>审核状态</span>
|
||
<span>操作</span>
|
||
</div>
|
||
{loading ? (
|
||
<div className="review-v2-empty">正在加载提问审核任务…</div>
|
||
) : records.length === 0 ? (
|
||
<div className="review-v2-empty">暂无提问审核任务</div>
|
||
) : (
|
||
records.map((record) => (
|
||
<div className="row" key={record.batch_id || record.assignment_id}>
|
||
<span>
|
||
<b>
|
||
<code>{record.user_id}</code>
|
||
<code>{record.task_id}</code>
|
||
</b>
|
||
<small>
|
||
{record.phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")}
|
||
</small>
|
||
</span>
|
||
<span>{record.product_name}</span>
|
||
<span>{formatTime(record.updated_at)}</span>
|
||
<span>
|
||
<strong>
|
||
{record.questions.filter((item) => item.selected).length}
|
||
</strong>{" "}
|
||
条提问
|
||
</span>
|
||
<span>
|
||
<StatusBadge status={record.status} />
|
||
</span>
|
||
<span>
|
||
<button onClick={() => openDetail(record)}>
|
||
{record.status === "待审核" ? "查看并审核" : "查看详情"}
|
||
</button>
|
||
</span>
|
||
</div>
|
||
))
|
||
)}
|
||
</section>
|
||
{detail && (
|
||
<AskReviewDetail
|
||
record={detail}
|
||
close={() => setDetail(null)}
|
||
onChanged={load}
|
||
api={api}
|
||
onApproved={onApproved}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default function ReviewCenterPage({ api }) {
|
||
const [queue, setQueue] = useState("ask");
|
||
const [statusFilter, setStatusFilter] = useState("待审核");
|
||
const [records, setRecords] = useState([]);
|
||
const [detail, setDetail] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
const [refundPrompt, setRefundPrompt] = useState(false);
|
||
const loadVersion = useRef(0);
|
||
async function openAnswerDetail(record) {
|
||
setDetailLoading(true);
|
||
try {
|
||
const result = await api(
|
||
`/api/admin/answer-completion-reviews/${record.id}`,
|
||
);
|
||
setDetail(result.data);
|
||
} catch (err) {
|
||
window.alert(err.message);
|
||
} finally {
|
||
setDetailLoading(false);
|
||
}
|
||
}
|
||
async function load() {
|
||
const version = ++loadVersion.current;
|
||
setLoading(true);
|
||
try {
|
||
const result = await api("/api/admin/answer-completion-reviews");
|
||
if (version !== loadVersion.current) return;
|
||
setRecords(result.data);
|
||
setError("");
|
||
} catch (err) {
|
||
if (version === loadVersion.current) setError(err.message);
|
||
} finally {
|
||
if (version === loadVersion.current) setLoading(false);
|
||
}
|
||
}
|
||
useEffect(() => {
|
||
load();
|
||
}, []);
|
||
useEffect(() => {
|
||
if (queue !== "answer") return undefined;
|
||
const timer = window.setInterval(() => load(), 5000);
|
||
return () => window.clearInterval(timer);
|
||
}, [queue]);
|
||
const allRows = useMemo(
|
||
() => records.filter((record) => record.status === "待审核"),
|
||
[records],
|
||
);
|
||
const rows = useMemo(
|
||
() =>
|
||
statusFilter === "全部"
|
||
? allRows
|
||
: allRows.filter((record) => record.status === statusFilter),
|
||
[allRows, statusFilter],
|
||
);
|
||
const count = (status) =>
|
||
status === "全部"
|
||
? allRows.length
|
||
: allRows.filter((record) => record.status === status).length;
|
||
async function decide(record, approved, reasonCode = "", reasonText = "") {
|
||
setBusy(true);
|
||
try {
|
||
await api(`/api/admin/answer-completion-reviews/${record.id}/decision`, {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
approved,
|
||
reason_code: reasonCode,
|
||
reason_text: reasonText,
|
||
}),
|
||
});
|
||
setDetail(null);
|
||
await load();
|
||
window.dispatchEvent(new Event("review-count-changed"));
|
||
setStatusFilter("待审核");
|
||
if (approved) setRefundPrompt(true);
|
||
} catch (err) {
|
||
window.alert(err.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
return (
|
||
<>
|
||
<div className="review-v2-heading">
|
||
<div>
|
||
<h2>审核中心</h2>
|
||
<p>提问审核与回答审核,对照用户提交截图进行任务终审。</p>
|
||
</div>
|
||
<div>
|
||
<button
|
||
className={queue === "ask" ? "active" : ""}
|
||
onClick={() => setQueue("ask")}
|
||
>
|
||
队列一:提问审核
|
||
</button>
|
||
<button
|
||
className={queue === "answer" ? "active" : ""}
|
||
onClick={() => {
|
||
setQueue("answer");
|
||
load();
|
||
}}
|
||
>
|
||
队列二:回答审核
|
||
</button>
|
||
<button
|
||
className="review-v2-refresh"
|
||
type="button"
|
||
onClick={() => load()}
|
||
disabled={loading}
|
||
title="刷新审核列表"
|
||
>
|
||
<RefreshCw size={14} className={loading ? "spin" : ""} />
|
||
{loading ? "刷新中…" : "刷新列表"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className={`review-v2-guide ${queue}`}>
|
||
<MessageCircleMore size={17} />
|
||
<div>
|
||
<strong>
|
||
{queue === "answer" ? "回答审核说明" : "提问审核说明"}
|
||
</strong>
|
||
<p>
|
||
{queue === "answer"
|
||
? "同一用户在同一任务下的多组问答按「用户ID + 任务ID」归并为一条;点击查看任务列表,逐组对照问答和用户完成截图。"
|
||
: "审核提问用户提交的问题、截图及任务完成情况。提问任务链路接入后会显示在这里。"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{error && <div className="form-error">{error}</div>}
|
||
{queue === "ask" ? (
|
||
<AskReviewQueue api={api} onApproved={() => setRefundPrompt(true)} />
|
||
) : (
|
||
<>
|
||
<div className="review-v2-status-filter">
|
||
<strong>审核状态:</strong>
|
||
{STATUSES.map((status) => (
|
||
<button
|
||
key={status}
|
||
className={statusFilter === status ? "active" : ""}
|
||
onClick={() => setStatusFilter(status)}
|
||
>
|
||
{status}
|
||
<small>{count(status)}</small>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<section className="review-v2-table panel">
|
||
<div className="head">
|
||
<span>用户 / 任务</span>
|
||
<span>商品</span>
|
||
<span>提交时间</span>
|
||
<span>问答数</span>
|
||
<span>审核状态</span>
|
||
<span>操作</span>
|
||
</div>
|
||
{loading ? (
|
||
<div className="review-v2-empty">正在加载审核任务…</div>
|
||
) : rows.length === 0 ? (
|
||
<div className="review-v2-empty">当前状态暂无回答审核任务</div>
|
||
) : (
|
||
rows.map((record) => (
|
||
<div className="row" key={record.id}>
|
||
<span>
|
||
<b>
|
||
<code>{record.user_id}</code>
|
||
<code>{record.submission_no}</code>
|
||
</b>
|
||
<small>
|
||
{record.phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")}
|
||
</small>
|
||
</span>
|
||
<span>{productName(record)}</span>
|
||
<span>{formatTime(record.updated_at)}</span>
|
||
<span>
|
||
<strong>
|
||
{record.questions.filter((q) => q.selected).length}
|
||
</strong>{" "}
|
||
组问答
|
||
</span>
|
||
<span>
|
||
<StatusBadge status={record.status} />
|
||
</span>
|
||
<span>
|
||
<button onClick={() => openAnswerDetail(record)}>
|
||
{record.status === "待审核"
|
||
? "查看并审核"
|
||
: "查看任务列表"}
|
||
</button>
|
||
</span>
|
||
</div>
|
||
))
|
||
)}
|
||
</section>
|
||
</>
|
||
)}
|
||
{detail && (
|
||
<ReviewDetail
|
||
record={detail}
|
||
close={() => setDetail(null)}
|
||
decide={decide}
|
||
busy={busy}
|
||
/>
|
||
)}
|
||
{refundPrompt && (
|
||
<RefundPrompt
|
||
onCancel={() => setRefundPrompt(false)}
|
||
onConfirm={() => {
|
||
setRefundPrompt(false);
|
||
window.dispatchEvent(new CustomEvent("open-task-board", { detail: { status: "待返款" } }));
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|