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;