183 lines
14 KiB
JavaScript
183 lines
14 KiB
JavaScript
'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} 个条目有标签`);
|
||
}
|
||
|