;(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(//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, '初始化加载') })()