Files

491 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
;(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, '初始化加载')
})()