refactor: 企业级目录重构与播放/关于页体验升级

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

No files matched your search

+129
View File
@@ -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>
);
}