chore: 初始提交 - 移除所有硬编码凭据,统一从环境变量读取

This commit is contained in:
yangxiangyuan
2026-06-11 17:10:58 +08:00
commit c223c79b36
1699 changed files with 313940 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"_comment": "墙(Wall)工具的前端鉴权配置",
"enable_auth_comment": "true:启用鉴权(需要从导航登录);false:完全放开访问",
"enable_auth": true,
"max_age_days_comment": "鉴权 cookie 的最大有效天数(1~365)",
"max_age_days": 30
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+111
View File
@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Yang's Toolbox · 告知墙</title>
<link rel="icon" href="/icons/wall.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/common/common.css" />
<link rel="stylesheet" href="/tools/wall/wall.css" />
</head>
<body>
<main>
<section class="debug" id="w-debug-section" style="display:none">
<div class="debug-actions">
<button id="w-btn-copy" class="btn">复制调试信息</button>
<button id="w-btn-refresh" class="btn">刷新</button>
<button id="w-btn-clear" class="btn">清空</button>
</div>
<div id="w-debug-output" class="debug-output"></div>
</section>
<section id="wall" class="wall" aria-label="提醒墙">
<div id="cards" class="cards" tabindex="0"></div>
<div id="preview" class="preview"></div>
<div class="controls">
<button id="btn-prev-card" class="btn" title="上一屏">⬅️</button>
<button id="btn-next-card" class="btn" title="下一屏">➡️</button>
<button id="btn-settings" class="btn" onclick="window.wallOpenSettings ? window.wallOpenSettings() : (function(){var m=document.getElementById('settings-modal'); if(m) m.classList.remove('hidden')})()">设置</button>
<button id="btn-cards-maintain" class="btn">数据维护</button>
<div id="status" class="status" style="display:none"></div>
</div>
</section>
<div class="modal hidden" id="settings-modal">
<div class="modal-body wall-settings-modal">
<h3>告知墙设置</h3>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px">
<div><label>自动播放(ms)</label><input id="autoplay-ms" type="number" value="45000" /></div>
<div><label>滑动暂停(ms)</label><input id="swipe-pause-ms" type="number" value="300000" /></div>
<div><label>同时展示卡片数</label><input id="max-cards" type="number" value="2" min="1" max="3" /></div>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px">
<div style="grid-column:1 / span 2"><label>背景图URL</label><input id="bg-url" type="text" placeholder="https://.../bg.png" /></div>
<div><label>模式</label><select id="bg-mode"><option value="tile">平铺</option><option value="cover">铺满</option></select></div>
<div><label>平铺尺寸(px)</label><input id="bg-size" type="number" value="120" /></div>
<div><label>透明度(0-1)</label><input id="bg-opacity" type="number" step="0.01" min="0" max="1" value="0.08" /></div>
</div>
<div class="modal-actions">
<button id="btn-save" class="btn">保存</button>
<button id="btn-close" class="btn">关闭</button>
</div>
</div>
</div>
<div class="modal hidden" id="cards-modal">
<div class="modal-body wall-cards-modal">
<h3>公告数据维护</h3>
<div class="cards-modal-toolbar">
<div>
<button id="cards-btn-add" class="btn">新增卡片</button>
</div>
</div>
<div class="cards-modal-main">
<div class="cards-modal-list">
<table class="table cards-table">
<thead>
<tr>
<th style="text-align:left">标题</th>
<th style="text-align:left">备注</th>
<th style="text-align:left">操作</th>
</tr>
</thead>
<tbody id="cards-table-body"></tbody>
</table>
</div>
<div class="cards-modal-editor">
<div>
<label>标题</label>
<input id="cards-edit-title" type="text" />
</div>
<div class="cards-modal-textarea">
<label>内容(每行一条)</label>
<textarea id="cards-edit-items" rows="6"></textarea>
</div>
<div>
<label>备注</label>
<input id="cards-edit-meta" type="text" />
</div>
</div>
</div>
<div class="modal-actions">
<button id="cards-btn-save" class="btn">保存</button>
<button id="cards-btn-cancel" class="btn">关闭</button>
</div>
</div>
</div>
</main>
<script src="/common/common.js"></script>
<script src="/tools/wall/wall.js"></script>
<script>
(async function(){
try {
const res = await fetch('/api/flags')
const data = await res.json()
if (data && data.debug && data.debug.wall) {
const sec = document.getElementById('w-debug-section')
if (sec) sec.style.display = ''
}
} catch {}
})()
</script>
</body>
</html>
+205
View File
@@ -0,0 +1,205 @@
.root, :root { --card-title-size: 32px; --card-list-size: 24px; --card-padding: 24px }
.html, body { height: 100%; overflow: hidden }
.wall { display: grid; gap: 12px; padding: 8px; min-height: calc(100vh - 60px); height: 100vh; box-sizing: border-box }
.cards { display: grid; grid-template-columns: 1fr; grid-auto-rows: 1fr; height: 100%; gap: 0; align-content: stretch; overflow: hidden }
.cards-inner { display: flex; height: 100%; gap: 12px; will-change: transform; transition: transform 300ms ease }
.cards-inner .card { flex: 0 0 calc(50% - 6px) }
.cards { position: relative }
.card { border: 2px solid #111; border-radius: 16px; padding: var(--card-padding); background: rgba(255,255,255,0.96) }
.card { height: 100%; display: flex; flex-direction: column; position: relative; z-index: 1; overflow: hidden }
.card::before { content: ""; position: absolute; inset: 0; border-radius: 16px; background-image: var(--card-bg-image, none); background-repeat: var(--card-bg-repeat, repeat); background-size: var(--card-bg-size, 120px 120px); background-position: center; opacity: var(--card-bg-opacity, 0.08); z-index: 0 }
.card > * { position: relative; z-index: 1 }
.card .list { flex: 1; font-size: var(--card-list-size) }
.card .lines { flex: 1; font-size: var(--card-list-size); line-height: 1.5 }
.card .lines div { margin: 6px 0 }
.card .title { font-size: var(--card-title-size) }
.card .title { font-size: 32px; font-weight: 700; margin-bottom: 12px }
.card .list { font-size: 24px; line-height: 1.5 }
.card .list li { margin: 6px 0 }
.card .meta { margin-top: 8px; font-size: 16px; color: #666 }
.status { margin: 8px 0; padding: 8px; border: 1px solid #ffd666; background: #fffbe6; color: #a87000; border-radius: 8px }
.controls { display: flex; gap: 8px; align-items: center }
.preview { display: grid; grid-template-columns: repeat(auto-fill, minmax(105px, 1fr)); gap: 6px; max-height: 100%; overflow-y: auto }
.thumb { border: 1px solid #bbb; border-radius: 10px; padding: 6px; background: #fff; cursor: pointer }
.thumb .title { font-size: 12px; font-weight: 600; margin-bottom: 4px }
.preview .title { display: block; max-width: 6em; word-break: break-all; overflow-wrap: anywhere; white-space: normal }
.thumb .list { display: none }
.thumb.active { border-color: #1677ff }
.debug { margin: 12px 0; padding: 8px; border: 1px dashed #aaa; }
.debug-actions { display: flex; gap: 8px; margin-bottom: 8px }
.debug-output { height: 240px; overflow: auto; background: #fafafa; padding: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; white-space: pre-wrap }
/* Landscape preference */
@media (orientation: landscape) {
.wall {
grid-template-columns: 1fr auto;
grid-template-rows: 1fr auto;
grid-template-areas:
"cards preview"
"controls controls";
align-items: start;
}
.preview { display: grid; grid-template-columns: 1fr; grid-auto-rows: min-content; gap: 6px; overflow: auto; width: 8em; box-sizing: border-box }
.thumb { min-width: unset; width: 100% }
.preview .title { max-width: 8em }
.weather { grid-column: 1 / -1 !important }
.controls { grid-column: 1 / -1 !important; width: 100% }
}
/* Portrait phones (iPhone 14/Pro/Max widths ~393–430px) */
@media (max-width: 480px) {
.wall { gap: 8px; padding: 6px; min-height: 100vh }
.cards { gap: 8px }
.card { padding: var(--card-padding) }
.card .title { font-size: var(--card-title-size) }
.card .list { font-size: var(--card-list-size) }
.preview { display: flex; gap: 6px; overflow: hidden; flex-wrap: nowrap }
.thumb { min-width: 90px; padding: 4px }
.thumb .title { font-size: 11px }
.thumb .list { font-size: 9px; line-height: 1.25 }
.controls { gap: 6px }
}
/* Portrait mobile: allow vertical scroll */
@media (max-width: 480px) and (orientation: portrait) {
html, body { overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch }
.wall { min-height: auto }
}
/* Mobile iPad/iPhone friendly */
@media (max-width: 800px) {
.card .title { font-size: 28px }
.card .list { font-size: 20px }
.debug-output { height: 160px }
.controls { flex-wrap: wrap }
}
/* Portrait strict sizing for preview and weather */
@media (orientation: portrait) {
.preview { height: 4em; display: flex; gap: 6px; overflow-x: auto; overflow-y: hidden; align-items: center }
.thumb { height: 3.6em; padding: 4px 6px; display: flex; align-items: center }
.preview .title { line-height: 1em; max-width: 8em; max-height: 4em; word-break: break-all; overflow: hidden; white-space: normal }
}
.wall-settings-modal {
max-width: 640px;
width: 90vw;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
border-radius: 14px;
padding: 20px;
gap: 12px;
}
.wall-settings-modal h3 {
margin: 0 0 8px 0;
font-size: 18px;
}
.wall-settings-modal label {
display: block;
margin-bottom: 4px;
font-size: 13px;
color: #555;
}
.wall-settings-modal input,
.wall-settings-modal select {
width: 100%;
}
.wall-cards-modal {
width: 80vw;
max-width: 80vw;
max-height: 80vh;
height: 80vh;
padding: 20px;
border-radius: 14px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.22);
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 12px;
}
.wall-cards-modal h3 {
margin: 0;
font-size: 18px;
}
.cards-modal-toolbar {
display: flex;
justify-content: flex-start;
align-items: center;
}
.cards-modal-main {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.4fr);
gap: 12px;
min-height: 0;
}
.cards-modal-list {
border: 1px solid #e5e5e5;
border-radius: 8px;
padding: 8px;
background: #fafafa;
overflow: auto;
}
.cards-modal-editor {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 8px;
min-height: 0;
}
.cards-modal-textarea {
display: grid;
grid-template-rows: auto 1fr;
min-height: 0;
}
.cards-modal-textarea textarea {
width: 100%;
height: 100%;
resize: none;
}
.cards-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.cards-table th,
.cards-table td {
padding: 4px 6px;
border-bottom: 1px solid #e5e5e5;
}
.cards-table tr:hover {
background: #f5f5f5;
}
.wall-cards-modal label {
display: block;
margin-bottom: 4px;
font-size: 13px;
color: #555;
}
.wall-cards-modal input,
.wall-cards-modal textarea {
width: 100%;
}
@media (max-width: 800px) {
.wall-cards-modal {
width: 96vw;
max-width: 96vw;
grid-template-rows: auto auto 1fr auto;
}
.cards-modal-main {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 0.9fr) minmax(0, 1.1fr);
}
}
+386
View File
@@ -0,0 +1,386 @@
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 {}
}