491 lines
18 KiB
JavaScript
491 lines
18 KiB
JavaScript
;(function () {
|
||
const el = id => document.getElementById(id)
|
||
const $year = el('week-year')
|
||
const $weeks = el('week-list')
|
||
const $status = el('status')
|
||
const $title = el('report-title')
|
||
const $summary = el('report-summary')
|
||
const $content = el('report-content')
|
||
const $events = el('events')
|
||
const $prev = el('btn-prev-week')
|
||
const $next = el('btn-next-week')
|
||
const $refreshReport = el('btn-refresh-report')
|
||
const $refreshEvents = el('btn-refresh-events')
|
||
const page = document.body.getAttribute('data-page') || 'report'
|
||
const $overlay = el('loading-overlay')
|
||
const $logs = el('loading-logs')
|
||
const $ltitle = el('loading-title')
|
||
const $btnClose = el('btn-loading-close')
|
||
const $btnCopy = el('btn-copy-logs')
|
||
const $btnX = el('loading-close')
|
||
|
||
const qs = new URLSearchParams(location.search)
|
||
const urlKey = qs.get('key') || ''
|
||
const storedKey = (() => { try { return sessionStorage.getItem('weekly_embed_key') || '' } catch { return '' } })()
|
||
const accessKey = urlKey || storedKey || ''
|
||
const apiBase = '/api/weekly_embed'
|
||
|
||
const persistKey = () => {
|
||
if (!urlKey || !accessKey) return
|
||
try { sessionStorage.setItem('weekly_embed_key', accessKey) } catch {}
|
||
try {
|
||
const u = new URL(location.href)
|
||
u.searchParams.delete('key')
|
||
history.replaceState(null, '', `${u.pathname}${u.search}${u.hash}`)
|
||
} catch {}
|
||
}
|
||
|
||
const openLoading = (title) => {
|
||
if ($overlay) {
|
||
$overlay.style.display = 'flex'
|
||
if ($logs) $logs.innerHTML = ''
|
||
if ($ltitle) $ltitle.textContent = title || '加载中'
|
||
if ($btnClose) { $btnClose.disabled = true; $btnClose.onclick = () => { $overlay.style.display = 'none' } }
|
||
if ($btnCopy) { $btnCopy.disabled = true }
|
||
if ($btnX) { $btnX.disabled = true; $btnX.onclick = () => { if (!$btnClose || !$btnClose.disabled) $overlay.style.display = 'none' } }
|
||
}
|
||
}
|
||
|
||
const maskKeyInText = (text) => {
|
||
if (!accessKey) return String(text || '')
|
||
return String(text || '').split(accessKey).join('***')
|
||
}
|
||
|
||
const getLogsText = () => {
|
||
const title = ($ltitle && $ltitle.textContent) ? $ltitle.textContent : '加载中'
|
||
const body = $logs ? $logs.innerText : ''
|
||
return `${title}\n${body}`.trim()
|
||
}
|
||
|
||
const copyLogs = async () => {
|
||
const text = getLogsText()
|
||
if (!text) return
|
||
try {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
await navigator.clipboard.writeText(text)
|
||
} else {
|
||
const ta = document.createElement('textarea')
|
||
ta.value = text
|
||
ta.style.position = 'fixed'
|
||
ta.style.left = '-9999px'
|
||
ta.style.top = '0'
|
||
document.body.appendChild(ta)
|
||
ta.focus()
|
||
ta.select()
|
||
document.execCommand('copy')
|
||
ta.remove()
|
||
}
|
||
addLoadingStep('已复制到剪贴板', '📋')
|
||
} catch (e) {
|
||
addLoadingStep(`复制失败:${String(e && e.message ? e.message : e)}`, '⚠️')
|
||
}
|
||
}
|
||
const addLoadingStep = (text, icon = '⏳') => {
|
||
if (!$logs) return
|
||
const row = document.createElement('div')
|
||
row.className = 'log-step'
|
||
const i = document.createElement('div'); i.className = 'icon'; i.textContent = icon
|
||
const t = document.createElement('div'); t.className = 'text'; t.textContent = maskKeyInText(text)
|
||
row.appendChild(i); row.appendChild(t)
|
||
$logs.appendChild(row)
|
||
$logs.scrollTop = $logs.scrollHeight
|
||
if ($btnCopy) $btnCopy.disabled = false
|
||
}
|
||
const closeLoading = (ok) => {
|
||
if ($overlay) {
|
||
if ($btnClose) $btnClose.disabled = false
|
||
if ($btnCopy) $btnCopy.disabled = !$logs || !$logs.innerText
|
||
if ($btnX) $btnX.disabled = false
|
||
if (ok) setTimeout(() => { $overlay.style.display = 'none' }, 200)
|
||
}
|
||
}
|
||
const runWithLoading = async (fn, title) => {
|
||
openLoading(title || '加载中')
|
||
try {
|
||
await fn()
|
||
addLoadingStep('完成', '✔️')
|
||
closeLoading(true)
|
||
} catch (e) {
|
||
if ($ltitle) $ltitle.textContent = '发生错误'
|
||
addLoadingStep(String(e && (e.stack || e.message) ? (e.stack || e.message) : (e || '失败')), '❌')
|
||
closeLoading(false)
|
||
throw e
|
||
}
|
||
}
|
||
|
||
const setStatus = (msg, type) => {
|
||
if (!$status) return
|
||
$status.textContent = msg || ''
|
||
$status.style.color = type === 'success' ? '#10b981' : '#ef4444'
|
||
}
|
||
|
||
const setNavLinks = () => {
|
||
const linkReport = el('link-report')
|
||
const linkEvents = el('link-events')
|
||
const reportPage = location.pathname.endsWith('weekly_embed.html') ? 'weekly_embed.html' : 'index.html'
|
||
if (linkReport) linkReport.href = `${reportPage}`
|
||
if (linkEvents) linkEvents.href = `events.html`
|
||
}
|
||
|
||
const sanitizeHtml = (html) => {
|
||
const tpl = document.createElement('template')
|
||
tpl.innerHTML = String(html || '')
|
||
const blocked = new Set(['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META', 'STYLE', 'BASE'])
|
||
const safeUrl = (raw) => {
|
||
const s = String(raw || '').trim()
|
||
if (!s) return ''
|
||
if (s.startsWith('#') || s.startsWith('/') || s.startsWith('./') || s.startsWith('../')) return s
|
||
if (/^(https?:|mailto:|tel:)/i.test(s)) return s
|
||
if (/^data:image\//i.test(s)) return s
|
||
return ''
|
||
}
|
||
const walk = (root) => {
|
||
const nodes = Array.from(root.querySelectorAll('*'))
|
||
nodes.forEach(node => {
|
||
if (blocked.has(node.tagName)) { node.remove(); return }
|
||
Array.from(node.attributes).forEach(attr => {
|
||
const name = attr.name.toLowerCase()
|
||
const val = attr.value
|
||
if (name.startsWith('on')) { node.removeAttribute(attr.name); return }
|
||
if (name === 'srcdoc') { node.removeAttribute(attr.name); return }
|
||
if (name === 'href' || name === 'src' || name === 'xlink:href') {
|
||
const v = safeUrl(val)
|
||
if (!v) node.removeAttribute(attr.name)
|
||
else node.setAttribute(attr.name, v)
|
||
return
|
||
}
|
||
if (name === 'style') {
|
||
const s = String(val || '')
|
||
if (/expression\s*\(|javascript:/i.test(s)) node.removeAttribute(attr.name)
|
||
return
|
||
}
|
||
})
|
||
if (node.tagName === 'A') {
|
||
const target = (node.getAttribute('target') || '').toLowerCase()
|
||
if (target === '_blank') {
|
||
const rel = (node.getAttribute('rel') || '').split(/\s+/).filter(Boolean)
|
||
;['noopener', 'noreferrer'].forEach(v => { if (!rel.includes(v)) rel.push(v) })
|
||
node.setAttribute('rel', rel.join(' '))
|
||
}
|
||
}
|
||
})
|
||
}
|
||
walk(tpl.content)
|
||
return tpl.innerHTML
|
||
}
|
||
|
||
const fetchJson = async (path, params = {}) => {
|
||
const url = new URL(`${apiBase}${path}`, location.origin)
|
||
if (accessKey) url.searchParams.set('key', accessKey)
|
||
Object.entries({ ...params }).forEach(([k, v]) => {
|
||
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v))
|
||
})
|
||
addLoadingStep(`请求 ${url.pathname}${url.search.replace(/key=[^&]+/i, 'key=***')}`, '🌐')
|
||
const res = await fetch(url.toString(), {
|
||
cache: 'no-store',
|
||
credentials: 'same-origin',
|
||
headers: { 'X-Weekly-Embed-Key': accessKey }
|
||
})
|
||
const raw = await res.text().catch(() => '')
|
||
let data = {}
|
||
try { data = raw ? JSON.parse(raw) : {} } catch { data = {} }
|
||
if (!res.ok) {
|
||
const reason = String((data && data.error) ? data.error : (res.statusText || '请求失败'))
|
||
addLoadingStep(`HTTP ${res.status} ${reason}`, '🧯')
|
||
if (raw && raw !== '[object Object]') addLoadingStep(`响应:${raw.slice(0, 2000)}`, '📦')
|
||
addLoadingStep(`key_len=${accessKey ? String(accessKey).length : 0}`, '🔑')
|
||
throw new Error(reason)
|
||
}
|
||
if (Array.isArray(data.trace)) data.trace.forEach(s => addLoadingStep(`后端:${s}`, '⚙️'))
|
||
return data
|
||
}
|
||
|
||
const formatCNDeadline = (ts) => {
|
||
if (!ts) return ''
|
||
const s = String(ts).replace('T', ' ')
|
||
return s.length >= 19 ? s.slice(0, 19) : (s.length >= 16 ? s.slice(0, 16) : s)
|
||
}
|
||
|
||
const statusLabel = (code) => {
|
||
return ({ doing: '▶️进行中', done: '✔️完成', pause: '⏸️暂停', new: '📅本周新增', close: '❌关闭', not: '👋未做', ok: '👌成功', fail: '⛔失败' })[code] || code || '未知'
|
||
}
|
||
|
||
let weekList = []
|
||
let currentYear = new Date().getFullYear()
|
||
let currentWeekId = ''
|
||
|
||
const updateWeekButtons = () => {
|
||
const idx = weekList.findIndex(w => w.week_id === $weeks.value)
|
||
if ($prev) $prev.disabled = idx < 0 || idx >= weekList.length - 1
|
||
if ($next) $next.disabled = idx <= 0
|
||
}
|
||
|
||
const toTime = (v) => {
|
||
const s = String(v || '')
|
||
if (!s) return 0
|
||
const t = Date.parse(s)
|
||
if (!Number.isNaN(t)) return t
|
||
return Date.parse(s.replace(/\//g, '-')) || 0
|
||
}
|
||
|
||
const renderWeekOptions = (list) => {
|
||
weekList = list.slice().sort((a, b) => {
|
||
const ta = toTime(a.end || a.start)
|
||
const tb = toTime(b.end || b.start)
|
||
if (ta !== tb) return tb - ta
|
||
return String(b.week_id || '').localeCompare(String(a.week_id || ''))
|
||
})
|
||
$weeks.innerHTML = ''
|
||
weekList.forEach(w => {
|
||
const opt = document.createElement('option')
|
||
opt.value = w.week_id
|
||
const type = w.week_id === currentWeekId ? '📗 ' : '📘 '
|
||
const icon = (w.has_report && w.has_events) ? ' 📅💬' : (w.has_report ? ' 📅' : (w.has_events ? ' 💬' : ''))
|
||
opt.textContent = `${type}${w.week_id} (${w.start}~${w.end})${icon}`
|
||
$weeks.appendChild(opt)
|
||
})
|
||
if (currentWeekId && weekList.find(w => w.week_id === currentWeekId)) $weeks.value = currentWeekId
|
||
else if (weekList.length) $weeks.value = weekList[0].week_id
|
||
updateWeekButtons()
|
||
}
|
||
|
||
const loadWeeks = async (year) => {
|
||
addLoadingStep(`准备计算 ${year} 年的 ISO 周`, '🗓️')
|
||
const data = await fetchJson('/weeks', { year })
|
||
currentWeekId = data.currentWeekId || ''
|
||
addLoadingStep('渲染周下拉', '🧩')
|
||
renderWeekOptions(data.weeks || [])
|
||
await loadCurrent()
|
||
}
|
||
|
||
const loadYears = async () => {
|
||
let years = []
|
||
try {
|
||
const data = await fetchJson('/years')
|
||
years = Array.isArray(data.years) ? data.years : []
|
||
} catch {}
|
||
if (!years.length) years = [currentYear, currentYear - 1]
|
||
years = Array.from(new Set(years.concat([currentYear, currentYear + 1, currentYear - 1]))).sort((a, b) => b - a)
|
||
$year.innerHTML = ''
|
||
years.forEach(y => {
|
||
const opt = document.createElement('option')
|
||
opt.value = y
|
||
opt.textContent = `${y} 年`
|
||
$year.appendChild(opt)
|
||
})
|
||
if (years.includes(currentYear)) $year.value = currentYear
|
||
else if (years.length) { currentYear = years[0]; $year.value = currentYear }
|
||
await loadWeeks(currentYear)
|
||
}
|
||
|
||
const setText = (node, text, placeholder) => {
|
||
if (!node) return
|
||
const value = String(text || '').trim()
|
||
if (!value) {
|
||
node.textContent = placeholder
|
||
node.classList.add('muted')
|
||
return
|
||
}
|
||
node.classList.remove('muted')
|
||
node.textContent = value
|
||
}
|
||
|
||
const setHtml = (node, html, placeholder) => {
|
||
if (!node) return
|
||
const value = String(html || '').trim()
|
||
if (!value) {
|
||
node.textContent = placeholder
|
||
node.classList.add('muted')
|
||
return
|
||
}
|
||
node.classList.remove('muted')
|
||
node.innerHTML = sanitizeHtml(value)
|
||
}
|
||
|
||
const TITLE_INLINE_HAN_LIMIT = 50
|
||
const normalizeInlineText = text => String(text || '').replace(/\s+/g, ' ').trim()
|
||
const normalizeBodyForTitle = content => normalizeInlineText(String(content || '').replace(/<br\s*\/?>/gi, ';').replace(/<[^>]+>/g, ' ').replace(/\r?\n+/g, ';'))
|
||
const countHanChars = text => (String(text || '').match(/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g) || []).length
|
||
const countDisplayChars = text => Array.from(String(text || '')).length
|
||
const takePrefixByHanLimit = (text, hanLimit) => {
|
||
const chars = Array.from(String(text || ''))
|
||
let han = 0
|
||
let out = ''
|
||
for (const ch of chars) {
|
||
const isHan = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/.test(ch)
|
||
if (isHan) {
|
||
if (han >= hanLimit) break
|
||
han += 1
|
||
}
|
||
out += ch
|
||
}
|
||
return out.trim()
|
||
}
|
||
const hasTitlePolicyApplied = (title, detail) => {
|
||
const t = normalizeInlineText(title)
|
||
const d = normalizeInlineText(detail)
|
||
if (!d) return true
|
||
if (t.includes('【剩余') && t.includes('详见正文】')) return true
|
||
if (d.length >= 10 && t.includes(d.slice(0, 10))) return true
|
||
return false
|
||
}
|
||
const buildEventTitleByPolicy = (title, content) => {
|
||
const base = normalizeInlineText(title)
|
||
const detail = normalizeBodyForTitle(content)
|
||
if (!detail || hasTitlePolicyApplied(base, detail)) return base
|
||
const hanCount = countHanChars(detail)
|
||
const effectiveCount = hanCount > 0 ? hanCount : countDisplayChars(detail)
|
||
if (effectiveCount <= TITLE_INLINE_HAN_LIMIT) return normalizeInlineText(`${base} ${detail}`)
|
||
const prefix = hanCount > 0 ? takePrefixByHanLimit(detail, TITLE_INLINE_HAN_LIMIT) : Array.from(detail).slice(0, TITLE_INLINE_HAN_LIMIT).join('')
|
||
const remaining = Math.max(0, effectiveCount - TITLE_INLINE_HAN_LIMIT)
|
||
return normalizeInlineText(`${base} ${prefix}【剩余${remaining}汉字,详见正文】`)
|
||
}
|
||
|
||
const loadReport = async () => {
|
||
if (!$weeks.value) return
|
||
addLoadingStep(`获取周报 ${$weeks.value}`, '📄')
|
||
const data = await fetchJson('/report', { week_id: $weeks.value })
|
||
const report = data.report || null
|
||
addLoadingStep('更新标题/摘要/正文', '📝')
|
||
setText($title, report ? report.title : '', '暂无周报')
|
||
setText($summary, report ? report.summary : '', '暂无摘要')
|
||
setHtml($content, report ? report.content : '', '暂无内容')
|
||
}
|
||
|
||
const renderEvents = (events) => {
|
||
if (!$events) return
|
||
$events.innerHTML = ''
|
||
if (!events.length) {
|
||
const empty = document.createElement('div')
|
||
empty.className = 'empty'
|
||
empty.textContent = '暂无事件'
|
||
$events.appendChild(empty)
|
||
return
|
||
}
|
||
events.forEach(ev => {
|
||
const wrap = document.createElement('div')
|
||
wrap.className = 'event'
|
||
|
||
const header = document.createElement('div')
|
||
header.className = 'event-header'
|
||
|
||
const tag = document.createElement('span')
|
||
tag.className = 'tag'
|
||
tag.textContent = statusLabel(ev.status_code)
|
||
|
||
const title = document.createElement('div')
|
||
title.className = 'event-title'
|
||
title.textContent = buildEventTitleByPolicy(ev.title || '', ev.content || '')
|
||
|
||
header.appendChild(tag)
|
||
header.appendChild(title)
|
||
|
||
const meta = document.createElement('div')
|
||
meta.className = 'event-meta'
|
||
if (ev.deadline) {
|
||
const item = document.createElement('div')
|
||
item.className = 'event-meta-item'
|
||
item.textContent = `截止时间:${formatCNDeadline(ev.deadline)}`
|
||
meta.appendChild(item)
|
||
}
|
||
const pushItem = document.createElement('div')
|
||
pushItem.className = 'event-meta-item'
|
||
pushItem.textContent = `推送:${ev.push_required ? '是' : '否'}`
|
||
meta.appendChild(pushItem)
|
||
|
||
const body = document.createElement('div')
|
||
body.className = 'event-body'
|
||
body.innerHTML = sanitizeHtml(ev.content || '')
|
||
|
||
wrap.appendChild(header)
|
||
wrap.appendChild(meta)
|
||
wrap.appendChild(body)
|
||
$events.appendChild(wrap)
|
||
})
|
||
}
|
||
|
||
const loadEvents = async () => {
|
||
if (!$weeks.value) return
|
||
addLoadingStep(`查询周报 ${$weeks.value}`, '🔍')
|
||
const reportData = await fetchJson('/report', { week_id: $weeks.value })
|
||
const report = reportData.report || null
|
||
if (!report || !report.id) {
|
||
addLoadingStep('本周暂无周报或事件', 'ℹ️')
|
||
renderEvents([])
|
||
return
|
||
}
|
||
addLoadingStep(`获取事件列表 report_id=${report.id}`, '📑')
|
||
const eventsData = await fetchJson('/events', { report_id: report.id })
|
||
const events = eventsData.rows || eventsData.events || []
|
||
addLoadingStep(`渲染事件 ${events.length} 条`, '🧩')
|
||
renderEvents(events)
|
||
}
|
||
|
||
const loadCurrent = async () => {
|
||
setStatus('')
|
||
try {
|
||
addLoadingStep(`进入${page === 'report' ? '周报' : '事件列表'}页面`, '📌')
|
||
if (page === 'report') await loadReport()
|
||
else await loadEvents()
|
||
} catch (e) {
|
||
setStatus(String(e.message || e || '加载失败'))
|
||
}
|
||
}
|
||
|
||
const movePrevWeek = () => {
|
||
const idx = weekList.findIndex(w => w.week_id === $weeks.value)
|
||
if (idx >= 0 && idx < weekList.length - 1) {
|
||
$weeks.value = weekList[idx + 1].week_id
|
||
updateWeekButtons()
|
||
runWithLoading(loadCurrent, '切换上一周')
|
||
}
|
||
}
|
||
|
||
const moveNextWeek = () => {
|
||
const idx = weekList.findIndex(w => w.week_id === $weeks.value)
|
||
if (idx > 0) {
|
||
$weeks.value = weekList[idx - 1].week_id
|
||
updateWeekButtons()
|
||
runWithLoading(loadCurrent, '切换下一周')
|
||
}
|
||
}
|
||
|
||
if (!accessKey) {
|
||
setNavLinks()
|
||
setStatus('缺少访问 key,请在 URL 中添加 ?key=你的key')
|
||
if ($year) $year.disabled = true
|
||
if ($weeks) $weeks.disabled = true
|
||
if ($prev) $prev.disabled = true
|
||
if ($next) $next.disabled = true
|
||
return
|
||
}
|
||
|
||
persistKey()
|
||
setNavLinks()
|
||
|
||
if ($btnCopy) $btnCopy.onclick = () => { copyLogs() }
|
||
|
||
if ($year) $year.onchange = () => {
|
||
currentYear = parseInt($year.value, 10) || currentYear
|
||
runWithLoading(() => loadWeeks(currentYear), '切换年份')
|
||
}
|
||
if ($weeks) $weeks.onchange = () => {
|
||
updateWeekButtons()
|
||
runWithLoading(loadCurrent, '切换周')
|
||
}
|
||
if ($prev) $prev.onclick = movePrevWeek
|
||
if ($next) $next.onclick = moveNextWeek
|
||
if ($refreshReport) $refreshReport.onclick = () => runWithLoading(loadReport, '刷新周报')
|
||
if ($refreshEvents) $refreshEvents.onclick = () => runWithLoading(loadEvents, '刷新事件')
|
||
|
||
if ($overlay) {
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key !== 'Escape') return
|
||
if ($overlay.style.display !== 'flex') return
|
||
if (!$btnClose || !$btnClose.disabled) $overlay.style.display = 'none'
|
||
})
|
||
}
|
||
|
||
runWithLoading(loadYears, '初始化加载')
|
||
})()
|