diff --git a/api/serversetting.ts b/api/serversetting.ts index 3ff8367..2a2f198 100644 --- a/api/serversetting.ts +++ b/api/serversetting.ts @@ -1,4 +1,5 @@ import { BASE_URL } from "./constants"; +import { post } from './request'; // 获取路演配置数据 export const fetchRoadshowConfigs = async () => { @@ -64,3 +65,54 @@ export const fetchRoadshowConfigs = async () => { 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 => { + const res = await post(`/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 []; + } +}; diff --git a/app/share/page.tsx b/app/share/page.tsx new file mode 100644 index 0000000..7057788 --- /dev/null +++ b/app/share/page.tsx @@ -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('VF-ABCD-1234'); + const [invitedCount] = React.useState(37); + const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'error'>('idle'); + + const [records] = React.useState(() => generateMockRecords(47)); + const [pageIndex, setPageIndex] = React.useState(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 ( + +
+
+
+
+

Invite Friends

+

Invite friends to join and earn rewards.

+
+
+ + {/* Section 1: Invite Flow */} +
+

Invitation Flow

+
    +
  1. +
    + Step 1 + Share +
    +

    Copy your invitation code and share it with friends.

    +
  2. +
  3. +
    + Step 2 + Register +
    +

    Friends register and enter your invitation code.

    +
  4. +
  5. +
    + Step 3 + Reward +
    +

    You both receive rewards after successful registration.

    +
  6. +
+
+ + {/* Section 2: My Invitation Code */} +
+
+
+

My Invitation Code

+
+
+ {inviteCode} +
+ +
+

Share this code. Your friends can enter it during registration.

+
+
+ Invited Friends + {invitedCount} + Points detail will be available soon. +
+
+
+ + {/* Section 3: Invite Records */} +
+
+

Invite Records

+
+ + + {pageIndex + 1} / {totalPages} + + +
+
+ +
+ + + + + + + + + + + {pagedRecords.map((r) => ( + + + + + + + ))} + +
Invited UsernameRegistered AtRegistration RewardFirst Payment Reward
{r.invitedUsername}{formatLocalTime(r.registeredAt)}{r.rewardA}{r.rewardB}
+
+
+
+
+
+ ); +} + + diff --git a/components/pages/home-page2.tsx b/components/pages/home-page2.tsx index e0d197a..209db41 100644 --- a/components/pages/home-page2.tsx +++ b/components/pages/home-page2.tsx @@ -18,6 +18,8 @@ import LazyLoad from "react-lazyload"; import { fetchSubscriptionPlans, SubscriptionPlan } from "@/lib/stripe"; +import VideoCoverflow from "@/components/ui/VideoCoverflow"; +import { fetchTabsByCode, HomeTabItem } from "@/api/serversetting"; import { useCallbackModal } from "@/app/layout"; /** 视频预加载系统 - 后台静默运行 */ @@ -151,13 +153,121 @@ function useVideoPreloader() { export function HomePage2() { // 后台静默预加载视频,不显示任何加载界面 useVideoPreloader(); + + /** 导航滚动容器与各锚点 */ + const containerRef = useRef(null); + const sectionRefs = useRef>({ + home: null, + core: null, + cases: null, + showreel: null, + process: null, + pricing: null, + footer: null, + }); + + const [menuOpen, setMenuOpen] = useState(false); + const [homeTabs, setHomeTabs] = useState([]); + + // 旧锚点兼容已移除,完全使用接口 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 ( +
+
+
+ {/* 桌面端菜单(居中,仅三个项) */} +
+ {homeTabs.map((tab) => ( + + ))} +
+ {/* 移动端开关 */} + +
+ {/* 移动端下拉(仅三个项) */} + {menuOpen && ( +
+
+ {homeTabs.map((tab) => ( + + ))} +
+
+ )} +
+
+ ); + }; + return ( - // -
+
+ @@ -165,6 +275,12 @@ export function HomePage2() { + {/* 动态锚点:来源于服务端 homeTab 配置,title 作为锚点与标题 */} + {homeTabs.map((tab) => ( +
(sectionRefs.current as any)[tab.title.toLowerCase()] = el}> + +
+ ))} diff --git a/components/ui/VideoCoverflow.tsx b/components/ui/VideoCoverflow.tsx new file mode 100644 index 0000000..b64b350 --- /dev/null +++ b/components/ui/VideoCoverflow.tsx @@ -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 = ({ + title = '', + subtitle = '', + videos = DEFAULT_VIDEOS, + autoplayDelay = 4500, +}) => { + const swiperRef = React.useRef(null); + const videoRefs = React.useRef>({}); + const [isMobile, setIsMobile] = React.useState(false); + const [activeIndex, setActiveIndex] = React.useState(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 ( +
+

+ {title} +

+

+ {subtitle} +

+
+ + {videos.map((src, index) => ( + +
+
+
+ ))} +
+
+
+ ); +}; + +export default React.memo(VideoCoverflow); + +