wendajia/frontend/src/ReviewCenterPage.jsx

800 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import React, { useEffect, useMemo, useRef, useState } from "react";
import { ChevronRight, LoaderCircle, MessageCircleMore, RefreshCw, X } from "lucide-react";
import { formatDateTime } from "./time.js";
const REASONS = [
"内容不清晰",
"数量不足",
"问题不匹配",
"回答不符合要求",
"商品不一致",
"其他",
];
const STATUSES = ["全部", "待审核"];
const formatTime = formatDateTime;
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 RefundRecipientInfo({ record }) {
return (
<div className="refund-recipient-info">
<strong>支付宝收款信息</strong>
<span>
支付宝账号
<b>{record.alipay_account || "未填写(历史任务)"}</b>
</span>
<span>
收款人姓名
<b>{record.payee_name || "未填写(历史任务)"}</b>
</span>
</div>
);
}
const reviewKind = (record) => record?.batch_id ? "batch" : "assignment";
const reviewId = (record) => record?.batch_id || record?.assignment_id || record?.id;
const reviewKey = (record) => `${reviewKind(record)}:${reviewId(record)}`;
const reviewEndpoint = (record) => reviewKind(record) === "batch"
? `/api/admin/ask-batch-reviews/${reviewId(record)}`
: `/api/admin/ask-assignment-reviews/${reviewId(record)}`;
function DetailLoadingModal({ title }) {
return (
<div className="review-v2-backdrop review-detail-loading-backdrop">
<section className="review-detail-loading" role="status" aria-live="polite">
<LoaderCircle size={24} className="spin" />
<strong>{title}</strong>
<span>请求已收到,图片将在弹窗内按需加载…</span>
</section>
</div>
);
}
function NextReviewButton({ hasNext, onNext, busy, label = "下一个审核任务" }) {
return (
<button
type="button"
className="review-next-button"
disabled={!hasNext || busy}
onClick={onNext}
>
{busy ? "正在打开…" : hasNext ? label : "已是最后一个"}
{hasNext && !busy && <ChevronRight size={15} />}
</button>
);
}
function RefundPrompt({ onConfirm, onCancel, onNext, hasNext }) {
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>
{hasNext && (
<button type="button" className="refund-prompt-next" onClick={onNext}>
继续下一个审核
<ChevronRight size={15} />
</button>
)}
<button type="button" className="refund-prompt-confirm" onClick={onConfirm}>前往待返款</button>
</div>
</section>
</div>
);
}
function ReviewDetail({ record, close, decide, busy, hasNext, onNext, nextBusy }) {
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>
<RefundRecipientInfo record={record} />
<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}`}
loading="lazy"
decoding="async"
/>
<figcaption>任务截图 {index + 1}</figcaption>
</figure>
))}
</div>
</section>
{!!record.payment_screenshots?.length && <section>
<h3>③ 返款二维码截图</h3>
<div>
{record.payment_screenshots.map((image) => (
<figure key={image.id}>
<img
src={image.data_url}
alt="收款码"
loading="lazy"
decoding="async"
/>
<figcaption>审核通过后用于后续人工处理</figcaption>
</figure>
))}
</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>
<NextReviewButton hasNext={hasNext} onNext={onNext} busy={nextBusy || busy} />
<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>
<NextReviewButton hasNext={hasNext} onNext={onNext} busy={nextBusy} />
<button onClick={close}>关闭</button>
</footer>
)}
</section>
</div>
);
}
function AskReviewDetail({ record, close, api, onDecision, hasNext, onNext, nextBusy }) {
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 {
const result = await api(
`${reviewEndpoint(record)}/decision`,
{
method: "POST",
body: JSON.stringify({
approved,
reason_code: approved ? "" : reason,
reason_text: reasonText.trim(),
}),
},
);
onDecision({
...record,
...result.data,
question_screenshots: record.question_screenshots || [],
payment_screenshots: record.payment_screenshots || [],
}, approved);
} 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>创建人:{record.creator_name || "运营后台"}</span>
<span>提交时间:{formatTime(record.updated_at)}</span>
</div>
<RefundRecipientInfo record={record} />
<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}`}
loading="lazy"
decoding="async"
/>
<figcaption>提问截图 {index + 1}</figcaption>
</figure>
))}
</div>
</section>
{!!record.payment_screenshots?.length && <section>
<h3>③ 用户上传的收款码</h3>
<div>
{record.payment_screenshots.map((image) => (
<figure key={image.id}>
<img
src={image.data_url}
alt="收款码"
loading="lazy"
decoding="async"
/>
<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>
<NextReviewButton hasNext={hasNext} onNext={onNext} busy={nextBusy || busy} />
<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>
<NextReviewButton hasNext={hasNext} onNext={onNext} busy={nextBusy} />
<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);
const [detailLoading, setDetailLoading] = useState(null);
const [nextRecord, setNextRecord] = useState(null);
const detailCache = useRef(new Map());
async function openDetail(record) {
if (!record || detailLoading) return;
const key = reviewKey(record);
const index = records.findIndex((item) => reviewKey(item) === key);
const nextCandidate = index >= 0 ? records[index + 1] || null : null;
setNextRecord(nextCandidate);
const cached = detailCache.current.get(key);
if (cached) {
setDetail(cached);
return;
}
setDetailLoading(key);
try {
const result = await api(
reviewEndpoint(record),
);
detailCache.current.set(key, result.data);
setDetail(result.data);
if (nextCandidate && !detailCache.current.has(reviewKey(nextCandidate))) {
void api(reviewEndpoint(nextCandidate))
.then((nextResult) => detailCache.current.set(reviewKey(nextCandidate), nextResult.data))
.catch(() => {});
}
} catch (error) {
window.alert(error.message);
} finally {
setDetailLoading(null);
}
}
async function load(showLoading = true) {
if (showLoading) setLoading(true);
try {
const result = await api("/api/admin/ask-completion-reviews");
setRecords(result.data);
} catch (error) {
window.alert(error.message);
} finally {
if (showLoading) setLoading(false);
}
}
function handleDecision(updated, approved) {
const key = reviewKey(updated);
detailCache.current.set(key, updated);
setDetail(updated);
setRecords((current) => current.filter((item) => reviewKey(item) !== key));
window.dispatchEvent(new Event("review-count-changed"));
void load(false);
if (approved) {
onApproved({
hasNext: Boolean(nextRecord),
onNext: () => openDetail(nextRecord),
});
}
}
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>
{(record.taobao_id || record.phone) !== record.user_id && <small>{record.taobao_id || record.phone}</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 disabled={Boolean(detailLoading)} onClick={() => openDetail(record)}>
{detailLoading === reviewKey(record)
? "正在打开…"
: record.status === "待审核" ? "查看并审核" : "查看详情"}
</button>
</span>
</div>
))
)}
</section>
{detailLoading && !detail && <DetailLoadingModal title="正在打开提问审核详情" />}
{detail && (
<AskReviewDetail
key={reviewKey(detail)}
record={detail}
close={() => setDetail(null)}
api={api}
onDecision={handleDecision}
hasNext={Boolean(nextRecord)}
nextBusy={Boolean(detailLoading)}
onNext={() => openDetail(nextRecord)}
/>
)}
</>
);
}
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(null);
const [nextRecord, setNextRecord] = useState(null);
const [refundPrompt, setRefundPrompt] = useState(null);
const loadVersion = useRef(0);
const detailCache = useRef(new Map());
async function openAnswerDetail(record) {
if (!record || detailLoading) return;
const index = allRows.findIndex((item) => item.id === record.id);
const nextCandidate = index >= 0 ? allRows[index + 1] || null : null;
setNextRecord(nextCandidate);
const cached = detailCache.current.get(record.id);
if (cached) {
setDetail(cached);
return;
}
setDetailLoading(record.id);
try {
const result = await api(
`/api/admin/answer-completion-reviews/${record.id}`,
);
detailCache.current.set(record.id, result.data);
setDetail(result.data);
if (nextCandidate && !detailCache.current.has(nextCandidate.id)) {
void api(`/api/admin/answer-completion-reviews/${nextCandidate.id}`)
.then((nextResult) => detailCache.current.set(nextCandidate.id, nextResult.data))
.catch(() => {});
}
} catch (err) {
window.alert(err.message);
} finally {
setDetailLoading(null);
}
}
async function load(showLoading = true) {
const version = ++loadVersion.current;
if (showLoading) 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 (showLoading && version === loadVersion.current) setLoading(false);
}
}
useEffect(() => {
load();
}, []);
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 {
const result = await api(`/api/admin/answer-completion-reviews/${record.id}/decision`, {
method: "POST",
body: JSON.stringify({
approved,
reason_code: reasonCode,
reason_text: reasonText,
}),
});
const updated = {
...record,
...result.data,
invited_screenshots: record.invited_screenshots || [],
completion_screenshots: record.completion_screenshots || [],
payment_screenshots: record.payment_screenshots || [],
};
detailCache.current.set(record.id, updated);
setDetail(updated);
setRecords((current) => current.filter((item) => item.id !== record.id));
void load(false);
window.dispatchEvent(new Event("review-count-changed"));
setStatusFilter("待审核");
if (approved) {
setRefundPrompt({
hasNext: Boolean(nextRecord),
onNext: () => openAnswerDetail(nextRecord),
});
}
} 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={(navigation) => setRefundPrompt(navigation)} />
) : (
<>
<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>
{(record.taobao_id || record.phone) !== record.user_id && <small>{record.taobao_id || record.phone}</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 disabled={Boolean(detailLoading)} onClick={() => openAnswerDetail(record)}>
{detailLoading === record.id
? "正在打开…"
: record.status === "待审核"
? "查看并审核"
: "查看任务列表"}
</button>
</span>
</div>
))
)}
</section>
</>
)}
{detail && (
<ReviewDetail
key={detail.id}
record={detail}
close={() => setDetail(null)}
decide={decide}
busy={busy}
hasNext={Boolean(nextRecord)}
nextBusy={Boolean(detailLoading)}
onNext={() => openAnswerDetail(nextRecord)}
/>
)}
{detailLoading && !detail && <DetailLoadingModal title="正在打开回答审核详情" />}
{refundPrompt && (
<RefundPrompt
hasNext={refundPrompt.hasNext}
onNext={() => {
setRefundPrompt(null);
refundPrompt.onNext();
}}
onCancel={() => setRefundPrompt(null)}
onConfirm={() => {
setRefundPrompt(null);
window.dispatchEvent(new CustomEvent("open-task-board", { detail: { status: "待返款" } }));
}}
/>
)}
</>
);
}