let WALL_CONF = { autoplay_ms: 45000, swipe_pause_ms: 5 * 60 * 1000, max_cards_on_screen: 2 } let SCENES = [] let editingCardId = null const elCards = () => document.getElementById('cards') const elStatus = () => document.getElementById('status') let carouselTimer = null let pausedUntil = 0 let currentIndex = 0 const fmtTime = d => (window.fmtCNSimpleTime ? window.fmtCNSimpleTime(d) : `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`) const dayIndex = () => Math.floor((Date.now() / (24*3600*1000))) % 3 const buildCardEl = (c) => { const card = document.createElement('div') card.className = 'card' const title = document.createElement('div') title.className = 'title' title.textContent = c.title const lines = document.createElement('div') lines.className = 'lines' for (const s of c.items) { const ln = document.createElement('div'); ln.textContent = s; lines.appendChild(ln) } const meta = document.createElement('div') meta.className = 'meta' meta.textContent = c.meta card.appendChild(title) card.appendChild(lines) card.appendChild(meta) return card } const renderCards = (mode = 'normal') => { const box = elCards(); if (!box) return let inner = document.getElementById('cards-inner') if (!inner) { inner = document.createElement('div'); inner.id = 'cards-inner'; inner.className = 'cards-inner'; box.innerHTML = ''; box.appendChild(inner) } else { inner.innerHTML = '' } const idx = currentIndex if (mode === 'prevPrep') { const prev = SCENES[(idx - 1 + SCENES.length) % SCENES.length] const cur = SCENES[idx % SCENES.length] const next = SCENES[(idx + 1) % SCENES.length] inner.appendChild(buildCardEl(prev)) inner.appendChild(buildCardEl(cur)) inner.appendChild(buildCardEl(next)) inner.style.transform = 'translateX(-50%)' } else if (mode === 'nextPrep') { const cur = SCENES[idx % SCENES.length] const next = SCENES[(idx + 1) % SCENES.length] const next2 = SCENES[(idx + 2) % SCENES.length] inner.appendChild(buildCardEl(cur)) inner.appendChild(buildCardEl(next)) inner.appendChild(buildCardEl(next2)) inner.style.transform = 'translateX(0)' } else { const cur = SCENES[idx % SCENES.length] const next = SCENES[(idx + 1) % SCENES.length] inner.appendChild(buildCardEl(cur)) inner.appendChild(buildCardEl(next)) inner.style.transform = 'translateX(0)' } updatePreviewActive() fitLayout() } const rotate = () => { currentIndex = (currentIndex + 1) % SCENES.length; renderCards(); updatePreviewActive() } const rotatePrev = () => { currentIndex = (currentIndex - 1 + SCENES.length) % SCENES.length; renderCards(); updatePreviewActive() } const updatePreviewActive = () => { const box = document.getElementById('preview'); if (!box) return box.querySelectorAll('.thumb').forEach(node => { const i = parseInt(node.getAttribute('data-idx') || '0', 10) if (i === currentIndex) node.classList.add('active'); else node.classList.remove('active') }) } const startAutoplay = () => { if (carouselTimer) clearInterval(carouselTimer) const status = elStatus(); if (status) { status.style.display = 'none'; status.textContent = '' } carouselTimer = setInterval(() => { if (Date.now() < pausedUntil) return rotate() }, WALL_CONF.autoplay_ms) } const pauseAutoplay = ms => { pausedUntil = Date.now() + ms; const status = elStatus(); if (status) { status.style.display = ''; const t = new Date(pausedUntil); status.textContent = `已暂停自动播放,至 ${fmtTime(t)}` } } const bindSwipe = () => { const area = elCards(); if (!area) return let x0 = 0, y0 = 0, dragging = false area.addEventListener('touchstart', (e) => { const t = e.touches[0]; x0 = t.clientX; y0 = t.clientY; dragging = true; const inner = document.getElementById('cards-inner'); if (inner) inner.style.transition = 'none' }, { passive: true }) area.addEventListener('touchmove', (e) => { if (!dragging) return; const t = e.touches[0]; const dx = t.clientX - x0; const dy = t.clientY - y0; if (Math.abs(dx) > Math.abs(dy)) { const inner = document.getElementById('cards-inner'); if (inner) inner.style.transform = `translateX(${dx}px)` } }, { passive: true }) area.addEventListener('touchend', (e) => { dragging = false const t = e.changedTouches[0]; const dx = t.clientX - x0; const dy = t.clientY - y0 const inner = document.getElementById('cards-inner') const threshold = 40 if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > threshold) { if (dx < 0) animateRotate('next'); else animateRotate('prev'); pauseAutoplay(WALL_CONF.swipe_pause_ms) } else { if (inner) { inner.style.transition = 'transform 300ms ease'; inner.style.transform = 'translateX(0px)'; setTimeout(() => { inner.style.transition = ''; inner.style.transform = '' }, 300) } } }) } const animateRotate = (dir) => { const inner = document.getElementById('cards-inner') const box = elCards() if (!inner || !box) { if (dir === 'next') rotate(); else rotatePrev(); return } const step = Math.round(box.clientWidth / 2) inner.style.transition = 'transform 300ms ease' inner.style.transform = dir === 'next' ? `translateX(-${step}px)` : `translateX(${step}px)` setTimeout(() => { inner.style.transition = ''; inner.style.transform = ''; if (dir === 'next') rotate(); else rotatePrev() }, 300) } const renderPreview = () => { const box = document.getElementById('preview'); if (!box) return box.innerHTML = '' for (let i = 0; i < SCENES.length; i++) { const c = SCENES[i] const t = document.createElement('div') t.className = 'thumb' t.setAttribute('data-idx', String(i)) const title = document.createElement('div'); title.className = 'title'; title.textContent = c.title t.appendChild(title) t.addEventListener('click', () => { currentIndex = i; renderCards(); updatePreviewActive(); pauseAutoplay(WALL_CONF.swipe_pause_ms) }) box.appendChild(t) } updatePreviewActive() fitLayout() } const openSettings = async () => { const res = await fetch('/api/wall/config') const data = await res.json() if (data.ok) { const cfg = data.config || {} document.getElementById('autoplay-ms').value = cfg.autoplay_ms || WALL_CONF.autoplay_ms document.getElementById('swipe-pause-ms').value = cfg.swipe_pause_ms || WALL_CONF.swipe_pause_ms document.getElementById('max-cards').value = cfg.max_cards_on_screen || WALL_CONF.max_cards_on_screen const bg = cfg.background || {} document.getElementById('bg-url').value = bg.url || '' document.getElementById('bg-mode').value = bg.mode || 'tile' document.getElementById('bg-size').value = bg.size || 120 document.getElementById('bg-opacity').value = bg.opacity || 0.08 } document.getElementById('settings-modal').classList.remove('hidden') } const saveSettings = async () => { const body = { autoplay_ms: parseInt(document.getElementById('autoplay-ms').value, 10) || WALL_CONF.autoplay_ms, swipe_pause_ms: parseInt(document.getElementById('swipe-pause-ms').value, 10) || WALL_CONF.swipe_pause_ms, max_cards_on_screen: parseInt(document.getElementById('max-cards').value, 10) || WALL_CONF.max_cards_on_screen, background: { url: String(document.getElementById('bg-url').value || ''), mode: String(document.getElementById('bg-mode').value || 'tile'), size: parseInt(document.getElementById('bg-size').value || '120', 10), opacity: Number(document.getElementById('bg-opacity').value || '0.08') } } await fetch('/api/wall/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) document.getElementById('settings-modal').classList.add('hidden') await initWall() } const resetCardForm = () => { const title = document.getElementById('cards-edit-title'); if (title) title.value = '' const items = document.getElementById('cards-edit-items'); if (items) items.value = '' const meta = document.getElementById('cards-edit-meta'); if (meta) meta.value = '' } const reloadCardsTable = async () => { try { const res = await fetch('/api/wall/cards') const data = await res.json() if (!data.ok) return const tbody = document.getElementById('cards-table-body') if (!tbody) return tbody.innerHTML = '' const rows = Array.isArray(data.rows) ? data.rows : [] rows.forEach(row => { const tr = document.createElement('tr') const tdTitle = document.createElement('td') tdTitle.textContent = row.title || '' const tdMeta = document.createElement('td') tdMeta.textContent = row.meta || '' const tdOps = document.createElement('td') const btnEdit = document.createElement('button') btnEdit.className = 'btn' btnEdit.textContent = '编辑' btnEdit.addEventListener('click', () => fillCardForm(row)) const btnDel = document.createElement('button') btnDel.className = 'btn' btnDel.textContent = '删除' btnDel.style.marginLeft = '4px' btnDel.addEventListener('click', async () => { if (!confirm('确定删除该卡片?')) return await fetch(`/api/wall/cards/${row.id}`, { method: 'DELETE' }) await reloadCardsTable() await initWall() }) tdOps.appendChild(btnEdit) tdOps.appendChild(btnDel) tr.appendChild(tdTitle) tr.appendChild(tdMeta) tr.appendChild(tdOps) tbody.appendChild(tr) }) } catch {} } const openCardsMaintain = async () => { editingCardId = null resetCardForm() await reloadCardsTable() const m = document.getElementById('cards-modal') if (m) m.classList.remove('hidden') } const closeCardsMaintain = () => { const m = document.getElementById('cards-modal') if (m) m.classList.add('hidden') initWall() } const fillCardForm = (row) => { editingCardId = row.id const title = document.getElementById('cards-edit-title'); if (title) title.value = row.title || '' const items = document.getElementById('cards-edit-items'); if (items) items.value = (Array.isArray(row.items) ? row.items : []).join('\n') const meta = document.getElementById('cards-edit-meta'); if (meta) meta.value = row.meta || '' } const saveCardFromForm = async () => { const titleEl = document.getElementById('cards-edit-title') const itemsEl = document.getElementById('cards-edit-items') const metaEl = document.getElementById('cards-edit-meta') if (!titleEl || !itemsEl || !metaEl) return const title = String(titleEl.value || '').trim() const meta = String(metaEl.value || '') const items = String(itemsEl.value || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean) if (!title) return const payload = { title, meta, items } if (editingCardId) { await fetch(`/api/wall/cards/${editingCardId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) } else { await fetch('/api/wall/cards', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) } await reloadCardsTable() await initWall() } const initWall = async () => { let cfg = null let scenes = [] try { const cfgResp = await fetch('/api/wall/config') const cardsResp = await fetch('/api/wall/cards').catch(() => null) const cfgData = await cfgResp.json() if (cfgData.ok) { cfg = cfgData.config || {} WALL_CONF = { autoplay_ms: cfg.autoplay_ms || WALL_CONF.autoplay_ms, swipe_pause_ms: cfg.swipe_pause_ms || WALL_CONF.swipe_pause_ms, max_cards_on_screen: cfg.max_cards_on_screen || WALL_CONF.max_cards_on_screen } } if (cardsResp) { const cardsData = await cardsResp.json() if (cardsData.ok && Array.isArray(cardsData.rows)) { scenes = cardsData.rows.map(r => ({ title: String(r.title || ''), items: Array.isArray(r.items) ? r.items.map(x => String(x || '')) : [], meta: String(r.meta || '') })) } } if ((!scenes || scenes.length === 0) && cfg && Array.isArray(cfg.cards)) { scenes = cfg.cards.map(c => ({ title: String(c.title || ''), items: Array.isArray(c.items) ? c.items.map(x => String(x || '')) : [], meta: String(c.meta || '') })) } } catch {} SCENES = Array.isArray(scenes) ? scenes : [] if (SCENES.length === 0) SCENES = [{ title: '示例卡片', items: ['在设置中添加你的提醒'], meta: '示例' }] currentIndex = 0 renderCards() renderPreview() startAutoplay() bindSwipe() loadLogs() fitLayout() applyScrollPolicy() applyCardsBackground() } const fitLayout = () => { const wall = document.getElementById('wall') const cards = elCards() const preview = document.getElementById('preview') const controls = document.querySelector('.controls') if (!wall || !cards) return const avail = wall.clientHeight - ((preview && preview.offsetHeight) || 0) - ((controls && controls.offsetHeight) || 0) - 16 const needed = cards.scrollHeight let scale = 1 if (avail > 0 && needed > avail) { scale = Math.max(0.7, Math.min(1, avail / needed)) } const isPhone = window.innerWidth <= 480 const baseTitle = isPhone ? 22 : 32 const baseList = isPhone ? 16 : 24 const basePad = isPhone ? 12 : 24 const baseWeatherPad = isPhone ? 8 : 12 const set = (k, v) => document.documentElement.style.setProperty(k, `${Math.round(v)}px`) set('--card-title-size', baseTitle * scale) set('--card-list-size', baseList * scale) set('--card-padding', basePad * scale) set('--weather-padding', baseWeatherPad * scale) } document.addEventListener('DOMContentLoaded', () => { initWall() const bs = document.getElementById('btn-settings'); if (bs) bs.addEventListener('click', openSettings) const bl = document.getElementById('btn-prev-card'); if (bl) bl.addEventListener('click', () => { animateRotate('prev'); pauseAutoplay(WALL_CONF.swipe_pause_ms) }) const br = document.getElementById('btn-next-card'); if (br) br.addEventListener('click', () => { animateRotate('next'); pauseAutoplay(WALL_CONF.swipe_pause_ms) }) const bsv = document.getElementById('btn-save'); if (bsv) bsv.addEventListener('click', saveSettings) const bc = document.getElementById('btn-close'); if (bc) bc.addEventListener('click', () => document.getElementById('settings-modal').classList.add('hidden')) const bm = document.getElementById('btn-cards-maintain'); if (bm) bm.addEventListener('click', openCardsMaintain) const bAdd = document.getElementById('cards-btn-add'); if (bAdd) bAdd.addEventListener('click', () => { editingCardId = null; resetCardForm() }) const bCardSave = document.getElementById('cards-btn-save'); if (bCardSave) bCardSave.addEventListener('click', () => { saveCardFromForm() }) const bCardCancel = document.getElementById('cards-btn-cancel'); if (bCardCancel) bCardCancel.addEventListener('click', () => { closeCardsMaintain() }) window.wallOpenSettings = openSettings const bcp = document.getElementById('w-btn-copy'); if (bcp) bcp.addEventListener('click', async () => { try { await navigator.clipboard.writeText((wOut() && wOut().textContent) || '') } catch {} }) const brf = document.getElementById('w-btn-refresh'); if (brf) brf.addEventListener('click', loadLogs) const bcl = document.getElementById('w-btn-clear'); if (bcl) bcl.addEventListener('click', async () => { try { await fetch('/api/debug/clear?channel=wall', { method: 'POST' }) } catch {}; loadLogs() }) window.addEventListener('resize', () => fitLayout()) window.addEventListener('resize', () => applyScrollPolicy()) window.addEventListener('orientationchange', () => applyScrollPolicy()) window.addEventListener('resize', () => applyCardsBackground()) }) const wOut = () => document.getElementById('w-debug-output') const mts = () => (window.fmtCNDateTime ? window.fmtCNDateTime(new Date()) : new Date().toLocaleString('zh-CN')) const logLocal = msg => { const out = wOut(); if (!out) return; const line = `${mts()} ${msg}`; const c = out.textContent || ''; out.textContent = c ? c + "\n" + line : line; out.scrollTop = out.scrollHeight } const loadLogs = async () => { try { const res = await fetch('/api/debug/logs?limit=5000&channel=wall'); const data = await res.json(); const out = wOut(); if (data.ok && out) { out.textContent = (data.lines || []).join('\n'); out.scrollTop = out.scrollHeight } } catch {} } const applyScrollPolicy = () => { const ua = navigator.userAgent || '' const isPhoneUA = /iPhone|Android.+Mobile/i.test(ua) const portraitSmall = window.innerWidth <= 480 && window.matchMedia('(orientation: portrait)').matches const allowScroll = isPhoneUA || portraitSmall const html = document.documentElement const body = document.body if (allowScroll) { html.style.overflowY = 'auto'; html.style.overflowX = 'hidden'; body.style.overflowY = 'auto'; body.style.overflowX = 'hidden' } else { html.style.overflow = 'hidden'; body.style.overflow = 'hidden' } } const applyCardsBackground = async () => { try { const [cfgResp, listResp] = await Promise.all([ fetch('/api/wall/config'), fetch('/api/wall/backgrounds') ]) const data = await cfgResp.json() const listData = await listResp.json().catch(() => ({ ok:false, images: [] })) const cfg = (data && data.config) || {} const bg = cfg.background || {} let url = String(bg.url || '') if (!url) { const arr = Array.isArray(listData.images) ? listData.images : [] if (arr.length > 0) url = arr[Math.floor(Math.random() * arr.length)] else url = '/tools/wall/bjt.jpg' } const mode = String(bg.mode || 'tile') const isPhone = window.innerWidth <= 480 const size = parseInt(bg.size || (isPhone ? 72 : 120), 10) const opacity = Math.max(0, Math.min(1, Number(bg.opacity || 0.12))) // apply to CSS variables for card pseudo background const root = document.documentElement.style root.setProperty('--card-bg-image', `url(${url})`) root.setProperty('--card-bg-repeat', mode === 'cover' ? 'no-repeat' : 'repeat') root.setProperty('--card-bg-size', mode === 'cover' ? 'cover' : `${size}px ${size}px`) root.setProperty('--card-bg-opacity', String(opacity)) // remove legacy overlay if exists const ov = document.getElementById('cards-bg') if (ov && ov.parentNode) ov.parentNode.removeChild(ov) } catch {} }