'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])=>``).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=>``).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 = `

安全箱现在是空的,这是好事。

安全箱不是普通分类,它只在“本地以前有,但这次远端完整同步后突然没有出现”的时候才会有内容。

1. 正常同步新增条目:你在 Bangumi 网页新增或修改收藏后,点“同步最新变动”,它会写入本地。
2. 远端疑似吞条目:条目不会被本地删除,而是进入安全箱。
3. 你手动判断:确认线上删除 / 本地保留 / 等下次同步恢复。
`; } else { $('content').innerHTML = `

这里还没有条目

先在设置里粘贴 Access Token,然后点“同步最新变动”。

`; } return; } const shown = arr.slice(0, renderLimit); $('content').innerHTML = `
${shown.map((c,i)=>cardHTML(c,i)).join('')}
${arr.length>shown.length?`
`:''}`; $('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' ? '疑似消失' : c.status === 'protected_local' ? '本地保留' : c.status === 'confirmed_deleted' ? '已确认删除' : ''; const comment = c.comment ? `
${esc(c.comment)}
` : '
没有填写吐槽。
'; return `
${esc(statusLabel)}
${c.rate?`
★ ${esc(c.rate)}
`:''}${img?``:`
${esc(c.title)}
`}
${c.nsfw?`
NSFW
`:''}
${esc(c.title)}
${esc(SUBJECT_LABEL[c.subject_type]||'条目')}${c.date?`${esc(c.date)}`:''}${flag}
${comment}
${tags.map(t=>`${esc(t)}`).join('')}
`; } 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)=>``).join(''); const more = count > visibleTotal ? ` · 显示前 ${visibleTotal} 格` : ''; const percent = total ? Math.round(Math.min(watched,total)/total*100) : 100; return `
观看进度${watched}${total?` / ${total}`:''} 集 · ${percent}%${more}
${cells}
`; } 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)=>``).join(''); return `
${personal ? esc(personalText) + '/10' : '未评分'}
我的评分
${personal ? personalPct+'%' : 'NO RATE'}
${stars}
Bangumi
${avg ? esc(avgText) + '/10' : '暂无'}
${c.rank?`Bangumi 排名 #${esc(c.rank)}`:''}Bangumi 平均分
`; } // 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 `
${esc(k)}
${esc(v)}
`; }).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 `

条目资料

${rows.join('')}
`; return `

条目资料

${rows.slice(0,IB_HEAD).join('')}
${rows.slice(IB_HEAD).join('')}
`; } 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) : '
'; const myTags = myTagsOf(c); const publicTags = publicTagsOf(c); const publicTagsShown = publicTagsForDisplay(c); const myTagsHtml = myTags.length ? myTags.map(t=>``).join(' ') : ''; const publicTagsHtml = publicTagsShown.length ? publicTagsShown.map(t=>``).join('') : '未缓存。可点“刷新公共标签”。'; 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 = `${esc(SUBJECT_LABEL[c.subject_type]||'条目')}${esc(statusLabelFor(c.collection_type,c.subject_type))}${c.nsfw?`NSFW`:''}${(c.platform && c.platform !== (SUBJECT_LABEL[c.subject_type]||''))?`${esc(c.platform)}`:''}${c.private?`私密`:''}${c.status==='missing_candidate'?`安全箱 · 疑似消失`:''}${c.mark_changed_at?`标记于 ${esc(formatDate(c.mark_changed_at))}`:''}`; const actions = `${c.status==='missing_candidate'?``:''}${c.status==='confirmed_deleted'?``:''}`; // 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 = `
${img?``:`
`}
`; const scoreH = scoreCardHTML(c); const actionsH = `
${actions}
`; $('drawer').innerHTML = `
${img?``:''}
Bangumi 本地备份详情

${esc(c.title)}

${statusPills}
${episodeProgressHTML(c)}
${mobileDetail ? scoreH + actionsH : ''}

我的吐槽 / 观后感

${esc(c.comment || '没有填写吐槽。')}
${c.summary?`

条目简介

${esc(c.summary)}
`:''}
${ibCard}

资料库信息

条目 ID
${esc(c.subject_id||'-')}
原名
${esc(c.name||'-')}
中文名
${esc(c.name_cn||'-')}
上映日期
${esc(c.date||'-')}
${(c.platform && c.platform !== (SUBJECT_LABEL[c.subject_type]||''))?`
平台
${esc(c.platform)}
`:''}${c.subject_fetched_at?`
NSFW
${c.nsfw?'是':'否'}
`:''}
进度
${esc([c.ep_status?`章节 ${c.ep_status}`:'',c.vol_status?`卷 ${c.vol_status}`:''].filter(Boolean).join(' / ') || '-')}
${c.subject_fetched_at?`
条目详情获取
${esc(formatDate(c.subject_fetched_at))}
`:''}
条目最后更新时间
${esc(formatDate(c.bangumi_updated_at)||'-')}
首次备份
${esc(formatDate(c.first_seen_at)||'-')}
本地同步
${esc(formatDate(c.last_synced_at || c.last_seen_at)||'-')}
状态
${esc(c.status||'active')}${c.missing_count?`,缺失次数 ${c.missing_count}`:''}

我的标签

${myTagsHtml}

公共标签

${publicTagsHtml}

${publicTagsNote}
点击标签在本地筛选;点 ↗ 打开 Bangumi 对应标签页。

备份历史

${hist.length?hist.map(h=>`
${esc(h.change_type)} · ${esc(formatDate(h.created_at))}
${historyChangeHtml(h)}
`).join(''):'
还没有历史变更。
'}
`; $('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 ? `@${esc(acctUser)}` : '未登录'; 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(); }