Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user