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,129 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocale } from '../../app/providers/AppProviders';
|
||||
import { AsyncBoundary } from '../../shared/components/AsyncBoundary';
|
||||
import { MediaCard } from '../../shared/components/MediaCard';
|
||||
import { VideoGrid } from '../../shared/components/VideoGrid';
|
||||
import { CategoryChipsSkeleton, ShortsShelfSkeleton, VideoGridSkeleton } from '../../shared/components/skeletons';
|
||||
import { videos } from '../../shared/data/catalog';
|
||||
import { searchCatalog } from '../../shared/data/catalogService';
|
||||
import { useRequest } from '../../shared/hooks/useRequest';
|
||||
import { normalizePage } from '../../shared/lib/pagination';
|
||||
import { dramaApi } from '../../entities/drama/api';
|
||||
import { categoryApi } from '../../entities/category/api';
|
||||
import { normalizeDrama, uniqueDramas } from '../../entities/drama/model';
|
||||
import { CategoryBar } from './components/CategoryBar';
|
||||
|
||||
const SHORTS_LIMIT = 5;
|
||||
const FIRST_SHELF_LIMIT = 6;
|
||||
const ALL_CATEGORY_ID = 'all';
|
||||
const FALLBACK_GENRE_PREFIX = 'genre-';
|
||||
|
||||
function readTitle(locales, localeCode) {
|
||||
return locales?.[localeCode] || locales?.en || locales?.id || locales?.zh || '';
|
||||
}
|
||||
|
||||
function normalizeHomeItems(payload, localeCode) {
|
||||
const sections = payload?.sections || [];
|
||||
return uniqueDramas(sections.flatMap((section) => section.dramas || []).map((item) => normalizeDrama(item, localeCode)));
|
||||
}
|
||||
|
||||
function normalizeCategoryItems(payload, localeCode) {
|
||||
return uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode)));
|
||||
}
|
||||
|
||||
function normalizeCategories(payload, localeCode) {
|
||||
return (Array.isArray(payload) ? payload : [])
|
||||
.map((item) => ({
|
||||
id: String(item.id ?? ''),
|
||||
label: readTitle(item.locales, localeCode) || item.code || '',
|
||||
}))
|
||||
.filter((item) => item.id && item.label);
|
||||
}
|
||||
|
||||
function fallbackGenreIndex(categoryId) {
|
||||
const matched = /^genre-(\d+)$/.exec(categoryId);
|
||||
return matched ? Number(matched[1]) : 0;
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { locale } = useLocale();
|
||||
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_ID);
|
||||
const isAllCategory = activeCategory === ALL_CATEGORY_ID;
|
||||
|
||||
const feedRequest = useRequest(
|
||||
({ signal }) => dramaApi.homeFeed({ signal }).then((payload) => normalizeHomeItems(payload, locale.code)),
|
||||
[locale.code],
|
||||
);
|
||||
const categoriesRequest = useRequest(
|
||||
({ signal }) => categoryApi.list({ signal }).then((payload) => normalizeCategories(payload, locale.code)),
|
||||
[locale.code],
|
||||
);
|
||||
const categoryRequest = useRequest(
|
||||
({ signal }) => categoryApi.dramas(activeCategory, 1, 20, { signal }).then((payload) => normalizeCategoryItems(payload, locale.code)),
|
||||
[activeCategory, locale.code],
|
||||
{ enabled: !isAllCategory },
|
||||
);
|
||||
|
||||
const homeItems = feedRequest.data || [];
|
||||
const serverCategories = useMemo(() => categoriesRequest.data || [], [categoriesRequest.data]);
|
||||
const fallbackCategories = useMemo(
|
||||
() => locale.genres.slice(1).map((label, index) => ({ id: `${FALLBACK_GENRE_PREFIX}${index + 1}`, label })),
|
||||
[locale.genres],
|
||||
);
|
||||
const categories = useMemo(() => {
|
||||
const allCategory = { id: ALL_CATEGORY_ID, label: locale.genres[0] };
|
||||
if (serverCategories.length) return [allCategory, ...serverCategories];
|
||||
return categoriesRequest.loading ? [allCategory] : [allCategory, ...fallbackCategories];
|
||||
}, [categoriesRequest.loading, fallbackCategories, locale.genres, serverCategories]);
|
||||
|
||||
const fallbackItems = useMemo(() => {
|
||||
const genreIndex = fallbackGenreIndex(activeCategory);
|
||||
return searchCatalog('', locale.genres[genreIndex], genreIndex);
|
||||
}, [activeCategory, locale.genres]);
|
||||
|
||||
const loading = isAllCategory ? feedRequest.loading : categoryRequest.loading;
|
||||
const error = isAllCategory ? feedRequest.error : categoryRequest.error;
|
||||
const remoteItems = isAllCategory ? homeItems : (categoryRequest.data || []);
|
||||
const items = error ? fallbackItems : remoteItems;
|
||||
const shortsItems = (homeItems.length ? homeItems : videos).slice(0, SHORTS_LIMIT);
|
||||
const firstShelf = items.slice(0, FIRST_SHELF_LIMIT);
|
||||
const secondShelf = items.slice(FIRST_SHELF_LIMIT);
|
||||
|
||||
return (
|
||||
<>
|
||||
{categoriesRequest.loading && !serverCategories.length ? <CategoryChipsSkeleton /> : (
|
||||
<CategoryBar categories={categories} activeId={activeCategory} onChange={setActiveCategory} />
|
||||
)}
|
||||
<section className="youtube-home-feed" aria-label={locale.home}>
|
||||
{error && <div className="api-note warning">{locale.apiFallback}</div>}
|
||||
<AsyncBoundary
|
||||
loading={loading && !items.length}
|
||||
skeleton={<VideoGridSkeleton count={FIRST_SHELF_LIMIT} label={locale.loading} />}
|
||||
empty={!loading && !error && !items.length}
|
||||
emptyMessage={locale.empty}
|
||||
>
|
||||
<VideoGrid items={firstShelf} />
|
||||
</AsyncBoundary>
|
||||
</section>
|
||||
|
||||
<section className="youtube-shorts-shelf" aria-labelledby="shorts-title">
|
||||
<div className="youtube-shelf-heading">
|
||||
<h2 id="shorts-title"><span className="shorts-mark">▶</span>{locale.shortTitle}</h2>
|
||||
<button type="button">{locale.explore}</button>
|
||||
</div>
|
||||
{feedRequest.loading && !homeItems.length ? <ShortsShelfSkeleton count={SHORTS_LIMIT} label={locale.loading} /> : (
|
||||
<div className="shorts-row" aria-label={locale.shortTitle}>
|
||||
{shortsItems.map((video) => <MediaCard key={`short-${video.id}`} video={video} compact to={`/shorts?start=${video.id}`} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{secondShelf.length > 0 && (
|
||||
<section className="youtube-home-feed youtube-home-feed-secondary" aria-label={locale.picks}>
|
||||
<VideoGrid items={secondShelf} />
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Author: fruit.com.cn
|
||||
export function CategoryBar({ categories, activeId, onChange }) {
|
||||
return (
|
||||
<div className="youtube-category-bar" role="tablist" aria-label="Categories">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
className={`youtube-category-chip ${activeId === category.id ? 'active' : ''}`}
|
||||
key={category.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeId === category.id}
|
||||
onClick={() => onChange(category.id)}
|
||||
>
|
||||
{category.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user