Files
omniai-web/src/features/community-review/CommunityReviewPage.tsx
T

393 lines
16 KiB
TypeScript
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 {
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";
import "../../styles/pages/compliance.css";
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> adminstaffreviewer 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>
);
}