Files
djk/internal/webui/rankings.js
T

253 lines
12 KiB
JavaScript

(() => {
'use strict';
window.JukuRankings = {init};
function init({api, post, getSource, getDrama = () => null, sourceLabel, onLibraryChanged, onDownloadsChanged, getDownloadQuality = () => 0, canDownload = () => true, play}) {
const $ = id => document.getElementById(id);
const dialog = $('rankingPanel');
const list = $('rankingList');
const tabs = $('rankingTabs');
let boards = [], board = null, entries = [], page = 0, hasMore = false;
let busy = false, controller = null, sequence = 0, changed = false, fetchedAt = '', updatedText = '';
const lastBoards = new Map(), submitted = new Set(), submitting = new Set();
function node(tag, className, text) {
const result = document.createElement(tag);
if (className) result.className = className;
if (text !== undefined) result.textContent = text;
return result;
}
function action(text, handler) {
const result = node('button', 'secondary', text);
result.type = 'button';
result.addEventListener('click', handler);
return result;
}
function cancel() {
sequence++;
if (controller) controller.abort();
controller = null;
busy = false;
}
function controls() {
$('refreshRankingBtn').disabled = busy;
$('rankingMoreBtn').disabled = busy;
$('rankingMoreBtn').hidden = !hasMore;
$('rankingMoreBtn').textContent = busy ? '加载中…' : '加载更多';
$('rankingContent').setAttribute('aria-busy', String(busy));
}
function state(text) {
list.replaceChildren(node('li', 'ranking-state', text));
}
function setError(text) { $('rankingError').textContent = text || ''; }
function renderEntries() {
const visible = entries.filter(item => window.JukuVIP.visible(dramaFor(item)));
list.replaceChildren(...visible.map(renderEntry));
if (!visible.length) state(entries.length ? '本页 VIP 内容已隐藏,可将 VIP 开关切换为“显示”或继续加载。' : '此榜单暂时没有条目');
}
window.addEventListener('jukuvipfilterchange', event => {if (dialog.open) {renderEntries(); updateSummary(false); if (event.detail?.interactive) event.detail.priority = entries.map(item => item.drama.id);}});
function updateSummary(stale) {
$('rankingSummary').textContent = (updatedText || board?.description || '按站点名次展示') + (entries.length ? ' · 显示 ' + entries.filter(item => window.JukuVIP.visible(dramaFor(item))).length + ' / ' + entries.length + ' 部' : '') + (board?.source === 'huangdou' ? ' · ' + window.JukuVIP.summary(entries.map(dramaFor)) : '');
$('rankingFetchedAt').textContent = fetchedAt ? (stale ? '上次获取:' : '获取于 ') + new Date(fetchedAt).toLocaleString() : '';
}
function dramaFor(item) {
const known = getDrama(item.drama.id) || {};
if ((Date.parse(known.sortMetadata?.checkedAt) || 0) > (Date.parse(fetchedAt) || 0)) return {...item.drama, ...known};
const drama = {...known, ...item.drama};
for (const [key, value] of Object.entries(known)) {if (drama[key] == null || drama[key] === '') drama[key] = value;}
return drama;
}
window.addEventListener('jukulibrarychange', () => {if (dialog.open) {renderEntries(); updateSummary(false);}});
function renderEntry(item) {
const drama = dramaFor(item), title = drama.title || drama.name || '短剧';
const row = node('li', 'ranking-row');
row.value = item.rank;
const rank = node('span', 'ranking-number' + (item.rank <= 3 ? ' top' : ''), String(item.rank).padStart(2, '0'));
rank.setAttribute('aria-label', '第 ' + item.rank + ' 名');
const content = node('div');
const heading = node('div', 'ranking-title', title);
heading.title = title;
content.appendChild(heading);
const count = drama.totalEpisode || drama.episodeCount;
const details = [window.JukuVIP.isVIP(drama) ? 'VIP · 仅试看' : '', item.metric, drama.remark || (count ? count + ' 集' : ''), drama.categoryName].filter(Boolean);
if (details.length) content.appendChild(node('div', 'ranking-meta', details.join(' · ')));
const actions = node('div', 'ranking-actions');
const watchLabel = window.JukuHistory?.get(drama.id) ? '继续观看' : '播放';
const watch = action(watchLabel, () => { dialog.close(); play(drama.id, title); });
watch.setAttribute('aria-label', watchLabel + ' ' + title);
const download = action(submitted.has(drama.id) ? '已提交' : '下载', async () => {
if (!canDownload() || submitting.has(drama.id) || submitted.has(drama.id)) return;
submitting.add(drama.id);
download.disabled = true;
download.textContent = '提交中';
try {
await post('/api/ui/download', {ids: [drama.id], quality: getDownloadQuality()});
submitted.add(drama.id);
download.textContent = '已提交';
$('rankingActionStatus').textContent = '已将《' + title + '》加入下载队列';
onDownloadsChanged();
} catch (error) {
download.textContent = '下载';
download.disabled = false;
$('rankingActionStatus').textContent = '加入下载失败:' + error.message;
} finally {
submitting.delete(drama.id);
}
});
download.disabled = submitted.has(drama.id) || submitting.has(drama.id);
download.setAttribute('aria-label', '下载 ' + title);
actions.appendChild(watch);
if (canDownload()) actions.appendChild(download);
row.append(rank, content, actions);
return row;
}
async function loadPage(nextPage, refresh = false) {
if (!board) return;
cancel();
const ticket = sequence, activeBoard = board;
controller = new AbortController();
const signal = controller.signal;
busy = true;
setError('');
$('rankingActionStatus').textContent = '';
if (!entries.length) state('正在获取站点榜单…');
controls();
try {
const result = await api('/api/ui/rankings?board=' + encodeURIComponent(activeBoard.id) + '&page=' + nextPage + (refresh ? '&refresh=1' : ''), {signal});
if (ticket !== sequence || !dialog.open) return;
if (result.boardId !== activeBoard.id || result.page !== nextPage || !Array.isArray(result.items)) throw new Error('榜单响应不完整,请刷新重试');
if (nextPage > 1 && result.stale) throw new Error('此页暂时只能取得旧榜单,请刷新后继续');
const incoming = result.items;
let previousRank = nextPage > 1 && entries.length ? entries[entries.length - 1].rank : 0;
const ids = new Set(nextPage > 1 ? entries.map(item => item.drama.id) : []);
for (const item of incoming) {
if (!item.drama?.id || !Number.isInteger(item.rank) || item.rank <= previousRank || ids.has(item.drama.id)) throw new Error('榜单顺序已变化,请刷新后继续');
previousRank = item.rank;
ids.add(item.drama.id);
}
if (nextPage > 1 && updatedText && result.updatedText && result.updatedText !== updatedText) throw new Error('站点榜单已更新,请刷新后继续');
if (nextPage === 1) {
entries = [];
list.replaceChildren();
$('rankingContent').scrollTop = 0;
fetchedAt = result.fetchedAt || '';
updatedText = result.updatedText || '';
}
entries.push(...incoming);
renderEntries();
page = nextPage;
hasMore = Boolean(result.hasMore) && incoming.length > 0 && !result.stale;
changed = changed || incoming.length > 0;
if (!entries.length) state('此榜单暂时没有条目');
updateSummary(result.stale);
setError([result.warning, result.saved === false && incoming.length ? '剧库缓存未保存,本次仍可播放和下载。' : ''].filter(Boolean).join(' '));
} catch (error) {
if (ticket !== sequence || signal.aborted) return;
setError(error.message + (entries.length ? ';已保留当前列表。' : ''));
if (!entries.length) state('暂时无法取得榜单,点击“刷新”重试');
} finally {
if (ticket === sequence) { busy = false; controller = null; controls(); }
}
}
function selectBoard(id) {
board = boards.find(item => item.id === id);
if (!board) return;
$('rankingVipFilterBtn').hidden = board.source !== 'huangdou';
lastBoards.set(board.source, board.id);
entries = []; page = 0; hasMore = false; fetchedAt = ''; updatedText = '';
for (const tab of tabs.children) {
const active = tab.dataset.board === id;
tab.setAttribute('aria-selected', String(active));
tab.tabIndex = active ? 0 : -1;
}
$('rankingContent').setAttribute('aria-labelledby', 'ranking-tab-' + id);
updateSummary(false);
loadPage(1);
}
function selectSource() {
const source = $('rankingSource').value;
const available = boards.filter(item => item.source === source);
tabs.replaceChildren();
for (const item of available) {
const tab = action(item.name, () => selectBoard(item.id));
tab.id = 'ranking-tab-' + item.id;
tab.dataset.board = item.id;
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-controls', 'rankingContent');
tabs.appendChild(tab);
}
selectBoard(available.find(item => item.id === lastBoards.get(source))?.id || available[0]?.id);
}
async function loadCatalog() {
cancel();
const ticket = sequence;
busy = true;
controller = new AbortController();
const signal = controller.signal;
controls();
setError('');
state('正在读取可用榜单…');
try {
if (!boards.length) {
const result = await api('/api/ui/rankings', {signal});
if (ticket !== sequence || !dialog.open) return;
boards = Array.isArray(result.boards) ? result.boards : [];
}
if (!boards.length) throw new Error('暂未提供可用榜单');
const sources = [...new Set(boards.map(item => item.source))];
const wanted = getSource() || $('rankingSource').value;
$('rankingSource').replaceChildren(...sources.map(source => {
const option = node('option', '', sourceLabel(source));
option.value = source;
return option;
}));
$('rankingSource').value = sources.includes(wanted) ? wanted : sources[0];
selectSource();
} catch (error) {
if (ticket !== sequence || signal.aborted) return;
state('点击“刷新”重试');
setError(error.message);
} finally {
if (ticket === sequence) { busy = false; controller = null; controls(); }
}
}
$('openRankingsBtn').addEventListener('click', () => {
dialog.showModal();
$('closeRankingsBtn').focus();
$('rankingActionStatus').textContent = '';
loadCatalog();
});
$('closeRankingsBtn').addEventListener('click', () => dialog.close());
$('refreshRankingBtn').addEventListener('click', () => board ? loadPage(1, true) : loadCatalog());
$('rankingMoreBtn').addEventListener('click', () => { if (!busy && hasMore) loadPage(page + 1); });
$('rankingSource').addEventListener('change', selectSource);
tabs.addEventListener('keydown', event => {
const buttons = [...tabs.children], index = buttons.indexOf(event.target);
if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length;
buttons[next].focus();
selectBoard(buttons[next].dataset.board);
});
dialog.addEventListener('click', event => {
if (event.target !== dialog) return;
const box = dialog.getBoundingClientRect();
if (event.clientX < box.left || event.clientX > box.right || event.clientY < box.top || event.clientY > box.bottom) dialog.close();
});
dialog.addEventListener('close', () => {
if (dialog.open) return;
cancel();
controls();
if (changed) { changed = false; onLibraryChanged(); }
});
}
})();