328 lines
13 KiB
TypeScript
328 lines
13 KiB
TypeScript
import {
|
||
ArrowLeftOutlined,
|
||
CheckCircleOutlined,
|
||
DeleteOutlined,
|
||
PlayCircleOutlined,
|
||
SearchOutlined,
|
||
ShoppingOutlined,
|
||
TagsOutlined,
|
||
} from "@ant-design/icons";
|
||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||
import "../../styles/pages/more-tools.css";
|
||
import "../../styles/pages/image-workbench.css";
|
||
import "../../styles/pages/ecommerce.css";
|
||
import "../../styles/pages/local-theme-parity.css";
|
||
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;
|