Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckCircleOutlined,
|
||||
FileTextOutlined,
|
||||
LoginOutlined,
|
||||
PictureOutlined,
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { communityClient } from "../../api/communityClient";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import type { WebCanvasWorkflow, WebUserSession } from "../../types";
|
||||
import { getWorkflowCoverUrl, isCanvasWorkflow } from "../community/communityCaseUtils";
|
||||
import { canManageCommunityCases } from "./communityPermissions";
|
||||
|
||||
interface CommunityCaseAddPageProps {
|
||||
session: WebUserSession | null;
|
||||
onOpenLogin: () => void;
|
||||
onOpenReview: () => void;
|
||||
}
|
||||
|
||||
type CaseTarget = "generation" | "canvas";
|
||||
|
||||
const RATIO_OPTIONS = ["16:9", "9:16", "1:1", "4:5", "3:4", "21:9"];
|
||||
|
||||
function readFileAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("图片读取失败"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function readFileAsText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("JSON 读取失败"));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
function textToDataUrl(text: string, mimeType: string): string {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let binary = "";
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return `data:${mimeType};base64,${btoa(binary)}`;
|
||||
}
|
||||
|
||||
function parseWorkflowText(text: string): WebCanvasWorkflow {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!isCanvasWorkflow(parsed)) {
|
||||
throw new Error("JSON 需要包含完整画布工作流:nodes、edges 和 settings。");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function shortDescription(value: string): string {
|
||||
return value.trim().replace(/\s+/g, " ").slice(0, 120);
|
||||
}
|
||||
|
||||
function ratioToCssAspectRatio(value: string): string {
|
||||
const [width, height] = value.split(":").map((item) => Number(item));
|
||||
return width > 0 && height > 0 ? `${width} / ${height}` : "16 / 9";
|
||||
}
|
||||
|
||||
export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenReview }: CommunityCaseAddPageProps) {
|
||||
const allowed = canManageCommunityCases(session);
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const workflowInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [target, setTarget] = useState<CaseTarget>("generation");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [category, setCategory] = useState("图片案例");
|
||||
const [ratio, setRatio] = useState("16:9");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [imageUrl, setImageUrl] = useState("");
|
||||
const [imageDataUrl, setImageDataUrl] = useState("");
|
||||
const [imageFileName, setImageFileName] = useState("");
|
||||
const [workflowText, setWorkflowText] = useState("");
|
||||
const [workflowFileName, setWorkflowFileName] = useState("");
|
||||
const [workflow, setWorkflow] = useState<WebCanvasWorkflow | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const previewUrl = imageDataUrl || imageUrl.trim();
|
||||
const workflowCoverUrl = useMemo(() => getWorkflowCoverUrl(workflow), [workflow]);
|
||||
const targetCopy = target === "generation" ? "生成页面社区" : "画布页面社区";
|
||||
const previewAspectRatio = target === "generation" ? ratioToCssAspectRatio(ratio) : "16 / 10";
|
||||
|
||||
useEffect(() => {
|
||||
setCategory((current) => {
|
||||
if (target === "generation" && current === "工作流案例") return "图片案例";
|
||||
if (target === "canvas" && current === "图片案例") return "工作流案例";
|
||||
return current;
|
||||
});
|
||||
}, [target]);
|
||||
|
||||
const handleImageChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setImageFileName(file.name);
|
||||
setImageDataUrl(await readFileAsDataUrl(file));
|
||||
};
|
||||
|
||||
const applyWorkflowText = (text: string, fileName = "") => {
|
||||
setWorkflowText(text);
|
||||
setWorkflowFileName(fileName);
|
||||
try {
|
||||
const parsedWorkflow = parseWorkflowText(text);
|
||||
setWorkflow(parsedWorkflow);
|
||||
setError(null);
|
||||
if (!title.trim()) setTitle(parsedWorkflow.title || "画布社区案例");
|
||||
if (!description.trim()) setDescription(parsedWorkflow.description || "");
|
||||
if (!category.trim()) setCategory("工作流案例");
|
||||
} catch (parseError) {
|
||||
setWorkflow(null);
|
||||
setError(parseError instanceof Error ? parseError.message : "JSON 解析失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkflowChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
applyWorkflowText(await readFileAsText(file), file.name);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
const cleanTitle = title.trim();
|
||||
if (!cleanTitle) throw new Error("请填写案例标题。");
|
||||
|
||||
if (target === "generation") {
|
||||
if (!prompt.trim()) throw new Error("请填写生成页面展示的提示词。");
|
||||
if (!previewUrl) throw new Error("请上传案例图片或填写图片 URL。");
|
||||
|
||||
let coverUrl = imageUrl.trim();
|
||||
let ossKey: string | undefined;
|
||||
if (imageDataUrl) {
|
||||
const uploaded = await aiGenerationClient.uploadAsset({
|
||||
dataUrl: imageDataUrl,
|
||||
name: imageFileName || `${cleanTitle}.png`,
|
||||
scope: "community-case-cover",
|
||||
});
|
||||
coverUrl = uploaded.url || imageDataUrl;
|
||||
ossKey = uploaded.ossKey;
|
||||
}
|
||||
|
||||
await communityClient.publishCase({
|
||||
title: cleanTitle,
|
||||
description: description.trim() || shortDescription(prompt),
|
||||
coverUrl,
|
||||
tags: [category.trim() || "图片案例", "生成页面社区"],
|
||||
metadata: {
|
||||
source: "admin-case-add",
|
||||
communitySurface: "generation",
|
||||
prompt: prompt.trim(),
|
||||
ratio,
|
||||
category: category.trim() || "图片案例",
|
||||
},
|
||||
assets: [
|
||||
{
|
||||
assetType: "cover",
|
||||
title: cleanTitle,
|
||||
url: coverUrl,
|
||||
ossKey,
|
||||
metadata: { prompt: prompt.trim(), ratio },
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
if (!workflow) throw new Error("请上传或粘贴有效的画布工作流 JSON。");
|
||||
const workflowJson = JSON.stringify(workflow);
|
||||
const uploadedWorkflow = await aiGenerationClient.uploadAsset({
|
||||
dataUrl: textToDataUrl(workflowJson, "application/json"),
|
||||
name: workflowFileName || `${cleanTitle}.json`,
|
||||
mimeType: "application/json",
|
||||
scope: "community-case-workflow",
|
||||
});
|
||||
const coverUrl = workflowCoverUrl || imageUrl.trim();
|
||||
await communityClient.publishCase({
|
||||
title: cleanTitle,
|
||||
description: description.trim() || workflow.description || "管理员添加的画布工作流案例。",
|
||||
coverUrl,
|
||||
tags: [category.trim() || "工作流案例", "画布页面社区"],
|
||||
metadata: {
|
||||
source: "admin-case-add",
|
||||
communitySurface: "canvas",
|
||||
category: category.trim() || "工作流案例",
|
||||
workflow,
|
||||
workflowOssKey: uploadedWorkflow.ossKey || null,
|
||||
workflowUrl: uploadedWorkflow.url || null,
|
||||
},
|
||||
assets: [
|
||||
{
|
||||
assetType: "workflow",
|
||||
title: cleanTitle,
|
||||
url: uploadedWorkflow.url,
|
||||
ossKey: uploadedWorkflow.ossKey,
|
||||
metadata: { workflow, fileName: workflowFileName || null, contentType: "application/json" },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
setNotice(`已提交到${targetCopy}审核列表,审核通过后会展示到对应社区。`);
|
||||
} 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">
|
||||
<FileTextOutlined />
|
||||
<h1>当前账号没有管理权限</h1>
|
||||
<p>请切换到 admin 角色账号后再添加案例。</p>
|
||||
</section>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<button type="button" onClick={onOpenReview}>
|
||||
<ArrowLeftOutlined />
|
||||
返回审核
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="community-review-tabs community-case-add-targets" role="tablist" aria-label="案例展示位置">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={target === "generation"}
|
||||
className={target === "generation" ? "is-active" : ""}
|
||||
onClick={() => setTarget("generation")}
|
||||
>
|
||||
<PictureOutlined />
|
||||
生成页面社区
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={target === "canvas"}
|
||||
className={target === "canvas" ? "is-active" : ""}
|
||||
onClick={() => setTarget("canvas")}
|
||||
>
|
||||
<FileTextOutlined />
|
||||
画布页面社区
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? <p className="community-review-error">{error}</p> : null}
|
||||
{notice ? (
|
||||
<p className="community-case-add-success">
|
||||
<CheckCircleOutlined />
|
||||
{notice}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="community-case-add-layout">
|
||||
<form className="community-case-add-form" onSubmit={(event) => event.preventDefault()}>
|
||||
<div className="community-case-add-form__grid">
|
||||
<label>
|
||||
<span>案例标题</span>
|
||||
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="例如:横版商品主图" />
|
||||
</label>
|
||||
<label>
|
||||
<span>分类标签</span>
|
||||
<input value={category} onChange={(event) => setCategory(event.target.value)} placeholder="图片案例 / 工作流案例" />
|
||||
</label>
|
||||
{target === "generation" ? (
|
||||
<label>
|
||||
<span>展示比例</span>
|
||||
<select value={ratio} onChange={(event) => setRatio(event.target.value)}>
|
||||
{RATIO_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>案例简介</span>
|
||||
<textarea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="审核页和社区卡片使用的简短说明" />
|
||||
</label>
|
||||
|
||||
{target === "generation" ? (
|
||||
<>
|
||||
<label>
|
||||
<span>提示词</span>
|
||||
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} placeholder="输入用户点击案例后可套用的提示词" />
|
||||
</label>
|
||||
<div className="community-case-add-upload-row">
|
||||
<input ref={imageInputRef} type="file" accept="image/*" hidden onChange={handleImageChange} />
|
||||
<button type="button" onClick={() => imageInputRef.current?.click()}>
|
||||
<UploadOutlined />
|
||||
上传图片
|
||||
</button>
|
||||
<label>
|
||||
<span>或填写图片 URL</span>
|
||||
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." />
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="community-case-add-upload-row">
|
||||
<input ref={workflowInputRef} type="file" accept="application/json,.json" hidden onChange={handleWorkflowChange} />
|
||||
<button type="button" onClick={() => workflowInputRef.current?.click()}>
|
||||
<UploadOutlined />
|
||||
上传 JSON
|
||||
</button>
|
||||
<label>
|
||||
<span>可选封面 URL</span>
|
||||
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="未填写时从节点预览图提取" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>画布工作流 JSON</span>
|
||||
<textarea
|
||||
value={workflowText}
|
||||
onChange={(event) => applyWorkflowText(event.target.value, workflowFileName)}
|
||||
placeholder='{"id":"...","version":1,"title":"...","nodes":[...],"edges":[...],"settings":{...}}'
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="community-case-add-actions">
|
||||
<button type="button" onClick={() => void handleSubmit()} disabled={submitting}>
|
||||
<CheckCircleOutlined />
|
||||
{submitting ? "提交中..." : "提交审核"}
|
||||
</button>
|
||||
<button type="button" onClick={onOpenReview}>
|
||||
查看审核列表
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside className="community-case-add-preview">
|
||||
<span>{targetCopy}</span>
|
||||
<strong>{title.trim() || "未命名案例"}</strong>
|
||||
<p>{description.trim() || (target === "generation" ? shortDescription(prompt) : workflow?.description) || "提交前可在这里预览案例信息。"}</p>
|
||||
{target === "generation" ? (
|
||||
previewUrl ? (
|
||||
<img src={previewUrl} alt="" style={{ aspectRatio: previewAspectRatio }} />
|
||||
) : (
|
||||
<div className="community-case-add-preview__empty" style={{ aspectRatio: previewAspectRatio }}>等待图片</div>
|
||||
)
|
||||
) : workflowCoverUrl || imageUrl.trim() ? (
|
||||
<img src={workflowCoverUrl || imageUrl.trim()} alt="" />
|
||||
) : (
|
||||
<div className="community-case-add-preview__empty">等待工作流封面</div>
|
||||
)}
|
||||
<dl>
|
||||
<dt>目标</dt>
|
||||
<dd>{targetCopy}</dd>
|
||||
<dt>{target === "generation" ? "比例" : "节点"}</dt>
|
||||
<dd>{target === "generation" ? ratio : workflow ? workflow.nodes.length : 0}</dd>
|
||||
<dt>文件</dt>
|
||||
<dd>{target === "generation" ? imageFileName || "未上传" : workflowFileName || "未上传"}</dd>
|
||||
</dl>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { WebUserSession } from "../../types";
|
||||
|
||||
const REVIEW_ROLES = new Set(["admin", "staff", "reviewer", "moderator"]);
|
||||
|
||||
function getRole(session: WebUserSession | null): string {
|
||||
return String(session?.user.role || "").toLowerCase();
|
||||
}
|
||||
|
||||
export function canReviewCommunity(session: WebUserSession | null): boolean {
|
||||
return REVIEW_ROLES.has(getRole(session));
|
||||
}
|
||||
|
||||
export function canManageCommunityCases(session: WebUserSession | null): boolean {
|
||||
return getRole(session) === "admin";
|
||||
}
|
||||
Reference in New Issue
Block a user