Initial commit: OmniAI Web Frontend

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:38:01 +08:00
commit bedee3ba8d
183 changed files with 94805 additions and 0 deletions
+529
View File
@@ -0,0 +1,529 @@
import {
DeleteOutlined,
HeartFilled,
HeartOutlined,
ImportOutlined,
LeftOutlined,
PictureOutlined,
PlusOutlined,
RightOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import OptimizedImage from "../../components/OptimizedImage";
import { EmptyState } from "../../components/EmptyState";
import { cloneWorkflow, createBlankWorkflow } from "../../data/workflows";
import type { WebCanvasWorkflow, WebProjectSummary } from "../../types";
import { getCommunityCaseCover, getWorkflowFromCase, shouldShowInCanvasCommunity } from "./communityCaseUtils";
import { ossThumb } from "../../utils/ossImageOptimize";
import wechatCaseImage10 from "../../../tu/微信图片_20260514125332_10_2.png";
import wechatCaseImage11 from "../../../tu/微信图片_20260514125332_11_2.png";
import wechatCaseImage12 from "../../../tu/微信图片_20260514125332_12_2.png";
import wechatCaseImage7 from "../../../tu/微信图片_20260514125332_7_2.png";
import wechatCaseImage8 from "../../../tu/微信图片_20260514125332_8_2.png";
import wechatCaseImage9 from "../../../tu/微信图片_20260514125332_9_2.png";
interface CommunityPageProps {
projects: WebProjectSummary[];
isAuthenticated: boolean;
onStartCreate: () => void;
onOpenProject: (project: WebProjectSummary) => void;
onDeleteProject?: (project: WebProjectSummary) => void;
onImportWorkflow: (workflow: WebCanvasWorkflow) => void;
onRequireLogin?: (action: string) => boolean | void;
}
const communityCardImages = [
wechatCaseImage7,
wechatCaseImage8,
wechatCaseImage9,
wechatCaseImage10,
wechatCaseImage11,
wechatCaseImage12,
];
const SLIDE_INTERVAL = 3000;
const CAROUSEL_VISIBLE_COUNT = 3;
const MANUAL_PAUSE_DURATION = 2000;
const COMMUNITY_CAROUSEL_VIDEOS = [
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test3.mp4",
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test4.mp4",
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test6.mp4",
];
function buildWorkflowFromServerCase(item: ServerCommunityCase, fallback: WebCanvasWorkflow): WebCanvasWorkflow {
const workflow = getWorkflowFromCase(item);
if (workflow) {
return cloneWorkflow(workflow);
}
const coverUrl = getCommunityCaseCover(item);
return {
...cloneWorkflow(fallback),
id: `community-server-${item.id}`,
title: item.title,
description: item.description || fallback.description,
author: item.username || fallback.author,
source: "community",
nodes: fallback.nodes.map((node) =>
node.id === "reference" && coverUrl ? { ...node, previewUrl: coverUrl } : node,
),
};
}
function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject, onDeleteProject, onImportWorkflow, onRequireLogin }: CommunityPageProps) {
const [serverCases, setServerCases] = useState<ServerCommunityCase[]>([]);
const [serverNotice, setServerNotice] = useState<string | null>(null);
const [favoriteIds, setFavoriteIds] = useState<string[]>([]);
const canUseProtectedAction = (action: string) => onRequireLogin?.(action) !== false;
const handleStartCreate = () => {
if (!canUseProtectedAction("start-community-project")) return;
onStartCreate();
};
const handleImportWorkflow = (workflow: WebCanvasWorkflow) => {
if (!canUseProtectedAction("import-community-workflow")) return;
onImportWorkflow(workflow);
};
useEffect(() => {
let cancelled = false;
communityClient
.listApprovedCases()
.then((items) => {
if (!cancelled) {
const canvasItems = items.filter(shouldShowInCanvasCommunity);
setServerCases(canvasItems);
setServerNotice(
canvasItems.length
? "已连接服务器画布社区"
: items.length
? "服务器暂无已审核画布案例"
: "社区暂无模板",
);
}
})
.catch((error) => {
if (!cancelled) {
setServerCases([]);
setServerNotice(error instanceof Error ? error.message : "社区服务暂时不可用");
}
});
return () => {
cancelled = true;
};
}, []);
const getProjectTitle = (project: WebProjectSummary, index: number) =>
project.source === "server" ? project.name : `预览项目 ${index + 1}`;
const getProjectSummary = (project: WebProjectSummary) => {
if (project.source === "server") {
return project.description || "最近更新的项目";
}
return "预览数据";
};
/* Carousel state */
const [currentIndex, setCurrentIndex] = useState(0);
const [carouselTransitionEnabled, setCarouselTransitionEnabled] = useState(true);
const [isHoverPaused, setIsHoverPaused] = useState(false);
const [isManualPaused, setIsManualPaused] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const manualPauseTimerRef = useRef<number | null>(null);
const carouselCases = useMemo(
() =>
COMMUNITY_CAROUSEL_VIDEOS.map((videoUrl, index) => ({
title: `社区视频 ${index + 1}`,
videoUrl,
})),
[],
);
const totalSlides = carouselCases.length;
const visibleCarouselCases = useMemo(
() => [...carouselCases, ...carouselCases.slice(0, CAROUSEL_VISIBLE_COUNT)],
[carouselCases],
);
const activeCarouselIndex = (currentIndex + 1) % totalSlides;
const centerCarouselItemIndex = currentIndex + Math.floor(CAROUSEL_VISIBLE_COUNT / 2);
const isAutoPaused = isHoverPaused || isManualPaused;
const pauseAutoCarousel = useCallback(() => {
setIsManualPaused(true);
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (manualPauseTimerRef.current) {
window.clearTimeout(manualPauseTimerRef.current);
}
manualPauseTimerRef.current = window.setTimeout(() => {
setIsManualPaused(false);
manualPauseTimerRef.current = null;
}, MANUAL_PAUSE_DURATION);
}, []);
const goNext = useCallback((pause = true) => {
if (pause) pauseAutoCarousel();
setCarouselTransitionEnabled(true);
setCurrentIndex((prev) => prev + 1);
}, [pauseAutoCarousel]);
const goPrev = useCallback(() => {
pauseAutoCarousel();
setCarouselTransitionEnabled(true);
setCurrentIndex((prev) => {
if (prev > 0) return prev - 1;
setCarouselTransitionEnabled(false);
window.requestAnimationFrame(() => {
setCurrentIndex(totalSlides);
window.requestAnimationFrame(() => {
setCarouselTransitionEnabled(true);
setCurrentIndex(totalSlides - 1);
});
});
return prev;
});
}, [pauseAutoCarousel, totalSlides]);
const handlePrevClick = useCallback(() => {
goPrev();
}, [goPrev]);
const handleNextClick = useCallback(() => {
pauseAutoCarousel();
goNext(false);
}, [goNext, pauseAutoCarousel]);
const goTo = useCallback((index: number) => {
setCarouselTransitionEnabled(true);
setCurrentIndex((index - 1 + totalSlides) % totalSlides);
}, [totalSlides]);
const handleCarouselTransitionEnd = () => {
if (currentIndex < totalSlides) return;
setCarouselTransitionEnabled(false);
setCurrentIndex(0);
window.requestAnimationFrame(() => {
setCarouselTransitionEnabled(true);
});
};
useEffect(() => {
if (isAutoPaused) return;
timerRef.current = setInterval(() => goNext(false), SLIDE_INTERVAL);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [isAutoPaused, goNext]);
useEffect(() => {
return () => {
if (manualPauseTimerRef.current) {
window.clearTimeout(manualPauseTimerRef.current);
}
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, []);
useEffect(() => {
let cancelled = false;
const timeoutId = window.setTimeout(() => {
communityClient
.listApprovedCases({
limit: 30,
sort: "latest",
})
.then((items) => {
if (!cancelled) {
const canvasItems = items.filter(shouldShowInCanvasCommunity);
setServerCases(canvasItems);
setServerNotice(
canvasItems.length
? "已连接服务器画布社区"
: items.length
? "服务器暂无匹配画布案例"
: "社区暂无模板",
);
}
})
.catch((error) => {
if (!cancelled) {
setServerNotice(error instanceof Error ? error.message : "社区服务暂时不可用");
}
});
}, 280);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, []);
const handleToggleFavorite = async (item: ServerCommunityCase, cardId: string) => {
const nextActive = !(item.isFavorited || favoriteIds.includes(cardId));
setFavoriteIds((current) =>
nextActive ? Array.from(new Set([...current, cardId])) : current.filter((id) => id !== cardId),
);
setServerCases((current) =>
current.map((caseItem) =>
caseItem.id === item.id
? {
...caseItem,
isFavorited: nextActive,
favoriteCount: Math.max(0, caseItem.favoriteCount + (nextActive ? 1 : -1)),
}
: caseItem,
),
);
if (!isAuthenticated || !serverCases.some((caseItem) => caseItem.id === item.id)) return;
try {
const stats = await communityClient.setReaction(item.id, "favorite", nextActive);
setServerCases((current) =>
current.map((caseItem) => (caseItem.id === item.id ? { ...caseItem, ...stats } : caseItem)),
);
} catch (error) {
setServerNotice(error instanceof Error ? error.message : "收藏状态同步失败");
}
};
const liveCases: ServerCommunityCase[] = serverCases.slice(0, 12);
return (
<WorkspacePageShell title="社区" fullWidth className="community-page page-motion">
<section className="community-hero">
<div
className="community-carousel"
onMouseEnter={() => setIsHoverPaused(true)}
onMouseLeave={() => setIsHoverPaused(false)}
role="region"
aria-label="示例预览轮播"
>
<div
className={`community-carousel__track${carouselTransitionEnabled ? "" : " is-resetting"}`}
style={{ transform: `translateX(-${currentIndex * (100 / CAROUSEL_VISIBLE_COUNT)}%)` }}
onTransitionEnd={handleCarouselTransitionEnd}
>
{visibleCarouselCases.map((card, index) => (
<article
key={`${card.videoUrl}-${index}`}
className={`community-carousel__slide community-carousel__slide--video${index === centerCarouselItemIndex ? " is-center" : ""}`}
aria-hidden={index !== centerCarouselItemIndex}
>
<video
className="community-carousel__video"
src={card.videoUrl}
title={card.title}
autoPlay
muted
loop
playsInline
preload="metadata"
/>
</article>
))}
</div>
<button
type="button"
className="community-carousel__arrow community-carousel__arrow--prev"
onClick={handlePrevClick}
aria-label="上一张"
>
<LeftOutlined />
</button>
<button
type="button"
className="community-carousel__arrow community-carousel__arrow--next"
onClick={handleNextClick}
aria-label="下一张"
>
<RightOutlined />
</button>
<div className="community-carousel__dots" role="tablist" aria-label="幻灯片指示器">
{carouselCases.map((_, index) => (
<button
key={index}
type="button"
role="tab"
aria-selected={index === activeCarouselIndex}
aria-label={`${index + 1}`}
className={index === activeCarouselIndex ? "is-active" : ""}
onClick={() => goTo(index)}
/>
))}
</div>
</div>
</section>
<section className="community-section">
<div className="community-section__head">
<div>
<h2></h2>
</div>
{isAuthenticated && <span className="studio-pill">{projects.length} </span>}
</div>
<div className="project-row">
<button
type="button"
className="project-card project-card--button project-card--new"
onClick={handleStartCreate}
>
<PlusOutlined style={{ fontSize: 28 }} />
<div className="project-card__meta">
<strong></strong>
</div>
</button>
{isAuthenticated && projects.map((project, index) => (
<div key={project.id} className="project-card-shell">
<button
type="button"
className="project-card project-card--button"
onClick={() => onOpenProject(project)}
>
{project.thumbnailUrl ? (
<OptimizedImage src={ossThumb(project.thumbnailUrl)} alt="" />
) : (
<span className="project-card__empty project-card__empty--dark">
<PictureOutlined />
</span>
)}
<span className="project-card__caption">{getProjectSummary(project)}</span>
<div className="project-card__meta">
<strong>{getProjectTitle(project, index)}</strong>
</div>
</button>
{onDeleteProject ? (
<button
type="button"
className="project-card__delete"
aria-label={`删除项目 ${project.name}`}
onClick={() => onDeleteProject(project)}
>
<DeleteOutlined />
</button>
) : null}
</div>
))}
</div>
</section>
<section className="community-section">
<div className="community-section__head">
<div>
<h2></h2>
</div>
{serverNotice ? <span className="studio-pill">{serverNotice}</span> : null}
</div>
{liveCases.length ? (
<div className="community-case-grid community-case-grid--mosaic motion-stagger">
{liveCases.map((item, index) => {
const fallbackWorkflow = createBlankWorkflow(item.title);
const workflow = buildWorkflowFromServerCase(item, fallbackWorkflow);
const cardId = `case-${item.id}`;
const isFavorite = item.isFavorited || favoriteIds.includes(cardId);
const imageUrl = getCommunityCaseCover(item) || communityCardImages[index % communityCardImages.length] || fallbackWorkflow.nodes.find((node) => node.previewUrl)?.previewUrl || "";
return (
<article
key={cardId}
className={`community-case-card community-case-card--mosaic community-case-card--tile-${index % 8}`}
onClick={() => handleImportWorkflow(workflow)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleImportWorkflow(workflow);
}
}}
tabIndex={0}
>
<button
type="button"
className="community-case-card__preview"
onClick={(event) => {
event.stopPropagation();
handleImportWorkflow(workflow);
}}
aria-label={`导入 ${item.title} 工作流`}
>
<OptimizedImage src={ossThumb(imageUrl)} alt={item.title} />
</button>
<p className="community-case-card__caption">{item.description || "可一键导入工作流继续创作。"}</p>
<div
className="community-case-card__body"
role="button"
tabIndex={0}
onClick={() => handleImportWorkflow(workflow)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleImportWorkflow(workflow);
}
}}
>
<div className="community-case-card__author">
<span>{item.username || "OmniAI"}</span>
<span>{item.tags[0] || item.status}</span>
</div>
<strong>{item.title}</strong>
<p>{item.description || "Import this workflow and continue creating."}</p>
<div className="community-card-actions">
<button
type="button"
className={isFavorite ? "is-active" : ""}
aria-pressed={isFavorite}
onClick={(event) => {
event.stopPropagation();
void handleToggleFavorite(item, cardId);
}}
>
{isFavorite ? <HeartFilled /> : <HeartOutlined />}
{isFavorite ? "已收藏" : `收藏${item.favoriteCount ? ` ${item.favoriteCount}` : ""}`}
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleImportWorkflow(workflow);
}}
>
<ImportOutlined />
</button>
</div>
</div>
</article>
);
})}
</div>
) : (
<EmptyState
icon={<ImportOutlined style={{ fontSize: 48 }} />}
title="社区暂无模板"
description="管理员审核通过后,画布社区案例会显示在这里。"
/>
)}
</section>
<section className="community-section community-section--browse-more">
<button type="button" onClick={handleStartCreate}>
<RightOutlined />
</button>
</section>
</WorkspacePageShell>
);
}
export default CommunityPage;
@@ -0,0 +1,166 @@
import type { ServerCommunityAsset, ServerCommunityCase } from "../../api/communityClient";
import type { WebCanvasWorkflow } from "../../types";
export type CommunityCaseSurface = "generation" | "canvas" | "unknown";
export interface PromptCaseViewModel {
id: string;
title: string;
author: string;
category: string;
imageUrl: string;
summary: string;
prompt: string;
ratio: string;
}
export interface PromptCaseRatioLike {
ratio?: string | null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function toStringValue(value: unknown, fallback = ""): string {
if (typeof value === "string") return value.trim() || fallback;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return fallback;
}
function normalizeSurface(value: unknown): CommunityCaseSurface {
const normalized = toStringValue(value).toLowerCase();
if (!normalized) return "unknown";
if (normalized === "generation" || normalized === "workbench" || normalized.includes("生成")) return "generation";
if (normalized === "canvas" || normalized === "workflow" || normalized.includes("画布")) return "canvas";
return "unknown";
}
function getMetadata(item: Pick<ServerCommunityCase, "metadata">): Record<string, unknown> {
return isRecord(item.metadata) ? item.metadata : {};
}
function getAssetUrl(asset: ServerCommunityAsset): string {
return toStringValue(asset.url);
}
export function isCanvasWorkflow(value: unknown): value is WebCanvasWorkflow {
return Boolean(
isRecord(value) &&
Array.isArray(value.nodes) &&
Array.isArray(value.edges) &&
isRecord(value.settings),
);
}
export function getWorkflowFromCase(item: Pick<ServerCommunityCase, "metadata">): WebCanvasWorkflow | null {
const metadata = getMetadata(item);
const candidates = [
metadata.workflow,
metadata.workflowSnapshot,
metadata.workflowData,
metadata.canvasWorkflow,
];
return candidates.find(isCanvasWorkflow) ?? null;
}
export function getWorkflowCoverUrl(workflow: unknown): string {
if (!isRecord(workflow) || !Array.isArray(workflow.nodes)) return "";
const previewNode = workflow.nodes.find((node) => isRecord(node) && toStringValue(node.previewUrl));
return isRecord(previewNode) ? toStringValue(previewNode.previewUrl) : "";
}
export function getCommunityCaseCover(item: Pick<ServerCommunityCase, "coverUrl" | "assets" | "metadata">): string {
return (
toStringValue(item.coverUrl) ||
getWorkflowCoverUrl(getWorkflowFromCase(item)) ||
item.assets.find((asset) => asset.assetType === "cover" && getAssetUrl(asset))?.url ||
item.assets.find((asset) => (asset.assetType === "image" || asset.assetType === "asset") && getAssetUrl(asset))?.url ||
""
);
}
export function getCommunityCaseSurface(item: Pick<ServerCommunityCase, "metadata" | "tags" | "coverUrl" | "assets">): CommunityCaseSurface {
const metadata = getMetadata(item);
const explicitSurface = normalizeSurface(
metadata.communitySurface ??
metadata.targetSurface ??
metadata.surface ??
metadata.communityTarget ??
metadata.targetCommunity,
);
if (explicitSurface !== "unknown") return explicitSurface;
const tags = item.tags.map((tag) => tag.trim()).filter(Boolean);
if (tags.some((tag) => tag.includes("生成页面社区") || tag === "Web生成")) return "generation";
if (tags.some((tag) => tag.includes("画布页面社区") || tag.includes("工作流"))) return "canvas";
if (getWorkflowFromCase(item)) return "canvas";
if (toStringValue(metadata.prompt) && getCommunityCaseCover(item)) return "generation";
return "unknown";
}
export function shouldShowInCanvasCommunity(item: Pick<ServerCommunityCase, "metadata" | "tags" | "coverUrl" | "assets">): boolean {
return getCommunityCaseSurface(item) === "canvas";
}
export function communityCaseToPromptCase(item: ServerCommunityCase): PromptCaseViewModel | null {
if (getCommunityCaseSurface(item) !== "generation") return null;
const metadata = getMetadata(item);
const imageUrl = getCommunityCaseCover(item);
const prompt = toStringValue(metadata.prompt ?? metadata.inputPrompt ?? metadata.descriptionPrompt, item.description || "");
if (!imageUrl || !prompt) return null;
const category =
toStringValue(metadata.category) ||
item.tags.find((tag) => tag !== "生成页面社区" && tag !== "Web生成" && tag !== "图片") ||
"图片案例";
return {
id: `server-prompt-case-${item.id}`,
title: item.title,
author: item.username || "OmniAI",
category,
imageUrl,
summary: item.description || "管理员添加的生成页图片案例。",
prompt,
ratio: toStringValue(metadata.ratio ?? metadata.imageRatio ?? metadata.aspectRatio, "16:9"),
};
}
function parseRatioValue(value: string): number | null {
const normalized = value.replace(/\s+/g, "");
const pair = normalized.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
if (pair) {
const width = Number(pair[1]);
const height = Number(pair[2]);
return width > 0 && height > 0 ? width / height : null;
}
const numeric = Number(normalized);
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
}
function getPromptCaseRatioClassName(numericRatio: number): string {
return numericRatio >= 1.15
? "wb-prompt-case-card--ratio-wide"
: numericRatio >= 0.95
? "wb-prompt-case-card--ratio-square"
: numericRatio >= 0.72
? "wb-prompt-case-card--ratio-portrait"
: "wb-prompt-case-card--ratio-tall";
}
export function getPromptCaseCardClassName(
item: PromptCaseRatioLike,
index: number,
measuredRatio?: number | null,
): string {
const ratio = toStringValue(item.ratio, "16:9").replace(/\s+/g, "");
const measured = typeof measuredRatio === "number" && Number.isFinite(measuredRatio) && measuredRatio > 0 ? measuredRatio : null;
const numericRatio = measured ?? parseRatioValue(ratio) ?? 16 / 9;
const ratioClass = getPromptCaseRatioClassName(numericRatio);
const sourceRatioClass = ratio ? ` wb-prompt-case-card--source-${ratio.replace(":", "-")}` : "";
const measuredClass = measured ? " wb-prompt-case-card--measured" : "";
return `wb-prompt-case-card ${ratioClass} wb-prompt-case-card--index-${index % 8}${sourceRatioClass}${measuredClass}`;
}