Files
ymb-site/src/features/shorts/ShortsFeedPage.jsx
T
Zhou Weidong ecfbd3b8b5 refactor: 企业级目录重构与播放/关于页体验升级
- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件
- 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分
- 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈
- 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口
- 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题
- 语言按本地偏好与 IP 识别,国旗改为内联 SVG
- 清理未使用的旧站点文件,补充 ESLint 配置
2026-11-12 12:32:29 +08:00

157 lines
6.5 KiB
React

// Author: fruit.com.cn
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useLocale, useUi } from '../../app/providers/AppProviders';
import { CommentsSheet } from '../../shared/components/CommentsSheet';
import { ShortsSkeleton } from '../../shared/components/skeletons';
import { useFollowedChannels } from '../../shared/hooks/useFollowedChannels';
import { useToast } from '../../shared/hooks/useToast';
import { normalizePage } from '../../shared/lib/pagination';
import { dramaApi } from '../../entities/drama/api';
import { normalizeDrama, uniqueDramas } from '../../entities/drama/model';
import { ShortSlide } from './components/ShortSlide';
const PAGE_SIZE = 6;
const PRELOAD_THRESHOLD = 2;
export function ShortsFeedPage() {
const { locale } = useLocale();
const { sidebarOpen } = useUi();
const [params] = useSearchParams();
const startId = params.get('start');
const [items, setItems] = useState([]);
const [activeId, setActiveId] = useState('');
const [status, setStatus] = useState({ loading: true, error: '', hasMore: true });
const { channels: followedChannels, toggle: toggleFollow } = useFollowedChannels();
const [commentsVideo, setCommentsVideo] = useState(null);
const toast = useToast();
const feedRef = useRef(null);
const cursorRef = useRef(null);
const loadingRef = useRef(false);
const requestIdRef = useRef(0);
const loadPage = useCallback(async (reset) => {
if (loadingRef.current && !reset) return;
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
loadingRef.current = true;
setStatus((prev) => ({ ...prev, loading: true, error: '' }));
try {
const payload = await dramaApi.feed({ cursor: reset ? null : cursorRef.current, size: PAGE_SIZE });
if (requestId !== requestIdRef.current) return;
const list = uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)));
cursorRef.current = payload?.nextCursor ?? null;
setItems((prev) => (reset ? list : uniqueDramas([...prev, ...list])));
setStatus({ loading: false, error: '', hasMore: Boolean(payload?.hasMore) });
} catch (error) {
if (requestId !== requestIdRef.current) return;
setStatus((prev) => ({ ...prev, loading: false, error: error.message || 'Request failed' }));
} finally {
if (requestId === requestIdRef.current) loadingRef.current = false;
}
}, [locale.code]);
useEffect(() => {
cursorRef.current = null;
setItems([]);
setActiveId('');
setStatus({ loading: true, error: '', hasMore: true });
loadPage(true);
}, [loadPage]);
useEffect(() => {
if (!items.length) return;
if (items.some((item) => item.id === activeId)) return;
const startIndex = startId ? items.findIndex((item) => item.id === startId) : -1;
const nextIndex = startIndex >= 0 ? startIndex : 0;
const node = feedRef.current;
if (node && nextIndex > 0) node.scrollTop = nextIndex * node.clientHeight;
setActiveId(items[nextIndex].id);
}, [activeId, items, startId]);
useEffect(() => {
const node = feedRef.current;
if (!node) return undefined;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) setActiveId(entry.target.dataset.id);
});
}, { root: node, threshold: 0.7 });
node.querySelectorAll('[data-id]').forEach((slide) => observer.observe(slide));
return () => observer.disconnect();
}, [items.length]);
useEffect(() => {
if (!status.hasMore || status.loading || !activeId) return;
const index = items.findIndex((item) => item.id === activeId);
if (index >= items.length - PRELOAD_THRESHOLD) loadPage(false);
}, [activeId, items, loadPage, status.hasMore, status.loading]);
const activeIndex = Math.max(0, items.findIndex((item) => item.id === activeId));
const move = (delta) => {
const node = feedRef.current;
if (!node) return;
const next = Math.min(items.length - 1, Math.max(0, activeIndex + delta));
if (next !== activeIndex) node.scrollTo({ top: next * node.clientHeight, behavior: 'smooth' });
};
const shareVideo = async (video) => {
const url = `${window.location.origin}/watch/${video.id}`;
if (navigator.share) {
try {
await navigator.share({ title: video.title, url });
return;
} catch (error) {
if (error?.name === 'AbortError') return;
}
}
try {
await navigator.clipboard.writeText(url);
toast.show(locale.linkCopied);
} catch {
toast.show(url);
}
};
useEffect(() => {
const onKeyDown = (event) => {
if (event.key === 'ArrowDown' || event.key === 'PageDown') { event.preventDefault(); move(1); }
if (event.key === 'ArrowUp' || event.key === 'PageUp') { event.preventDefault(); move(-1); }
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeIndex, items.length]);
return (
<>
<section className={`shorts-stage ${sidebarOpen ? '' : 'shorts-stage-wide'}`}>
{status.loading && !items.length && <ShortsSkeleton label={locale.loading} />}
{status.error && <div className="shorts-state warning">{status.error}</div>}
{!status.loading && !status.error && !items.length && <div className="shorts-state">{locale.empty}</div>}
<div className="shorts-scroll" ref={feedRef} aria-label={locale.shortTitle}>
{items.map((video) => (
<ShortSlide
key={video.id}
video={video}
active={video.id === activeId}
followed={followedChannels.includes(video.channel)}
onToggleFollow={() => toggleFollow(video.channel)}
onOpenComments={() => setCommentsVideo(video)}
onShare={() => shareVideo(video)}
/>
))}
</div>
{items.length > 1 && (
<div className="feed-arrows">
<button onClick={() => move(-1)} disabled={!activeIndex} aria-label="Previous video" type="button">↑</button>
<button onClick={() => move(1)} disabled={activeIndex >= items.length - 1} aria-label="Next video" type="button">↓</button>
</div>
)}
</section>
{commentsVideo && <CommentsSheet video={commentsVideo} locale={locale} onClose={() => setCommentsVideo(null)} />}
{toast.message && <div className="shorts-toast">{toast.message}</div>}
</>
);
}