2026-06-02 12:38:01 +08:00
|
|
|
|
import {
|
|
|
|
|
|
CheckCircleOutlined,
|
|
|
|
|
|
CloseCircleOutlined,
|
|
|
|
|
|
FileSearchOutlined,
|
|
|
|
|
|
FlagOutlined,
|
|
|
|
|
|
LoginOutlined,
|
|
|
|
|
|
PlusCircleOutlined,
|
|
|
|
|
|
ReloadOutlined,
|
|
|
|
|
|
} from "@ant-design/icons";
|
|
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
|
|
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
|
|
|
|
|
import { reportClient, type AdminReportItem } from "../../api/reportClient";
|
|
|
|
|
|
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
2026-06-08 16:32:16 +08:00
|
|
|
|
import "../../styles/pages/compliance.css";
|
2026-06-02 12:38:01 +08:00
|
|
|
|
import type { WebUserSession } from "../../types";
|
|
|
|
|
|
import { canManageCommunityCases, canReviewCommunity } from "./communityPermissions";
|
|
|
|
|
|
|
|
|
|
|
|
interface CommunityReviewPageProps {
|
|
|
|
|
|
session: WebUserSession | null;
|
|
|
|
|
|
onOpenLogin: () => void;
|
|
|
|
|
|
onOpenReport: () => void;
|
|
|
|
|
|
onOpenCaseAdd: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ReviewStatus = "" | ServerCommunityCase["status"];
|
|
|
|
|
|
type ModerationTab = "cases" | "reports";
|
|
|
|
|
|
|
|
|
|
|
|
const REVIEW_STATUS_OPTIONS: Array<{ value: ReviewStatus; label: string }> = [
|
|
|
|
|
|
{ value: "pending", label: "待审核" },
|
|
|
|
|
|
{ value: "approved", label: "已通过" },
|
|
|
|
|
|
{ value: "rejected", label: "已驳回" },
|
|
|
|
|
|
{ value: "", label: "全部" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const STATUS_LABEL: Record<ServerCommunityCase["status"], string> = {
|
|
|
|
|
|
pending: "待审核",
|
|
|
|
|
|
approved: "已通过",
|
|
|
|
|
|
rejected: "已驳回",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function getCaseCover(item: ServerCommunityCase): string {
|
|
|
|
|
|
return (
|
|
|
|
|
|
item.coverUrl ||
|
|
|
|
|
|
item.assets.find((asset) => asset.assetType === "cover" && asset.url)?.url ||
|
|
|
|
|
|
item.assets.find((asset) => (asset.assetType === "image" || asset.assetType === "asset") && asset.url)?.url ||
|
|
|
|
|
|
""
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getWorkflowStats(item: ServerCommunityCase): { nodes: number; edges: number } {
|
|
|
|
|
|
const workflow =
|
|
|
|
|
|
item.metadata.workflow ||
|
|
|
|
|
|
item.metadata.workflowSnapshot ||
|
|
|
|
|
|
(item.metadata.workflowData && typeof item.metadata.workflowData === "object"
|
|
|
|
|
|
? (item.metadata.workflowData as Record<string, unknown>)
|
|
|
|
|
|
: null);
|
|
|
|
|
|
|
|
|
|
|
|
if (!workflow || typeof workflow !== "object" || Array.isArray(workflow)) {
|
|
|
|
|
|
return { nodes: 0, edges: 0 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const workflowRecord = workflow as Record<string, unknown>;
|
|
|
|
|
|
const nodes = Array.isArray(workflowRecord.nodes) ? workflowRecord.nodes.length : 0;
|
|
|
|
|
|
const edges = Array.isArray(workflowRecord.edges) ? workflowRecord.edges.length : 0;
|
|
|
|
|
|
return { nodes, edges };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDate(value?: string | null): string {
|
|
|
|
|
|
if (!value) return "暂无时间";
|
|
|
|
|
|
const date = new Date(value);
|
|
|
|
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
|
|
|
|
return date.toLocaleString("zh-CN", {
|
|
|
|
|
|
month: "2-digit",
|
|
|
|
|
|
day: "2-digit",
|
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function compactType(value?: string | null): string {
|
|
|
|
|
|
return value?.trim() || "未填写";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function CommunityReviewPage({ session, onOpenLogin, onOpenReport, onOpenCaseAdd }: CommunityReviewPageProps) {
|
|
|
|
|
|
const allowed = canReviewCommunity(session);
|
|
|
|
|
|
const canAddCase = canManageCommunityCases(session);
|
|
|
|
|
|
const [activeTab, setActiveTab] = useState<ModerationTab>("cases");
|
|
|
|
|
|
const [status, setStatus] = useState<ReviewStatus>("pending");
|
|
|
|
|
|
const [cases, setCases] = useState<ServerCommunityCase[]>([]);
|
|
|
|
|
|
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
|
|
|
|
|
const [reports, setReports] = useState<AdminReportItem[]>([]);
|
|
|
|
|
|
const [selectedReportId, setSelectedReportId] = useState<string | null>(null);
|
|
|
|
|
|
const [reviewNote, setReviewNote] = useState("");
|
|
|
|
|
|
const [loadingCases, setLoadingCases] = useState(false);
|
|
|
|
|
|
const [loadingReports, setLoadingReports] = useState(false);
|
|
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedCase = useMemo(
|
|
|
|
|
|
() => cases.find((item) => String(item.id) === selectedCaseId) ?? cases[0] ?? null,
|
|
|
|
|
|
[cases, selectedCaseId],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedReport = useMemo(
|
|
|
|
|
|
() => reports.find((item) => String(item.id) === selectedReportId) ?? reports[0] ?? null,
|
|
|
|
|
|
[reports, selectedReportId],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const loadCases = useCallback(async () => {
|
|
|
|
|
|
if (!allowed) return;
|
|
|
|
|
|
setLoadingCases(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const items = await communityClient.listCasesForReview(status);
|
|
|
|
|
|
setCases(items);
|
|
|
|
|
|
setSelectedCaseId((current) =>
|
|
|
|
|
|
current && items.some((item) => String(item.id) === current) ? current : String(items[0]?.id || ""),
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (loadError) {
|
|
|
|
|
|
setCases([]);
|
|
|
|
|
|
setError(loadError instanceof Error ? loadError.message : "审核列表加载失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingCases(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [allowed, status]);
|
|
|
|
|
|
|
|
|
|
|
|
const loadReports = useCallback(async () => {
|
|
|
|
|
|
if (!allowed) return;
|
|
|
|
|
|
setLoadingReports(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const items = await reportClient.listAdminReports();
|
|
|
|
|
|
setReports(items);
|
|
|
|
|
|
setSelectedReportId((current) =>
|
|
|
|
|
|
current && items.some((item) => String(item.id) === current) ? current : String(items[0]?.id || ""),
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (loadError) {
|
|
|
|
|
|
setReports([]);
|
|
|
|
|
|
setError(loadError instanceof Error ? loadError.message : "举报列表加载失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingReports(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [allowed]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (activeTab === "cases") {
|
|
|
|
|
|
void loadCases();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
void loadReports();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [activeTab, loadCases, loadReports]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleDecision = async (nextStatus: "approved" | "rejected") => {
|
|
|
|
|
|
if (!selectedCase || submitting) return;
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await communityClient.updateReviewStatus(selectedCase.id, nextStatus, reviewNote.trim());
|
|
|
|
|
|
setReviewNote("");
|
|
|
|
|
|
await loadCases();
|
|
|
|
|
|
} catch (submitError) {
|
|
|
|
|
|
setError(submitError instanceof Error ? submitError.message : "审核操作失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (!session) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<WorkspacePageShell title="社区审核" fullWidth className="community-review-page page-motion">
|
|
|
|
|
|
<section className="community-review-access">
|
|
|
|
|
|
<LoginOutlined />
|
|
|
|
|
|
<h1>请登录工作人员账号</h1>
|
|
|
|
|
|
<p>社区作品审核和举报查看需要登录后访问。</p>
|
|
|
|
|
|
<button type="button" onClick={onOpenLogin}>登录 / 注册</button>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</WorkspacePageShell>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!allowed) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<WorkspacePageShell title="社区审核" fullWidth className="community-review-page page-motion">
|
|
|
|
|
|
<section className="community-review-access">
|
|
|
|
|
|
<FileSearchOutlined />
|
|
|
|
|
|
<h1>当前账号没有审核权限</h1>
|
|
|
|
|
|
<p>请切换到 admin、staff、reviewer 或 moderator 角色账号后再进入审核台。</p>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</WorkspacePageShell>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const caseStats = selectedCase ? getWorkflowStats(selectedCase) : { nodes: 0, edges: 0 };
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<WorkspacePageShell title="社区审核" fullWidth className="community-review-page page-motion">
|
|
|
|
|
|
<div className="community-review-page__inner">
|
|
|
|
|
|
<section className="community-review-toolbar">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span>内部审核台</span>
|
|
|
|
|
|
<h1>社区审核与举报</h1>
|
|
|
|
|
|
<p>读取现有服务器审核接口,处理待审作品并查看用户举报。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="community-review-toolbar__actions">
|
|
|
|
|
|
{canAddCase ? (
|
|
|
|
|
|
<button type="button" onClick={onOpenCaseAdd}>
|
|
|
|
|
|
<PlusCircleOutlined />
|
|
|
|
|
|
添加案例
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<button type="button" onClick={onOpenReport}>
|
|
|
|
|
|
<FlagOutlined />
|
|
|
|
|
|
举报入口
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => (activeTab === "cases" ? void loadCases() : void loadReports())}
|
|
|
|
|
|
disabled={loadingCases || loadingReports}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ReloadOutlined />
|
|
|
|
|
|
刷新
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="community-review-tabs" role="tablist" aria-label="审核类型">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="tab"
|
|
|
|
|
|
aria-selected={activeTab === "cases"}
|
|
|
|
|
|
className={activeTab === "cases" ? "is-active" : ""}
|
|
|
|
|
|
onClick={() => setActiveTab("cases")}
|
|
|
|
|
|
>
|
|
|
|
|
|
作品审核
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="tab"
|
|
|
|
|
|
aria-selected={activeTab === "reports"}
|
|
|
|
|
|
className={activeTab === "reports" ? "is-active" : ""}
|
|
|
|
|
|
onClick={() => setActiveTab("reports")}
|
|
|
|
|
|
>
|
|
|
|
|
|
举报工单
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{error ? <p className="community-review-error">{error}</p> : null}
|
|
|
|
|
|
|
|
|
|
|
|
{activeTab === "cases" ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="community-review-status-tabs" role="tablist" aria-label="作品审核状态">
|
|
|
|
|
|
{REVIEW_STATUS_OPTIONS.map((option) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={option.value || "all"}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="tab"
|
|
|
|
|
|
aria-selected={status === option.value}
|
|
|
|
|
|
className={status === option.value ? "is-active" : ""}
|
|
|
|
|
|
onClick={() => setStatus(option.value)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{option.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="community-review-layout">
|
|
|
|
|
|
<aside className="community-review-list" aria-label="作品列表">
|
|
|
|
|
|
{loadingCases ? <div className="community-review-list__empty">正在加载审核作品...</div> : null}
|
|
|
|
|
|
{!loadingCases && cases.length === 0 ? (
|
|
|
|
|
|
<div className="community-review-list__empty">暂无需要显示的作品</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{cases.map((item) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`community-review-list__item${String(item.id) === String(selectedCase?.id) ? " is-active" : ""}`}
|
|
|
|
|
|
onClick={() => setSelectedCaseId(String(item.id))}
|
|
|
|
|
|
>
|
|
|
|
|
|
{getCaseCover(item) ? <img src={getCaseCover(item)} alt="" /> : <span className="community-review-list__thumb" />}
|
|
|
|
|
|
<span>{STATUS_LABEL[item.status]}</span>
|
|
|
|
|
|
<strong>{item.title}</strong>
|
|
|
|
|
|
<small>{item.username || `UID ${item.userId || "-"}`} · {formatDate(item.updatedAt)}</small>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
|
|
{selectedCase ? (
|
|
|
|
|
|
<article className="community-review-detail">
|
|
|
|
|
|
<div className="community-review-detail__cover">
|
|
|
|
|
|
{getCaseCover(selectedCase) ? <img src={getCaseCover(selectedCase)} alt={selectedCase.title} /> : null}
|
|
|
|
|
|
<span>{STATUS_LABEL[selectedCase.status]}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="community-review-detail__body">
|
|
|
|
|
|
<header>
|
|
|
|
|
|
<span>{selectedCase.tags[0] || "社区作品"}</span>
|
|
|
|
|
|
<h2>{selectedCase.title}</h2>
|
|
|
|
|
|
<p>{selectedCase.description || "作者未填写作品简介。"}</p>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<div className="community-review-meta">
|
|
|
|
|
|
<span>作者:{selectedCase.username || `UID ${selectedCase.userId || "-"}`}</span>
|
|
|
|
|
|
<span>状态:{STATUS_LABEL[selectedCase.status]}</span>
|
|
|
|
|
|
<span>节点:{caseStats.nodes}</span>
|
|
|
|
|
|
<span>连线:{caseStats.edges}</span>
|
|
|
|
|
|
<span>收藏:{selectedCase.favoriteCount}</span>
|
|
|
|
|
|
<span>点赞:{selectedCase.likeCount}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{selectedCase.reviewNote ? (
|
|
|
|
|
|
<p className="community-review-note-preview">上次备注:{selectedCase.reviewNote}</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<label className="community-review-note">
|
|
|
|
|
|
<span>审核备注</span>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
value={reviewNote}
|
|
|
|
|
|
onChange={(event) => setReviewNote(event.target.value)}
|
|
|
|
|
|
placeholder="填写通过说明或驳回原因"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div className="community-review-actions">
|
|
|
|
|
|
<button type="button" disabled={submitting} onClick={() => void handleDecision("rejected")}>
|
|
|
|
|
|
<CloseCircleOutlined />
|
|
|
|
|
|
驳回
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" disabled={submitting} onClick={() => void handleDecision("approved")}>
|
|
|
|
|
|
<CheckCircleOutlined />
|
|
|
|
|
|
通过
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="community-review-detail community-review-detail--empty">选择左侧作品查看详情</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<section className="community-review-layout community-review-layout--reports">
|
|
|
|
|
|
<aside className="community-review-list" aria-label="举报列表">
|
|
|
|
|
|
{loadingReports ? <div className="community-review-list__empty">正在加载举报...</div> : null}
|
|
|
|
|
|
{!loadingReports && reports.length === 0 ? (
|
|
|
|
|
|
<div className="community-review-list__empty">暂无举报工单</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{reports.map((item) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`community-review-list__item${String(item.id) === String(selectedReport?.id) ? " is-active" : ""}`}
|
|
|
|
|
|
onClick={() => setSelectedReportId(String(item.id))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>{item.status}</span>
|
|
|
|
|
|
<strong>{item.title}</strong>
|
|
|
|
|
|
<small>{compactType(item.reportType)} · {formatDate(item.createdAt)}</small>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
|
|
{selectedReport ? (
|
|
|
|
|
|
<article className="community-review-detail community-review-report-detail">
|
|
|
|
|
|
<div className="community-review-detail__body">
|
|
|
|
|
|
<header>
|
|
|
|
|
|
<span>{selectedReport.status}</span>
|
|
|
|
|
|
<h2>{selectedReport.title}</h2>
|
|
|
|
|
|
<p>{selectedReport.description || "举报人未填写详情。"}</p>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<div className="community-review-meta">
|
|
|
|
|
|
<span>举报类型:{compactType(selectedReport.reportType)}</span>
|
|
|
|
|
|
<span>对象类型:{compactType(selectedReport.targetType)}</span>
|
|
|
|
|
|
<span>对象 ID:{compactType(selectedReport.targetId)}</span>
|
|
|
|
|
|
<span>提交人:{selectedReport.username || selectedReport.contactName || "匿名"}</span>
|
|
|
|
|
|
<span>邮箱:{compactType(selectedReport.contactEmail)}</span>
|
|
|
|
|
|
<span>电话:{compactType(selectedReport.contactPhone)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{selectedReport.pageUrl ? (
|
|
|
|
|
|
<a className="community-review-link" href={selectedReport.pageUrl} target="_blank" rel="noreferrer">
|
|
|
|
|
|
打开举报页面来源
|
|
|
|
|
|
</a>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<div className="community-review-report-raw">
|
|
|
|
|
|
<span>客户端信息</span>
|
|
|
|
|
|
<p>{selectedReport.userAgent || "暂无"}</p>
|
|
|
|
|
|
<small>{selectedReport.ipAddress || "未记录 IP"}</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="community-review-detail community-review-detail--empty">选择左侧举报查看详情</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</WorkspacePageShell>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|