Files
omniai-web/src/features/ecommerce/EcommerceTemplatesPage.tsx
T

328 lines
13 KiB
TypeScript
Raw Normal View History

2026-06-02 12:38:01 +08:00
import {
ArrowLeftOutlined,
CheckCircleOutlined,
DeleteOutlined,
PlayCircleOutlined,
SearchOutlined,
ShoppingOutlined,
TagsOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
2026-06-05 19:49:50 +08:00
import "../../styles/pages/more-tools.css";
2026-06-05 17:19:38 +08:00
import "../../styles/pages/image-workbench.css";
import "../../styles/pages/ecommerce.css";
import "../../styles/pages/local-theme-parity.css";
2026-06-02 12:38:01 +08:00
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;