refactor: 企业级目录重构与播放/关于页体验升级
- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件 - 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分 - 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈 - 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口 - 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题 - 语言按本地偏好与 IP 识别,国旗改为内联 SVG - 清理未使用的旧站点文件,补充 ESLint 配置
This commit is contained in:
1 parent
8dd5283892
commit
ecfbd3b8b5
90 files changed
+4535
-886
No files matched your search
@@ -0,0 +1,156 @@
|
||||
// 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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user