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

445 lines
18 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 {
ArrowLeftOutlined,
CheckCircleOutlined,
FileTextOutlined,
LoginOutlined,
PictureOutlined,
UploadOutlined,
} from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { communityClient } from "../../api/communityClient";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import "../../styles/pages/compliance.css";
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 [isImageDragging, setIsImageDragging] = useState(false);
const [isWorkflowDragging, setIsWorkflowDragging] = useState(false);
const handleImageDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsImageDragging(true); };
const handleImageDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsImageDragging(false); };
const handleImageDrop = (e: DragEvent) => {
e.preventDefault();
setIsImageDragging(false);
if (e.dataTransfer.files.length) {
void handleImageChange({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
const handleWorkflowDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsWorkflowDragging(true); };
const handleWorkflowDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsWorkflowDragging(false); };
const handleWorkflowDrop = (e: DragEvent) => {
e.preventDefault();
setIsWorkflowDragging(false);
if (e.dataTransfer.files.length) {
void handleWorkflowChange({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
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"
className={isImageDragging ? "is-dragging" : ""}
onClick={() => imageInputRef.current?.click()}
onDragOver={handleImageDragOver}
onDragLeave={handleImageDragLeave}
onDrop={handleImageDrop}
>
<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"
className={isWorkflowDragging ? "is-dragging" : ""}
onClick={() => workflowInputRef.current?.click()}
onDragOver={handleWorkflowDragOver}
onDragLeave={handleWorkflowDragLeave}
onDrop={handleWorkflowDrop}
>
<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>
);
}