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;
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user