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,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 || [] };
|
||||
}
|
||||
@@ -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 || '/'}`);
|
||||
}
|
||||
@@ -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 || '';
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
Reference in new issue
Block a user