Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckCircleOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
SearchOutlined,
|
||||
ShoppingOutlined,
|
||||
TagsOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import type { WebProjectSummary } from "../../types";
|
||||
import { useDebounce } from "../../hooks/useDebounce";
|
||||
import { templateCarouselCases, templateCases, templateCategories, type TemplateCase } from "./ecommerceTemplates";
|
||||
|
||||
interface EcommerceTemplatesPageProps {
|
||||
projects: WebProjectSummary[];
|
||||
onOpenMore?: () => void;
|
||||
onOpenEcommerce?: () => void;
|
||||
onSelectTemplate?: (template: TemplateCase) => void;
|
||||
onStartCreate?: () => void;
|
||||
onOpenProject: (project: WebProjectSummary) => void;
|
||||
onDeleteProject?: (project: WebProjectSummary) => void;
|
||||
}
|
||||
|
||||
const APPLE_CAROUSEL_SLOTS = [-4, -3, -2, -1, 0, 1, 2, 3, 4];
|
||||
const APPLE_CAROUSEL_TRANSITION_MS = 760;
|
||||
|
||||
interface AppleCarouselMotion {
|
||||
direction: number;
|
||||
progress: 0 | 1;
|
||||
}
|
||||
|
||||
function getPositiveModulo(value: number, length: number) {
|
||||
return ((value % length) + length) % length;
|
||||
}
|
||||
|
||||
function getAppleCarouselCardStyle(offset: number): CSSProperties {
|
||||
const depth = Math.abs(offset);
|
||||
const direction = Math.sign(offset);
|
||||
const isActive = depth === 0;
|
||||
const xByDepth = [0, 286, 456, 610, 735, 840];
|
||||
const yByDepth = [8, -2, -8, -13, -18, -24];
|
||||
const rotateByDepth = [0, 0, 0, 0, 0];
|
||||
const scaleByDepth = [1, 0.98, 0.94, 0.91, 0.88, 0.84];
|
||||
const x = direction * (xByDepth[depth] ?? xByDepth[xByDepth.length - 1]!);
|
||||
const y = yByDepth[depth] ?? yByDepth[yByDepth.length - 1]!;
|
||||
const z = isActive ? 90 : 28 - depth;
|
||||
const rotateY = 0;
|
||||
const rotateZ = direction * (rotateByDepth[depth] ?? rotateByDepth[rotateByDepth.length - 1]!);
|
||||
const scale = scaleByDepth[depth] ?? scaleByDepth[scaleByDepth.length - 1]!;
|
||||
|
||||
return {
|
||||
"--apple-card-offset": offset,
|
||||
"--apple-card-depth": depth,
|
||||
"--apple-card-z": 80 - depth,
|
||||
"--apple-card-x": `${x}px`,
|
||||
"--apple-card-y": `${y}px`,
|
||||
"--apple-card-z-offset": `${z}px`,
|
||||
"--apple-card-rotate-y": `${rotateY}deg`,
|
||||
"--apple-card-rotate-z": `${rotateZ}deg`,
|
||||
"--apple-card-scale": String(scale),
|
||||
"--apple-card-opacity": String(depth > 4 ? 0 : 1),
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
function EcommerceTemplatesPage({
|
||||
projects,
|
||||
onOpenMore,
|
||||
onOpenEcommerce,
|
||||
onSelectTemplate,
|
||||
onStartCreate,
|
||||
onOpenProject,
|
||||
onDeleteProject,
|
||||
}: EcommerceTemplatesPageProps) {
|
||||
const [activeTemplateCategory, setActiveTemplateCategory] = useState("全部");
|
||||
const [templateSearch, setTemplateSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(templateSearch, 300);
|
||||
const [carouselIndex, setCarouselIndex] = useState(0);
|
||||
const [carouselMotion, setCarouselMotion] = useState<AppleCarouselMotion | null>(null);
|
||||
const [carouselIsResetting, setCarouselIsResetting] = useState(false);
|
||||
const carouselFrameRef = useRef<number | null>(null);
|
||||
const carouselResetFrameRef = useRef<number | null>(null);
|
||||
const carouselTimerRef = useRef<number | null>(null);
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const keyword = debouncedSearch.trim();
|
||||
return templateCases.filter((item) => {
|
||||
const categoryMatches = activeTemplateCategory === "全部" || item.category === activeTemplateCategory;
|
||||
const keywordMatches =
|
||||
!keyword || item.title.includes(keyword) || item.summary.includes(keyword) || item.category.includes(keyword);
|
||||
return categoryMatches && keywordMatches;
|
||||
});
|
||||
}, [activeTemplateCategory, debouncedSearch]);
|
||||
const carouselItems = useMemo(() => templateCarouselCases.slice(0, 5), []);
|
||||
const carouselSlotOffsets = useMemo(() => {
|
||||
const direction = carouselMotion?.direction ?? 0;
|
||||
const minSlot = APPLE_CAROUSEL_SLOTS[0]! + Math.min(direction, 0);
|
||||
const maxSlot = APPLE_CAROUSEL_SLOTS[APPLE_CAROUSEL_SLOTS.length - 1]! + Math.max(direction, 0);
|
||||
return Array.from({ length: maxSlot - minSlot + 1 }, (_, index) => minSlot + index);
|
||||
}, [carouselMotion?.direction]);
|
||||
|
||||
const startCarouselShift = useCallback(
|
||||
(rawDirection: number) => {
|
||||
const direction = Math.sign(rawDirection);
|
||||
if (!direction || carouselItems.length <= 1 || carouselMotion) return;
|
||||
|
||||
if (carouselFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(carouselFrameRef.current);
|
||||
}
|
||||
if (carouselTimerRef.current !== null) {
|
||||
window.clearTimeout(carouselTimerRef.current);
|
||||
}
|
||||
if (carouselResetFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(carouselResetFrameRef.current);
|
||||
}
|
||||
|
||||
setCarouselIsResetting(false);
|
||||
setCarouselMotion({ direction, progress: 0 });
|
||||
carouselFrameRef.current = window.requestAnimationFrame(() => {
|
||||
carouselFrameRef.current = window.requestAnimationFrame(() => {
|
||||
setCarouselMotion((current) => (current?.direction === direction ? { direction, progress: 1 } : current));
|
||||
});
|
||||
});
|
||||
carouselTimerRef.current = window.setTimeout(() => {
|
||||
setCarouselIsResetting(true);
|
||||
setCarouselIndex((current) => getPositiveModulo(current + direction, carouselItems.length));
|
||||
setCarouselMotion(null);
|
||||
carouselResetFrameRef.current = window.requestAnimationFrame(() => {
|
||||
carouselResetFrameRef.current = window.requestAnimationFrame(() => {
|
||||
setCarouselIsResetting(false);
|
||||
});
|
||||
});
|
||||
}, APPLE_CAROUSEL_TRANSITION_MS);
|
||||
},
|
||||
[carouselItems.length, carouselMotion],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (carouselItems.length <= 1) return undefined;
|
||||
const intervalId = window.setInterval(() => {
|
||||
startCarouselShift(-1);
|
||||
}, 2200);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [carouselItems.length, startCarouselShift]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (carouselFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(carouselFrameRef.current);
|
||||
}
|
||||
if (carouselTimerRef.current !== null) {
|
||||
window.clearTimeout(carouselTimerRef.current);
|
||||
}
|
||||
if (carouselResetFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(carouselResetFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="image-workbench-page ecommerce-template-page" aria-label="示例模板">
|
||||
<header className="image-workbench-topbar">
|
||||
<button type="button" className="image-workbench-back-to-more" onClick={onOpenMore}>
|
||||
工具盒
|
||||
</button>
|
||||
<div className="image-workbench-tool-strip" aria-label="电商工具入口">
|
||||
<button type="button" onClick={onOpenEcommerce}>
|
||||
<ShoppingOutlined />
|
||||
电商生成
|
||||
</button>
|
||||
<button type="button" className="is-active">
|
||||
<TagsOutlined />
|
||||
示例模板
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="image-workbench-subbar">
|
||||
<button type="button" className="image-workbench-icon-btn" aria-label="返回工具盒" onClick={onOpenMore}>
|
||||
<ArrowLeftOutlined />
|
||||
</button>
|
||||
<strong>示例模板</strong>
|
||||
<div className="image-workbench-camera-summary" aria-label="模板数量">
|
||||
<strong>{filteredTemplates.length} 个模板</strong>
|
||||
<span>最近项目 {projects.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="ecommerce-template-page__scroll omni-commerce-page">
|
||||
<section className={`ecommerce-template-apple-carousel${carouselIsResetting ? " is-resetting" : ""}`} aria-label="苹果卡片式模板轮播">
|
||||
<div className="ecommerce-template-apple-carousel__stage" aria-roledescription="carousel">
|
||||
<div className="ecommerce-template-apple-carousel__deck">
|
||||
{carouselSlotOffsets.map((slotOffset) => {
|
||||
const itemIndex = getPositiveModulo(carouselIndex + slotOffset, carouselItems.length);
|
||||
const item = carouselItems[itemIndex];
|
||||
const visualOffset = slotOffset - (carouselMotion?.direction ?? 0) * (carouselMotion?.progress ?? 0);
|
||||
const isActive = visualOffset === 0;
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={slotOffset}
|
||||
type="button"
|
||||
className={`ecommerce-template-apple-card${isActive ? " is-active" : ""}`}
|
||||
style={getAppleCarouselCardStyle(visualOffset)}
|
||||
aria-label={`套用模板:${item.title}`}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
onSelectTemplate?.(item);
|
||||
return;
|
||||
}
|
||||
startCarouselShift(slotOffset);
|
||||
}}
|
||||
>
|
||||
<img src={item.imageUrl} alt="" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="omni-commerce-band ecommerce-template-page__band" aria-labelledby="commerce-template-title">
|
||||
<div className="omni-commerce-section-head">
|
||||
<div>
|
||||
<span>
|
||||
<TagsOutlined />
|
||||
示例模板
|
||||
</span>
|
||||
<h2 id="commerce-template-title">常用电商场景</h2>
|
||||
</div>
|
||||
<label className="omni-commerce-search">
|
||||
<SearchOutlined />
|
||||
<input value={templateSearch} placeholder="搜索模板" onChange={(event) => setTemplateSearch(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="omni-commerce-filter" role="tablist" aria-label="模板分类">
|
||||
{templateCategories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTemplateCategory === category}
|
||||
className={activeTemplateCategory === category ? "is-active" : ""}
|
||||
onClick={() => setActiveTemplateCategory(category)}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="omni-commerce-template-grid motion-stagger">
|
||||
{filteredTemplates.map((item) => (
|
||||
<article key={item.title} className="omni-commerce-template">
|
||||
<button type="button" className="omni-commerce-template__select" onClick={() => onSelectTemplate?.(item)}>
|
||||
<img src={item.imageUrl} alt={item.title} />
|
||||
<div>
|
||||
<span>{item.category}</span>
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.summary}</p>
|
||||
<em>选择此场景</em>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="omni-commerce-band ecommerce-template-page__band omni-commerce-band--projects" aria-labelledby="commerce-project-title">
|
||||
<div className="omni-commerce-section-head">
|
||||
<div>
|
||||
<span>
|
||||
<CheckCircleOutlined />
|
||||
最近处理
|
||||
</span>
|
||||
<h2 id="commerce-project-title">最近项目</h2>
|
||||
</div>
|
||||
<button type="button" className="omni-commerce-text-button" onClick={onStartCreate}>
|
||||
新建项目
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{projects.length ? (
|
||||
<div className="omni-commerce-project-grid motion-stagger">
|
||||
{projects.slice(0, 4).map((project, index) => (
|
||||
<article key={project.id} className="omni-commerce-project">
|
||||
<button type="button" onClick={() => onOpenProject(project)}>
|
||||
<img src={project.thumbnailUrl || templateCases[index % templateCases.length]!.imageUrl} alt="" />
|
||||
<span>{project.description || "最近更新的电商项目"}</span>
|
||||
<strong>{project.name || `预览项目 ${index + 1}`}</strong>
|
||||
</button>
|
||||
{onDeleteProject ? (
|
||||
<button type="button" className="omni-commerce-project__delete" aria-label={`删除项目 ${project.name}`} onClick={() => onDeleteProject(project)}>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="omni-commerce-empty">
|
||||
<PlayCircleOutlined />
|
||||
<strong>还没有最近项目</strong>
|
||||
<span>完成一次生成后,这里会显示最近处理的商品内容。</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="image-workbench-status">
|
||||
<span>模板</span>
|
||||
<p>选择模板后会回到电商生成并自动套用场景。</p>
|
||||
<em>{activeTemplateCategory}</em>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default EcommerceTemplatesPage;
|
||||
Reference in New Issue
Block a user