forked from 77media/video-flow
新增 首页tab,支持获取配置
This commit is contained in:
parent
febba98e65
commit
0824ea0af9
@ -1,4 +1,5 @@
|
|||||||
import { BASE_URL } from "./constants";
|
import { BASE_URL } from "./constants";
|
||||||
|
import { post } from './request';
|
||||||
|
|
||||||
// 获取路演配置数据
|
// 获取路演配置数据
|
||||||
export const fetchRoadshowConfigs = async () => {
|
export const fetchRoadshowConfigs = async () => {
|
||||||
@ -64,3 +65,54 @@ export const fetchRoadshowConfigs = async () => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据服务端配置 code 拉取配置
|
||||||
|
* 约定返回结构:{ code, message, successful, data: { value: string(JSON) } }
|
||||||
|
*/
|
||||||
|
export interface ServerSettingVideoItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
cover?: string;
|
||||||
|
duration?: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerSettingTabItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
status: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
videos: ServerSettingVideoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeTabItem {
|
||||||
|
subtitle: string;
|
||||||
|
title: string;
|
||||||
|
videos: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchTabsByCode = async (code: string): Promise<HomeTabItem[]> => {
|
||||||
|
const res = await post<any>(`/api/server-setting/find_by_code`, { code });
|
||||||
|
if (!res || res.code !== 0 || !res.successful || !res.data) return [];
|
||||||
|
const raw = res.data.value;
|
||||||
|
if (typeof raw !== 'string' || raw.length === 0) return [];
|
||||||
|
try {
|
||||||
|
const parsed: ServerSettingTabItem[] = JSON.parse(raw);
|
||||||
|
return parsed
|
||||||
|
.filter((t) => t && t.status === 1)
|
||||||
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
|
.map((t) => ({
|
||||||
|
title: t.title,
|
||||||
|
subtitle: t.subtitle || '',
|
||||||
|
videos: (t.videos || [])
|
||||||
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
|
.map((v) => v.url)
|
||||||
|
.filter(Boolean),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
215
app/share/page.tsx
Normal file
215
app/share/page.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share (Invite) Page - Static UI with mocked data.
|
||||||
|
* Sections: Invite Flow, My Invitation Code, Invite Records (with pagination)
|
||||||
|
*/
|
||||||
|
|
||||||
|
type InviteRecord = {
|
||||||
|
id: string;
|
||||||
|
invitedUsername: string;
|
||||||
|
registeredAt: number; // epoch ms
|
||||||
|
rewardA: string; // reward item 1 (content TBD)
|
||||||
|
rewardB: string; // reward item 2 (content TBD)
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format epoch ms using browser preferred language.
|
||||||
|
* @param {number} epochMs - timestamp in milliseconds
|
||||||
|
* @returns {string} - localized date time string
|
||||||
|
*/
|
||||||
|
function formatLocalTime(epochMs: number): string {
|
||||||
|
try {
|
||||||
|
const locale = typeof navigator !== 'undefined' ? navigator.language : 'en-US';
|
||||||
|
const dtf = new Intl.DateTimeFormat(locale, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'medium',
|
||||||
|
});
|
||||||
|
return dtf.format(new Date(epochMs));
|
||||||
|
} catch {
|
||||||
|
return new Date(epochMs).toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate mocked invite records for demo purpose only.
|
||||||
|
* @param {number} count - number of items
|
||||||
|
* @returns {InviteRecord[]} - mocked records
|
||||||
|
*/
|
||||||
|
function generateMockRecords(count: number): InviteRecord[] {
|
||||||
|
const now = Date.now();
|
||||||
|
return Array.from({ length: count }).map((_, index) => ({
|
||||||
|
id: String(index + 1),
|
||||||
|
invitedUsername: `user_${index + 1}`,
|
||||||
|
registeredAt: now - index * 36_000, // different times
|
||||||
|
rewardA: index % 3 === 0 ? '+100 points' : '—',
|
||||||
|
rewardB: index % 5 === 0 ? '3-day membership' : '—',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SharePage(): JSX.Element {
|
||||||
|
// Mocked data (to be replaced by real API integration later)
|
||||||
|
const [inviteCode] = React.useState<string>('VF-ABCD-1234');
|
||||||
|
const [invitedCount] = React.useState<number>(37);
|
||||||
|
const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'error'>('idle');
|
||||||
|
|
||||||
|
const [records] = React.useState<InviteRecord[]>(() => generateMockRecords(47));
|
||||||
|
const [pageIndex, setPageIndex] = React.useState<number>(0);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(records.length / PAGE_SIZE));
|
||||||
|
const pagedRecords = React.useMemo(() => {
|
||||||
|
const start = pageIndex * PAGE_SIZE;
|
||||||
|
return records.slice(start, start + PAGE_SIZE);
|
||||||
|
}, [records, pageIndex]);
|
||||||
|
|
||||||
|
const handleCopy = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteCode);
|
||||||
|
setCopyState('copied');
|
||||||
|
window.setTimeout(() => setCopyState('idle'), 1600);
|
||||||
|
} catch {
|
||||||
|
setCopyState('error');
|
||||||
|
window.setTimeout(() => setCopyState('idle'), 1600);
|
||||||
|
}
|
||||||
|
}, [inviteCode]);
|
||||||
|
|
||||||
|
const canPrev = pageIndex > 0;
|
||||||
|
const canNext = pageIndex < totalPages - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div data-alt="share-page" className="w-full h-full overflow-y-auto bg-black text-white">
|
||||||
|
<div
|
||||||
|
data-alt="container"
|
||||||
|
className="w-full max-w-[95%] mx-auto px-4 py-10 sm:px-6 md:px-8 lg:px-12 xl:px-16 2xl:px-20"
|
||||||
|
>
|
||||||
|
<header data-alt="page-header" className="mb-8 flex items-end justify-between">
|
||||||
|
<div data-alt="title-box">
|
||||||
|
<h1 data-alt="title" className="text-2xl font-semibold text-white">Invite Friends</h1>
|
||||||
|
<p data-alt="subtitle" className="mt-1 text-sm text-white/60">Invite friends to join and earn rewards.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Section 1: Invite Flow */}
|
||||||
|
<section data-alt="invite-flow" className="mb-8 rounded-lg border border-white/20 bg-black p-6 shadow-sm">
|
||||||
|
<h2 data-alt="section-title" className="text-lg font-medium text-white">Invitation Flow</h2>
|
||||||
|
<ol data-alt="steps" className="mt-4 grid gap-4 sm:grid-cols-3">
|
||||||
|
<li data-alt="step" className="rounded-md border border-white/20 p-4">
|
||||||
|
<div data-alt="step-header" className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-white/80">Step 1</span>
|
||||||
|
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white">Share</span>
|
||||||
|
</div>
|
||||||
|
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Copy your invitation code and share it with friends.</p>
|
||||||
|
</li>
|
||||||
|
<li data-alt="step" className="rounded-md border border-white/20 p-4">
|
||||||
|
<div data-alt="step-header" className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-white/80">Step 2</span>
|
||||||
|
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white">Register</span>
|
||||||
|
</div>
|
||||||
|
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends register and enter your invitation code.</p>
|
||||||
|
</li>
|
||||||
|
<li data-alt="step" className="rounded-md border border-white/20 p-4">
|
||||||
|
<div data-alt="step-header" className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-white/80">Step 3</span>
|
||||||
|
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white">Reward</span>
|
||||||
|
</div>
|
||||||
|
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after successful registration.</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 2: My Invitation Code */}
|
||||||
|
<section data-alt="my-invite-code" className="mb-8 rounded-lg border border-white/20 bg-black p-6 shadow-sm">
|
||||||
|
<div data-alt="code-panel" className="mt-4 grid gap-6 sm:grid-cols-3">
|
||||||
|
<div data-alt="code-box" className="sm:col-span-2">
|
||||||
|
<h2 data-alt="section-title" className="text-lg font-medium text-white">My Invitation Code</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div data-alt="code" className="rounded-md border border-white/20 bg-white/10 px-4 py-2 text-lg font-semibold tracking-wider text-white">
|
||||||
|
{inviteCode}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
data-alt="copy-button"
|
||||||
|
className="inline-flex h-9 items-center justify-center rounded-full bg-gradient-to-r from-custom-blue to-custom-purple px-3 text-sm font-medium text-black/90 hover:opacity-90 active:translate-y-px"
|
||||||
|
onClick={handleCopy}
|
||||||
|
type="button"
|
||||||
|
aria-label="Copy invitation code"
|
||||||
|
>
|
||||||
|
{copyState === 'copied' ? 'Copied' : copyState === 'error' ? 'Failed' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p data-alt="hint" className="mt-2 text-xs text-white/60">Share this code. Your friends can enter it during registration.</p>
|
||||||
|
</div>
|
||||||
|
<div data-alt="invited-count" className="flex flex-col items-start justify-center rounded-md border border-white/20 bg-white/5 p-4">
|
||||||
|
<span className="text-sm text-white/70">Invited Friends</span>
|
||||||
|
<span className="mt-1 text-2xl font-semibold text-white">{invitedCount}</span>
|
||||||
|
<span className="mt-2 text-xs text-white/60">Points detail will be available soon.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 3: Invite Records */}
|
||||||
|
<section data-alt="invite-records" className="rounded-lg border border-white/20 bg-black p-6 shadow-sm">
|
||||||
|
<div data-alt="section-header" className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 data-alt="section-title" className="text-lg font-medium text-white">Invite Records</h2>
|
||||||
|
<div data-alt="pagination" className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
data-alt="prev-page"
|
||||||
|
className="inline-flex h-8 items-center justify-center rounded border border-gray-300 bg-white px-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={!canPrev}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span data-alt="page-info" className="text-sm text-white/70">
|
||||||
|
{pageIndex + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
data-alt="next-page"
|
||||||
|
className="inline-flex h-8 items-center justify-center rounded border border-gray-300 bg-white px-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPageIndex((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={!canNext}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-alt="table-wrapper" className="overflow-x-auto">
|
||||||
|
<table data-alt="records-table" className="min-w-full divide-y divide-white/10">
|
||||||
|
<thead data-alt="table-head" className="bg-black">
|
||||||
|
<tr data-alt="table-head-row">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60">Invited Username</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60">Registered At</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60">Registration Reward</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60">First Payment Reward</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody data-alt="table-body" className="divide-y divide-white/10 bg-black">
|
||||||
|
{pagedRecords.map((r) => (
|
||||||
|
<tr key={r.id} data-alt="table-row" className="hover:bg-white/5">
|
||||||
|
<td className="px-4 py-3 text-sm text-white">{r.invitedUsername}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-white/80">{formatLocalTime(r.registeredAt)}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-white/90">{r.rewardA}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-white/90">{r.rewardB}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -18,6 +18,8 @@ import LazyLoad from "react-lazyload";
|
|||||||
|
|
||||||
|
|
||||||
import { fetchSubscriptionPlans, SubscriptionPlan } from "@/lib/stripe";
|
import { fetchSubscriptionPlans, SubscriptionPlan } from "@/lib/stripe";
|
||||||
|
import VideoCoverflow from "@/components/ui/VideoCoverflow";
|
||||||
|
import { fetchTabsByCode, HomeTabItem } from "@/api/serversetting";
|
||||||
import { useCallbackModal } from "@/app/layout";
|
import { useCallbackModal } from "@/app/layout";
|
||||||
|
|
||||||
/** 视频预加载系统 - 后台静默运行 */
|
/** 视频预加载系统 - 后台静默运行 */
|
||||||
@ -151,13 +153,121 @@ function useVideoPreloader() {
|
|||||||
export function HomePage2() {
|
export function HomePage2() {
|
||||||
// 后台静默预加载视频,不显示任何加载界面
|
// 后台静默预加载视频,不显示任何加载界面
|
||||||
useVideoPreloader();
|
useVideoPreloader();
|
||||||
|
|
||||||
|
/** 导航滚动容器与各锚点 */
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({
|
||||||
|
home: null,
|
||||||
|
core: null,
|
||||||
|
cases: null,
|
||||||
|
showreel: null,
|
||||||
|
process: null,
|
||||||
|
pricing: null,
|
||||||
|
footer: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [homeTabs, setHomeTabs] = useState<HomeTabItem[]>([]);
|
||||||
|
|
||||||
|
// 旧锚点兼容已移除,完全使用接口 homeTab 渲染
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 示例:从后端拉取视频分类数据并更新到对应状态。
|
||||||
|
* 将下面的伪代码替换为真实请求逻辑。
|
||||||
|
*/
|
||||||
|
async function loadCategoryVideos() {
|
||||||
|
try {
|
||||||
|
const tabs = await fetchTabsByCode('homeTab');
|
||||||
|
if (isMounted && Array.isArray(tabs) && tabs.length > 0) {
|
||||||
|
console.log(tabs);
|
||||||
|
setHomeTabs(tabs);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略错误,保持空态
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCategoryVideos();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 在容器内平滑滚动到锚点,兼容有固定导航时的微调 */
|
||||||
|
const scrollToSection = (key: keyof typeof sectionRefs.current) => {
|
||||||
|
try {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const target = sectionRefs.current[key];
|
||||||
|
if (!container || !target) return;
|
||||||
|
const containerTop = container.getBoundingClientRect().top;
|
||||||
|
const targetTop = target.getBoundingClientRect().top;
|
||||||
|
const currentScrollTop = container.scrollTop;
|
||||||
|
const NAV_OFFSET = 64; // 顶部导航高度微调
|
||||||
|
const delta = targetTop - containerTop + currentScrollTop - NAV_OFFSET;
|
||||||
|
container.scrollTo({ top: Math.max(delta, 0), behavior: 'smooth' });
|
||||||
|
setMenuOpen(false);
|
||||||
|
} catch {
|
||||||
|
// 忽略滚动异常
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const NavBar = () => {
|
||||||
|
if (homeTabs.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div data-alt="home-navbar" className="fixed h-16 top-0 left-0 right-0 z-50">
|
||||||
|
<div className="mx-auto h-full">
|
||||||
|
<div className="flex h-full items-center justify-center px-4 sm:px-6 py-3 bg-black/60 backdrop-blur-md border-b border-white/10">
|
||||||
|
{/* 桌面端菜单(居中,仅三个项) */}
|
||||||
|
<div data-alt="desktop-menu" className="hidden md:flex items-center gap-6 text-white/90 text-sm h-full">
|
||||||
|
{homeTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.title}
|
||||||
|
data-alt={`nav-${tab.title.toLowerCase()}`}
|
||||||
|
className="hover:text-white"
|
||||||
|
onClick={() => scrollToSection(tab.title.toLowerCase() as any)}
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 移动端开关 */}
|
||||||
|
<button
|
||||||
|
data-alt="mobile-menu-toggle"
|
||||||
|
className="md:hidden text-white/90 px-3 py-1 border border-white/20 rounded"
|
||||||
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* 移动端下拉(仅三个项) */}
|
||||||
|
{menuOpen && (
|
||||||
|
<div data-alt="mobile-menu" className="md:hidden bg-black/80 backdrop-blur-md border-b border-white/10 px-4 py-2 text-white/90 text-sm">
|
||||||
|
<div className="grid grid-cols-1 gap-1">
|
||||||
|
{homeTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.title}
|
||||||
|
data-alt={`m-nav-${tab.title.toLowerCase()}`}
|
||||||
|
className="text-center py-2"
|
||||||
|
onClick={() => scrollToSection(tab.title.toLowerCase() as any)}
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
//
|
<div className="w-full h-screen overflow-y-auto" id="home-page" ref={containerRef} style={{ paddingBottom: `2rem` }}>
|
||||||
<div
|
<NavBar />
|
||||||
className="w-full h-screen overflow-y-auto"
|
|
||||||
id="home-page"
|
|
||||||
style={{ paddingBottom: `2rem` }}
|
|
||||||
>
|
|
||||||
<HomeModule1 />
|
<HomeModule1 />
|
||||||
<LazyLoad once>
|
<LazyLoad once>
|
||||||
<HomeModule2 />
|
<HomeModule2 />
|
||||||
@ -165,6 +275,12 @@ export function HomePage2() {
|
|||||||
<LazyLoad once>
|
<LazyLoad once>
|
||||||
<HomeModule3 />
|
<HomeModule3 />
|
||||||
</LazyLoad>
|
</LazyLoad>
|
||||||
|
{/* 动态锚点:来源于服务端 homeTab 配置,title 作为锚点与标题 */}
|
||||||
|
{homeTabs.map((tab) => (
|
||||||
|
<div key={tab.title} data-test={JSON.stringify(tab)} data-alt={`anchor-${tab.title.toLowerCase()}`} ref={(el) => (sectionRefs.current as any)[tab.title.toLowerCase()] = el}>
|
||||||
|
<VideoCoverflow title={tab.title} subtitle={tab.subtitle} videos={tab.videos} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
<LazyLoad once>
|
<LazyLoad once>
|
||||||
<HomeModule4 />
|
<HomeModule4 />
|
||||||
</LazyLoad>
|
</LazyLoad>
|
||||||
|
|||||||
170
components/ui/VideoCoverflow.tsx
Normal file
170
components/ui/VideoCoverflow.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
|
import { Autoplay, EffectCoverflow } from 'swiper/modules';
|
||||||
|
import type { Swiper as SwiperType } from 'swiper/types';
|
||||||
|
|
||||||
|
import 'swiper/css';
|
||||||
|
import 'swiper/css/effect-coverflow';
|
||||||
|
|
||||||
|
/** 默认视频列表(来自 home-page2.tsx 中的数组) */
|
||||||
|
const DEFAULT_VIDEOS: string[] = [
|
||||||
|
'https://cdn.qikongjian.com/1756474023656_60twk5.mp4',
|
||||||
|
'https://cdn.qikongjian.com/1756474023644_14n7is.mp4',
|
||||||
|
'https://cdn.qikongjian.com/1756474023648_kocq6z.mp4',
|
||||||
|
'https://cdn.qikongjian.com/1756474023657_w10boo.mp4',
|
||||||
|
'https://cdn.qikongjian.com/1756474023657_nf8799.mp4',
|
||||||
|
'https://cdn.qikongjian.com/1756474230992_vw0ubf.mp4',
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface VideoCoverflowProps {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
/** 视频地址数组 */
|
||||||
|
videos?: string[];
|
||||||
|
/** 自动播放间隔(毫秒) */
|
||||||
|
autoplayDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Swiper 的 Coverflow 效果展示视频,自动滚动并仅播放当前居中的视频。
|
||||||
|
* @param {VideoCoverflowProps} props - 组件属性
|
||||||
|
* @returns {JSX.Element} - 组件节点
|
||||||
|
*/
|
||||||
|
const VideoCoverflow: React.FC<VideoCoverflowProps> = ({
|
||||||
|
title = '',
|
||||||
|
subtitle = '',
|
||||||
|
videos = DEFAULT_VIDEOS,
|
||||||
|
autoplayDelay = 4500,
|
||||||
|
}) => {
|
||||||
|
const swiperRef = React.useRef<SwiperType | null>(null);
|
||||||
|
const videoRefs = React.useRef<Record<number, HTMLVideoElement | null>>({});
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean>(false);
|
||||||
|
const [activeIndex, setActiveIndex] = React.useState<number>(0);
|
||||||
|
|
||||||
|
const playActive = React.useCallback((activeIndex: number) => {
|
||||||
|
Object.entries(videoRefs.current).forEach(([key, el]) => {
|
||||||
|
if (!el) return;
|
||||||
|
const index = Number(key);
|
||||||
|
if (index === activeIndex) {
|
||||||
|
// 尝试播放当前居中视频
|
||||||
|
el.play().catch(() => {});
|
||||||
|
} else {
|
||||||
|
// 暂停其他视频,重置到起点以减少解码负担
|
||||||
|
el.pause();
|
||||||
|
try {
|
||||||
|
el.currentTime = 0;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAfterInit = React.useCallback((sw: SwiperType) => {
|
||||||
|
swiperRef.current = sw;
|
||||||
|
const idx = sw.realIndex ?? sw.activeIndex ?? 0;
|
||||||
|
setActiveIndex(idx);
|
||||||
|
playActive(idx);
|
||||||
|
}, [playActive]);
|
||||||
|
|
||||||
|
const handleSlideChange = React.useCallback((sw: SwiperType) => {
|
||||||
|
const idx = sw.realIndex ?? sw.activeIndex ?? 0;
|
||||||
|
setActiveIndex(idx);
|
||||||
|
playActive(idx);
|
||||||
|
}, [playActive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-alt="video-coverflow-container"
|
||||||
|
className="center h-[calc(100svh-4rem)] z-10 flex flex-col items-center justify-center lg:px-20 xl:px-30 2xl:px-40"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
className="text-white font-normal text-center
|
||||||
|
-mt-4
|
||||||
|
/* 移动端字体 */
|
||||||
|
text-[2rem] leading-[110%] mb-4
|
||||||
|
/* 平板字体 */
|
||||||
|
sm:-mt-6 sm:text-[2.5rem] sm:leading-[110%] sm:mb-6
|
||||||
|
/* 小屏笔记本字体 */
|
||||||
|
md:-mt-8 md:text-[3rem] md:leading-[110%] md:mb-8
|
||||||
|
/* 大屏笔记本字体 */
|
||||||
|
lg:-mt-10 lg:text-[3.25rem] lg:leading-[110%] lg:mb-10
|
||||||
|
/* 桌面端字体 */
|
||||||
|
xl:-mt-12 xl:text-[3.375rem] xl:leading-[110%] xl:mb-12
|
||||||
|
/* 大屏显示器字体 */
|
||||||
|
2xl:-mt-16 2xl:text-[3.5rem] 2xl:leading-[110%] 2xl:mb-16"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-white font-normal text-center
|
||||||
|
-mt-2
|
||||||
|
/* 移动端字体 */
|
||||||
|
text-[1rem] leading-[140%]
|
||||||
|
/* 平板字体 */
|
||||||
|
sm:-mt-3 sm:text-[1.25rem] sm:leading-[140%]
|
||||||
|
/* 小屏笔记本字体 */
|
||||||
|
md:-mt-4 md:text-[1.5rem] md:leading-[140%]
|
||||||
|
/* 大屏笔记本字体 */
|
||||||
|
lg:-mt-5 lg:text-[1.6rem] lg:leading-[140%]
|
||||||
|
/* 桌面端字体 */
|
||||||
|
xl:-mt-6 xl:text-[1.7rem] xl:leading-[140%]
|
||||||
|
/* 大屏显示器字体 */
|
||||||
|
2xl:-mt-8 2xl:text-[1.8rem] 2xl:leading-[140%]"
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
<div data-alt="video-coverflow" className="w-screen sm:w-full mx-auto overflow-hidden">
|
||||||
|
<Swiper
|
||||||
|
modules={[Autoplay, EffectCoverflow]}
|
||||||
|
effect="coverflow"
|
||||||
|
centeredSlides
|
||||||
|
slidesPerView={isMobile ? 1 : 2}
|
||||||
|
loop
|
||||||
|
autoplay={{ delay: autoplayDelay, disableOnInteraction: false }}
|
||||||
|
speed={1000}
|
||||||
|
coverflowEffect={{
|
||||||
|
rotate: -56,
|
||||||
|
stretch: 10,
|
||||||
|
depth: 80,
|
||||||
|
scale: 0.6,
|
||||||
|
modifier: 1,
|
||||||
|
slideShadows: true,
|
||||||
|
}}
|
||||||
|
onAfterInit={handleAfterInit}
|
||||||
|
onSlideChange={handleSlideChange}
|
||||||
|
className="w-full py-8"
|
||||||
|
>
|
||||||
|
{videos.map((src, index) => (
|
||||||
|
<SwiperSlide key={src} className="select-none">
|
||||||
|
<div data-alt="video-card" className={`${isMobile ? (activeIndex === index ? 'w-screen' : 'w-[80vw]') : 'w-[48vw]'} mx-auto aspect-video overflow-hidden rounded-xl shadow-lg`}>
|
||||||
|
<video
|
||||||
|
data-alt="video"
|
||||||
|
ref={(el) => { videoRefs.current[index] = el; }}
|
||||||
|
src={src}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
loop
|
||||||
|
preload="metadata"
|
||||||
|
onLoadedData={() => {
|
||||||
|
// 进入视口/初始化后,确保当前是激活项就播放
|
||||||
|
const sw = swiperRef.current;
|
||||||
|
if (!sw) return;
|
||||||
|
const isActive = (sw.realIndex ?? sw.activeIndex ?? 0) === index;
|
||||||
|
if (isActive) {
|
||||||
|
videoRefs.current[index]?.play().catch(() => {});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(VideoCoverflow);
|
||||||
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user