Initial release
Build and Push Docker Image / build (push) Successful in 17s

This commit is contained in:
Stardream
2026-06-16 19:26:32 +10:00
commit e3bf77296c
142 changed files with 5481 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
'use strict';
// Bangumi API caller — still used by the token test and public-tag refresh (read-only helpers).
async function apiFetch(path, token){
const headers = {'Accept':'application/json'}; if(token) headers.Authorization = 'Bearer ' + token.trim();
const res = await fetch(bgmApiBase() + path, {headers, mode:'cors'});
if(!res.ok){ const text=await res.text().catch(()=>res.statusText); throw new Error(`HTTP ${res.status}: ${text.slice(0,200)}`); }
return res.json();
}
async function testLogin(){
const token = $('tokenInput').value.trim() || runtimeToken || state.settings.token;
if(!token){ toast('请先粘贴 Access Token'); return; }
$('loginLog').textContent = ''; log('loginLog','读取 /v0/me ...');
try{
const me = await apiFetch('/v0/me', token); const username = me.username || me.name || $('usernameInput').value.trim();
$('usernameInput').value = username || ''; log('loginLog',`登录成功:${username || JSON.stringify(me).slice(0,80)}`); toast('登录测试成功');
}catch(e){ log('loginLog','失败:' + e.message); toast('登录失败:' + e.message); }
}
function readToken(){ return $('tokenInput').value.trim() || runtimeToken || state.settings.token || ''; }
async function saveSettingsFromUI(){
const btn=$('saveSettingsBtn');
if(btn){ btn.disabled=true; btn.textContent='保存中…'; }
try{
state.settings.username = $('usernameInput').value.trim(); if(String(state.settings.autoSyncRangeMode) !== 'interval'){ state.settings.autoSyncRangeValue = Math.max(1, Math.min(999, Number($('recentMonthsInput')?.value || 3))); state.settings.autoSyncRangeUnit = $('autoSyncRangeUnit')?.value || 'month'; if(state.settings.autoSyncRangeUnit === 'month') state.settings.recentMonths = state.settings.autoSyncRangeValue; } state.settings.pageSize = clampNum($('pageSizeInput')?.value, 40, 360, 120); state.settings.commentLines = clampNum($('commentLinesInput')?.value, 0, 6, 3); state.settings.borderWidth = clampNum($('borderWidthInput')?.value, 0, 4, 1); state.settings.token = $('tokenInput').value.trim(); state.settings.autoCacheCovers = !!$('autoCacheCoversInput')?.checked; if($('autoSyncEnabledInput')) state.settings.autoSyncEnabled = !!$('autoSyncEnabledInput').checked; state.settings.bgmApiBase = ($('bgmApiBaseInput')?.value || '').trim(); state.settings.imageProxy = ($('imageProxyInput')?.value || '').trim(); state.settings.siteBase = ($('siteBaseInput')?.value || '').trim(); if(typeof refreshCronUI === 'function') refreshCronUI(); // writes state.settings.autoSyncCron from the editor
runtimeToken = $('tokenInput').value.trim() || runtimeToken; applyAppearance(false); refreshSegmentedControls(); resetRenderLimit();
if(typeof snapshotAppearance==='function') snapshotAppearance(); // saved values become the new baseline (close won't revert)
renderStats(); applyAuthVisibility(); toast('设置已保存');
// Persist asynchronously without blocking the UI (settings subtree only, never collections).
saveSettings().catch(()=>{});
if($('publicModeInput') && $('publicNsfwInput')){
localPostJSON('/api/admin/public-mode',{enabled:!!$('publicModeInput').checked, showNsfw:!!$('publicNsfwInput').checked}).catch(()=>{});
}
}catch(e){ toast('保存失败:'+e.message); }
finally{ if(btn){ btn.disabled=false; btn.textContent='保存设置'; } }
}
function fillSettingsUI(){ $('tokenInput').value = state.settings.token || runtimeToken; $('usernameInput').value = state.settings.username||''; if(state.settings.autoSyncRangeValue == null) state.settings.autoSyncRangeValue = Number(state.settings.recentMonths || 3); if(!state.settings.autoSyncRangeUnit) state.settings.autoSyncRangeUnit = 'month'; if($('recentMonthsInput')) $('recentMonthsInput').value = state.settings.autoSyncRangeValue || 3; if($('autoSyncRangeUnit')) $('autoSyncRangeUnit').value = state.settings.autoSyncRangeUnit || 'month'; if($('pageSizeInput')) $('pageSizeInput').value = pageSize(); if($('commentLinesInput')) $('commentLinesInput').value = clampNum(state.settings.commentLines,0,6,3); if($('borderWidthInput')) $('borderWidthInput').value = clampNum(state.settings.borderWidth,0,4,1); if($('autoCacheCoversInput')) $('autoCacheCoversInput').checked = !!state.settings.autoCacheCovers; if($('bgmApiBaseInput')) $('bgmApiBaseInput').value = state.settings.bgmApiBase || ''; if($('imageProxyInput')) $('imageProxyInput').value = state.settings.imageProxy || ''; if($('siteBaseInput')) $('siteBaseInput').value = state.settings.siteBase || ''; if($('autoSyncEnabledInput')) $('autoSyncEnabledInput').checked = (state.settings.autoSyncEnabled === true || state.settings.autoSyncEnabled === 'true'); if(!state.settings.autoSyncCron && (state.settings.autoSyncTime || Array.isArray(state.settings.autoSyncDays))){ const [h,m]=String(state.settings.autoSyncTime||'02:00').split(':').map(Number); const days=Array.isArray(state.settings.autoSyncDays)?state.settings.autoSyncDays:[]; const dow=(days.length===0||days.length>=7)?'*':[...days].sort((a,b)=>a-b).join(','); state.settings.autoSyncCron=`${m||0} ${h||0} * * ${dow}`; } if(typeof applyCronToControls==='function') applyCronToControls(state.settings.autoSyncCron || '0 2 * * *'); if(typeof refreshAutoSyncCollapse==='function') refreshAutoSyncCollapse(); if(typeof refreshAutoSyncRange==='function') refreshAutoSyncRange(); refreshSegmentedControls(); apiJSON('/api/session').then(s=>{ if($('publicModeInput')) $('publicModeInput').checked=!!s?.publicMode; if($('publicNsfwInput')) $('publicNsfwInput').checked=!!s?.publicShowNsfw; }).catch(()=>{}); }
// --- Server-side sync: the container runs it; the browser only kicks it off and polls progress. ---
// Open the pre-sync confirm dialog; the shared modal adapts to recent/full mode.
function openSyncConfirm(mode){
const isRecent = mode === 'recent';
const modal = $('syncConfirmModal'); if(!modal) return;
modal.dataset.mode = isRecent ? 'recent' : 'full';
if($('syncConfirmTitle')) $('syncConfirmTitle').textContent = isRecent ? '同步最近' : '完整同步';
if($('syncConfirmHelp')) $('syncConfirmHelp').textContent = isRecent
? '只扫描最近指定月份内更新过的收藏,不触发安全箱判断,适合日常同步。'
: '完整分页核对全部收藏,并对远端缺失的条目做安全箱判断。';
// Months row is recent-only; the subject-data toggle is full-only; cover toggle shows in both.
const monthsRow = $('syncConfirmMonthsRow'); if(monthsRow) monthsRow.style.display = isRecent ? '' : 'none';
const subjectsRow = $('syncConfirmSubjectsRow'); if(subjectsRow) subjectsRow.style.display = isRecent ? 'none' : '';
if(isRecent){
// Months default to the auto-sync range until the user overrides it once (then sticky).
const autoMonths = Number(state.settings.recentMonths || 3);
const shown = state.settings.manualRecentMonths != null ? state.settings.manualRecentMonths : autoMonths;
if($('syncConfirmMonthsInput')) $('syncConfirmMonthsInput').value = shown;
if($('syncConfirmCoversInput')) $('syncConfirmCoversInput').checked = !!state.settings.manualRecentCovers;
}else{
if($('syncConfirmCoversInput')) $('syncConfirmCoversInput').checked = !!state.settings.fullCovers;
if($('syncConfirmSubjectsInput')) $('syncConfirmSubjectsInput').checked = state.settings.fullRefreshSubjects !== false;
}
openModal('syncConfirmModal');
}
// Read the dialog choices, persist the months range, then start the server sync.
async function confirmStartSync(){
const mode = $('syncConfirmModal')?.dataset.mode === 'full' ? 'full' : 'recent';
let opts;
if(mode === 'recent'){
const autoMonths = Number(state.settings.recentMonths || 3);
const m = Math.max(1, Math.min(120, Number($('syncConfirmMonthsInput')?.value || autoMonths)));
const covers = !!$('syncConfirmCoversInput')?.checked;
// Manual recent months are independent of the auto-sync range; once the user picks a value
// different from the auto default, remember it (sticky) for next time.
if(state.settings.manualRecentMonths != null || m !== autoMonths) state.settings.manualRecentMonths = m;
state.settings.manualRecentCovers = covers;
// Recent sync always refreshes subject data (few items, cheap).
opts = { recentMonths: m, cacheCovers: covers, refreshSubjects: true };
}else{
const covers = !!$('syncConfirmCoversInput')?.checked;
const subjects = !!$('syncConfirmSubjectsInput')?.checked;
state.settings.fullCovers = covers;
state.settings.fullRefreshSubjects = subjects;
opts = { cacheCovers: covers, refreshSubjects: subjects };
}
saveSettings().catch(()=>{});
closeModal('syncConfirmModal');
return startServerSync(mode, opts);
}
let _syncPollTimer = null;
let _lastSyncMsg = '';
async function startServerSync(mode, opts = {}){
try{
const r = await apiJSON('/api/sync', {method:'POST', body:JSON.stringify({mode, ...opts})});
if(!r?.started){ toast('同步已在进行中'); }
}catch(e){ toast('无法启动同步:' + e.message); return; }
openModal('syncModal'); $('syncLog').textContent=''; setProgress(0); _lastSyncMsg='';
if($('syncTitle')) $('syncTitle').textContent = mode === 'recent' ? '同步最近' : '完整同步中';
if($('syncHelp')) $('syncHelp').textContent = '同步在服务器后台进行。可关闭此窗口,进度会显示在左上角;完成后目录会自动刷新。';
syncInProgress = true;
pollSyncStatus();
}
async function pollSyncStatus(){
if(_syncPollTimer){ clearTimeout(_syncPollTimer); _syncPollTimer=null; }
let st = null;
try{ st = await apiJSON('/api/sync/status'); }catch{}
if(st){
syncInProgress = !!st.running;
if(typeof st.percent === 'number') setProgress(st.percent);
if(st.message && st.message !== _lastSyncMsg){ _lastSyncMsg = st.message; log('syncLog', st.message); }
if(!st.running){
const r = st.lastResult;
if(st.phase === 'error') toast('同步失败:' + (st.error || ''));
else if(r && r.aborted) toast('安全保护触发:本次没有覆盖本地');
else if(st.phase === 'done' && r) toast(`同步完成:新增 ${r.added||0},修改 ${r.modified||0}`);
try{ await loadState(); }catch{}
resetRenderLimit(); render();
const mb=$('syncMiniBar');
if(mb && mb.classList.contains('show')){
mb.classList.add('smi-done');
if($('syncMiniLabel')) $('syncMiniLabel').textContent='同步完成';
if($('syncMiniFill')) $('syncMiniFill').style.width='100%';
setTimeout(()=>mb.classList.remove('show','smi-done'), 2400);
}
return;
}
}
_syncPollTimer = setTimeout(pollSyncStatus, 1500);
}
// On page load, if the container is mid-sync, resume into the unobtrusive mini bar
// (top-right) rather than popping the big modal. The user can click it to expand.
async function resumeSyncIfRunning(){
try{
const st = await apiJSON('/api/sync/status');
if(st && st.running){
$('syncLog').textContent=''; setProgress(st.percent||0); _lastSyncMsg='';
if($('syncTitle')) $('syncTitle').textContent = (st.mode==='full'?'完整同步中':'同步最近') + (st.trigger==='auto'?'(定时)':'');
if($('syncHelp')) $('syncHelp').textContent = '服务器正在后台同步;完成后目录会自动刷新。';
syncInProgress = true;
showSyncMini();
pollSyncStatus();
}
}catch{}
}
async function fetchSubjectPublicTags(subjectId, token){
// Fetch public tags via the API only (no website scraping).
// Note: API tags may differ slightly in count/order from the website's "tagged by" section.
const js = await apiFetch(`/v0/subjects/${encodeURIComponent(subjectId)}`, token || '');
return {
tags: normalizePublicTags(js.tags || js.subject?.tags || []).sort((a,b)=>(Number(b.count)||0)-(Number(a.count)||0) || String(a.name).localeCompare(String(b.name),'zh-Hans-CN')),
source: 'api'
};
}
async function enrichPublicTagsCurrent(){
const token = readToken();
const arr = visibleCollections();
if(!arr.length){ toast('当前筛选下没有条目'); return; }
if(!confirm(`将为当前筛选下 ${arr.length} 个条目逐个刷新公共标签,并覆盖旧的公共标签缓存。条目较多时会比较慢,继续吗?`)) return;
openModal('syncModal'); $('syncLog').textContent=''; setProgress(0);
if($('syncTitle')) $('syncTitle').textContent = '刷新公共标签';
if($('syncHelp')) $('syncHelp').textContent = '公共标签来自 Bangumi API,不抓取官网网页;可能与网页标签区的展示数量和顺序略有差异。';
let ok=0, empty=0, fail=0; const started_at=nowISO();
for(let i=0;i<arr.length;i++){
const c = arr[i]; setProgress((i+1)/arr.length*100);
try{
const tagResult = await fetchSubjectPublicTags(c.subject_id, token);
const tags = Array.isArray(tagResult) ? tagResult : (tagResult.tags || []);
const row = state.collections[String(c.subject_id)];
if(row){
// History is server-authoritative now (the browser no longer holds it), so don't push
// history entries here — saveState() posts an empty history and the server preserves its own.
row.publicTags = tags;
row.public_tags_source = Array.isArray(tagResult) ? 'api' : (tagResult.source || 'api');
row.public_tags_fetched_at = nowISO();
row.content_hash = hashString(stableStringify(comparable(row)));
}
if(tags.length) ok++; else empty++;
if((i+1)%10===0){ log('syncLog',`已处理 ${i+1}/${arr.length},有标签 ${ok},空标签 ${empty},失败 ${fail}`); await saveState(); await sleep(220); }
else await sleep(120);
}catch(e){ fail++; if(fail <= 8) log('syncLog',`失败 ${c.subject_id} ${c.title||''}${e.message || e}`); await sleep(300); }
}
await saveState(); resetRenderLimit(); render(); log('syncLog',`完成:有标签 ${ok},空标签 ${empty},失败 ${fail}`); toast(`公共标签刷新完成:${ok} 个条目有标签`);
}