Files
Toolbox/public/tools/wall/wall.js
T

387 lines
18 KiB
JavaScript

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 {}
}