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

+21
View File
@@ -0,0 +1,21 @@
// Author: fruit.com.cn
const DEVICE_KEY = 'ymb.device.id';
function createDeviceId() {
const uuid = globalThis.crypto?.randomUUID?.() || `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
return `d-${uuid}`;
}
let cachedDeviceId = '';
export function getDeviceId() {
if (cachedDeviceId) return cachedDeviceId;
try {
const stored = window.localStorage.getItem(DEVICE_KEY);
cachedDeviceId = stored || createDeviceId();
if (!stored) window.localStorage.setItem(DEVICE_KEY, cachedDeviceId);
} catch {
cachedDeviceId = createDeviceId();
}
return cachedDeviceId;
}
+39
View File
@@ -0,0 +1,39 @@
// Author: fruit.com.cn
const UNITS = {
id: [[1e9, 'M'], [1e6, 'jt'], [1e3, 'rb']],
en: [[1e9, 'B'], [1e6, 'M'], [1e3, 'K']],
zh: [[1e8, '亿'], [1e4, '万']],
};
const DAYS_AGO = { id: '2 hari lalu', en: '2 days ago', zh: '2天前' };
const VIEW_LABEL = { id: 'tayangan', en: 'views', zh: '次观看' };
function formatDecimal(value, code) {
const rounded = value >= 100 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, '');
return code === 'id' ? rounded.replace('.', ',') : rounded;
}
export function formatCount(value, code = 'id') {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return value == null ? '' : String(value);
for (const [size, unit] of UNITS[code] || UNITS.id) {
if (numeric >= size) {
const separator = code === 'id' ? ' ' : '';
return `${formatDecimal(numeric / size, code)}${separator}${unit}`;
}
}
return String(Math.round(numeric));
}
export function formatViews(value, code = 'id') {
if (value === null || value === undefined || value === '') return '';
if (typeof value === 'string' && !Number.isFinite(Number(value))) return value;
const count = formatCount(value, code);
if (!count) return '';
const label = VIEW_LABEL[code] || VIEW_LABEL.id;
return code === 'zh' ? `${count}${label}` : `${count} ${label}`;
}
export function daysAgoLabel(code = 'id') {
return DAYS_AGO[code] || DAYS_AGO.id;
}
+24
View File
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
const GEO_ENDPOINT = 'https://ipwho.is/?fields=country_code';
const GEO_TIMEOUT_MS = 4000;
const CHINESE_REGIONS = ['CN', 'TW', 'HK', 'MO'];
/** 根据 IP 归属地推断站点语言;失败返回空字符串,由调用方回退。 */
export async function detectLocaleFromIp() {
const controller = new AbortController();
const timer = globalThis.setTimeout(() => controller.abort(), GEO_TIMEOUT_MS);
try {
const response = await fetch(GEO_ENDPOINT, { signal: controller.signal });
if (!response.ok) return '';
const payload = await response.json();
const country = typeof payload?.country_code === 'string' ? payload.country_code.toUpperCase() : '';
if (!country) return '';
if (country === 'ID') return 'id';
if (CHINESE_REGIONS.includes(country)) return 'zh';
return 'en';
} catch {
return '';
} finally {
globalThis.clearTimeout(timer);
}
}
+18
View File
@@ -0,0 +1,18 @@
// Author: fruit.com.cn
export const FALLBACK_IMAGE = '/assets/hero.jpg';
const MEDIA_BASE_URL = (import.meta.env.VITE_MEDIA_BASE_URL || '').replace(/\/$/, '');
export const resolveMedia = (value) => {
if (!value) return FALLBACK_IMAGE;
return /^https?:\/\//.test(value) ? value : `${MEDIA_BASE_URL}${value}`;
};
export const resolvePlaybackUrl = resolveMedia;
export const getImage = (item = {}) => resolveMedia(item.coverLandscape || item.coverUrl || item.cover || item.posterUrl || item.imageUrl);
export function selectPlaybackUrl(playback, fallback = '') {
const tracks = playback?.tracks || [];
const preferred = tracks.find((track) => track.quality === '360p') || tracks.find((track) => track.quality === 'AUTO') || tracks[0];
return resolveMedia(preferred?.playUrl || playback?.playUrl || fallback);
}
+5
View File
@@ -0,0 +1,5 @@
// Author: fruit.com.cn
export function normalizePage(payload) {
if (Array.isArray(payload)) return { total: payload.length, list: payload };
return { total: payload?.total || 0, list: payload?.list || payload?.records || [] };
}
+7
View File
@@ -0,0 +1,7 @@
// Author: fruit.com.cn
export function applySeo(locale, route = '') {
document.title = locale.title;
document.documentElement.lang = locale.htmlLang;
document.querySelector('meta[name="description"]')?.setAttribute('content', locale.description);
document.querySelector('link[rel="canonical"]')?.setAttribute('href', `https://ymb.example.com${route || '/'}`);
}
+5
View File
@@ -0,0 +1,5 @@
// Author: fruit.com.cn
export function getText(value, lang = 'id') {
if (typeof value === 'string') return value;
return value?.[lang] || value?.en || value?.id || value?.zh || '';
}
+22
View File
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
import { watchApi } from '../api/watch';
const REPORT_INTERVAL_MS = 10000;
const lastReportedAt = new Map();
export function reportWatchProgress(episodeId, positionSeconds, durationSeconds, source = 'web', force = false) {
// 剧集 ID 为 19 位长整型,超出 JS 安全整数范围,必须保持字符串,禁止 Number 转换
const id = String(episodeId ?? '');
if (!/^\d+$/.test(id)) return;
const now = Date.now();
if (!force && now - (lastReportedAt.get(id) || 0) < REPORT_INTERVAL_MS) return;
lastReportedAt.set(id, now);
const positionMs = Math.max(0, Math.round((Number(positionSeconds) || 0) * 1000));
const durationMs = Math.max(0, Math.round((Number(durationSeconds) || 0) * 1000));
watchApi.report({
episodeId: id,
positionMs,
...(durationMs > 0 ? { durationMs, finished: durationMs - positionMs < 1000 } : {}),
source,
}).catch(() => {});
}