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
+320
View File
@@ -0,0 +1,320 @@
'use strict';
let authReady = false;
let isPublicMode = false;
let publicShowNsfw = false;
async function apiJSON(path, options = {}){
const res = await fetch(path, {
cache: 'no-store',
credentials: 'same-origin',
...options,
headers: {
...(options.body && !(options.body instanceof Blob) ? {'Content-Type':'application/json'} : {}),
...(options.headers || {})
}
});
if(!res.ok){
let message = res.statusText;
try{ const js = await res.json(); message = js.error || message; }catch{}
throw new Error(message || `HTTP ${res.status}`);
}
if(res.status === 204) return null;
const text = await res.text();
return text ? JSON.parse(text) : null;
}
async function ensureSession(){
const shell = document.getElementById('loginShell');
const status = document.getElementById('loginStatus');
const form = document.getElementById('loginForm');
async function check(){
try{
const session = await apiJSON('/api/session');
authReady = !!session?.authenticated;
isPublicMode = !!session?.publicMode;
publicShowNsfw = !!session?.publicShowNsfw;
}catch{ authReady = false; isPublicMode = false; publicShowNsfw = false; }
if(shell) shell.classList.toggle('hidden', authReady || isPublicMode);
return authReady || isPublicMode;
}
if(await check()) return true;
if(status) status.textContent = '';
if(form){
form.onsubmit = async (e) => {
e.preventDefault();
const input = document.getElementById('adminPasswordInput');
const button = document.getElementById('loginSubmitBtn');
if(button) button.disabled = true;
if(status) status.textContent = '验证中…';
try{
await apiJSON('/api/login', {method:'POST', body:JSON.stringify({password: input?.value || ''})});
if(input) input.value = '';
location.reload();
}catch(err){
if(status) status.textContent = '密码不正确,请重试';
}finally{
if(button) button.disabled = false;
}
};
}
return false;
}
async function changeAdminPassword(){
const current = $('currentAdminPasswordInput')?.value || '';
const next = $('newAdminPasswordInput')?.value || '';
if(next.length < 10){ toast('New admin password must be at least 10 characters'); return; }
try{
await apiJSON('/api/admin/password', {method:'POST', body:JSON.stringify({currentPassword:current, newPassword:next})});
$('currentAdminPasswordInput').value = '';
$('newAdminPasswordInput').value = '';
toast('Admin password changed');
}catch(err){ toast('Password change failed: ' + err.message); }
}
async function logout(){
try{ await apiJSON('/api/logout', {method:'POST'}); }catch{}
location.reload();
}
function bgmApiBase(){ return (state.settings.bgmApiBase || '').trim().replace(/\/+$/,'') || 'https://api.bgm.tv'; }
// Bangumi site base for opening subject/tag pages in the browser (mirror-friendly; not used server-side).
function bgmSiteBase(){ return (state.settings.siteBase || '').trim().replace(/\/+$/,'') || 'https://bgm.tv'; }
const TOKEN_URL = 'https://next.bgm.tv/demo/access-token';
const SUBJECT_TYPES = [2,1,4,3,6];
const COLLECTION_TYPES = [1,2,3,4,5];
const STATUS = {0:'全部',1:'想看',2:'看过',3:'在看',4:'搁置',5:'抛弃',99:'安全箱'};
const STATUS_BY_TYPE = {
1:{1:'想读',2:'读过',3:'在读'},
3:{1:'想听',2:'听过',3:'在听'},
4:{1:'想玩',2:'玩过',3:'在玩'},
};
function statusLabelFor(ct, st){ return (STATUS_BY_TYPE[st]?.[ct]) || STATUS[ct] || '未知'; }
const SUBJECT_LABEL = {1:'书籍',2:'动画',3:'音乐',4:'游戏',6:'三次元'};
const BGM_TYPE_PATH = {1:'book',2:'anime',3:'music',4:'game',6:'real'};
const DEFAULT_STATE = {schema:9, settings:{username:'', token:'', autoCacheCovers:false, autoSyncEnabled:false, autoSyncCron:'0 2 * * *', autoSyncMode:'recent', autoSyncRangeMode:'fixed', autoSyncRangeValue:3, autoSyncRangeUnit:'month', autoSyncLastRunKey:'', theme:'dark', themeMode:'dark', recentMonths:3, fontFamily:'system', cardSize:'standard', pageSize:120, commentLines:0, motion:'full', borderWidth:1, accentColor:'pink', bgmApiBase:'', imageProxy:'', siteBase:''}, collections:{}, history:[], runs:[]};
let state = structuredClone(DEFAULT_STATE);
let filters = {status:0, tag:'全部', type:0, q:'', sort:'updated_desc', nsfw:'all', hasComment:false};
let runtimeToken = '';
let activeDrawerId = null;
let syncInProgress = false;
let renderLimit = 0;
const $ = (id) => document.getElementById(id);
const nowISO = () => new Date().toISOString();
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
const safeName = (s) => String(s || 'bangumi_vault').replace(/[\\/:*?"<>|]+/g,'_').slice(0,80);
function toast(msg){ const t=$('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(t._tm); t._tm=setTimeout(()=>t.classList.remove('show'),3200); }
function log(id,msg){ const el=$(id); if(!el) return; el.textContent += `[${new Date().toLocaleTimeString()}] ${msg}\n`; el.scrollTop = el.scrollHeight; }
function setProgress(p){ const pct=Math.max(0,Math.min(100,p)); $('syncBar').style.width = pct + '%'; const mf=$('syncMiniFill'); if(mf) mf.style.width = pct + '%'; const ml=$('syncMiniLabel'); if(ml) ml.textContent = `同步中 ${Math.round(pct)}%`; }
function showSyncMini(){ const el=$('syncMiniBar'); if(el && syncInProgress){ el.classList.remove('smi-done'); el.classList.add('show'); } }
function hideSyncMini(){ const el=$('syncMiniBar'); if(el) el.classList.remove('show'); }
function clampNum(v, min, max, fallback){
const n = Number(v);
return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : fallback;
}
function pageSize(){ return clampNum(state.settings.pageSize, 40, 360, 120); }
function resetRenderLimit(){ renderLimit = pageSize(); }
function effectiveTheme(){
const mode = state.settings.themeMode || state.settings.theme || 'dark';
if(mode === 'system') return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
return mode === 'light' ? 'light' : 'dark';
}
// Theme toggle icons (stroke = currentColor, matching .tag-search-toggle). Dark active → sun (click to lighten); light → moon.
const THEME_ICON_SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.2M12 19.3v2.2M4.5 4.5l1.6 1.6M17.9 17.9l1.6 1.6M2.5 12h2.2M19.3 12h2.2M4.5 19.5l1.6-1.6M17.9 6.1l1.6-1.6"/></svg>';
const THEME_ICON_MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
function applyAppearance(animate=false, ev=null){
const update = () => {
const root = document.documentElement;
const theme = effectiveTheme();
root.dataset.theme = theme;
root.dataset.themeMode = state.settings.themeMode || state.settings.theme || 'dark';
root.dataset.font = state.settings.fontFamily || 'system';
root.dataset.cardSize = state.settings.cardSize || 'standard';
root.dataset.motion = state.settings.motion || 'full';
root.dataset.accent = state.settings.accentColor || 'pink';
root.style.setProperty('--comment-lines', String(clampNum(state.settings.commentLines, 0, 6, 0)));
root.style.setProperty('--ui-border-width', clampNum(state.settings.borderWidth, 0, 4, 1) + 'px');
root.dataset.borderZero = clampNum(state.settings.borderWidth, 0, 4, 1) === 0 ? 'true' : 'false';
const themeIcon = theme === 'dark' ? THEME_ICON_SUN : THEME_ICON_MOON;
document.querySelectorAll('.theme-toggle').forEach(b => b.innerHTML = themeIcon);
};
if(!animate || !document.startViewTransition){ update(); return; }
const x = ev?.clientX ?? (window.innerWidth - 96);
const y = ev?.clientY ?? 38;
const endRadius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y));
const transition = document.startViewTransition(update);
transition.ready.then(() => {
document.documentElement.animate({clipPath:[`circle(0px at ${x}px ${y}px)`,`circle(${endRadius}px at ${x}px ${y}px)`], filter:['blur(18px)','blur(0px)']}, {duration:520, easing:'cubic-bezier(.22,1,.36,1)', pseudoElement:'::view-transition-new(root)'});
document.documentElement.animate({filter:['blur(0px)','blur(18px)']}, {duration:520, easing:'cubic-bezier(.22,1,.36,1)', pseudoElement:'::view-transition-old(root)'});
}).catch(()=>{});
}
async function setThemeMode(mode, ev){
state.settings.themeMode = mode;
state.settings.theme = mode === 'system' ? effectiveTheme() : mode;
applyAppearance(true, ev);
refreshSegmentedControls();
await saveSettings();
}
async function toggleTheme(ev){
const next = effectiveTheme() === 'dark' ? 'light' : 'dark';
await setThemeMode(next, ev);
}
function refreshSegmentedControls(){
document.querySelectorAll('[data-segment]').forEach(group => {
const key = group.dataset.segment;
const value = String(state.settings[key] ?? DEFAULT_STATE.settings[key] ?? '');
group.querySelectorAll('button[data-value]').forEach(btn => btn.classList.toggle('active', String(btn.dataset.value) === value));
});
if($('pageSizeInput')) $('pageSizeInput').value = pageSize();
if($('commentLinesInput')) $('commentLinesInput').value = clampNum(state.settings.commentLines, 0, 6, 0);
if($('borderWidthInput')) $('borderWidthInput').value = clampNum(state.settings.borderWidth, 0, 4, 1);
}
const APPEARANCE_SEGMENTS = ['themeMode','fontFamily','cardSize','motion','accentColor'];
// Appearance preview snapshot: admin previews on click and we revert on close-without-save.
let appearanceSnapshot = null;
const APPEARANCE_KEYS = ['themeMode','theme','fontFamily','cardSize','motion','accentColor','pageSize','commentLines','borderWidth'];
function snapshotAppearance(){ appearanceSnapshot = {}; for(const k of APPEARANCE_KEYS) appearanceSnapshot[k] = state.settings[k]; }
function restoreAppearance(){
if(!appearanceSnapshot) return;
let changed = false;
for(const k of APPEARANCE_KEYS){ if(state.settings[k] !== appearanceSnapshot[k]){ state.settings[k] = appearanceSnapshot[k]; changed = true; } }
if(changed){ applyAppearance(false); refreshSegmentedControls(); renderCards(); }
}
function bindSegmentedControls(){
document.querySelectorAll('[data-segment]').forEach(group => {
const key = group.dataset.segment;
group.querySelectorAll('button[data-value]').forEach(btn => {
btn.onclick = async (e) => {
const value = btn.dataset.value;
state.settings[key] = value;
if(key === 'themeMode'){
state.settings.theme = value === 'system' ? effectiveTheme() : value;
applyAppearance(true, e);
} else {
applyAppearance(false);
}
refreshSegmentedControls();
if(APPEARANCE_SEGMENTS.includes(key)){
// Admin: click is preview-only (persisted via 保存设置). Guest: no save button, so persist locally now.
if(!authReady) await saveSettings();
if(key === 'cardSize' || key === 'motion') renderCards();
}
if(key === 'autoSyncMode' || key === 'autoSyncEnabled') updateAutoSyncHelp();
if(key === 'autoSyncEnabled' && typeof refreshAutoSyncCollapse === 'function') refreshAutoSyncCollapse();
};
});
});
}
function openDB(){
return new Promise((resolve,reject)=>{
const req = indexedDB.open('BangumiVaultPosterWall', 1);
req.onupgradeneeded = () => { const db=req.result; if(!db.objectStoreNames.contains('kv')) db.createObjectStore('kv', {keyPath:'key'}); };
req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error);
});
}
async function idbGet(key){ const db=await openDB(); return new Promise((resolve,reject)=>{ const tx=db.transaction('kv','readonly'); const req=tx.objectStore('kv').get(key); req.onsuccess=()=>resolve(req.result?.value); req.onerror=()=>reject(req.error); }); }
async function idbSet(key,value){ const db=await openDB(); return new Promise((resolve,reject)=>{ const tx=db.transaction('kv','readwrite'); tx.objectStore('kv').put({key,value}); tx.oncomplete=()=>resolve(); tx.onerror=()=>reject(tx.error); }); }
async function idbDelete(key){ const db=await openDB(); return new Promise((resolve,reject)=>{ const tx=db.transaction('kv','readwrite'); tx.objectStore('kv').delete(key); tx.oncomplete=()=>resolve(); tx.onerror=()=>reject(tx.error); }); }
const LOCAL_API = (location.protocol === 'http:' || location.protocol === 'https:') ? location.origin : '';
let localApiKnown = null;
async function localApiAvailable(){
if(!LOCAL_API) return false;
if(localApiKnown !== null) return localApiKnown;
try{ const r = await fetch('/api/ping', {cache:'no-store'}); localApiKnown = !!(r.ok); }
catch{ localApiKnown = false; }
return localApiKnown;
}
async function localGetJSON(path){ return await apiJSON(path); }
async function localPostJSON(path, obj){ return await apiJSON(path, {method:'POST', body:JSON.stringify(obj)}); }
// History is no longer bundled into /api/state (kept the payload huge); fetch it on demand.
async function fetchSubjectHistory(id){
if(window.BANGUMI_VAULT_OFFLINE_STATE) return (state.history||[]).filter(h=>String(h.subject_id)===String(id));
try{ const r = await apiJSON('/api/history?subject_id='+encodeURIComponent(id)); return r?.history || []; }catch{ return []; }
}
// Subject infobox (game type/engine/developer…) is stripped from /api/state. Kept in a separate
// in-memory cache (NOT in `state`, so saveState never posts it back and re-bloats app_state.json).
let infoboxCache = {};
let infoboxPrefetch = null;
async function fetchSubjectInfobox(id){
if(window.BANGUMI_VAULT_OFFLINE_STATE){ const local = state.collections?.[id]?.infobox; return Array.isArray(local) ? local : []; }
if(Array.isArray(infoboxCache[id])) return infoboxCache[id];
// Warm-up window: wait for the in-flight bulk prefetch instead of firing another slow per-subject query.
if(infoboxPrefetch){ try{ await infoboxPrefetch; }catch{} if(Array.isArray(infoboxCache[id])) return infoboxCache[id]; }
try{ const r = await apiJSON('/api/subject-meta?subject_id='+encodeURIComponent(id)); const ib = r?.infobox || []; infoboxCache[id] = ib; return ib; }catch{ return []; }
}
// Background-prefetch every infobox after load so opening any drawer shows 条目资料 instantly.
function prefetchInfoboxes(){
if(window.BANGUMI_VAULT_OFFLINE_STATE) return;
infoboxPrefetch = (async () => {
try{
const r = await apiJSON('/api/infoboxes');
const map = (r && r.infoboxes) || {};
const next = {};
for(const id in state.collections) next[id] = Array.isArray(map[id]) ? map[id] : [];
infoboxCache = next;
}catch{}
})();
return infoboxPrefetch;
}
// Pull the full history back into state (only needed before a backup export).
async function loadFullHistory(){
if(window.BANGUMI_VAULT_OFFLINE_STATE) return;
if(Array.isArray(state.history) && state.history.length) return;
try{ const r = await apiJSON('/api/history'); if(r?.history) state.history = r.history; }catch{}
}
// Appearance/display prefs a guest may keep locally (never affects the shared library).
const GUEST_PREF_KEYS = ['theme','themeMode','fontFamily','cardSize','motion','borderWidth','pageSize','commentLines'];
async function applyGuestPrefs(){
if(authReady || !isPublicMode) return;
try{
const prefs = await idbGet('guestSettings');
if(prefs) for(const k of GUEST_PREF_KEYS){ if(prefs[k] !== undefined) state.settings[k] = prefs[k]; }
}catch{}
}
async function loadState(){
try{
if(window.BANGUMI_VAULT_OFFLINE_STATE){
state = mergeState(window.BANGUMI_VAULT_OFFLINE_STATE);
} else {
if(await localApiAvailable()){
const loaded = await localGetJSON('/api/state');
if(loaded){ state = mergeState(loaded); await applyGuestPrefs(); }
}
// Server (PostgreSQL) is the single source of truth — purge any legacy full-state browser cache.
idbDelete('state').catch(()=>{}); try{ localStorage.removeItem('BangumiVaultState'); }catch{}
}
}catch(e){ console.warn(e); }
runtimeToken = state.settings.token || '';
applyAppearance(false); resetRenderLimit();
// Warm the infobox cache ASAP (fire-and-forget; network-bound so it won't block render). Re-runs after each sync's loadState.
if(!window.BANGUMI_VAULT_OFFLINE_STATE) prefetchInfoboxes();
}
function mergeState(loaded){
const settings = {...DEFAULT_STATE.settings, ...(loaded.settings||{})}; if(!settings.themeMode) settings.themeMode = settings.theme || 'dark'; return {schema:9, settings, collections:loaded.collections||{}, history:loaded.history||[], runs:loaded.runs||[]};
}
async function saveState(){
if(window.BANGUMI_VAULT_OFFLINE_STATE) return;
if(!authReady && isPublicMode){
try{ await idbSet('guestSettings', structuredClone(state.settings)); }catch{}
return;
}
if(await localApiAvailable()) await localPostJSON('/api/state', structuredClone(state));
}
// Save only the settings subtree; never touch collections (avoids clobbering server-side sync results).
async function saveSettings(){
if(window.BANGUMI_VAULT_OFFLINE_STATE) return;
const settings = structuredClone(state.settings);
if(!authReady && isPublicMode){
try{ await idbSet('guestSettings', settings); }catch{}
return;
}
if(await localApiAvailable()) await localPostJSON('/api/settings', {settings});
}
// Update one collection's status fields (safety box). Updates local state then patches the server row.
async function setCollectionStatus(id, status, missingCount){
const row = state.collections[String(id)];
if(row){ row.status = status; if(missingCount !== undefined) row.missing_count = missingCount; }
try{ await localPostJSON('/api/collections/status', {subject_id:String(id), status, missing_count:missingCount}); }
catch(e){ toast('状态更新失败:' + e.message); }
}
+85
View File
@@ -0,0 +1,85 @@
'use strict';
async function cacheCoversAuto(){
const portable = await localApiAvailable();
const todo = getAllCollections().filter(c => c.status !== 'confirmed_deleted' && !c.cover_local_url);
if(!todo.length){ log('syncLog','封面自动缓存:全部已缓存,跳过。'); return; }
log('syncLog', `封面自动缓存:开始缓存 ${todo.length} 个未缓存封面${portable ? '到 Docker 文件卷' : '到浏览器 IndexedDB'}...`);
let ok=0, fail=0;
for(let i=0; i<todo.length; i++){
const c=todo[i]; const id=String(c.subject_id);
const url=coverUrl({...c, cover_local_url:'', cover_data_url:''}); if(!url){ fail++; continue; }
try{
if(portable){
const js=await localPostJSON('/api/cache-cover', {subject_id:id, url});
if(!js?.ok || !js.url) throw new Error(js?.error||'no url');
const row=state.collections[id];
if(row){ row.cover_local_url=js.url; row.cover_local_file=js.file||''; row.cover_cached_at=nowISO(); }
} else {
const res=await fetch(url,{mode:'cors',referrerPolicy:'no-referrer'}); if(!res.ok) throw new Error(res.statusText);
const blob=await res.blob(); const dataUrl=await blobToDataURL(blob); await idbSet('img:'+id,{dataUrl,updated_at:nowISO(),source:url});
}
ok++; if((i+1)%20===0){ log('syncLog',`封面自动缓存 ${i+1}/${todo.length}`); await saveState(); }
await sleep(portable ? 35 : 80);
}catch(e){ fail++; if(fail<=5) log('syncLog',`封面失败 ${id}${e.message||e}`); }
}
await saveState();
log('syncLog', `封面自动缓存完成:成功 ${ok},失败 ${fail}`);
}
async function cacheCoversCurrent(){
const arr = visibleCollections(); if(!arr.length){ toast('没有可缓存的封面'); return; }
const portable = await localApiAvailable();
openModal('syncModal'); $('syncLog').textContent='';
log('syncLog', portable ? `开始缓存当前筛选下 ${arr.length} 个封面到 Docker 文件卷...` : `未检测到 Web 服务,临时缓存到浏览器 IndexedDB:${arr.length} 个封面...`);
setProgress(0);
let ok=0, fail=0, i=0;
for(const c of arr){
i++; setProgress(i/arr.length*100); const id=String(c.subject_id);
if(c.cover_local_url || (!portable && await getCachedImage(id))) { ok++; continue; }
const url=coverUrl({...c, cover_local_url:'', cover_data_url:''}); if(!url){ fail++; continue; }
try{
if(portable){
const js = await localPostJSON('/api/cache-cover', {subject_id:id, url, title:c.title||''});
if(!js?.ok || !js.url) throw new Error(js?.error || '本地服务没有返回图片路径');
const row = state.collections[id];
if(row){ row.cover_local_url = js.url; row.cover_local_file = js.file || ''; row.cover_cached_at = nowISO(); }
} else {
const res=await fetch(url, {mode:'cors', referrerPolicy:'no-referrer'}); if(!res.ok) throw new Error(res.statusText); const blob=await res.blob(); const dataUrl=await blobToDataURL(blob); await idbSet('img:'+id, {dataUrl, updated_at:nowISO(), source:url});
}
ok++; if(i%10===0){ log('syncLog',`已处理 ${i}/${arr.length}`); await saveState(); }
await sleep(portable ? 35 : 80);
}catch(e){ fail++; if(fail <= 5) log('syncLog',`封面失败 ${id}${e.message || e}`); }
}
await saveState();
log('syncLog',`封面缓存完成:成功 ${ok},失败 ${fail}`); toast(`封面缓存完成:成功 ${ok},失败 ${fail}`); render();
}
function blobToDataURL(blob){ return new Promise((resolve,reject)=>{ const r=new FileReader(); r.onload=()=>resolve(r.result); r.onerror=()=>reject(r.error); r.readAsDataURL(blob); }); }
async function imageBlobToDataURLFromCache(c){
const offlineData = c.cover_data_url || '';
const localUrl = c.cover_local_url || '';
const remoteUrl = onlineCoverUrl(c);
// v0.20: 导出用的 base64 只在内存中的离线副本里生成,不写回 收藏数据.json。
// Web 运行界面优先使用服务端缓存封面或在线原图。
if(localUrl){
try{ const res = await fetch(localUrl, {cache:'no-store'}); if(res.ok) return await blobToDataURL(await res.blob()); }catch{}
}
try{ const cached = await idbGet('img:'+c.subject_id); if(cached?.dataUrl) return cached.dataUrl; }catch{}
if(offlineData) return offlineData;
// 没有本地缓存时,不主动在线拉远端图片进 HTML,避免导出完整 ZIP 时大量跨域下载卡住。
// 需要离线封面的条目,请先点“缓存当前封面到目录”。
return '';
}
async function collectCachedImageFiles(arr,onProgress){
const files = {};
let i = 0;
for(const c of arr){
i++;
if(onProgress) onProgress(i, arr.length, c);
if(!c.cover_local_url) { if(i % 40 === 0) await sleep(0); continue; }
const file = (c.cover_local_file || '').split(/[\\/]/).pop() || (`${c.subject_id}.jpg`);
try{ const res = await fetch(c.cover_local_url, {cache:'no-store'}); if(!res.ok) continue; files['images/' + file] = new Uint8Array(await res.arrayBuffer()); }
catch{}
if(i % 30 === 0) await sleep(0);
}
return files;
}
+267
View File
@@ -0,0 +1,267 @@
'use strict';
// Visual cron editor + pure cron helpers (5-field: min hour day-of-month month day-of-week).
// Classic script sharing the global scope (uses $, state, updateAutoSyncHelp from other modules).
const CRON_WEEKDAY_NAMES = ['日', '一', '二', '三', '四', '五', '六'];
// Editor UI state: which frequency preset is active, and the weekly day picks.
const cronUI = { mode: 'daily', weeklyDays: new Set() };
let rangeUnitDropdown = null; // custom .selectish for the auto-sync range unit
// Parse one cron field into a sorted array of allowed values. Mirrors the server matcher.
function cronParseField(field, min, max){
const out = new Set();
for(const part of String(field).split(',')){
const m = part.trim().match(/^(\*|\d+)(?:-(\d+))?(?:\/(\d+))?$/);
if(!m) return null; // any invalid token => whole field invalid
const step = m[3] ? Number(m[3]) : 1;
if(step < 1) return null;
let lo, hi;
if(m[1] === '*'){ lo = min; hi = max; }
else { lo = Number(m[1]); hi = m[2] !== undefined ? Number(m[2]) : (m[3] ? max : lo); }
if(lo < min || hi > max || lo > hi) return null;
for(let v = lo; v <= hi; v += step) out.add(v);
}
return [...out].sort((a,b)=>a-b);
}
function cronValidate(expr){
const parts = String(expr || '').trim().split(/\s+/);
if(parts.length !== 5) return false;
const ranges = [[0,59],[0,23],[1,31],[1,12],[0,6]];
return parts.every((p,i)=>cronParseField(p, ranges[i][0], ranges[i][1]) !== null);
}
function cronMatchesClient(expr, date){
const parts = String(expr).trim().split(/\s+/);
if(parts.length !== 5) return false;
const [mi, ho, dom, mon, dow] = parts;
const miS = cronParseField(mi,0,59), hoS = cronParseField(ho,0,23), monS = cronParseField(mon,1,12);
const domS = cronParseField(dom,1,31), dowS = cronParseField(dow,0,6);
if(!miS || !hoS || !monS || !domS || !dowS) return false;
if(!miS.includes(date.getMinutes())) return false;
if(!hoS.includes(date.getHours())) return false;
if(!monS.includes(date.getMonth()+1)) return false;
const domR = dom.trim() !== '*', dowR = dow.trim() !== '*';
const domOk = domS.includes(date.getDate()), dowOk = dowS.includes(date.getDay());
if(domR && dowR) return domOk || dowOk;
if(domR) return domOk;
if(dowR) return dowOk;
return true;
}
// Next n run times after `from`, scanning minute by minute up to ~366 days.
function cronNextRuns(expr, n = 3, from = new Date()){
if(!cronValidate(expr)) return [];
const out = [];
const d = new Date(from.getTime());
d.setSeconds(0, 0);
d.setMinutes(d.getMinutes() + 1); // start from the next minute
const limit = 366 * 24 * 60;
for(let i = 0; i < limit && out.length < n; i++){
if(cronMatchesClient(expr, d)) out.push(new Date(d.getTime()));
d.setMinutes(d.getMinutes() + 1);
}
return out;
}
// Human-readable Chinese description for common cron shapes; falls back to echoing the expression.
function cronDescribe(expr){
if(!cronValidate(expr)) return '表达式无效';
const [mi, ho, dom, mon, dow] = expr.trim().split(/\s+/);
const everyMin = mi.match(/^\*\/(\d+)$/);
if(everyMin && ho === '*' && dom === '*' && mon === '*' && dow === '*') return `每隔 ${everyMin[1]} 分钟`;
if(mi === '*' && ho === '*' && dom === '*' && mon === '*' && dow === '*') return '每分钟';
const everyHour = ho.match(/^\*\/(\d+)$/);
if(/^\d+$/.test(mi) && everyHour && dom === '*' && mon === '*' && dow === '*') return `每隔 ${everyHour[1]} 小时(第 ${mi} 分)`;
const hhmm = (/^\d+$/.test(mi) && /^\d+$/.test(ho)) ? `${String(ho).padStart(2,'0')}:${String(mi).padStart(2,'0')}` : null;
if(hhmm && dom === '*' && mon === '*' && dow === '*') return `每天 ${hhmm}`;
if(hhmm && dom === '*' && mon === '*' && dow !== '*'){
const days = cronParseField(dow,0,6) || [];
return `每周${days.map(d=>CRON_WEEKDAY_NAMES[d]).join('、')} ${hhmm}`;
}
if(hhmm && dom !== '*' && mon === '*' && dow === '*'){
const days = cronParseField(dom,1,31) || [];
return `每月 ${days.join('、')}${hhmm}`;
}
return `cron${expr.trim()}`;
}
// --- editor wiring ---
function clampInt(v, lo, hi, dflt){ const n = Math.round(Number(v)); return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : dflt; }
function selectCronFreq(mode){
cronUI.mode = mode;
document.querySelectorAll('#cronFreqRow button[data-freq]').forEach(b=>b.classList.toggle('active', b.dataset.freq === mode));
const panes = { minutes:'cronFreqMinutes', hours:'cronFreqHours', daily:'cronFreqDaily', weekly:'cronFreqWeekly', custom:'cronFreqCustom' };
// minutes/hours/daily are inline rows (need flex so their gap/align-items apply); weekly/custom are blocks.
const flexRow = { minutes:1, hours:1, daily:1 };
for(const [m, id] of Object.entries(panes)){
const el = $(id); if(!el) continue;
el.style.display = (m === mode) ? (flexRow[m] ? 'flex' : 'block') : 'none';
}
refreshCronUI();
}
// Collapse the auto-sync options when auto-sync is off (values stay in state and are still saved).
function refreshAutoSyncCollapse(){
const wrap = $('autoSyncOptions'); if(!wrap) return;
wrap.style.display = (String(state.settings.autoSyncEnabled) === 'true') ? 'grid' : 'none';
}
// A rough "约 N 小时/天/分钟" estimate of the scheduling interval (gap between the next two fires).
function cronIntervalText(){
const runs = cronNextRuns(state.settings.autoSyncCron || '0 2 * * *', 2);
if(runs.length < 2) return '';
const mins = Math.round((runs[1] - runs[0]) / 60000);
if(mins < 60) return `(约 ${mins} 分钟)`;
if(mins % 1440 === 0) return `(约 ${mins/1440} 天)`;
if(mins % 60 === 0) return `(约 ${mins/60} 小时)`;
return `(约 ${Math.round(mins/60*10)/10} 小时)`;
}
const CRON_UNIT_LABEL = { minute:'分钟', hour:'小时', day:'天', month:'个月' };
// Convert the scheduling interval (gap between the next two fires) into a {value, unit} pair.
function cronIntervalParts(){
const runs = cronNextRuns(state.settings.autoSyncCron || '0 2 * * *', 2);
if(runs.length < 2) return null;
const mins = Math.round((runs[1] - runs[0]) / 60000);
if(mins <= 0) return null;
if(mins % 1440 === 0) return { value: mins/1440, unit:'day' };
if(mins % 60 === 0) return { value: mins/60, unit:'hour' };
return { value: mins, unit:'minute' };
}
// Update only the help line (so live typing in fixed mode isn't reset).
function updateAutoSyncRangeHelp(){
const help = $('autoSyncRangeHelp'); if(!help) return;
const interval = String(state.settings.autoSyncRangeMode) === 'interval';
const num = $('recentMonthsInput'), unit = $('autoSyncRangeUnit');
const v = num ? (num.value || 3) : 3;
const u = CRON_UNIT_LABEL[unit ? unit.value : 'month'] || '个月';
help.textContent = interval
? `自动同步跟随调度频率,只回看一个调度周期(≈ ${v} ${u});手动“同步最近”不受影响。`
: `自动同步回看最近 ${v} ${u}内更新的条目。手动“同步最近”的范围在它的弹窗里单独设置。`;
}
// Full apply: 'interval' fills the inputs with the computed schedule interval and disables them;
// fixed restores the user's saved value/unit and enables them.
function refreshAutoSyncRange(){
const interval = String(state.settings.autoSyncRangeMode) === 'interval';
const num = $('recentMonthsInput'), unit = $('autoSyncRangeUnit'), btn = $('autoSyncRangeBtn');
if(btn){ btn.classList.toggle('good', interval); btn.textContent = interval ? '跟随调度频率 ✓' : '跟随调度频率'; }
const dd = $('autoSyncRangeUnitDropdown');
const setUnit = (v)=>{ if(rangeUnitDropdown) rangeUnitDropdown.setValue(v, true); else if(unit) unit.value = v; };
if(interval){
const p = cronIntervalParts();
if(num){ if(p) num.value = p.value; num.disabled = true; }
if(p) setUnit(p.unit);
}else{
if(num){ num.value = state.settings.autoSyncRangeValue || 3; num.disabled = false; }
setUnit(state.settings.autoSyncRangeUnit || 'month');
}
if(dd) dd.classList.toggle('disabled', interval);
updateAutoSyncRangeHelp();
}
function refreshCronWeeklyDays(){
document.querySelectorAll('#cronWeeklyDaysRow button[data-day]').forEach(btn=>{
btn.classList.toggle('active', cronUI.weeklyDays.has(Number(btn.dataset.day)));
});
}
// Read the active preset controls and produce a cron string.
function buildCronFromControls(){
switch(cronUI.mode){
case 'minutes': {
const n = clampInt($('cronEveryMinutes')?.value, 1, 59, 30);
return `*/${n} * * * *`;
}
case 'hours': {
const n = clampInt($('cronEveryHours')?.value, 1, 23, 4);
const m = clampInt($('cronHourMinute')?.value, 0, 59, 0);
return `${m} */${n} * * *`;
}
case 'daily': {
const [h, m] = String($('cronDailyTime')?.value || '02:00').split(':').map(Number);
return `${m || 0} ${h || 0} * * *`;
}
case 'weekly': {
const [h, m] = String($('cronWeeklyTime')?.value || '08:00').split(':').map(Number);
const days = [...cronUI.weeklyDays].sort((a,b)=>a-b);
const dow = days.length ? days.join(',') : '*';
return `${m || 0} ${h || 0} * * ${dow}`;
}
case 'custom':
default:
return String($('cronCustomInput')?.value || '').trim();
}
}
// Parse a cron string and reflect it back into the preset controls (best-effort; else 'custom').
function applyCronToControls(cron){
const expr = cronValidate(cron) ? cron.trim() : '0 2 * * *';
const [mi, ho, dom, mon, dow] = expr.split(/\s+/);
if($('cronCustomInput')) $('cronCustomInput').value = expr;
let mode = 'custom';
const everyMin = mi.match(/^\*\/(\d+)$/);
const everyHour = ho.match(/^\*\/(\d+)$/);
if(everyMin && ho === '*' && dom === '*' && mon === '*' && dow === '*'){
mode = 'minutes'; if($('cronEveryMinutes')) $('cronEveryMinutes').value = everyMin[1];
} else if(/^\d+$/.test(mi) && everyHour && dom === '*' && mon === '*' && dow === '*'){
mode = 'hours'; if($('cronEveryHours')) $('cronEveryHours').value = everyHour[1]; if($('cronHourMinute')) $('cronHourMinute').value = Number(mi);
} else if(/^\d+$/.test(mi) && /^\d+$/.test(ho) && dom === '*' && mon === '*' && dow === '*'){
mode = 'daily'; if($('cronDailyTime')) $('cronDailyTime').value = `${String(ho).padStart(2,'0')}:${String(mi).padStart(2,'0')}`;
} else if(/^\d+$/.test(mi) && /^\d+$/.test(ho) && dom === '*' && mon === '*' && dow !== '*' && cronParseField(dow,0,6)){
mode = 'weekly';
if($('cronWeeklyTime')) $('cronWeeklyTime').value = `${String(ho).padStart(2,'0')}:${String(mi).padStart(2,'0')}`;
cronUI.weeklyDays = new Set(cronParseField(dow,0,6));
}
cronUI.mode = mode;
refreshCronWeeklyDays();
selectCronFreq(mode);
}
function refreshCronUI(){
const cron = buildCronFromControls();
state.settings.autoSyncCron = cron;
const exprEl = $('cronExprText'); if(exprEl) exprEl.textContent = cron || '(空)';
const nextEl = $('cronNextText');
if(nextEl){
if(!cronValidate(cron)){ nextEl.textContent = '表达式无效,请检查'; }
else {
const runs = cronNextRuns(cron, 3);
nextEl.textContent = runs.length ? '下次运行:' + runs.map(d=>d.toLocaleString()).join('') : '一年内无匹配时间';
}
}
if(typeof updateAutoSyncHelp === 'function') updateAutoSyncHelp();
// In interval mode the range tracks the cron, so re-fill it when the cron changes.
if(String(state.settings.autoSyncRangeMode) === 'interval') refreshAutoSyncRange();
}
function bindCronEditor(){
document.querySelectorAll('#cronFreqRow button[data-freq]').forEach(btn=>{ btn.onclick = ()=>selectCronFreq(btn.dataset.freq); });
const enabledInput = $('autoSyncEnabledInput');
if(enabledInput) enabledInput.onchange = ()=>{
state.settings.autoSyncEnabled = enabledInput.checked;
refreshAutoSyncCollapse();
if(typeof updateAutoSyncHelp === 'function') updateAutoSyncHelp();
};
const rangeBtn = $('autoSyncRangeBtn');
if(rangeBtn) rangeBtn.onclick = ()=>{
state.settings.autoSyncRangeMode = (String(state.settings.autoSyncRangeMode) === 'interval') ? 'fixed' : 'interval';
refreshAutoSyncRange();
};
// Live-update only the help when the user edits the value (don't reset the field).
const rangeNum = $('recentMonthsInput'); if(rangeNum) rangeNum.addEventListener('input', updateAutoSyncRangeHelp);
// Custom .selectish dropdown for the unit (matches the type/sort dropdowns); onChange refreshes the help.
if(typeof setupDropdown === 'function') rangeUnitDropdown = setupDropdown('autoSyncRangeUnitDropdown','autoSyncRangeUnit','autoSyncRangeUnitLabel', ()=>updateAutoSyncRangeHelp());
['cronEveryMinutes','cronEveryHours','cronHourMinute','cronDailyTime','cronWeeklyTime','cronCustomInput'].forEach(id=>{
const el = $(id); if(el) el.oninput = refreshCronUI;
});
document.querySelectorAll('#cronWeeklyDaysRow button[data-day]').forEach(btn=>{
btn.onclick = ()=>{
const d = Number(btn.dataset.day);
if(cronUI.weeklyDays.has(d)) cronUI.weeklyDays.delete(d); else cronUI.weeklyDays.add(d);
refreshCronWeeklyDays(); refreshCronUI();
};
});
}
+594
View File
@@ -0,0 +1,594 @@
'use strict';
function safeExportState(){
const s=structuredClone(state);
delete s.settings.token;
// v0.20: 常规 JSON 备份不携带 base64 图片,避免 state 变巨大;离线 HTML 会临时内嵌。
for(const c of Object.values(s.collections || {})) delete c.cover_data_url;
return s;
}
function manifest(){ const arr=getAllCollections(); return {app:'Bangumi 保管库 PosterWall', schema:9, exported_at:nowISO(), username:state.settings.username||'', collections_count:arr.length, active_count:arr.filter(c=>c.status!=='confirmed_deleted'&&c.status!=='missing_candidate').length, missing_count:arr.filter(c=>c.status==='missing_candidate').length, history_count:state.history.length}; }
function buildJson(){ return JSON.stringify({manifest:manifest(), state:safeExportState()}, null, 2); }
function csvCell(s){ return '"' + String(s ?? '').replace(/"/g,'""') + '"'; }
function buildCsv(){
const head=['subject_id','分类','状态','标题','原名','中文名','日期','我的评分','Bangumi均分','进度章节','进度卷','我的标签','公共标签','全部标签','吐槽/观后感','是否私密','条目最后更新时间','本地同步时间','本地状态','首次备份','最后确认存在'];
const lines=[head.map(csvCell).join(',')];
for(const c of getAllCollections()) lines.push([c.subject_id,SUBJECT_LABEL[c.subject_type]||c.subject_type,STATUS[c.collection_type]||c.collection_type,c.title,c.name,c.name_cn,c.date,c.rate,(formatScoreValue(c.bgm_score || c.raw?.subject?.score || c.raw?.subject?.rating?.score) || ''),c.ep_status,c.vol_status,myTagsOf(c).join(' / '),publicTagNames(c).join(' / '),allTagNames(c).join(' / '),c.comment,c.private?'是':'否',c.bangumi_updated_at,c.last_synced_at || c.last_seen_at || '',c.status,c.first_seen_at,c.last_seen_at].map(csvCell).join(','));
return '\ufeff' + lines.join('\r\n');
}
async function enrichExportCollections(mode='inline', onProgress){
const arr = getAllCollections().map(c=>structuredClone(c));
let i = 0;
for(const c of arr){
i++;
if(onProgress) onProgress(i, arr.length, c);
if(mode === 'relative' && c.cover_local_file){
const file = String(c.cover_local_file).split(/[\\/]/).pop();
c.cover_local_url = 'images/' + file;
delete c.cover_data_url;
} else {
const dataUrl = await imageBlobToDataURLFromCache(c);
if(dataUrl){
c.cover_data_url = dataUrl;
// 离线 HTML 优先使用内嵌图片,避免 /images/... 在 file:// 或压缩包预览中失效。
delete c.cover_local_url;
delete c.cover_local_file;
} else {
// 没缓存到本地的条目,离线 HTML 可以在有网时继续显示远程图;离线则显示占位。
delete c.cover_data_url;
}
}
if(i % 25 === 0) await sleep(0);
}
return arr;
}
async function buildOfflineHtml(mode='inline', onProgress){
const offlineState = safeExportState();
offlineState.collections = {};
for(const c of await enrichExportCollections(mode, onProgress)) offlineState.collections[String(c.subject_id)] = c;
const payload = JSON.stringify(offlineState).replace(/</g,'\\u003c');
// v0.19: 不再用正则从 drawer-mask 删到 toast。
// 之前的清理方式会把 <section id="drawer"> 一起删掉,导致离线 HTML 点击海报后没有详情容器。
// 这里克隆整页,只移除打开态/关闭态,并清空详情容器,保留设置、导出、drawer、toast 等必要节点。
const cloned = document.documentElement.cloneNode(true);
cloned.querySelectorAll('.show,.closing').forEach(el => el.classList.remove('show','closing'));
const drawer = cloned.querySelector('#drawer');
if(drawer) drawer.innerHTML = '';
cloned.querySelectorAll('.sync-log').forEach(el => { el.textContent = ''; });
const toastEl = cloned.querySelector('#toast');
if(toastEl) toastEl.textContent = '';
// Inline the stylesheet so the exported file is self-contained.
try{
const cssText = await fetch('css/styles.css').then(r=>r.text());
const link = cloned.querySelector('link[rel="stylesheet"]');
if(link){ const styleEl = document.createElement('style'); styleEl.textContent = cssText; link.replaceWith(styleEl); }
}catch{}
// Bundle every JS module into one inline script. The modules behave as ordered classic
// scripts (shared scope), so concatenating them inside one IIFE reproduces the live app.
const moduleNames = ['core','model','view','cron','sync','covers','exports','main'];
let bundleBody = '';
for(const n of moduleNames){
try{ bundleBody += (await fetch('js/'+n+'.js').then(r=>r.text())) + '\n'; }catch{}
}
// Escape any literal closing-script token so the inline <script> isn't terminated early.
bundleBody = bundleBody.replace(/<\/(script)>/gi, '<\\/$1>');
cloned.querySelectorAll('script[src]').forEach(el => el.remove());
const bundleScript = document.createElement('script');
bundleScript.textContent = `window.BANGUMI_VAULT_OFFLINE_STATE=${payload};\n(function(){\n${bundleBody}\n})();`;
(cloned.querySelector('body') || cloned).appendChild(bundleScript);
return '<!doctype html>\n' + cloned.outerHTML;
}
async function saveOrDownload(name, blob){
downloadBlob(name, blob);
toast('已触发浏览器下载');
}
function downloadBlob(name, blob){ const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=name; document.body.appendChild(a); a.click(); setTimeout(()=>{URL.revokeObjectURL(a.href); a.remove();},1000); }
function xmlEsc(s){
return String(s ?? '')
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]/g, '')
.replace(/[<>&"']/g, m => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&apos;'}[m]));
}
function exportDateParts(dateText){
const raw = String(dateText || '').trim();
const m = raw.match(/^(\d{4})(?:[-/.](\d{1,2}))?/);
const year = m ? m[1] : '';
const monthNum = m && m[2] ? Math.max(1, Math.min(12, Number(m[2]))) : '';
const month = monthNum ? String(monthNum).padStart(2,'0') : '';
const quarter = monthNum ? ('Q' + Math.ceil(monthNum / 3)) : '';
return {year, month, quarter, ym: year && month ? `${year}-${month}` : (year || '')};
}
function scoreNum(v){ const n = Number(v); return Number.isFinite(n) && n > 0 ? n : ''; }
function scoreBand(v){
const n = Number(v);
if(!Number.isFinite(n) || n <= 0) return '未评分';
if(n >= 9) return '9-10 神作/最爱';
if(n >= 8) return '8-8.9 很喜欢';
if(n >= 7) return '7-7.9 推荐';
if(n >= 6) return '6-6.9 还行';
return '1-5.9 一般/以下';
}
function progressInfo(c){
const watched = Number(c.ep_status || 0) || 0;
const total = Number(c.eps || c.raw?.subject?.eps || c.raw?.subject?.total_episodes || 0) || 0;
const pct = total > 0 ? Math.max(0, Math.min(100, Math.round(watched / total * 100))) : '';
let band = '未知';
if(total > 0){
if(watched <= 0) band = '未开始';
else if(watched >= total) band = '已完成';
else if(pct >= 75) band = '后段';
else if(pct >= 40) band = '中段';
else band = '前段';
} else if(watched > 0) band = '有进度';
return {watched, total, pct, band};
}
function commentLen(s){ return String(s || '').replace(/\s+/g,'').length; }
function exportRows(){
return getAllCollections().map(c=>({
subject_id:c.subject_id,
分类:SUBJECT_LABEL[c.subject_type]||c.subject_type,
状态:STATUS[c.collection_type]||c.collection_type,
标题:c.title||'',
原名:c.name||'',
中文名:c.name_cn||'',
日期:c.date||'',
我的评分:c.rate||'',
Bangumi均分:formatScoreValue(c.bgm_score || c.raw?.subject?.score || c.raw?.subject?.rating?.score) || '',
进度章节:c.ep_status||'',
进度卷:c.vol_status||'',
我的标签:myTagsOf(c).join(' / '),
公共标签:publicTagNames(c).join(' / '),
全部标签:allTagNames(c).join(' / '),
吐槽观后感:c.comment||'',
是否私密:c.private?'是':'否',
条目最后更新时间:c.bangumi_updated_at||'',
本地同步时间:c.last_synced_at || c.last_seen_at || '',
本地状态:c.status||'active',
首次备份:c.first_seen_at||'',
最后确认存在:c.last_seen_at||''
}));
}
function exportRichRows(){
return getAllCollections().map((c, idx)=>{
const d = exportDateParts(c.date);
const tags = allTagNames(c); const myTags = myTagsOf(c); const publicTags = publicTagNames(c);
const p = progressInfo(c);
const bgmScore = scoreNum(c.bgm_score || c.raw?.subject?.score || c.raw?.subject?.rating?.score);
const myScore = scoreNum(c.rate);
const hasComment = String(c.comment || '').trim() ? '是' : '否';
return {
序号: idx + 1,
条目ID: c.subject_id,
条目链接: `${bgmSiteBase()}/subject/${c.subject_id}`,
分类: SUBJECT_LABEL[c.subject_type]||c.subject_type,
状态: STATUS[c.collection_type]||c.collection_type,
分类状态: `${SUBJECT_LABEL[c.subject_type]||c.subject_type} / ${STATUS[c.collection_type]||c.collection_type}`,
年份: d.year,
月份: d.month,
季度: d.quarter,
年月: d.ym,
上映日期: c.date || '',
标题: c.title || '',
中文名: c.name_cn || '',
原名: c.name || '',
我的评分: myScore,
我的评分档: scoreBand(myScore),
Bangumi均分: bgmScore,
Bangumi评分档: scoreBand(bgmScore),
已看集数: p.watched || '',
总集数: p.total || '',
进度百分比: p.pct === '' ? '' : p.pct,
进度档: p.band,
进度卷: c.vol_status || '',
有吐槽: hasComment,
吐槽字数: commentLen(c.comment),
主标签: tags[0] || '',
标签数量: tags.length,
标签1: tags[0] || '', 标签2: tags[1] || '', 标签3: tags[2] || '', 标签4: tags[3] || '', 标签5: tags[4] || '',
全部标签: tags.join(' / '),
我的标签: myTags.join(' / '),
公共标签: publicTags.join(' / '),
吐槽观后感: c.comment || '',
是否私密: c.private ? '是' : '否',
本地状态: c.status || 'active',
是否安全箱: c.status === 'missing_candidate' ? '是' : '否',
条目最后更新时间: c.bangumi_updated_at || '',
本地同步时间: c.last_synced_at || c.last_seen_at || '',
首次备份: c.first_seen_at || '',
最后确认存在: c.last_seen_at || '',
封面文件: c.cover_local_file || '',
排序用更新时间: c.bangumi_updated_at || c.last_seen_at || c.first_seen_at || ''
};
});
}
function groupCount(rows, keyFn){
const m = new Map();
for(const r of rows){ const k = keyFn(r) || '未填写'; m.set(k, (m.get(k)||0)+1); }
return [...m.entries()].sort((a,b)=>b[1]-a[1] || String(a[0]).localeCompare(String(b[0])));
}
function mean(nums){ const arr = nums.map(Number).filter(n=>Number.isFinite(n) && n>0); return arr.length ? Math.round(arr.reduce((a,b)=>a+b,0)/arr.length*10)/10 : ''; }
function colName(n){
n = Number(n) || 1;
let name = '';
while(n > 0){
const rem = (n - 1) % 26;
name = String.fromCharCode(65 + rem) + name;
n = Math.floor((n - 1) / 26);
}
return name || 'A';
}
function xlsxRef(col,row){ return colName(col)+row; }
function xlsxCellXml(value, col, row, style=0, numeric=false){
const ref = xlsxRef(col,row);
const n = Number(value);
if(numeric && value !== '' && value != null && Number.isFinite(n)) return `<c r="${ref}"${style?` s="${style}"`:''}><v>${n}</v></c>`;
return `<c r="${ref}" t="inlineStr"${style?` s="${style}"`:''}><is><t xml:space="preserve">${xmlEsc(value)}</t></is></c>`;
}
function buildSheetXml(sheet){
const rows = sheet.rows || [];
const numericCols = new Set(sheet.numericCols || []);
const wrapCols = new Set(sheet.wrapCols || []);
const widths = sheet.widths || [];
const colsXml = widths.length ? `<cols>${widths.map((w,i)=>`<col min="${i+1}" max="${i+1}" width="${w}" customWidth="1"/>`).join('')}</cols>` : '';
const data = rows.map((row, ri)=>{
const r = ri + 1;
const styleRow = row.__style || 0;
const cells = row.map((v, ci)=>{
let style = styleRow;
if(r === sheet.headerRow) style = 1;
if(wrapCols.has(ci)) style = r === sheet.headerRow ? 1 : 3;
if(row.__styles && row.__styles[ci] != null) style = row.__styles[ci];
return xlsxCellXml(v, ci+1, r, style, numericCols.has(ci));
}).join('');
const ht = row.__height ? ` ht="${row.__height}" customHeight="1"` : '';
return `<row r="${r}"${ht}>${cells}</row>`;
}).join('');
const lastCol = Math.max(1, ...rows.map(r=>r.length));
const lastRow = Math.max(1, rows.length);
const freeze = sheet.freeze ? `<sheetViews><sheetView workbookViewId="0"><pane ySplit="${sheet.freeze}" topLeftCell="A${sheet.freeze+1}" activePane="bottomLeft" state="frozen"/><selection pane="bottomLeft"/></sheetView></sheetViews>` : '<sheetViews><sheetView workbookViewId="0"/></sheetViews>';
const autoFilter = sheet.autoFilterRow ? `<autoFilter ref="A${sheet.autoFilterRow}:${colName(lastCol)}${lastRow}"/>` : '';
const mergeCells = sheet.merges?.length ? `<mergeCells count="${sheet.merges.length}">${sheet.merges.map(m=>`<mergeCell ref="${m}"/>`).join('')}</mergeCells>` : '';
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><dimension ref="A1:${colName(lastCol)}${lastRow}"/>${freeze}<sheetFormatPr defaultRowHeight="18"/>${colsXml}<sheetData>${data}</sheetData>${autoFilter}${mergeCells}</worksheet>`;
}
function xlsxStylesXml(){
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="5"><font><sz val="10"/><name val="Microsoft YaHei"/></font><font><b/><color rgb="FFFFFFFF"/><sz val="10"/><name val="Microsoft YaHei"/></font><font><b/><color rgb="FFFFFFFF"/><sz val="16"/><name val="Microsoft YaHei"/></font><font><color rgb="FF6B637D"/><sz val="10"/><name val="Microsoft YaHei"/></font><font><b/><color rgb="FF242033"/><sz val="11"/><name val="Microsoft YaHei"/></font></fonts>
<fills count="7"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FF7C5CFF"/><bgColor indexed="64"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FF2B2438"/><bgColor indexed="64"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFEFE9FF"/><bgColor indexed="64"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFFFF2CC"/><bgColor indexed="64"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFE2F0D9"/><bgColor indexed="64"/></patternFill></fill></fills>
<borders count="2"><border><left/><right/><top/><bottom/><diagonal/></border><border><left style="thin"><color rgb="FFD9D2EA"/></left><right style="thin"><color rgb="FFD9D2EA"/></right><top style="thin"><color rgb="FFD9D2EA"/></top><bottom style="thin"><color rgb="FFD9D2EA"/></bottom><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="8"><xf numFmtId="0" fontId="0" fillId="0" borderId="1" xfId="0"/><xf numFmtId="0" fontId="1" fillId="2" borderId="1" xfId="0" applyFill="1" applyFont="1" applyAlignment="1"><alignment horizontal="center" vertical="center" wrapText="1"/></xf><xf numFmtId="0" fontId="2" fillId="3" borderId="1" xfId="0" applyFill="1" applyFont="1" applyAlignment="1"><alignment vertical="center"/></xf><xf numFmtId="0" fontId="0" fillId="0" borderId="1" xfId="0" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf><xf numFmtId="0" fontId="3" fillId="0" borderId="1" xfId="0"/><xf numFmtId="0" fontId="4" fillId="4" borderId="1" xfId="0" applyFill="1"/><xf numFmtId="0" fontId="4" fillId="5" borderId="1" xfId="0" applyFill="1"/><xf numFmtId="0" fontId="4" fillId="6" borderId="1" xfId="0" applyFill="1"/></cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
</styleSheet>`;
}
function buildXlsxBlob(){
const rich = exportRichRows();
const headers = ['序号','条目ID','条目链接','分类','状态','分类状态','年份','月份','季度','年月','上映日期','标题','中文名','原名','我的评分','我的评分档','Bangumi均分','Bangumi评分档','已看集数','总集数','进度百分比','进度档','进度卷','有吐槽','吐槽字数','主标签','标签数量','标签1','标签2','标签3','标签4','标签5','全部标签','我的标签','公共标签','吐槽观后感','是否私密','本地状态','是否安全箱','条目最后更新时间','首次备份','最后确认存在','封面文件','排序用更新时间'];
const detailRows = [headers, ...rich.map(r=>headers.map(h=>r[h]))];
const statusRows = [['分类','状态','数量','有吐槽','有评分','平均我的评分','平均Bangumi均分','安全箱数量']];
const cats = [...new Set(rich.map(r=>r.分类))].sort(); const sts = [...new Set(rich.map(r=>r.状态))].sort();
for(const cat of cats) for(const st of sts){ const arr=rich.filter(r=>r.分类===cat && r.状态===st); if(!arr.length) continue; statusRows.push([cat,st,arr.length,arr.filter(r=>r.有吐槽==='是').length,arr.filter(r=>r.我的评分).length,mean(arr.map(r=>r.我的评分)),mean(arr.map(r=>r.Bangumi均分)),arr.filter(r=>r.是否安全箱==='是').length]); }
const tagMap = new Map();
for(const r of rich){ String(r.全部标签||'').split(' / ').filter(Boolean).forEach(t=>{ const o=tagMap.get(t)||{tag:t,count:0,cats:new Set(),sts:new Set(),scores:[],bgm:[],examples:[]}; o.count++; o.cats.add(r.分类); o.sts.add(r.状态); if(r.我的评分) o.scores.push(r.我的评分); if(r.Bangumi均分) o.bgm.push(r.Bangumi均分); if(o.examples.length<8) o.examples.push(r.标题); tagMap.set(t,o); }); }
const tagRows = [['标签','数量','分类范围','状态范围','平均我的评分','平均Bangumi均分','示例条目']];
[...tagMap.values()].sort((a,b)=>b.count-a.count || a.tag.localeCompare(b.tag)).forEach(o=>tagRows.push([o.tag,o.count,[...o.cats].join(' / '),[...o.sts].join(' / '),mean(o.scores),mean(o.bgm),o.examples.join(' / ')]));
const commentRows = [['标题','分类','状态','我的评分','Bangumi均分','年份','标签','吐槽字数','吐槽/观后感','条目链接']];
rich.filter(r=>r.有吐槽==='是').sort((a,b)=>String(b.条目最后更新时间).localeCompare(String(a.条目最后更新时间))).forEach(r=>commentRows.push([r.标题,r.分类,r.状态,r.我的评分,r.Bangumi均分,r.年份,r.全部标签,r.吐槽字数,r.吐槽观后感,r.条目链接]));
const safetyRows = [['条目ID','标题','分类','状态','最后确认存在','条目最后更新时间','本地状态','吐槽/观后感','条目链接'], ...rich.filter(r=>r.是否安全箱==='是'||r.本地状态==='missing_candidate').map(r=>[r.条目ID,r.标题,r.分类,r.状态,r.最后确认存在,r.条目最后更新时间,r.本地状态,r.吐槽观后感,r.条目链接])];
const summaryRows = [
Object.assign(['Bangumi 保管库表格备份', '', '', '', '', '', ''], {__style:2, __height:28}),
['导出时间', nowISO(), '账号', state.settings.username||'', '总条目', rich.length, ''],
['活跃条目', rich.filter(r=>r.本地状态!=='confirmed_deleted'&&r.本地状态!=='missing_candidate').length, '安全箱', rich.filter(r=>r.是否安全箱==='是').length, '有吐槽', rich.filter(r=>r.有吐槽==='是').length, ''],
['有评分', rich.filter(r=>r.我的评分).length, '标签数', tagMap.size, '平均我的评分', mean(rich.map(r=>r.我的评分)), ''],
[],
['快速筛选建议','在“收藏明细”表使用筛选:分类/状态/年份/月/评分档/进度档/有吐槽/主标签/安全箱。吐槽长文请看“吐槽索引”。','','','','',''],
[],
['状态统计','','','','','',''],
...statusRows,
[],
['常用评分档','','','','','',''],
['评分档','数量','','','','',''],
...groupCount(rich, r=>r.我的评分档).map(([k,v])=>[k,v,'','','','',''])
];
const sheets = [
{name:'筛选面板', rows:summaryRows, widths:[22,14,14,14,14,14,18], merges:['A1:G1'], numericCols:[1,3,5], wrapCols:[0], freeze:0},
{name:'收藏明细', rows:detailRows, widths:[7,10,28,10,10,18,8,8,8,10,12,30,30,30,10,18,12,18,10,10,12,12,10,9,10,16,10,12,12,12,12,12,30,48,10,13,12,22,22,22,26,22], numericCols:[0,1,14,16,18,19,20,24,26], wrapCols:[11,12,13,32,33], freeze:1, autoFilterRow:1},
{name:'状态统计', rows:statusRows, widths:[12,12,10,10,10,14,16,12], numericCols:[2,3,4,5,6,7], freeze:1, autoFilterRow:1},
{name:'标签索引', rows:tagRows, widths:[22,10,24,24,14,16,60], numericCols:[1,4,5], wrapCols:[2,3,6], freeze:1, autoFilterRow:1},
{name:'吐槽索引', rows:commentRows, widths:[30,10,10,10,12,8,26,10,70,28], numericCols:[3,4,5,7], wrapCols:[6,8], freeze:1, autoFilterRow:1},
{name:'安全箱', rows:safetyRows, widths:[10,30,10,10,22,22,14,60,28], numericCols:[0], wrapCols:[1,7], freeze:1, autoFilterRow:1}
];
const sheetXmls = sheets.map(buildSheetXml);
const workbook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>${sheets.map((s,i)=>`<sheet name="${xmlEsc(s.name)}" sheetId="${i+1}" r:id="rId${i+1}"/>`).join('')}</sheets></workbook>`;
const files = {
'[Content_Types].xml':`<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${sheets.map((_,i)=>`<Override PartName="/xl/worksheets/sheet${i+1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join('')}</Types>`,
'_rels/.rels':'<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>',
'xl/workbook.xml':workbook,
'xl/styles.xml':xlsxStylesXml(),
'xl/_rels/workbook.xml.rels':`<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${sheets.map((_,i)=>`<Relationship Id="rId${i+1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i+1}.xml"/>`).join('')}<Relationship Id="rId${sheets.length+1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`
};
sheetXmls.forEach((xml,i)=>files[`xl/worksheets/sheet${i+1}.xml`]=xml);
return makeZip(files);
}
function wTextRuns(text){
const parts = String(text ?? '').split(/\r?\n/);
return parts.map((p,i)=>`${i?'<w:br/>':''}<w:t xml:space="preserve">${xmlEsc(p)}</w:t>`).join('');
}
function wP(text, opts={}){
const rPr = `${opts.bold?'<w:b/>':''}${opts.color?`<w:color w:val="${opts.color}"/>`:''}${opts.size?`<w:sz w:val="${opts.size}"/>`:''}`;
const pPr = `<w:pPr>${opts.heading?`<w:keepNext/>`:''}<w:spacing w:before="${opts.before||0}" w:after="${opts.after||120}" w:line="276" w:lineRule="auto"/>${opts.indent?`<w:ind w:left="${opts.indent}"/>`:''}</w:pPr>`;
return `<w:p>${pPr}<w:r>${rPr?`<w:rPr>${rPr}</w:rPr>`:''}${wTextRuns(text)}</w:r></w:p>`;
}
function wCell(content, width=2400, fill=''){
const shd = fill ? `<w:shd w:fill="${fill}"/>` : '';
return `<w:tc><w:tcPr><w:tcW w:w="${width}" w:type="dxa"/>${shd}<w:tcMar><w:top w:w="80" w:type="dxa"/><w:left w:w="100" w:type="dxa"/><w:bottom w:w="80" w:type="dxa"/><w:right w:w="100" w:type="dxa"/></w:tcMar></w:tcPr>${content}</w:tc>`;
}
function wTable(rows, widths=[]){
const trs = rows.map((row,ri)=>`<w:tr>${row.map((cell,i)=>{
const isRaw = typeof cell === 'string' && /^<w:(p|tbl)/.test(cell);
const content = isRaw ? cell : wP(cell,{after:40,bold:ri===0,color:ri===0?'FFFFFF':''});
return wCell(content, widths[i]||2400, ri===0?'7C5CFF':'');
}).join('')}</w:tr>`).join('');
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4" w:color="D9D2EA"/><w:left w:val="single" w:sz="4" w:color="D9D2EA"/><w:bottom w:val="single" w:sz="4" w:color="D9D2EA"/><w:right w:val="single" w:sz="4" w:color="D9D2EA"/><w:insideH w:val="single" w:sz="4" w:color="D9D2EA"/><w:insideV w:val="single" w:sz="4" w:color="D9D2EA"/></w:tblBorders></w:tblPr>${trs}</w:tbl>`;
}
function buildDocxBlob(){
const rich = exportRichRows();
const active = rich.filter(r=>r.本地状态!=='confirmed_deleted'&&r.本地状态!=='missing_candidate').length;
const tagCount = new Set(rich.flatMap(r=>String(r.全部标签||'').split(' / ').filter(Boolean))).size;
const paras = [];
paras.push(wP('Bangumi 保管库备份文档', {bold:true,size:44,color:'F09199',after:180}));
paras.push(wP(`账号:${state.settings.username||''} 导出时间:${nowISO()}`, {color:'6B637D',after:180}));
paras.push(wTable([
['总条目','活跃条目','安全箱','有吐槽','有评分','标签数'],
[String(rich.length),String(active),String(rich.filter(r=>r.是否安全箱==='是').length),String(rich.filter(r=>r.有吐槽==='是').length),String(rich.filter(r=>r.我的评分).length),String(tagCount)]
], [1500,1500,1500,1500,1500,1500]));
paras.push(wP('阅读说明', {bold:true,size:28,color:'7C5CFF',before:240,after:80}));
paras.push(wP('本文档按「分类 / 收藏状态」分组,每个条目保留标题、日期、评分、进度、标签和吐槽/观后感。更细的筛选、排序和标签索引请使用同时导出的 Bangumi保管库.xlsx。'));
const cats = [...new Set(rich.map(r=>r.分类))].sort();
for(const cat of cats){
paras.push(wP(cat, {bold:true,size:34,color:'F09199',before:360,after:120,heading:true}));
const sts = [...new Set(rich.filter(r=>r.分类===cat).map(r=>r.状态))].sort();
for(const st of sts){
const arr = rich.filter(r=>r.分类===cat && r.状态===st).sort((a,b)=>String(b.排序用更新时间).localeCompare(String(a.排序用更新时间)) || String(a.标题).localeCompare(String(b.标题)));
paras.push(wP(`${st}${arr.length}`, {bold:true,size:28,color:'46A3FF',before:220,after:80,heading:true}));
for(const r of arr){
const title = r.标题 || r.中文名 || r.原名 || `subject ${r.条目ID}`;
const meta = [`ID ${r.条目ID}`, r.上映日期, r.我的评分?`我的评分 ${r.我的评分}`:'未评分', r.Bangumi均分?`Bangumi ${r.Bangumi均分}`:'', r.进度百分比!==''?`进度 ${r.已看集数}/${r.总集数} · ${r.进度百分比}%`:'' ].filter(Boolean).join(' · ');
const tags = r.全部标签 ? `标签:${r.全部标签}` : '标签:无';
const comment = r.吐槽观后感 || '没有填写吐槽。';
paras.push(wTable([
[wP(title,{bold:true,size:24,color:'242033',after:40}) + wP(meta,{color:'6B637D',after:40}) + wP(tags,{color:'6B637D',after:60}) + wP('吐槽 / 观后感',{bold:true,color:'7C5CFF',after:40}) + wP(comment,{after:60})]
], [9000]));
}
}
}
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${paras.join('')}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="900" w:right="900" w:bottom="900" w:left="900"/></w:sectPr></w:body></w:document>`;
const files = {
'[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>',
'_rels/.rels':'<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>',
'word/document.xml':documentXml
};
return makeZip(files);
}
// v0.24: Optimized Word export. The older per-item table Word builder is extremely heavy
// on 1000+ collections and can freeze during full ZIP export. This compact builder keeps
// all entries, comments, tags and metadata, but uses paragraphs instead of one table per item.
function buildDocxBlob(){
const rich = exportRichRows();
const active = rich.filter(r=>r.本地状态!=='confirmed_deleted'&&r.本地状态!=='missing_candidate').length;
const safety = rich.filter(r=>r.是否安全箱==='是').length;
const tagCount = new Set(rich.flatMap(r=>String(r.全部标签||'').split(' / ').filter(Boolean))).size;
const withComment = rich.filter(r=>r.有吐槽==='是').length;
const withScore = rich.filter(r=>r.我的评分).length;
const paras = [];
paras.push(wP('Bangumi 保管库备份文档', {bold:true,size:44,color:'F09199',after:160}));
paras.push(wP(`账号:${state.settings.username||''} 导出时间:${nowISO()}`, {color:'6B637D',after:160}));
paras.push(wTable([
['总条目','活跃条目','安全箱','有吐槽','有评分','标签数'],
[String(rich.length),String(active),String(safety),String(withComment),String(withScore),String(tagCount)]
], [1500,1500,1500,1500,1500,1500]));
paras.push(wP('阅读说明', {bold:true,size:28,color:'7C5CFF',before:220,after:80}));
paras.push(wP('本文档使用轻量目录排版,适合大量条目导出。每个条目保留标题、日期、评分、进度、标签和吐槽/观后感。更细的筛选、排序、标签索引和吐槽索引请使用同时导出的 Bangumi保管库.xlsx。'));
const statusRows = [['分类','状态','数量','有吐槽','有评分','平均我的评分','平均Bangumi均分']];
const catsForStatus = [...new Set(rich.map(r=>r.分类))].sort();
for(const cat of catsForStatus){
const sts = [...new Set(rich.filter(r=>r.分类===cat).map(r=>r.状态))].sort();
for(const st of sts){
const arr = rich.filter(r=>r.分类===cat && r.状态===st);
statusRows.push([cat, st, String(arr.length), String(arr.filter(r=>r.有吐槽==='是').length), String(arr.filter(r=>r.我的评分).length), String(mean(arr.map(r=>r.我的评分))||''), String(mean(arr.map(r=>r.Bangumi均分))||'')]);
}
}
paras.push(wP('统计概览', {bold:true,size:28,color:'7C5CFF',before:240,after:80}));
paras.push(wTable(statusRows, [1300,1300,900,1000,1000,1500,1700]));
const cats = [...new Set(rich.map(r=>r.分类))].sort();
for(const cat of cats){
paras.push(wP(cat, {bold:true,size:34,color:'F09199',before:360,after:120,heading:true}));
const sts = [...new Set(rich.filter(r=>r.分类===cat).map(r=>r.状态))].sort();
for(const st of sts){
const arr = rich.filter(r=>r.分类===cat && r.状态===st).sort((a,b)=>String(b.排序用更新时间).localeCompare(String(a.排序用更新时间)) || String(a.标题).localeCompare(String(b.标题)));
paras.push(wP(`${st}${arr.length}`, {bold:true,size:28,color:'46A3FF',before:220,after:80,heading:true}));
for(const r of arr){
const title = r.标题 || r.中文名 || r.原名 || `subject ${r.条目ID}`;
const meta = [`ID ${r.条目ID}`, r.上映日期, r.我的评分?`我的评分 ${r.我的评分}`:'未评分', r.Bangumi均分?`Bangumi ${r.Bangumi均分}`:'', r.进度百分比!==''?`进度 ${r.已看集数}/${r.总集数} · ${r.进度百分比}%`:'' ].filter(Boolean).join(' · ');
const tags = r.全部标签 ? `标签:${r.全部标签}` : '标签:无';
const comment = r.吐槽观后感 || '没有填写吐槽。';
paras.push(wP(title, {bold:true,size:24,color:'242033',before:120,after:40,heading:true}));
paras.push(wP(meta, {color:'6B637D',after:40,indent:240}));
paras.push(wP(tags, {color:'6B637D',after:40,indent:240}));
paras.push(wP(`吐槽 / 观后感:${comment}`, {after:100,indent:240}));
}
}
}
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${paras.join('')}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="900" w:right="900" w:bottom="900" w:left="900"/></w:sectPr></w:body></w:document>`;
const files = {
'[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>',
'_rels/.rels':'<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>',
'word/document.xml':documentXml
};
return makeZip(files);
}
async function exportDocx(){
openModal('syncModal');
$('syncTitle').textContent='正在导出 Word 文档';
$('syncHelp').textContent='Word 已改成轻量目录版,适合大量条目稳定导出。';
$('syncLog').textContent=''; setProgress(0);
try{
log('syncLog','1/3 准备条目与统计...'); setProgress(18); await sleep(30);
log('syncLog','2/3 生成 Word 文档...'); setProgress(48); await sleep(30);
const docx = buildDocxBlob();
log('syncLog',` Word 已生成,约 ${(docx.size/1024/1024).toFixed(1)} MB。`); setProgress(78); await sleep(20);
log('syncLog','3/3 生成完成,触发浏览器下载...');
await saveOrDownload(`Bangumi保管库_Word_${safeName(state.settings.username)}_${dateStamp()}.docx`, docx);
setProgress(100); log('syncLog','完成。');
}catch(e){ console.error(e); log('syncLog','导出失败:'+(e?.message||e)); toast('Word 导出失败,请看日志'); }
}
async function exportXlsx(){
openModal('syncModal');
$('syncTitle').textContent='正在导出 Excel 表格';
$('syncHelp').textContent='Excel 包含筛选面板、收藏明细、状态统计、标签索引、吐槽索引和安全箱。生成大量长吐槽时需要等待几十秒,请不要重复点击。';
$('syncLog').textContent=''; setProgress(0);
try{
const rows = getAllCollections().length;
log('syncLog',`准备导出 ${rows} 条收藏。`); setProgress(8); await sleep(30);
log('syncLog','1/4 生成筛选辅助列、状态统计、标签索引、吐槽索引...'); setProgress(24); await sleep(30);
log('syncLog','2/4 写入 XLSX 工作表 XML...'); setProgress(48); await sleep(30);
const xlsx = buildXlsxBlob();
log('syncLog',` Excel 已生成,约 ${(xlsx.size/1024/1024).toFixed(1)} MB。`); setProgress(74); await sleep(30);
log('syncLog','3/4 生成表格文件并触发浏览器下载...');
await saveOrDownload(`Bangumi保管库_表格_${safeName(state.settings.username)}_${dateStamp()}.xlsx`, xlsx);
setProgress(100); log('syncLog','4/4 完成。如果浏览器没有弹出下载,请检查浏览器下载拦截或下载记录。');
toast('Excel 表格已生成');
}catch(e){ console.error(e); log('syncLog','导出失败:'+(e?.message||e)); toast('Excel 导出失败,请看日志'); }
}
async function exportJson(){ await loadFullHistory(); await saveOrDownload(`Bangumi保管库_${safeName(state.settings.username)}_${dateStamp()}.json`, new Blob([buildJson()], {type:'application/json;charset=utf-8'})); }
async function exportCsv(){ await saveOrDownload(`Bangumi保管库_${safeName(state.settings.username)}_${dateStamp()}.csv`, new Blob([buildCsv()], {type:'text/csv;charset=utf-8'})); }
async function exportHtml(){
await loadFullHistory();
openModal('syncModal'); $('syncTitle').textContent='正在导出单文件离线 HTML'; $('syncHelp').textContent='单文件 HTML 会把已缓存封面临时转成 Base64;条目很多时会很慢。完整 ZIP 推荐使用真实 images/ 文件夹。'; $('syncLog').textContent=''; setProgress(0);
log('syncLog','开始生成离线 HTML...');
const html=await buildOfflineHtml('inline',(i,total)=>{ if(i===1 || i%40===0 || i===total){ setProgress(8 + i/Math.max(total,1)*82); log('syncLog',`处理封面 ${i}/${total}`); } });
setProgress(94); log('syncLog','正在保存/触发下载...');
await saveOrDownload(`Bangumi保管库_离线海报墙_${safeName(state.settings.username)}_${dateStamp()}.html`, new Blob([html], {type:'text/html;charset=utf-8'}));
setProgress(100); log('syncLog','离线 HTML 已生成。如果浏览器没有弹出下载,请检查浏览器下载拦截或下载记录。');
}
async function exportZip(){
await loadFullHistory();
openModal('syncModal');
$('syncTitle').textContent='正在导出完整 ZIP';
$('syncHelp').textContent='完整 ZIP 使用 images/ 真实图片文件。v0.24 已把 Word 改成轻量目录版,并为单独 Excel/Word 导出加入进度日志,避免 1700+ 条目卡在 Word/Excel 生成阶段。请解压 ZIP 后打开 offline-poster-wall.html。';
$('syncLog').textContent=''; setProgress(0);
toast('正在生成 ZIP,进度见弹窗...');
const exportArr = getAllCollections().map(c=>structuredClone(c));
log('syncLog',`准备导出 ${exportArr.length} 条收藏。已缓存的封面会进入 images/ 目录。`);
await sleep(30);
log('syncLog','1/7 生成相对路径离线 HTML(不内嵌 Base64...');
const html=await buildOfflineHtml('relative',(i,total)=>{ if(i===1 || i%150===0 || i===total){ setProgress(4 + i/Math.max(total,1)*12); log('syncLog',` 写入条目 ${i}/${total}`); } });
await sleep(20);
log('syncLog','2/7 收集 images/ 封面文件...');
const imageFiles = await collectCachedImageFiles(exportArr,(i,total)=>{ if(i===1 || i%100===0 || i===total){ setProgress(16 + i/Math.max(total,1)*28); log('syncLog',` 收集封面 ${i}/${total}`); } });
const imageCount = Object.keys(imageFiles).length;
log('syncLog',` 已加入 ${imageCount} 个本地封面文件。`);
await sleep(20);
setProgress(46); log('syncLog','3/7 生成 JSON / CSV / 历史记录...');
const manifestJson = JSON.stringify(manifest(),null,2);
const backupJson = buildJson();
const csv = buildCsv();
const historyJson = JSON.stringify(state.history,null,2);
const runsJson = JSON.stringify(state.runs,null,2);
await sleep(20);
setProgress(52); log('syncLog','4/7 生成 Excel 表格(完整筛选版)...');
await sleep(30);
const xlsx = new Uint8Array(await buildXlsxBlob().arrayBuffer());
log('syncLog',` Excel 已生成,约 ${(xlsx.byteLength/1024/1024).toFixed(1)} MB。`);
await sleep(20);
setProgress(62); log('syncLog','5/7 生成 Word 文档(轻量目录版,避免卡死)...');
await sleep(30);
const docx = new Uint8Array(await buildDocxBlob().arrayBuffer());
log('syncLog',` Word 已生成,约 ${(docx.byteLength/1024/1024).toFixed(1)} MB。`);
await sleep(20);
setProgress(70); log('syncLog','6/7 打包 ZIP(大文件会分批处理)...');
const files={
'manifest.json': manifestJson,
'vault-backup.json': backupJson,
'collections.csv': csv,
'offline-poster-wall.html': html,
...imageFiles,
'history.json': historyJson,
'sync-runs.json': runsJson,
'Bangumi保管库.docx': docx,
'Bangumi保管库.xlsx': xlsx,
'README.txt': 'Bangumi 保管库完整备份包。\n\n使用方式:请先解压整个 ZIP,再双击 offline-poster-wall.html。不要直接在压缩包预览里打开,否则浏览器可能无法读取 images/ 里的海报。\n\noffline-poster-wall.html 使用 images/ 目录里的真实图片文件,不再把大量封面内嵌成 Base64,因此导出速度更快,也更不容易卡死。\n\nBangumi保管库.xlsx 是完整筛选表格;Bangumi保管库.docx 是轻量目录版 Word,适合大量条目时稳定导出。vault-backup.json 可在应用中导入恢复。'
};
const zip = await makeZipAsync(files,(done,total)=>{
if(done===1 || done%80===0 || done===total){
setProgress(70 + done/Math.max(total,1)*22);
log('syncLog',` 打包文件 ${done}/${total}`);
}
});
setProgress(94); log('syncLog',`7/7 触发浏览器下载,ZIP 大小约 ${(zip.size/1024/1024).toFixed(1)} MB...`);
await saveOrDownload(`Bangumi保管库完整备份_${safeName(state.settings.username)}_${dateStamp()}.zip`, zip);
setProgress(100);
log('syncLog', 'ZIP 已生成:已触发浏览器下载。请解压后打开 offline-poster-wall.html。');
toast('ZIP 已生成,请解压后打开离线 HTML');
}
function dateStamp(){ return new Date().toISOString().slice(0,19).replace(/[-:T]/g,''); }
async function makeZipAsync(files, onProgress){
const enc = new TextEncoder(); const chunks=[]; const central=[]; let offset=0;
const entries = Object.entries(files); let done = 0;
for(const [name,content] of entries){
const nameBytes=enc.encode(name);
const data = content instanceof Uint8Array ? content : enc.encode(String(content));
const crc=crc32(data);
const local=new Uint8Array(30+nameBytes.length);
const dv=new DataView(local.buffer);
dv.setUint32(0,0x04034b50,true); dv.setUint16(4,20,true); dv.setUint16(6,0,true); dv.setUint16(8,0,true);
dv.setUint32(14,crc,true); dv.setUint32(18,data.length,true); dv.setUint32(22,data.length,true); dv.setUint16(26,nameBytes.length,true);
local.set(nameBytes,30); chunks.push(local,data);
const cent=new Uint8Array(46+nameBytes.length); const cdv=new DataView(cent.buffer);
cdv.setUint32(0,0x02014b50,true); cdv.setUint16(4,20,true); cdv.setUint16(6,20,true);
cdv.setUint32(16,crc,true); cdv.setUint32(20,data.length,true); cdv.setUint32(24,data.length,true); cdv.setUint16(28,nameBytes.length,true); cdv.setUint32(42,offset,true);
cent.set(nameBytes,46); central.push(cent); offset += local.length + data.length;
done++; if(onProgress) onProgress(done, entries.length, name);
if(done % 20 === 0) await sleep(0);
}
const centralSize = central.reduce((n,c)=>n+c.length,0);
const end=new Uint8Array(22); const edv=new DataView(end.buffer);
edv.setUint32(0,0x06054b50,true); edv.setUint16(8,central.length,true); edv.setUint16(10,central.length,true); edv.setUint32(12,centralSize,true); edv.setUint32(16,offset,true);
return new Blob([...chunks,...central,end], {type:'application/zip'});
}
function makeZip(files){
const enc = new TextEncoder(); const chunks=[]; const central=[]; let offset=0;
for(const [name,content] of Object.entries(files)){
const nameBytes=enc.encode(name); const data = content instanceof Uint8Array ? content : enc.encode(String(content)); const crc=crc32(data); const local=new Uint8Array(30+nameBytes.length);
const dv=new DataView(local.buffer); dv.setUint32(0,0x04034b50,true); dv.setUint16(4,20,true); dv.setUint16(6,0,true); dv.setUint16(8,0,true); dv.setUint32(14,crc,true); dv.setUint32(18,data.length,true); dv.setUint32(22,data.length,true); dv.setUint16(26,nameBytes.length,true); local.set(nameBytes,30);
chunks.push(local,data); const cent=new Uint8Array(46+nameBytes.length); const cdv=new DataView(cent.buffer); cdv.setUint32(0,0x02014b50,true); cdv.setUint16(4,20,true); cdv.setUint16(6,20,true); cdv.setUint32(16,crc,true); cdv.setUint32(20,data.length,true); cdv.setUint32(24,data.length,true); cdv.setUint16(28,nameBytes.length,true); cdv.setUint32(42,offset,true); cent.set(nameBytes,46); central.push(cent); offset += local.length + data.length;
}
const centralSize = central.reduce((n,c)=>n+c.length,0); const end=new Uint8Array(22); const edv=new DataView(end.buffer); edv.setUint32(0,0x06054b50,true); edv.setUint16(8,central.length,true); edv.setUint16(10,central.length,true); edv.setUint32(12,centralSize,true); edv.setUint32(16,offset,true);
return new Blob([...chunks,...central,end], {type:'application/zip'});
}
const CRC_TABLE = (()=>{let c,tab=[];for(let n=0;n<256;n++){c=n;for(let k=0;k<8;k++) c=((c&1)?(0xedb88320^(c>>>1)):(c>>>1));tab[n]=c>>>0;}return tab;})();
function crc32(data){ let c=0xffffffff; for(let i=0;i<data.length;i++) c=CRC_TABLE[(c^data[i])&0xff]^(c>>>8); return (c^0xffffffff)>>>0; }
async function importBackup(file){
const text = await file.text(); const js = JSON.parse(text); const imported = js.state || js;
if(!imported.collections) throw new Error('不是有效的 Bangumi 保管库备份 JSON');
if(!confirm('导入会替换当前本地库。确定继续?')) return;
const keepToken = state.settings.token; state = mergeState(imported); state.settings.token = keepToken; await saveState(); render(); toast('导入完成');
}
+125
View File
@@ -0,0 +1,125 @@
'use strict';
function openModal(id){ const m=$(id); if(!m) return; m.classList.remove('closing'); m.classList.add('show'); }
function closeModal(id){ const m=$(id); if(!m || !m.classList.contains('show')) return; if(id==='settingsModal' && authReady && typeof restoreAppearance==='function') restoreAppearance(); m.classList.add('closing'); const ms=(state.settings.motion==='off')?0:200; setTimeout(()=>m.classList.remove('show','closing'), ms); }
function closeAllModals(){ closeDrawer(); document.querySelectorAll('.modal-mask.show').forEach(m=>closeModal(m.id)); }
function setupDropdown(rootId, hiddenId, labelId, onChange){
const root=$(rootId), hidden=$(hiddenId), label=$(labelId); if(!root || !hidden || !label) return;
const trigger=root.querySelector('.select-trigger'); const menu=root.querySelector('.select-menu');
function close(){ root.classList.remove('open'); }
function setValue(value, silent=false){
hidden.value = String(value); const opt=[...menu.querySelectorAll('button')].find(b=>String(b.dataset.value)===String(value)) || menu.querySelector('button');
if(opt) label.textContent = opt.textContent;
menu.querySelectorAll('button').forEach(b=>b.classList.toggle('active', String(b.dataset.value)===String(value)));
if(!silent) onChange?.(String(value));
}
trigger.onclick=(e)=>{ e.stopPropagation(); document.querySelectorAll('.selectish.open').forEach(x=>{ if(x!==root) x.classList.remove('open'); }); root.classList.toggle('open'); };
menu.querySelectorAll('button').forEach(btn=>btn.onclick=(e)=>{ e.stopPropagation(); setValue(btn.dataset.value); close(); });
document.addEventListener('click', close);
setValue(hidden.value || menu.querySelector('button')?.dataset.value || '', true);
return {setValue};
}
let typeDropdownCtl = null;
function syncCustomControls(){
typeDropdownCtl = setupDropdown('typeDropdown','typeFilter','typeDropdownLabel',(value)=>{ resetRenderLimit(); filters.type=Number(value); filters.tag='全部'; render(); });
setupDropdown('sortDropdown','sortSelect','sortDropdownLabel',(value)=>{ resetRenderLimit(); filters.sort=value; renderCards(); });
}
// Reset every filter (sort is ordering, not a filter, so it's kept). Also resets the related UI controls.
function clearFilters(){
filters.status=0; filters.type=0; filters.tag='全部'; filters.q=''; filters.nsfw='all'; filters.hasComment=false;
if($('searchInput')) $('searchInput').value='';
typeDropdownCtl?.setValue('0', true);
refreshNsfwBtn();
resetRenderLimit(); render();
}
function bindEvents(){
syncCustomControls();
bindSegmentedControls();
$('settingsBtn').onclick=()=>{
fillSettingsUI();
if(typeof snapshotAppearance==='function') snapshotAppearance();
if(!authReady && isPublicMode){
document.querySelectorAll('.settings-tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.settings-pane').forEach(p=>p.classList.remove('active'));
const at=document.querySelector('[data-tab="stab-appearance"]'), ap=$('stab-appearance');
if(at) at.classList.add('active');
if(ap) ap.classList.add('active');
}
openModal('settingsModal');
};
document.querySelectorAll('.settings-tab').forEach(tab=>{
tab.onclick=()=>{
document.querySelectorAll('.settings-tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.settings-pane').forEach(p=>p.classList.remove('active'));
tab.classList.add('active');
const pane=$( tab.dataset.tab); if(pane) pane.classList.add('active');
};
});
$('exportBtn').onclick=()=> openModal('exportModal');
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.dataset.close));
document.querySelectorAll('.modal-mask').forEach(m=>{ let downOnMask=false; m.addEventListener('pointerdown', e=>{ downOnMask=(e.target===m); }); m.addEventListener('click', e=>{ if(e.target===m && downOnMask){ closeModal(m.id); if(m.id==='syncModal') showSyncMini(); } }); });
$('syncMiniBar').onclick=()=>{ hideSyncMini(); openModal('syncModal'); };
$('openTokenBtn').onclick=()=> window.open(TOKEN_URL,'_blank','noopener'); $('testLoginBtn').onclick=testLogin;
if($('tokenInput')){ const emptyClip=e=>{ e.preventDefault(); try{ e.clipboardData&&e.clipboardData.setData('text/plain',''); }catch{} }; $('tokenInput').addEventListener('copy',emptyClip); $('tokenInput').addEventListener('cut',emptyClip); } $('saveSettingsBtn').onclick=saveSettingsFromUI; $('changePasswordBtn').onclick=changeAdminPassword; $('logoutBtn').onclick=logout;
$('syncBtn').onclick=()=>openSyncConfirm('recent'); $('fullSyncBtn').onclick=()=>openSyncConfirm('full'); $('syncConfirmStartBtn').onclick=confirmStartSync; $('cancelSyncBtn').onclick=()=>{ closeModal('syncModal'); showSyncMini(); };
// Appearance number inputs apply live (preview). Admin: preview only, persisted via 保存设置, reverted on close-without-save
// (these keys are in APPEARANCE_KEYS). Guest: no save button, so persist to guestSettings now.
if($('commentLinesInput')) $('commentLinesInput').addEventListener('input',()=>{ state.settings.commentLines=clampNum($('commentLinesInput').value,0,6,0); applyAppearance(false); if(!authReady) saveSettings(); });
if($('borderWidthInput')) $('borderWidthInput').addEventListener('input',()=>{ state.settings.borderWidth=clampNum($('borderWidthInput').value,0,4,1); applyAppearance(false); if(!authReady) saveSettings(); });
if($('pageSizeInput')) $('pageSizeInput').addEventListener('change',()=>{ state.settings.pageSize=clampNum($('pageSizeInput').value,40,360,120); resetRenderLimit(); renderCards(); if(!authReady) saveSettings(); });
$('searchInput').oninput=e=>{ resetRenderLimit(); filters.q=e.target.value; renderCards(); };
if($('tagFilterInput')) $('tagFilterInput').oninput=()=>renderTags();
// Mobile: off-canvas sidebar drawer
const openSide=(v)=>{ document.querySelector('.side')?.classList.toggle('open',v); $('sideBackdrop')?.classList.toggle('open',v); $('menuBtn')?.classList.toggle('open',v); };
if($('menuBtn')) $('menuBtn').onclick=()=>openSide(true);
if($('sideBackdrop')) $('sideBackdrop').onclick=()=>openSide(false);
window.addEventListener('keydown',e=>{ if(e.key==='Escape') openSide(false); });
// Filter-summary pill doubles as a one-click "clear all filters" button (shows ✕ when active — see CSS)
if($('filterSummary')) $('filterSummary').onclick=clearFilters;
// "有吐槽" stat: toggle a filter that shows only items with a comment
const toggleHasComment=()=>{ filters.hasComment=!filters.hasComment; resetRenderLimit(); render(); openSide(false); };
if($('statCommentsCard')){ $('statCommentsCard').onclick=toggleHasComment; $('statCommentsCard').onkeydown=e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); toggleHasComment(); } }; }
$('statusNav')?.addEventListener('click',e=>{ if(e.target.closest('button')) openSide(false); });
$('tagList')?.addEventListener('click',e=>{ if(e.target.closest('button')) openSide(false); });
// Drawer "操作" buttons delegate to the real (hidden-on-mobile) toolbar buttons
[['mSettings','settingsBtn'],['mSyncRecent','syncBtn'],['mFullSync','fullSyncBtn'],['mExport','exportBtn'],['mImport','importBtn'],['mCacheCovers','cacheCoversBtn'],['mEnrichTags','enrichTagsBtn']].forEach(([m,real])=>{ const b=$(m); if(b) b.onclick=()=>{ $(real)?.click(); openSide(false); }; });
if($('tagListMore')) $('tagListMore').onclick=()=>{ const tl=$('tagList'); const exp=tl.classList.toggle('tags-expanded'); $('tagListMore').textContent=exp?'收起':'展开更多'; };
if($('tagSearchToggle')) $('tagSearchToggle').onclick=()=>{ const f=$('tagFilter'); const inp=$('tagFilterInput'); const willOpen=!f.classList.contains('open'); $('tagSearchToggle').classList.toggle('active',willOpen); if(willOpen){ f.classList.add('open'); const ms=document.documentElement.dataset.motion==='off'?0:300; setTimeout(()=>{ if(f.classList.contains('open')){ f.style.overflow='visible'; inp?.focus(); } }, ms); } else { f.style.overflow=''; f.classList.remove('open'); if(inp){ inp.value=''; renderTags(); } } };
$('typeFilter').onchange=e=>{ resetRenderLimit(); filters.type=Number(e.target.value); filters.tag='全部'; render(); };
$('sortSelect').onchange=e=>{ resetRenderLimit(); filters.sort=e.target.value; renderCards(); };
document.querySelectorAll('.theme-toggle').forEach(b=>b.onclick=(e)=>toggleTheme(e));
$('cacheCoversBtn').onclick=cacheCoversCurrent; if($('enrichTagsBtn')) $('enrichTagsBtn').onclick=enrichPublicTagsCurrent;
$('nsfwBtn').onclick=()=>{ const next={all:'hide',hide:'only',only:'all'}; filters.nsfw=next[filters.nsfw]||'all'; resetRenderLimit(); refreshNsfwBtn(); render(); };
bindCronEditor();
const plsb=$('publicLoginSubmitBtn');
if(plsb) plsb.onclick=async()=>{ const pw=$('publicLoginPasswordInput')?.value||''; const st=$('publicLoginStatus'); if(st) st.textContent='验证中…'; try{ await apiJSON('/api/login',{method:'POST',body:JSON.stringify({password:pw})}); location.reload(); }catch(err){ if(st) st.textContent='密码不正确,请重试'; } };
$('exportJsonBtn').onclick=exportJson; $('exportCsvBtn').onclick=exportCsv; $('exportHtmlBtn').onclick=exportHtml; $('exportDocxBtn').onclick=exportDocx; $('exportXlsxBtn').onclick=exportXlsx; $('exportZipBtn').onclick=exportZip;
$('importBtn').onclick=()=> $('importFile').click(); $('importFile').onchange=e=>{ const f=e.target.files[0]; if(f) importBackup(f).catch(err=>toast('导入失败:'+err.message)); e.target.value=''; };
window.addEventListener('keydown',e=>{ if(e.key==='Escape') closeAllModals(); });
}
function hideLoadingScreen(){ const el=document.getElementById('loadingScreen'); if(el) el.classList.add('done'); }
async function init(){
try{
// Offline single-file export: no server — render the embedded state directly.
if(window.BANGUMI_VAULT_OFFLINE_STATE){
await loadState();
bindEvents();
refreshSegmentedControls();
render();
applyAuthVisibility();
return;
}
if(!(await ensureSession())){ hideLoadingScreen(); return; }
await loadState();
bindEvents();
refreshSegmentedControls();
window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener?.('change',()=>{ if((state.settings.themeMode||'dark')==='system') applyAppearance(true); });
render();
applyAuthVisibility();
// Scheduled sync now runs in the container (server cron). If a sync is already
// running server-side when the page opens, attach the progress UI to it.
resumeSyncIfRunning();
} finally { hideLoadingScreen(); }
}
init();
+170
View File
@@ -0,0 +1,170 @@
'use strict';
function uniqStrings(arr){
const seen = new Set(); const out = [];
for(const v of arr || []){
const name = String(v ?? '').trim();
if(!name || seen.has(name)) continue;
seen.add(name); out.push(name);
}
return out;
}
function normalizeTags(tags){
if(!tags) return [];
if(Array.isArray(tags)) return uniqStrings(tags.map(t => typeof t === 'string' ? t : (t?.name || t?.label || t?.tag || '')));
if(typeof tags === 'string') return uniqStrings(tags.split(/[,\s]+/));
return [];
}
function normalizePublicTags(tags){
if(!tags) return [];
const list = Array.isArray(tags) ? tags : (typeof tags === 'string' ? tags.split(/[,\s]+/) : []);
const out = []; const seen = new Set();
for(const t of list){
const name = String(typeof t === 'string' ? t : (t?.name || t?.label || t?.tag || '')).trim();
if(!name || seen.has(name)) continue;
seen.add(name);
const count = typeof t === 'object' && t ? Number(t.count ?? t.total_cont ?? t.total ?? t.num ?? 0) || 0 : 0;
out.push(count ? {name, count} : {name});
}
return out;
}
function myTagsOf(c){ return normalizeTags(c?.myTags || c?.my_tags || c?.tags || []); }
function publicTagsOf(c){ return normalizePublicTags(c?.publicTags || c?.public_tags || c?.subjectTags || c?.raw?.subject?.tags || []); }
function publicTagNames(c){ return publicTagsOf(c).map(t=>t.name).filter(Boolean); }
function allTagNames(c){ return uniqStrings([...myTagsOf(c), ...publicTagNames(c)]); }
function tagKey(scope, name){ return scope + '::' + String(name || ''); }
function parseTagKey(key){
if(key === '全部' || key === '无标签') return {scope:'special', name:key};
const idx = String(key || '').indexOf('::');
if(idx > 0) return {scope:String(key).slice(0, idx), name:String(key).slice(idx + 2)};
return {scope:'all', name:String(key || '')};
}
function tagLabelFromKey(key){
const t = parseTagKey(key);
if(t.scope === 'my') return '我的:' + t.name;
if(t.scope === 'public') return '公共:' + t.name;
return t.name || '全部';
}
function bgmTagUrl(subjectType, name){
const path = BGM_TYPE_PATH[Number(subjectType)] || 'subject_search';
const q = encodeURIComponent(name || '');
const base = bgmSiteBase();
return path === 'subject_search' ? `${base}/subject_search/${q}?cat=all` : `${base}/${path}/tag/${q}`;
}
function publicTagsForDisplay(c){ return publicTagsOf(c); }
function shortValue(v, max=80){
const t = Array.isArray(v) ? v.join(' / ') : String(v ?? '');
return t.length > max ? t.slice(0, max) + '…' : t;
}
function valuesSame(a,b){ return stableStringify(a ?? '') === stableStringify(b ?? ''); }
function collectionFieldDiff(oldC, newC){
const fields = [
['collection_type','收藏状态', v => STATUS[v] || v || '-'],
['rate','我的评分', v => v || '未评分'],
['ep_status','章节进度', v => v || '-'],
['vol_status','卷进度', v => v || '-'],
['comment','吐槽/观后感', v => shortValue(v || '无吐槽', 120)],
['private','私密状态', v => v ? '私密' : '公开'],
['tags','我的标签', v => shortValue(normalizeTags(v), 120)],
['publicTags','公共标签', v => shortValue(normalizePublicTags(v).map(x=>x.count ? `${x.name}(${x.count})` : x.name), 120)],
['bangumi_updated_at','API 更新时间', v => formatDate(v) || '-'],
['title','标题', v => v || '-'],
['date','上映日期', v => v || '-'],
['bgm_score','Bangumi 均分', v => formatScoreValue(v) || '-'],
['rank','排名', v => v || '-']
];
const changes = [];
for(const [key, label, fmt] of fields){
const oldVal = key === 'tags' ? myTagsOf(oldC) : (key === 'publicTags' ? publicTagsOf(oldC) : oldC?.[key]);
const newVal = key === 'tags' ? myTagsOf(newC) : (key === 'publicTags' ? publicTagsOf(newC) : newC?.[key]);
if(!valuesSame(oldVal, newVal)) changes.push({field:key, label, from:fmt(oldVal), to:fmt(newVal), old:oldVal ?? '', new:newVal ?? ''});
}
return changes;
}
function historyChangeHtml(h){
const changes = Array.isArray(h.changes) ? h.changes : [];
if(!changes.length) return esc(h.note || '');
return changes.slice(0,6).map(x => `<div class="hist-change"><span>${esc(x.label || x.field)}</span>${esc(x.from)}${esc(x.to)}</div>`).join('') + (changes.length > 6 ? `<div class="hist-change">还有 ${changes.length - 6} 项变化…</div>` : '');
}
function normalizeCollection(item, fallbackType){
const s = item.subject || {};
const imgs = s.images || {};
const st = item.subject_type || s.type || fallbackType?.subject_type || 0;
const ct = item.type || fallbackType?.collection_type || 0;
const title = s.name_cn || s.name || `Subject ${item.subject_id || s.id}`;
const raw = item;
return {
subject_id: Number(item.subject_id || s.id), subject_type:Number(st), collection_type:Number(ct),
title, name:s.name||'', name_cn:s.name_cn||'', date:s.date||'', eps:s.eps ?? '', volumes:s.volumes ?? '',
bgm_score: s.score ?? s.rating?.score ?? '', rank: s.rank ?? '',
summary:s.summary||s.short_summary||'', image: imgs.large || imgs.common || imgs.medium || imgs.grid || imgs.small || '', images:imgs,
rate:item.rate ?? 0, comment:item.comment || '', tags:normalizeTags(item.tags), myTags:normalizeTags(item.tags), publicTags:normalizePublicTags(s.tags || item.publicTags || item.public_tags), ep_status:item.ep_status ?? '', vol_status:item.vol_status ?? '',
private:!!item.private,
collection_created_at:item.created_at || item.createdAt || item.collect_created_at || '',
bangumi_updated_at:item.updated_at || item.updatedAt || '', raw
};
}
function stableStringify(obj){
if(obj === null || typeof obj !== 'object') return JSON.stringify(obj);
if(Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
return '{' + Object.keys(obj).sort().map(k => JSON.stringify(k)+':'+stableStringify(obj[k])).join(',') + '}';
}
function hashString(str){ let h=2166136261; for(let i=0;i<str.length;i++){ h ^= str.charCodeAt(i); h = Math.imul(h,16777619); } return (h>>>0).toString(16); }
function comparable(n){ return {subject_id:n.subject_id, subject_type:n.subject_type, collection_type:n.collection_type, title:n.title, name:n.name, name_cn:n.name_cn, date:n.date, eps:n.eps, volumes:n.volumes, bgm_score:n.bgm_score, rank:n.rank, summary:n.summary, image:n.image, rate:n.rate, comment:n.comment, tags:myTagsOf(n), publicTags:publicTagsOf(n), ep_status:n.ep_status, vol_status:n.vol_status, private:n.private, collection_created_at:n.collection_created_at, bangumi_updated_at:n.bangumi_updated_at}; }
function makeSnapshot(n){ return {...n, content_hash:hashString(stableStringify(comparable(n)))}; }
function getAllCollections(){ return Object.values(state.collections || {}); }
function visibleCollections(){
const q = filters.q.trim().toLowerCase();
let arr = getAllCollections().filter(c => c.status !== 'confirmed_deleted');
// Guests (read-only public mode) never see the safety box, even if the filter is somehow set to it.
const sf = (!authReady && filters.status === 99) ? 0 : filters.status;
if(sf === 99) arr = getAllCollections().filter(c => c.status === 'missing_candidate' || c.status === 'protected_local' || c.status === 'confirmed_deleted');
else if(sf) arr = arr.filter(c => c.collection_type === sf && c.status !== 'missing_candidate');
else arr = arr.filter(c => c.status !== 'missing_candidate');
if(filters.type) arr = arr.filter(c => c.subject_type === filters.type);
if(isPublicMode && !authReady && !publicShowNsfw) arr = arr.filter(c => c.nsfw !== true);
else if(filters.nsfw === 'only') arr = arr.filter(c => c.nsfw === true);
else if(filters.nsfw === 'hide') arr = arr.filter(c => c.nsfw !== true);
if(filters.tag !== '全部') arr = arr.filter(c => {
const tf = parseTagKey(filters.tag);
if(tf.name === '无标签') return !myTagsOf(c).length && !publicTagNames(c).length;
if(tf.scope === 'my') return myTagsOf(c).includes(tf.name);
if(tf.scope === 'public') return publicTagNames(c).includes(tf.name);
return allTagNames(c).includes(tf.name);
});
if(filters.hasComment) arr = arr.filter(c => !!(c.comment && String(c.comment).trim()));
if(q){ arr = arr.filter(c => [c.title,c.name,c.name_cn,c.comment,c.summary,c.date].join(' ').toLowerCase().includes(q)); }
const cmpDate = (x) => Date.parse(x || '') || 0;
arr.sort((a,b)=>{
if(filters.sort==='updated_asc') return cmpDate(a.bangumi_updated_at)-cmpDate(b.bangumi_updated_at);
if(filters.sort==='rate_desc') return (Number(b.rate)||0)-(Number(a.rate)||0) || cmpDate(b.bangumi_updated_at)-cmpDate(a.bangumi_updated_at);
if(filters.sort==='name_asc') return (a.title||'').localeCompare(b.title||'', 'zh-Hans-CN');
if(filters.sort==='first_seen_desc') return cmpDate(b.first_seen_at)-cmpDate(a.first_seen_at);
return cmpDate(b.bangumi_updated_at)-cmpDate(a.bangumi_updated_at);
});
return arr;
}
async function getCachedImage(id){
const c=state.collections?.[String(id)];
// v0.20: Data URL 只在离线导出页优先使用。在线/便携模式优先走真实文件或原始在线图片,
// 避免超大的 base64 封面残留到运行库后影响在线海报显示。
if(window.BANGUMI_VAULT_OFFLINE_STATE && c?.cover_data_url) return {dataUrl:c.cover_data_url};
if(c?.cover_local_url) return {url:c.cover_local_url, file:c.cover_local_file||''};
try{ const cached = await idbGet('img:'+id); if(cached?.dataUrl || cached?.url) return cached; }catch{}
if(c?.cover_data_url && !onlineCoverUrl(c)) return {dataUrl:c.cover_data_url};
return null;
}
function onlineCoverUrl(c){ return c.image || c.images?.large || c.images?.common || c.images?.medium || c.images?.grid || c.images?.small || ''; }
// Rewrite an online image URL through the configured proxy/mirror (empty = no change).
function applyImageProxy(url, proxy){
proxy = (proxy || '').trim();
if(!proxy || !url || !/^https?:\/\//i.test(url)) return url;
return proxy.includes('{url}') ? proxy.replace('{url}', encodeURIComponent(url)) : proxy + url;
}
function coverUrl(c){
if(window.BANGUMI_VAULT_OFFLINE_STATE && c.cover_data_url) return c.cover_data_url;
// Local cached covers (/images/...) are served by us — never proxy those; only online bgm images.
return c.cover_local_url || applyImageProxy(onlineCoverUrl(c), state.settings?.imageProxy) || c.cover_data_url || '';
}
+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} 个条目有标签`);
}
+315
View File
@@ -0,0 +1,315 @@
'use strict';
function renderStatusNav(){
const counts = {0:0,1:0,2:0,3:0,4:0,5:0,99:0};
for(const c of getAllCollections()){
if(c.status !== 'confirmed_deleted' && c.status !== 'missing_candidate') counts[0]++;
if(c.status === 'missing_candidate' || c.status === 'protected_local' || c.status === 'confirmed_deleted') counts[99]++;
if(c.status !== 'missing_candidate' && c.status !== 'confirmed_deleted') counts[c.collection_type] = (counts[c.collection_type]||0)+1;
}
// Hide the safety box (status 99) from guests (read-only public mode, not the admin).
$('statusNav').innerHTML = Object.entries(STATUS).filter(([k])=>authReady || Number(k)!==99).map(([k,v])=>`<button data-status="${k}" class="${filters.status==k?'active':''}"><span>${statusLabelFor(Number(k),filters.type)||v}</span><span class="badge ${k==99&&counts[99]?'danger':''}">${counts[k]||0}</span></button>`).join('');
$('statusNav').querySelectorAll('button').forEach(btn=>btn.onclick=()=>{ resetRenderLimit(); filters.status=Number(btn.dataset.status); filters.tag='全部'; render(); });
}
function renderTags(){
const base = getAllCollections().filter(c => {
if(filters.status === 99) return c.status === 'missing_candidate' || c.status === 'protected_local' || c.status === 'confirmed_deleted';
if(c.status === 'missing_candidate' || c.status === 'confirmed_deleted') return false;
if(filters.status && c.collection_type !== filters.status) return false;
if(filters.type && c.subject_type !== filters.type) return false;
return true;
});
const myMap = new Map(); const publicMap = new Map(); let no=0;
for(const c of base){
const my = myTagsOf(c); const pub = publicTagNames(c);
if(!my.length && !pub.length) no++;
for(const t of my) myMap.set(t,(myMap.get(t)||0)+1);
for(const t of pub) publicMap.set(t,(publicMap.get(t)||0)+1);
}
// Sidebar tag quick-filter (substring on tag name); applied BEFORE slicing so tags beyond top-N are findable.
const q = ($('tagFilterInput')?.value || '').trim().toLowerCase();
const matchTag = ([name]) => !q || String(name).toLowerCase().includes(q);
const myTags = [...myMap.entries()].filter(matchTag).sort((a,b)=>b[1]-a[1] || a[0].localeCompare(b[0],'zh-Hans-CN')).slice(0,70).map(([name,n])=>({key:tagKey('my',name), label:name, n, kind:'my', prefix:'我的'}));
const publicTags = [...publicMap.entries()].filter(matchTag).sort((a,b)=>b[1]-a[1] || a[0].localeCompare(b[0],'zh-Hans-CN')).slice(0,90).map(([name,n])=>({key:tagKey('public',name), label:name, n, kind:'public', prefix:'公共'}));
const items = [{key:'全部',label:'全部',n:base.length,kind:'all'}, ...(!q && no?[{key:'无标签',label:'无标签',n:no,kind:'none'}]:[]), ...myTags, ...publicTags];
$('tagList').innerHTML = items.map(it=>`<button class="tag-chip tag-filter-btn ${it.kind==='my'?'my-tag':it.kind==='public'?'public-tag':''} ${filters.tag===it.key?'active':''}" data-tag-key="${esc(it.key)}" title="${esc(it.prefix ? it.prefix + '标签:' + it.label : it.label)}">${it.prefix?`<span class="tag-kind">${esc(it.prefix)}</span>`:''}${esc(it.label)} · ${it.n}</button>`).join('');
$('tagList').querySelectorAll('button').forEach(b=>b.onclick=()=>{ resetRenderLimit(); filters.tag=b.dataset.tagKey; render(); });
updateTagListMore();
}
// Mobile sidebar: reveal the "展开更多" toggle only when the tag list overflows its collapsed height.
// COLLAPSED must stay in sync with the `.tag-list` max-height in the mobile media query.
function updateTagListMore(){
const tl = $('tagList'), btn = $('tagListMore'); if(!tl || !btn) return;
const COLLAPSED = 138;
const overflow = tl.scrollHeight > COLLAPSED + 8;
btn.classList.toggle('show', overflow);
if(!overflow){ tl.classList.remove('tags-expanded'); btn.textContent = '展开更多'; }
}
// Drop the hover-expand fade mask on comments whose full text fits within the 7-line expanded height
// (so a fully-shown comment isn't needlessly faded). 7-line threshold is fixed → measure once per card.
function markCommentHoverFade(){
const list = $('content').querySelectorAll('.comment:not(.cmt-checked)');
if(!list.length) return;
const lh = parseFloat(getComputedStyle(list[0]).lineHeight) || 20;
const maxHover = lh * 7 + 2;
list.forEach(c => { c.classList.add('cmt-checked'); if(c.scrollHeight <= maxHover) c.classList.add('cmt-fits-hover'); });
}
async function renderCards(){
const arr = visibleCollections();
if(!renderLimit) resetRenderLimit();
const step = pageSize();
$('countPill').textContent = `${arr.length}`;
$('filterSummary').textContent = `${STATUS[filters.status] || '全部'} / ${tagLabelFromKey(filters.tag)} / ${filters.type ? SUBJECT_LABEL[filters.type] : '全部分类'}${filters.hasComment?' / 有吐槽':''}${filters.nsfw==='only'?' / 仅 NSFW':filters.nsfw==='hide'?' / 屏蔽 NSFW':''}`;
const anyFilter = filters.status || filters.type || filters.tag!=='全部' || (filters.q && filters.q.trim()) || filters.nsfw!=='all' || filters.hasComment;
$('filterSummary').classList.toggle('has-active', !!anyFilter);
if(!arr.length){
if(filters.status === 99){
$('content').innerHTML = `<div class="safety-empty"><div class="safety-panel"><h2>安全箱现在是空的,这是好事。</h2><p>安全箱不是普通分类,它只在“本地以前有,但这次远端完整同步后突然没有出现”的时候才会有内容。</p><div class="safety-steps"><div class="safety-step"><b>1. 正常同步新增条目:</b>你在 Bangumi 网页新增或修改收藏后,点“同步最新变动”,它会写入本地。</div><div class="safety-step"><b>2. 远端疑似吞条目:</b>条目不会被本地删除,而是进入安全箱。</div><div class="safety-step"><b>3. 你手动判断:</b>确认线上删除 / 本地保留 / 等下次同步恢复。</div></div></div></div>`;
} else {
$('content').innerHTML = `<div class="empty"><div><h2>这里还没有条目</h2><p>先在设置里粘贴 Access Token,然后点“同步最新变动”。</p></div></div>`;
}
return;
}
const shown = arr.slice(0, renderLimit);
$('content').innerHTML = `<div class="poster-grid">${shown.map((c,i)=>cardHTML(c,i)).join('')}</div>${arr.length>shown.length?`<div class="load-more-wrap"><button class="btn" id="loadMoreBtn">再显示 ${Math.min(step, arr.length-shown.length)} 条(共 ${arr.length} 条)</button></div>`:''}`;
$('loadMoreBtn')?.addEventListener('click',()=>{
const btn=$('loadMoreBtn');
if(btn){ btn.disabled=true; btn.textContent='加载中…'; }
requestAnimationFrame(()=>{
const arr2=visibleCollections(); const step2=pageSize(); const from=renderLimit;
renderLimit=Math.min(renderLimit+step2, arr2.length);
const newItems=arr2.slice(from,renderLimit);
const grid=$('content')?.querySelector('.poster-grid');
if(grid && newItems.length){
const t=document.createElement('template');
t.innerHTML=newItems.map((c,i)=>cardHTML(c,from+i)).join('');
grid.append(t.content);
Array.from(grid.children).slice(from).forEach(el=>{
if(!el.classList.contains('card')) return;
el.onclick=()=>openDrawer(el.dataset.id);
el.onmousemove=(e)=>{ const r=el.getBoundingClientRect(); el.style.setProperty('--mx',((e.clientX-r.left)/r.width*100).toFixed(1)+'%'); el.style.setProperty('--my',((e.clientY-r.top)/r.height*100).toFixed(1)+'%'); };
});
grid.querySelectorAll('img[data-id]:not([data-lz])').forEach(img=>{ img.dataset.lz='1'; getCachedImage(img.dataset.id).then(c=>{ if(c?.dataUrl) img.src=c.dataUrl; else if(c?.url) img.src=c.url; }).catch(()=>{}); });
markCommentHoverFade();
}
const wrap=$('content')?.querySelector('.load-more-wrap');
if(renderLimit>=arr2.length){ if(wrap) wrap.remove(); }
else if(wrap){ const nb=wrap.querySelector('.btn'); if(nb){ nb.disabled=false; nb.textContent=`再显示 ${Math.min(step2,arr2.length-renderLimit)} 条(共 ${arr2.length} 条)`; } }
});
});
$('content').querySelectorAll('.card').forEach(el => {
el.onclick = () => openDrawer(el.dataset.id);
el.onmousemove = (e) => { const r=el.getBoundingClientRect(); el.style.setProperty('--mx', ((e.clientX-r.left)/r.width*100).toFixed(1)+'%'); el.style.setProperty('--my', ((e.clientY-r.top)/r.height*100).toFixed(1)+'%'); };
});
const imgs = [...$('content').querySelectorAll('img[data-id]')];
for(const img of imgs.slice(0, Math.min(pageSize()+80, 420))){
getCachedImage(img.dataset.id).then(cache=>{ if(cache?.dataUrl) img.src = cache.dataUrl; else if(cache?.url) img.src = cache.url; }).catch(()=>{});
}
markCommentHoverFade();
}
function cardHTML(c, i=0){
const img = coverUrl(c); const statusLabel = statusLabelFor(c.collection_type, c.subject_type); const tags=uniqStrings([...myTagsOf(c), ...publicTagNames(c)]).slice(0,5);
const flag = c.status === 'missing_candidate' ? '<span class="pill danger">疑似消失</span>' : c.status === 'protected_local' ? '<span class="pill">本地保留</span>' : c.status === 'confirmed_deleted' ? '<span class="pill danger">已确认删除</span>' : '';
const comment = c.comment ? `<div class="comment">${esc(c.comment)}</div>` : '<div class="comment no-comment">没有填写吐槽。</div>';
return `<article class="card" data-id="${c.subject_id}" style="--i:${i}" title="${esc(c.title)}">
<div class="card-glow"></div>
<div class="cover-wrap"><div class="status-ribbon" data-ct="${c.collection_type}">${esc(statusLabel)}</div>${c.rate?`<div class="rate">★ ${esc(c.rate)}</div>`:''}${img?`<img class="cover" data-id="${c.subject_id}" src="${esc(img)}" loading="lazy" referrerpolicy="no-referrer" />`:`<div class="no-cover">${esc(c.title)}</div>`}</div>
${c.nsfw?`<div class="r18-badge">NSFW</div>`:''}
<div class="card-body"><div class="name">${esc(c.title)}</div><div class="meta"><span>${esc(SUBJECT_LABEL[c.subject_type]||'条目')}</span>${c.date?`<span>${esc(c.date)}</span>`:''}${flag}</div>${comment}<div class="mini-tags">${tags.map(t=>`<span>${esc(t)}</span>`).join('')}</div></div>
</article>`;
}
function numericValue(v){
if(v === null || v === undefined || v === '') return 0;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
function formatScoreValue(v){
const n = numericValue(v);
if(!n) return '';
return Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/,'');
}
function episodeProgressHTML(c){
const watched = Math.max(0, Math.floor(numericValue(c.ep_status)));
const total = Math.max(0, Math.floor(numericValue(c.eps)));
if(!watched && !total) return '';
const count = total || watched;
if(!count) return '';
const visibleTotal = Math.min(count, 96);
const cols = Math.max(8, Math.min(24, visibleTotal));
const mobileCols = Math.max(8, Math.min(18, visibleTotal));
const cells = Array.from({length: visibleTotal}, (_,i)=>`<span class="ep-cell-v9 ${i < watched ? 'watched' : ''}" title="第 ${i+1} 集 · ${i < watched ? '已看' : '未看'}"></span>`).join('');
const more = count > visibleTotal ? ` · 显示前 ${visibleTotal}` : '';
const percent = total ? Math.round(Math.min(watched,total)/total*100) : 100;
return `<div class="episode-strip-v9 ${count>48?'compact':''}">
<div class="episode-strip-head"><span>观看进度</span><span>${watched}${total?` / ${total}`:''} 集 · ${percent}%${more}</span></div>
<div class="episode-grid-v9 ${count>48?'too-many':''}" style="--ep-cols:${cols};--ep-cols-mobile:${mobileCols}">${cells}</div>
</div>`;
}
function scoreCardHTML(c){
const personal = Math.max(0, Math.min(10, numericValue(c.rate)));
const personalPct = Math.round(personal * 10);
const avg = Math.max(0, Math.min(10, numericValue(c.bgm_score || c.raw?.subject?.score || c.raw?.subject?.rating?.score)));
const avgPct = Math.round(avg * 10);
const personalText = formatScoreValue(personal);
const avgText = formatScoreValue(avg);
const stars = Array.from({length:10}, (_,i)=>`<span class="${i < Math.round(personal) ? 'on' : ''}">★</span>`).join('');
return `<div class="score-card-v8" style="--score-pct:${personalPct}%;--sub-pct:${avgPct}%">
<div class="score-row-v8"><div class="score-big-v8 ${personal ? '' : 'score-empty-v9'}">${personal ? esc(personalText) + '<small>/10</small>' : '未评分'}</div><div class="score-word-v8">我的评分<br>${personal ? personalPct+'%' : 'NO RATE'}</div></div>
<div class="stars-v8" aria-label="我的星星评分">${stars}</div>
<div class="score-track-v8"><div class="score-fill-v8"></div></div>
<div class="score-sub-v9"><div class="score-sub-row"><b>Bangumi</b><div class="sub-track"><div class="sub-fill"></div></div><span>${avg ? esc(avgText) + '/10' : '暂无'}</span></div></div>
<div class="score-caption-v8"><span>${c.rank?`Bangumi 排名 #${esc(c.rank)}`:''}</span><span>Bangumi 平均分</span></div>
</div>`;
}
// Render Bangumi infobox into an array of kv-row HTML strings. value is a string or an array of {v:...}.
// Skips rows that exactly duplicate fields already shown in the 资料库信息 card (中文名 / 日期 / 平台).
function infoboxRowList(list, c){
if(!Array.isArray(list) || !list.length) return [];
const dup = new Set([
c?.name_cn ? `中文名 ${c.name_cn}` : '',
c?.date ? `发行日期 ${c.date}` : '',
c?.date ? `放送开始 ${c.date}` : '',
c?.platform ? `平台 ${c.platform}` : '',
].filter(Boolean));
return list.map(item=>{
const k = String(item?.key ?? '').trim();
let v = item?.value;
if(Array.isArray(v)) v = v.map(x=>(x && typeof x==='object') ? (x.v ?? '') : x).filter(s=>String(s).trim()!=='').join(' / ');
v = String(v ?? '').trim();
if(!k && !v) return '';
if(dup.has(`${k} ${v}`)) return '';
return `<div>${esc(k)}</div><div>${esc(v)}</div>`;
}).filter(Boolean);
}
// Build the 条目资料 card from rendered infobox rows (≤5 flat, >5 collapsible). '' when empty.
function infoboxCardHTML(rows, id){
const IB_HEAD = 5;
if(!rows.length) return '';
if(rows.length <= IB_HEAD) return `<div class="info-card"><h3>条目资料</h3><div class="kv">${rows.join('')}</div></div>`;
return `<div class="info-card infobox-card"><h3>条目资料</h3><input type="checkbox" id="ibx-${esc(String(id))}" class="infobox-check" hidden><div class="kv">${rows.slice(0,IB_HEAD).join('')}</div><div class="kv infobox-extra">${rows.slice(IB_HEAD).join('')}</div><label class="infobox-toggle" for="ibx-${esc(String(id))}"></label></div>`;
}
async function openDrawer(id){
activeDrawerId = id; const c = state.collections[id]; if(!c) return;
const imgCache = await getCachedImage(id); const img = imgCache?.dataUrl || imgCache?.url || coverUrl(c);
const hist = (await fetchSubjectHistory(id)).slice(-10).reverse();
const cachedIb = infoboxCache[id]; const ibCard = Array.isArray(cachedIb) ? infoboxCardHTML(infoboxRowList(cachedIb, c), id) : '<div id="infoboxSlot"></div>';
const myTags = myTagsOf(c);
const publicTags = publicTagsOf(c);
const publicTagsShown = publicTagsForDisplay(c);
const myTagsHtml = myTags.length ? myTags.map(t=>`<button class="tag-chip tag-filter-btn my-tag" data-tag-key="${esc(tagKey('my', t))}"><span class="tag-kind">我的</span>${esc(t)}</button>`).join(' ') : '<span class="muted-inline">无</span>';
const publicTagsHtml = publicTagsShown.length ? publicTagsShown.map(t=>`<span class="tag-pair"><button class="tag-chip tag-filter-btn public-tag" data-tag-key="${esc(tagKey('public', t.name))}"><span class="tag-kind">公共</span>${esc(t.name)}${t.count?` · ${esc(t.count)}`:''}</button><a class="tag-open" href="${esc(bgmTagUrl(c.subject_type, t.name))}" target="_blank" rel="noopener" title="打开 Bangumi 标签页">↗</a></span>`).join('') : '<span class="muted-inline">未缓存。可点“刷新公共标签”。</span>';
const hiddenPublicTags = Math.max(0, publicTags.length - publicTagsShown.length);
const publicTagsNote = publicTags.length
? `已缓存 ${publicTags.length} 个公共标签${c.public_tags_source === 'website' ? '。来源:Bangumi 条目页“大家将…标注为”区域' : '。来源:API ,可能与官网标签区不完全一致'}${c.public_tags_fetched_at ? '。刷新于 ' + esc(formatDate(c.public_tags_fetched_at)) : '。可点“刷新公共标签”重取。'}`
: '公共标签来自 Bangumi 条目公开信息;在线版/网络异常时可能无法补全。';
const statusPills = `<span class="pill">${esc(SUBJECT_LABEL[c.subject_type]||'条目')}</span><span class="pill">${esc(statusLabelFor(c.collection_type,c.subject_type))}</span>${c.nsfw?`<span class="pill bad">NSFW</span>`:''}${(c.platform && c.platform !== (SUBJECT_LABEL[c.subject_type]||''))?`<span class="pill">${esc(c.platform)}</span>`:''}${c.private?`<span class="pill">私密</span>`:''}${c.status==='missing_candidate'?`<span class="pill danger">安全箱 · 疑似消失</span>`:''}${c.mark_changed_at?`<span class="pill" style="opacity:.62;font-weight:600">标记于 ${esc(formatDate(c.mark_changed_at))}</span>`:''}`;
const actions = `${c.status==='missing_candidate'?`<button class="btn good small wide" id="keepLocalBtn">本地保留</button><button class="btn bad small wide" id="confirmDeleteBtn">确认线上删除</button>`:''}${c.status==='confirmed_deleted'?`<button class="btn good small wide" id="restoreBtn">恢复显示</button>`:''}`;
// Mobile (≤980px) detail layout: drop the standalone poster, move score + actions below the hero banner.
const mobileDetail = window.matchMedia('(max-width:980px)').matches;
const posterH = `<div class="detail-poster-frame">${img?`<img class="drawer-cover" src="${esc(img)}" referrerpolicy="no-referrer" />`:`<div class="drawer-cover"></div>`}</div>`;
const scoreH = scoreCardHTML(c);
const actionsH = `<div class="detail-actions-v8"><button class="btn small" id="openBgmBtn">打开 Bangumi</button><button class="btn small" id="copyCommentBtn">复制吐槽</button>${actions}</div>`;
$('drawer').innerHTML = `<div class="detail-bg">${img?`<img src="${esc(img)}" referrerpolicy="no-referrer" />`:''}</div>
<button class="btn small detail-close-v8" id="closeDrawerBtn">关闭</button>
<div class="detail-v8">
<aside class="detail-side-v8">${mobileDetail ? '' : posterH + scoreH + actionsH}</aside>
<main class="detail-main-v8">
<section class="detail-hero-v8" style="${img?`background-image:linear-gradient(90deg,rgba(8,9,15,.72),rgba(8,9,15,.30),rgba(8,9,15,.07)),url(&quot;${esc(img)}&quot;);background-size:cover;background-position:center;`:``}">
<div class="detail-hero-content">
<div class="detail-kicker-v8">Bangumi 本地备份详情</div>
<h2>${esc(c.title)}</h2>
<div class="detail-pills-v8">${statusPills}</div>
${episodeProgressHTML(c)}
</div>
</section>
${mobileDetail ? scoreH + actionsH : ''}
<section class="detail-grid-v8">
<div class="detail-stack">
<div class="info-card"><h3>我的吐槽 / 观后感</h3><div class="longtext big-comment">${esc(c.comment || '没有填写吐槽。')}</div></div>
${c.summary?`<div class="info-card"><h3>条目简介</h3><div class="longtext">${esc(c.summary)}</div></div>`:''}
</div>
<div class="detail-stack">
${ibCard}<div class="info-card"><h3>资料库信息</h3><div class="kv"><div>条目 ID</div><div>${esc(c.subject_id||'-')}</div><div>原名</div><div>${esc(c.name||'-')}</div><div>中文名</div><div>${esc(c.name_cn||'-')}</div><div>上映日期</div><div>${esc(c.date||'-')}</div>${(c.platform && c.platform !== (SUBJECT_LABEL[c.subject_type]||''))?`<div>平台</div><div>${esc(c.platform)}</div>`:''}${c.subject_fetched_at?`<div>NSFW</div><div>${c.nsfw?'是':'否'}</div>`:''}<div>进度</div><div>${esc([c.ep_status?`章节 ${c.ep_status}`:'',c.vol_status?`${c.vol_status}`:''].filter(Boolean).join(' / ') || '-')}</div>${c.subject_fetched_at?`<div>条目详情获取</div><div>${esc(formatDate(c.subject_fetched_at))}</div>`:''}<div>条目最后更新时间</div><div>${esc(formatDate(c.bangumi_updated_at)||'-')}</div><div>首次备份</div><div>${esc(formatDate(c.first_seen_at)||'-')}</div><div>本地同步</div><div>${esc(formatDate(c.last_synced_at || c.last_seen_at)||'-')}</div><div>状态</div><div>${esc(c.status||'active')}${c.missing_count?`,缺失次数 ${c.missing_count}`:''}</div></div></div>
<div class="info-card"><h3>我的标签</h3><div class="tag-cloud">${myTagsHtml}</div></div>
<div class="info-card"><h3>公共标签</h3><div class="tag-cloud">${publicTagsHtml}</div><p class="help" style="margin:10px 0 0">${publicTagsNote}<br>点击标签在本地筛选;点 ↗ 打开 Bangumi 对应标签页。</p></div>
<div class="info-card"><h3>备份历史</h3><div class="history-list">${hist.length?hist.map(h=>`<div class="hist"><b>${esc(h.change_type)}</b> · ${esc(formatDate(h.created_at))}<br>${historyChangeHtml(h)}</div>`).join(''):'<div class="hist">还没有历史变更。</div>'}</div></div>
</div>
</section>
</main>
</div>`;
$('drawerMask').classList.remove('closing'); $('drawer').classList.remove('closing'); $('drawerMask').classList.add('show'); $('drawer').classList.add('show');
$('closeDrawerBtn').onclick = closeDrawer; $('drawerMask').onclick = closeDrawer;
$('drawer').querySelectorAll('[data-tag-key]').forEach(btn=>btn.onclick=(ev)=>{ ev.stopPropagation(); resetRenderLimit(); filters.tag = btn.dataset.tagKey; closeDrawer(); render(); });
$('openBgmBtn').onclick = () => window.open(`${bgmSiteBase()}/subject/${c.subject_id}`,'_blank','noopener');
$('copyCommentBtn').onclick = async () => { await navigator.clipboard?.writeText(c.comment||''); toast('已复制吐槽'); };
const keep=$('keepLocalBtn'); if(keep) keep.onclick=async()=>{ await setCollectionStatus(id,'protected_local',0); render(); openDrawer(id); toast('已标记为本地保留'); };
const del=$('confirmDeleteBtn'); if(del) del.onclick=async()=>{ if(confirm('确认把这个条目标记为线上已删除?历史和导出仍会保留。')){ await setCollectionStatus(id,'confirmed_deleted',(c.missing_count||0)+1); render(); openDrawer(id); } };
const res=$('restoreBtn'); if(res) res.onclick=async()=>{ await setCollectionStatus(id,'active',0); render(); openDrawer(id); };
// Infobox loads off the render path (its /api/subject-meta query is slow); fill + cache async so the drawer opens instantly.
if(!Array.isArray(cachedIb)){
fetchSubjectInfobox(id).then(list=>{
infoboxCache[id] = list;
if(activeDrawerId !== id) return;
const slot = $('infoboxSlot');
if(slot) slot.outerHTML = infoboxCardHTML(infoboxRowList(list, c), id);
});
}
// Scroll the banner + columns together; once the banner is scrolled out, pin the grid so each
// column scrolls independently with their tops aligned (.cols-pinned, see styles.css).
const mainEl = $('drawer').querySelector('.detail-main-v8'), heroEl = $('drawer').querySelector('.detail-hero-v8'), gridEl = $('drawer').querySelector('.detail-grid-v8');
if(mainEl && heroEl && gridEl){
const threshold = Math.max(0, gridEl.offsetTop - heroEl.offsetTop);
const syncCols = () => gridEl.classList.toggle('cols-pinned', mainEl.scrollTop >= threshold - 1);
mainEl.addEventListener('scroll', syncCols, { passive: true });
syncCols();
}
}
function closeDrawer(){ const mask=$('drawerMask'), d=$('drawer'); if(!d.classList.contains('show')) return; d.classList.add('closing'); mask.classList.add('closing'); const ms=(state.settings.motion==='off')?0:190; setTimeout(()=>{ mask.classList.remove('show','closing'); d.classList.remove('show','closing'); activeDrawerId=null; }, ms); }
function formatDate(s){ if(!s) return ''; const d=new Date(s); return isNaN(d)?String(s):d.toLocaleString(); }
function renderStats(){
const arr = getAllCollections(); const active = arr.filter(c=>c.status!=='confirmed_deleted'&&c.status!=='missing_candidate');
$('statTotal').textContent = active.length; $('statMissing').textContent = arr.filter(c=>c.status==='missing_candidate').length;
$('statComments').textContent = active.filter(c=>c.comment).length; const tagSet = new Set(); active.forEach(c=>allTagNames(c).forEach(t=>tagSet.add(t))); $('statTags').textContent = tagSet.size;
$('statCommentsCard')?.classList.toggle('active', !!filters.hasComment);
const acctUser = state.settings.username;
$('accountPill').innerHTML = acctUser ? `<a href="${esc(bgmSiteBase())}/user/${encodeURIComponent(acctUser)}" target="_blank" rel="noopener">@${esc(acctUser)}</a>` : '未登录';
const last = state.runs.filter(r=>r.success).at(-1); $('lastSyncText').textContent = last ? `上次同步:${formatDate(last.finished_at)},新增 ${last.added||0},修改 ${last.modified||0},疑似消失 ${last.missing||0}` : '尚未同步';
const pill=$('syncModePill');
if(pill && last){ const t=last.trigger==='auto'?'定时':'手动'; const m=last.mode==='full'?'全局':'增量'; pill.textContent=`最近同步 · ${t}${m}`; }
}
function refreshNsfwBtn(){
const btn=$('nsfwBtn'); if(!btn) return;
const cfg={all:['NSFW',''],hide:['屏蔽 NSFW',' good'],only:['仅 NSFW',' bad']};
const [label,mod]=cfg[filters.nsfw]||cfg.all;
btn.textContent=label; btn.className=`btn small${mod}`;
}
function updateAutoSyncHelp(){
const el=$('autoSyncHelp'); if(!el) return;
const mode = state.settings.autoSyncMode || 'recent';
const desc = (typeof cronDescribe === 'function') ? cronDescribe(state.settings.autoSyncCron || '0 2 * * *') : (state.settings.autoSyncCron || '');
el.textContent = `${desc} · 自动${mode==='recent'?'增量':'完整'}同步`;
}
function applyAuthVisibility(){
// admin-only actions — incl. the mobile drawer duplicates (mFullSync etc.) so guests can't see/trigger them
['syncBtn','fullSyncBtn','exportBtn','cacheCoversBtn','importBtn','enrichTagsBtn','mSyncRecent','mFullSync','mExport','mCacheCovers','mImport','mEnrichTags'].forEach(id=>{
const el=$(id); if(el) el.classList.toggle('hidden',!authReady);
});
const sf=document.querySelector('.settings-footer');
if(sf) sf.classList.toggle('hidden',!authReady);
document.querySelectorAll('.settings-tab').forEach(t=>{
const tab=t.dataset.tab;
if(tab==='stab-appearance'){ t.classList.remove('hidden'); }
else if(tab==='stab-login'){ t.classList.toggle('hidden',authReady); }
else{ t.classList.toggle('hidden',!authReady); }
});
const nsfwHidden = isPublicMode && !authReady && !publicShowNsfw;
$('nsfwBtn')?.classList.toggle('hidden', nsfwHidden);
}
function render(){ renderStats(); renderStatusNav(); renderTags(); renderCards(); refreshNsfwBtn(); applyAuthVisibility(); }