Files

2082 lines
100 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.
const toFiniteNumber = v => {
const n = Number(v)
return Number.isFinite(n) ? n : null
}
const normalizeMove = (row) => {
const changeRaw = toFiniteNumber(row && row.change)
const prevClose = toFiniteNumber(row && row.prevClose)
const changePercentRaw = toFiniteNumber(row && row.change_percent)
const hasDerivedPercent = changeRaw !== null && prevClose !== null && prevClose !== 0
const changePercent = hasDerivedPercent ? (changeRaw / prevClose) * 100 : (changePercentRaw !== null ? changePercentRaw : 0)
const basis = changeRaw !== null ? changeRaw : changePercent
const sign = basis >= 0 ? 1 : -1
const change = changeRaw !== null ? changeRaw : 0
return { change, changePercent, sign }
}
const addDays = (dateStr, n) => {
try {
const parts = String(dateStr || '').split('-')
const d = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]))
d.setDate(d.getDate() + n)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
} catch { return dateStr }
}
const escHtml = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const globalNewsState = {
mode: '',
date: '',
rows: [],
stats: null,
fetchedAt: ''
}
const marketsUiState = {
uiMode: 'debug',
interaction: {
lines: [],
autoCloseTimer: null
}
}
const fmtInteractionTime = () => {
try {
if (window.fmtCNDateTime) return window.fmtCNDateTime(new Date())
return new Date().toLocaleString('zh-CN', { hour12: false })
} catch {
return new Date().toISOString()
}
}
const safeParseJsonText = text => {
try { return JSON.parse(text) } catch { return null }
}
const summarizeInteractionValue = value => {
if (Array.isArray(value)) {
if (value.length <= 4) return value
return { count: value.length, sample: value.slice(0, 2) }
}
if (!value || typeof value !== 'object') return value
const out = {}
for (const [key, item] of Object.entries(value)) {
if (Array.isArray(item) && item.length > 4) {
out[key] = { count: item.length, sample: item.slice(0, 2) }
} else {
out[key] = item
}
}
return out
}
const stringifyInteractionValue = value => {
if (value === undefined) return ''
if (typeof value === 'string') return value
try { return JSON.stringify(summarizeInteractionValue(value), null, 2) } catch { return String(value) }
}
const clearInteractionAutoClose = () => {
if (marketsUiState.interaction.autoCloseTimer) {
clearTimeout(marketsUiState.interaction.autoCloseTimer)
marketsUiState.interaction.autoCloseTimer = null
}
}
const renderInteractionLog = () => {
const logEl = el('interaction-log')
if (!logEl) return
logEl.textContent = marketsUiState.interaction.lines.join('\n\n')
logEl.scrollTop = logEl.scrollHeight
}
const setInteractionTone = (tone, text, hint) => {
const badge = el('interaction-badge')
const summary = el('interaction-summary')
const hintEl = el('interaction-hint')
if (badge) {
badge.className = 'interaction-badge'
if (tone === 'success') badge.classList.add('success')
if (tone === 'error') badge.classList.add('error')
badge.textContent = tone === 'success' ? '已完成' : tone === 'error' ? '已停止' : '进行中'
}
if (summary) {
summary.className = 'interaction-summary'
if (tone === 'success') summary.classList.add('success')
if (tone === 'error') summary.classList.add('error')
summary.textContent = text || ''
}
if (hintEl) hintEl.textContent = hint || ''
}
const openInteractionDialog = ({ title, meta, summary }) => {
clearInteractionAutoClose()
marketsUiState.interaction.lines = []
const modal = el('interaction-modal')
const titleEl = el('interaction-title')
const metaEl = el('interaction-meta')
if (titleEl) titleEl.textContent = title || '交互详情'
if (metaEl) metaEl.textContent = meta || ''
setInteractionTone('running', summary || '正在准备执行,请稍候...', '')
renderInteractionLog()
if (modal) modal.classList.remove('hidden')
}
const closeInteractionDialog = () => {
clearInteractionAutoClose()
const modal = el('interaction-modal')
if (modal) modal.classList.add('hidden')
}
const fitArchiveIframeHeight = iframe => {
if (!iframe) return
const fallbackHeight = 220
try {
const doc = iframe.contentDocument || (iframe.contentWindow && iframe.contentWindow.document)
if (!doc) throw new Error('missing_iframe_document')
const body = doc.body
const html = doc.documentElement
const height = Math.max(
body ? body.scrollHeight : 0,
body ? body.offsetHeight : 0,
html ? html.scrollHeight : 0,
html ? html.offsetHeight : 0,
fallbackHeight
)
iframe.style.height = `${height + 16}px`
} catch {
iframe.style.height = `${fallbackHeight}px`
}
}
const appendInteractionLog = (title, detail) => {
const head = `[${fmtInteractionTime()}] ${title}`
const body = stringifyInteractionValue(detail)
marketsUiState.interaction.lines.push(body ? `${head}\n${body}` : head)
renderInteractionLog()
}
const appendInteractionTrace = trace => {
if (!trace) return
if (trace.summary) appendInteractionLog('Trace Summary', trace.summary)
if (Array.isArray(trace.events)) {
trace.events.forEach((event, index) => appendInteractionLog(`Trace Event #${index + 1}`, event))
}
}
const setButtonBusy = (buttonOrId, busy, busyText) => {
const btn = typeof buttonOrId === 'string' ? el(buttonOrId) : buttonOrId
if (!btn) return
if (!btn.dataset.baseText) btn.dataset.baseText = btn.textContent || ''
btn.disabled = !!busy
btn.textContent = busy ? (busyText || `${btn.dataset.baseText || '处理中'}…`) : btn.dataset.baseText
}
const loadMarketsUiMode = async () => {
try {
const res = await fetch('/api/markets/config')
const data = await res.json()
if (data && data.ok && data.config && typeof data.config.ui_mode === 'string') {
marketsUiState.uiMode = String(data.config.ui_mode || 'debug').trim() || 'debug'
}
} catch {}
}
const scheduleInteractionAutoClose = () => {
clearInteractionAutoClose()
const seconds = marketsUiState.uiMode === 'normal' ? 2 : 3
const hintEl = el('interaction-hint')
if (hintEl) hintEl.textContent = `成功后 ${seconds}s 自动关闭,异常时会停留在此窗口`
marketsUiState.interaction.autoCloseTimer = setTimeout(() => {
closeInteractionDialog()
}, seconds * 1000)
}
const runTrackedInteraction = async (options, executor) => {
const startedAt = Date.now()
const title = options && options.title ? options.title : '交互详情'
const action = options && options.action ? options.action : title
const button = options && options.button ? options.button : null
openInteractionDialog({
title,
meta: `模式 ${marketsUiState.uiMode} | ${fmtInteractionTime()}`,
summary: options && options.summary ? options.summary : '正在执行...'
})
appendInteractionLog('触发动作', action)
if (options && options.request) appendInteractionLog('初始参数', options.request)
if (button) setButtonBusy(button, true, options && options.loadingText ? options.loadingText : '')
const ctx = {
log: appendInteractionLog,
setSummary: (text, tone) => setInteractionTone(tone || 'running', text, ''),
fetchJson: async (url, fetchOptions = {}, meta = {}) => {
const method = String(fetchOptions.method || 'GET').toUpperCase()
const reqDetail = { url, method, ...meta }
if (fetchOptions.body !== undefined) {
reqDetail.body = safeParseJsonText(fetchOptions.body) || fetchOptions.body
}
appendInteractionLog('HTTP 请求', reqDetail)
const res = await fetch(url, fetchOptions)
const text = await res.text()
const data = safeParseJsonText(text)
appendInteractionLog('HTTP 响应', { url, status: res.status, ok: res.ok, statusText: res.statusText })
appendInteractionLog('响应摘要', data || text)
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`.trim())
if (data && data.ok === false) throw new Error(data.error || data.message || '业务处理失败')
return data
}
}
try {
const result = await executor(ctx)
if (result && result.trace) appendInteractionTrace(result.trace)
appendInteractionLog('总耗时', `${Date.now() - startedAt} ms`)
const successText = typeof (options && options.successText) === 'function'
? options.successText(result)
: ((options && options.successText) || '执行完成')
setInteractionTone('success', successText, '')
scheduleInteractionAutoClose()
return result
} catch (e) {
appendInteractionLog('错误信息', String(e && e.message || e))
appendInteractionLog('总耗时', `${Date.now() - startedAt} ms`)
setInteractionTone('error', `执行失败:${String(e && e.message || e)}`, '窗口已停留,便于查看问题与排查。')
throw e
} finally {
if (button) setButtonBusy(button, false)
}
}
const applyGlobalNewsMode = mode => {
const translateBtn = el('news-btn-translate')
const analyzeBtn = el('news-btn-analyze')
if (translateBtn) {
if (!translateBtn.dataset.baseText) translateBtn.dataset.baseText = translateBtn.textContent || '🌐 AI翻译'
translateBtn.disabled = false
translateBtn.textContent = translateBtn.dataset.baseText
translateBtn.title = mode === 'remote_ephemeral' ? '当前模式默认已在抓取后自动执行 AI 翻译' : ''
}
if (analyzeBtn) {
if (!analyzeBtn.dataset.baseText) analyzeBtn.dataset.baseText = analyzeBtn.textContent || '🤖 AI研判'
analyzeBtn.disabled = false
analyzeBtn.textContent = analyzeBtn.dataset.baseText
analyzeBtn.title = mode === 'remote_ephemeral' ? '当前模式默认已在抓取后自动执行 AI 研判' : ''
}
}
const renderGlobalNewsPayload = (payload, activeDate) => {
const list = el('news-list')
const summary = el('news-summary')
const status = el('news-status')
if (!list) return
const d = payload || {}
const rows = Array.isArray(d.rows) ? d.rows : []
const date = activeDate || d.date || ''
if (summary) {
const sources = [...new Set(rows.map(r => r.source_name))]
const financeSources = [...new Set(rows.filter(r => r.category === 'finance').map(r => r.source_name))]
const remoteCount = rows.filter(r => r.fetch_method === 'remote_probe').length
let infoParts = [`📅 ${date}`, `${rows.length} 条新闻`, `${sources.length} 个媒体`]
if (d.mode === 'remote_ephemeral') infoParts.push('☁️ 远程探针')
if (financeSources.length) infoParts.push(`💰 财经 ${financeSources.length} 家`)
if (d.stats && d.stats.rss_fallback > 0) infoParts.push(`🔄 RSS 兜底 ${d.stats.rss_fallback} 条`)
if (remoteCount > 0) infoParts.push(`📡 探针 ${remoteCount} 条`)
const translatedCount = rows.filter(r => r.title_cn).length
if (translatedCount > 0) infoParts.push(`🌐 已翻译 ${translatedCount} 条`)
const analyzedCount = rows.filter(r => r.ai_status).length
if (analyzedCount > 0) infoParts.push(`🤖 已研判 ${analyzedCount} 条`)
summary.innerHTML = `<span style="font-size:14px;color:#64748b">${infoParts.join(' · ')}</span>`
}
if (rows.length === 0) {
list.innerHTML = '<tr><td colspan="8" class="muted" style="text-align:center;padding:30px">暂无数据,请点击「手动刷新」抓取</td></tr>'
if (status) { status.style.display = ''; status.textContent = '该日期暂无数据' }
return
}
list.innerHTML = rows.map(r => {
const titleCn = r.title_cn ? `<div style="font-size:16px;font-weight:700;color:#111827;line-height:1.4">${escHtml(r.title_cn)}</div>` : ''
const summaryCn = r.summary_cn ? `<div style="font-size:14px;color:#1e293b;line-height:1.5;margin-top:2px">${escHtml(r.summary_cn)}</div>` : ''
const hasInsight = r.ai_status && (r.ai_domestic || r.ai_overseas || r.ai_defensive)
const insightAttr = hasInsight ? ` data-insight="${escAttr(r.ai_domestic || '')}|||${escAttr(r.ai_overseas || '')}|||${escAttr(r.ai_defensive || '')}" data-status="${escAttr(r.ai_status)}"` : ''
const insightCell = hasInsight
? `<td class="news-insight expandable${r.ai_status === 'unrelated' ? ' unrelated' : ''}"${insightAttr}><div class="expandable-content">${r.ai_status === 'unrelated' ? '【无关】' : `🇨🇳 ${escHtml(r.ai_domestic || '')}<br><br>🌍 ${escHtml(r.ai_overseas || '')}<br><br>🛡️ ${escHtml(r.ai_defensive || '')}`}</div><span class="expandable-toggle">👇展开</span></td>`
: '<td class="news-insight" style="font-size:12px;color:#94a3b8">—</td>'
const link = String(r.link || '').trim()
const linkHtml = link
? `<a href="${escHtml(link)}" target="_blank" rel="noreferrer" class="news-link">🔗</a>`
: '<span class="news-link" style="color:#94a3b8">—</span>'
return `<tr>
<td><span class="news-continent-tag">${escHtml(r.continent)}</span></td>
<td style="font-size:13px">${escHtml(r.country)}</td>
<td style="font-weight:600">${escHtml(r.source_name)}</td>
<td style="font-size:12px;color:#64748b">${String(r.published_at || '').slice(0, 16)}</td>
<td style="font-size:13px;color:#64748b;line-height:1.5">${escHtml(r.title)}${titleCn}</td>
<td style="font-size:12px;color:#94a3b8;line-height:1.5">${escHtml(r.summary)}${summaryCn}</td>
<td>${linkHtml}</td>
${insightCell}
</tr>`
}).join('')
}
const loadGlobalNews = async (forceFetch, options = {}) => {
const status = el('news-status')
const dateInput = el('news-date')
const date = dateInput ? dateInput.value : yesterdayLocal()
const tracked = options && options.tracked === true
if (forceFetch) {
if (status) { status.style.display = ''; status.textContent = '正在抓取新闻...' }
try {
const executeFetch = async ctx => {
const result = ctx
? await ctx.fetchJson('/api/global_news/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date })
}, { api: '/api/global_news/fetch', date })
: await (async () => {
const r = await fetch('/api/global_news/fetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date }) })
return r.json()
})()
if (ctx) {
ctx.log('新闻抓取汇总', {
mode: result && result.mode,
saved: result && result.saved,
translated: result && result.translated,
analyzed: result && result.analyzed,
impacted_dates: result && result.impacted_dates
})
}
return result
}
const d = tracked
? await runTrackedInteraction({
title: '全球新闻抓取',
action: '手动刷新全球新闻',
button: el('news-btn-fetch'),
loadingText: '抓取中…',
request: { date },
summary: '正在抓取新闻并入库;AI 翻译与 AI 研判改为单独手工触发或由 8977 夜间任务执行...',
successText: result => `新闻抓取完成:保存 ${(result && result.saved) || 0} 条`
}, executeFetch)
: await executeFetch(null)
if (d.need_proxy) {
if (status) { status.style.display = ''; status.textContent = '❌ 所有数据源不可达,请在服务器配置代理后重试' }
return
}
if (!d.ok) {
if (status) { status.style.display = ''; status.textContent = `抓取失败: ${d.error || ''}` }
return
}
const st = d.stats || {}
globalNewsState.mode = String(d.mode || '')
globalNewsState.date = String(d.date || date)
globalNewsState.rows = Array.isArray(d.rows) ? d.rows : []
globalNewsState.stats = st
globalNewsState.fetchedAt = String(d.fetched_at || '')
applyGlobalNewsMode(globalNewsState.mode)
let msg = d.mode === 'remote_ephemeral'
? `✅ 远程探针已抓取并入库 ${d.saved} 条 (${st.remote_ok || 0} 个源成功 / ${st.remote_fail || 0} 个源失败)`
: `✅ 已抓取 ${d.saved} 条 (${st.google_ok || 0} Google / ${st.rss_fallback_used || 0} RSS兜底)`
if (d.mode !== 'remote_ephemeral' && st.google_fail > 0) msg += ` ⚠️ ${st.google_fail} 个源 Google 不通`
if (d.finance_sources > 0) msg += ` 💰 含 ${d.finance_sources} 家财经`
if (Array.isArray(d.next_actions) && d.next_actions.length > 0) msg += ` 🧭 后续可手工执行:${d.next_actions.join(' / ')}`
if (d.proxied_to_automation) msg += ` 🔁 当前由 ${d.automation_port} 自动服务执行`
if (d.mode === 'remote_ephemeral' && Array.isArray(d.impacted_dates) && d.impacted_dates.length > 0) msg += ` 📅 落到 ${d.impacted_dates.join(' / ')}`
if (status) { status.style.display = ''; status.textContent = msg }
if (Array.isArray(d.rows)) {
renderGlobalNewsPayload(d, date)
return
}
} catch (e) {
if (status) { status.style.display = ''; status.textContent = `请求失败: ${e.message}` }
return
}
}
try {
const r = await fetch(`/api/global_news/list?date=${encodeURIComponent(date)}`)
const d = await r.json()
if (!d.ok) {
const list = el('news-list')
if (list) list.innerHTML = `<tr><td colspan="8" class="muted" style="text-align:center;padding:20px">加载失败: ${escHtml(d.error || '')}</td></tr>`
return
}
globalNewsState.mode = String(d.mode || '')
globalNewsState.date = String(d.date || date)
globalNewsState.rows = Array.isArray(d.rows) ? d.rows : []
globalNewsState.stats = d.stats || null
globalNewsState.fetchedAt = String(d.fetched_at || '')
applyGlobalNewsMode(globalNewsState.mode)
renderGlobalNewsPayload(d, date)
if (status && !forceFetch) { status.style.display = 'none' }
} catch (e) {
const list = el('news-list')
if (list) list.innerHTML = `<tr><td colspan="8" class="muted" style="text-align:center;padding:20px;color:#dc2626">加载失败: ${escHtml(String(e.message || e))}</td></tr>`
}
}
const translateGlobalNews = async () => {
const status = el('news-status')
const dateInput = el('news-date')
const date = dateInput ? dateInput.value : yesterdayLocal()
if (status) { status.style.display = ''; status.textContent = '正在AI翻译…' }
try {
const d = await runTrackedInteraction({
title: '全球新闻 AI 翻译',
action: 'AI 翻译全球新闻',
button: el('news-btn-translate'),
loadingText: '翻译中…',
request: { date },
summary: '正在调用 AI 对当天新闻做翻译整理...',
successText: result => `翻译完成:${(result && result.translated) || 0} 条`
}, async ctx => {
const result = await ctx.fetchJson('/api/global_news/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date })
}, { api: '/api/global_news/translate', date })
ctx.log('翻译结果', {
translated: result && result.translated,
errors: result && result.errors
})
return result
})
let msg = `翻译完成: ${d.translated} 条`
if (d.errors && d.errors.length) {
msg += ` ❌ 失败 ${d.errors.length} 批: ${d.errors.slice(0, 3).join(';')}`
console.error('[global_news] translate errors:', d.errors)
}
if (status) status.textContent = msg
await loadGlobalNews(false)
} catch (e) {
if (status) status.textContent = `翻译请求失败: ${e.message}`
}
}
const analyzeGlobalNews = async () => {
const status = el('news-status')
const dateInput = el('news-date')
const date = dateInput ? dateInput.value : yesterdayLocal()
if (status) { status.style.display = ''; status.textContent = '正在AI研判…' }
try {
const d = await runTrackedInteraction({
title: '全球新闻 AI 研判',
action: 'AI 研判全球新闻',
button: el('news-btn-analyze'),
loadingText: '研判中…',
request: { date },
summary: '正在调用 AI 对当天新闻做市场影响研判...',
successText: result => `研判完成:${(result && result.analyzed) || 0} 条`
}, async ctx => {
const result = await ctx.fetchJson('/api/global_news/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date })
}, { api: '/api/global_news/analyze', date })
ctx.log('研判结果', {
analyzed: result && result.analyzed,
errors: result && result.errors
})
return result
})
let msg = `研判完成: ${d.analyzed} 条`
if (d.errors && d.errors.length) {
msg += ` ❌ 失败 ${d.errors.length} 批: ${d.errors.slice(0, 3).join(';')}`
console.error('[global_news] analyze errors:', d.errors)
}
if (status) status.textContent = msg
await loadGlobalNews(false)
} catch (e) {
if (status) status.textContent = `研判请求失败: ${e.message}`
}
}
const fmtSign = n => `${n >= 0 ? '+' : '-'}${Math.abs(n).toFixed(2)}`
const fmtDateLocal = d => { const y = d.getFullYear(); const m = String(d.getMonth()+1).padStart(2,'0'); const day = String(d.getDate()).padStart(2,'0'); return `${y}-${m}-${day}` }
const parseDateLocal = s => { const parts = String(s||'').split('-'); if (parts.length===3) { const y=Number(parts[0]); const m=Number(parts[1]); const d=Number(parts[2]); if (Number.isFinite(y)&&Number.isFinite(m)&&Number.isFinite(d)) return new Date(y, m-1, d) } return new Date() }
const todayLocal = () => fmtDateLocal(new Date())
const yesterdayLocal = () => { const d = new Date(); d.setDate(d.getDate() - 1); return fmtDateLocal(d) }
const offsetLocal = (s, d) => { const t = s ? parseDateLocal(s) : new Date(); const x = new Date(t.getFullYear(), t.getMonth(), t.getDate() + d); return fmtDateLocal(x) }
const fmtMonthLocal = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
const parseMonthLocal = s => {
const parts = String(s || '').split('-')
if (parts.length === 2) {
const y = Number(parts[0])
const m = Number(parts[1])
if (Number.isFinite(y) && Number.isFinite(m) && m >= 1 && m <= 12) return new Date(y, m - 1, 1)
}
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), 1)
}
const todayMonthLocal = () => fmtMonthLocal(new Date())
const offsetMonthLocal = (s, d) => {
const t = parseMonthLocal(s)
return fmtMonthLocal(new Date(t.getFullYear(), t.getMonth() + d, 1))
}
const getQueryParams = () => {
try {
const url = new URL(window.location.href)
return {
tab: String(url.searchParams.get('tab') || '').trim(),
month: String(url.searchParams.get('month') || '').trim(),
date: String(url.searchParams.get('date') || '').trim(),
week: String(url.searchParams.get('week') || '').trim()
}
} catch {
return { tab: '', month: '', date: '', week: '' }
}
}
const mOut = el('m-debug-output')
const mBtnCopy = el('m-btn-copy')
const mBtnRefresh = el('m-btn-refresh')
const mBtnClear = el('m-btn-clear')
const mts = () => (window.fmtCNDateTime ? window.fmtCNDateTime(new Date()) : new Date().toLocaleString('zh-CN'))
const logLocal = msg => { if (!mOut) return; const line = `${mts()} ${msg}`; const c = mOut.textContent || ''; mOut.textContent = c ? c + "\n" + line : line; mOut.scrollTop = mOut.scrollHeight }
const loadLogs = async () => { try { const res = await fetch('/api/debug/logs?limit=5000&channel=markets'); const data = await res.json(); if (data.ok && mOut) { mOut.textContent = (data.lines || []).join('\n'); mOut.scrollTop = mOut.scrollHeight } } catch {} }
const calendarState = {
month: '',
days: [],
summary: null,
syncStatus: null,
selectedDate: '',
pendingDateFromQuery: ''
}
const weeklyState = {
weekStart: '',
weekEnd: '',
rows: [],
summary: null,
weekType: '',
activeFilter: 'all'
}
const mondayLocal = s => {
const d = s ? parseDateLocal(s) : parseDateLocal(todayLocal())
const weekday = d.getDay()
const diff = weekday === 0 ? -6 : 1 - weekday
return fmtDateLocal(new Date(d.getFullYear(), d.getMonth(), d.getDate() + diff))
}
const offsetWeekLocal = (s, d) => offsetLocal(mondayLocal(s), d * 7)
const fmtWeekRange = (start, end) => `${start || '—'} 至 ${end || '—'}`
const fmtWeekPrice = n => Number.isFinite(Number(n)) ? Number(n).toFixed(2) : '—'
const fmtWeekPct = n => Number.isFinite(Number(n)) ? `${Number(n) >= 0 ? '+' : ''}${Number(n).toFixed(2)}%` : '—'
const fmtWeekSigned = n => Number.isFinite(Number(n)) ? `${Number(n) >= 0 ? '+' : ''}${Number(n).toFixed(2)}` : '—'
const getWeeklyFilterLabel = key => ({
all: '全部卡片',
available: '可用卡片',
positive: '上涨',
negative: '下跌',
missing: '待补数据'
}[key] || '全部卡片')
const getWeeklyFilterRows = () => {
const rows = Array.isArray(weeklyState.rows) ? weeklyState.rows : []
const key = String(weeklyState.activeFilter || 'all')
if (key === 'available') return rows.filter(row => Number(row && row.day_count) > 0)
if (key === 'positive') return rows.filter(row => Number(row && row.change_percent) > 0)
if (key === 'negative') return rows.filter(row => Number(row && row.change_percent) < 0)
if (key === 'missing') return rows.filter(row => Number(row && row.day_count) <= 0)
return rows
}
const updateWeeklyStatus = () => {
const status = el('weekly-status')
if (!status || !weeklyState.weekStart || !weeklyState.weekEnd) return
const count = getWeeklyFilterRows().length
status.style.display = ''
status.textContent = `已加载 ${fmtWeekRange(weeklyState.weekStart, weeklyState.weekEnd)} | 当前筛选:${getWeeklyFilterLabel(weeklyState.activeFilter)} | 共 ${count} 张卡`
}
const setWeeklyFilter = key => {
weeklyState.activeFilter = key || 'all'
renderWeeklySummary()
renderWeeklyGrid()
updateWeeklyStatus()
}
const fmtWeekAmount = (value, currencyLabel) => {
const num = Number(value)
const unit = currencyLabel || ''
if (!Number.isFinite(num) || num <= 0) return '—'
if (num >= 1000000000000) return `${(num / 1000000000000).toFixed(2)} 万亿${unit}`
if (num >= 100000000) return `${(num / 100000000).toFixed(2)} 亿${unit}`
if (num >= 10000) return `${(num / 10000).toFixed(2)} 万${unit}`
return `${num.toFixed(2)} ${unit}`.trim()
}
const getWeeklyMoveClass = row => Number(row && row.change_percent) >= 0 ? 'up' : 'down'
const fmtWeeklyBeijingTime = row => row && row.last_trade_beijing_at ? row.last_trade_beijing_at : '—'
const getTZOffset = (timeZone) => {
try {
const now = new Date();
const iso = now.toLocaleString('en-US', { timeZone, timeZoneName: 'longOffset' });
const match = iso.match(/GMT([+-]\d{2}):?(\d{2})?/);
if (!match) return 0;
const sign = match[1][0] === '-' ? -1 : 1;
const h = parseInt(match[1].substring(1));
const m = match[2] ? parseInt(match[2]) : 0;
return sign * (h + m / 60);
} catch (e) { return 0; }
};
const getMarketStatus = (config) => {
if (!config || !config.timezone || !config.sessions) return null;
const now = new Date();
const targetOffset = getTZOffset(config.timezone);
const bjOffset = 8; // Beijing is UTC+8
const diffHours = bjOffset - targetOffset;
// Check if Open (using target local time)
const localStr = now.toLocaleString('en-US', { timeZone: config.timezone, hour12: false, hour: '2-digit', minute: '2-digit' });
const [h, m] = localStr.split(':').map(Number);
const currentMins = h * 60 + m;
let isOpen = false;
let rangesBJ = [];
for (const session of config.sessions) {
const [sh, sm] = session.start.split(':').map(Number);
const [eh, em] = session.end.split(':').map(Number);
let startMins = sh * 60 + sm;
let endMins = eh * 60 + em;
// Check Status
if (endMins < startMins) { // Cross midnight
if (currentMins >= startMins || currentMins < endMins) isOpen = true;
} else {
if (currentMins >= startMins && currentMins < endMins) isOpen = true;
}
// Convert to BJ Time
const toBJ = (hh, mm) => {
let totalMins = (hh * 60 + mm) + (diffHours * 60);
while (totalMins >= 1440) totalMins -= 1440;
while (totalMins < 0) totalMins += 1440;
const h = Math.floor(totalMins / 60);
const m = Math.floor(totalMins % 60);
return `${h}:${String(m).padStart(2, '0')}`;
};
rangesBJ.push(`${toBJ(sh, sm)}-${toBJ(eh, em)}`);
}
return {
isOpen,
label: rangesBJ.join(', '),
text: isOpen ? '开市' : '休市'
};
};
const getSymbolUrl = (symbol) => {
if (!symbol) return 'https://gu.qq.com/'
// Tencent Finance URL format:
// US stocks: https://gu.qq.com/usIXIC (remove dot from us.IXIC)
if (symbol.startsWith('us.')) return `https://gu.qq.com/${symbol.replace('.', '')}`
// HK stocks: https://gu.qq.com/hk00700
// CN stocks: https://gu.qq.com/sh000001
// Futures: https://gu.qq.com/hf_GC
return `https://gu.qq.com/${symbol}`
}
const addCustomCardClass = (cardCls, customClass) => {
if (typeof customClass !== 'string') return cardCls
const trimmed = customClass.trim()
if (!trimmed) return cardCls
const safe = trimmed.split(/\s+/).filter(token => /^[A-Za-z0-9_-]+$/.test(token))
if (safe.length === 0) return cardCls
return `${cardCls} ${safe.join(' ')}`
}
const normalizeExplainText = text => {
if (typeof text !== 'string') return ''
return text.split(/\r?\n/).map(x => x.trim()).filter(Boolean).join(' ')
}
const isUSRelatedCard = row => {
const timezone = String((((row || {}).market_status) || {}).timezone || '')
const symbol = String((row || {}).symbol || '')
if (timezone === 'America/New_York') return true
if (symbol.startsWith('us')) return true
if (symbol.startsWith('hf_')) return true
return false
}
const render = (rows, thresholdPercent) => {
window.lastMarketData = rows; // Cache for archive generation
const box = el('quotes')
box.innerHTML = ''
const sorted = [...rows].sort((a, b) => {
const ao = Number.isFinite(a.card_order) ? a.card_order : 9999
const bo = Number.isFinite(b.card_order) ? b.card_order : 9999
if (ao !== bo) return ao - bo
const aKey = `${a.label || ''}${a.name || ''}${a.symbol || ''}`
const bKey = `${b.label || ''}${b.name || ''}${b.symbol || ''}`
return aKey.localeCompare(bKey)
})
for (const r of sorted) {
const card = document.createElement('div')
let cardCls = 'card'
let isAlert = false
if (r.card_type === 'overseas') cardCls += ' card-overseas'
if (r.card_type === 'domestic') cardCls += ' card-domestic'
if (r.card_type === 'enhanced') cardCls += ' card-enhanced'
if (r.card_type === 'defensive') cardCls += ' card-defensive'
if (r.card_type === '299') cardCls += ' card-299'
if (isUSRelatedCard(r)) cardCls += ' card-us'
if (Number.isFinite(r.card_span) && r.card_span >= 2) cardCls += ' card-span-2'
cardCls = addCustomCardClass(cardCls, r.card_custom_class)
// ── Error / Placeholder Card ──
if (r._error || r.source === 'error') {
cardCls += ' card-error'
card.className = cardCls
const symbolDisplay = r.displaySymbol || r.symbol
card.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>${r.label || r.name} (${symbolDisplay})</div>
${r.url ? `<a href="${r.url}" target="_blank" style="margin-left:8px;text-decoration:none">🏠</a>` : ''}
</div>
<div class="price-display" style="font-size:16px;font-weight:600;color:#8c8c8c;display:grid;gap:8px;padding:12px 0">
<div>— 暂无数据 —</div>
<div style="font-size:13px;font-weight:400;color:#bfbfbf">${r._error || '数据源异常'}</div>
</div>
<div class="meta">来源 ${r.source} · 时间 ${r.captured_at || ''}</div>
`
box.appendChild(card)
continue
}
const tp = typeof thresholdPercent === 'number' ? thresholdPercent : null
const move = normalizeMove(r)
if (tp !== null && move.changePercent <= tp) {
cardCls += ' card-alert'
isAlert = true
}
card.className = cardCls
// Header row with Title and Home Icon
const header = document.createElement('div')
header.style.display = 'flex'
header.style.justifyContent = 'space-between'
header.style.alignItems = 'flex-start'
const symbolDisplay = r.displaySymbol || r.symbol
const title = document.createElement('div')
if (r.isMaCard && r.render_type !== 'zhonghang_299_html') {
title.textContent = `${r.label} · MA120: ${r.ma120} | MA250: ${r.ma250} (${symbolDisplay})`
} else {
title.textContent = `${r.label} · ${r.name} (${symbolDisplay})`
}
if (isAlert) {
const flag = document.createElement('span')
flag.className = 'alert-flag'
flag.textContent = '⚠ 告警'
title.appendChild(document.createTextNode(' '))
title.appendChild(flag)
}
const homeLink = document.createElement('a')
homeLink.href = r.url || getSymbolUrl(symbolDisplay)
homeLink.target = '_blank'
homeLink.textContent = '🏠'
homeLink.style.textDecoration = 'none'
homeLink.style.marginLeft = '8px'
homeLink.title = '查看详情'
header.appendChild(title)
header.appendChild(homeLink)
const price = document.createElement('div')
price.className = 'price-display'
if (r.render_type === 'gold_silver_nav') {
const fmt = v => Number.isFinite(v) ? v.toFixed(4) : '—'
const fmtPct = v => Number.isFinite(v) ? `${v >= 0 ? '+' : ''}${v.toFixed(2)}%` : '—'
price.innerHTML = `
<div style="display:grid;gap:6px">
<div>黄金:净值 ${fmt(r.gold_nav)} | 价格 ${fmt(r.gold_price)} | 溢价率 ${fmtPct(r.gold_premium)} | ${r.gold_tip || '—'}</div>
<div>白银:净值 ${fmt(r.silver_nav)} | 价格 ${fmt(r.silver_price)} | 溢价率 ${fmtPct(r.silver_premium)} | ${r.silver_tip || '—'}</div>
</div>
`
} else if ((r.render_type === 'html' || r.render_type === 'zhonghang_299_html') && r.html) {
price.innerHTML = r.html
} else if (r.isMaCard) {
const isBull = parseFloat(r.ma120) > parseFloat(r.ma250)
price.innerHTML = `
<div style="font-size: 14px; margin-top: 8px;">
MA120 ${isBull ? '>' : '<'} MA250 :结论为“ <span style="font-weight:bold; color:${isBull ? '#f5222d' : '#52c41a'}">${r.conclusion}</span> ”
</div>
`
} else {
const changePercent = move.changePercent
const changeClass = move.sign >= 0 ? 'up' : 'down'
price.innerHTML = `
价格 <span style="font-weight:700">${r.price}</span>
涨跌 <span class="${changeClass}">${fmtSign(move.change)}</span>
(<span class="change-percent ${changeClass}">${fmtSign(changePercent)}%</span>)
`
}
const meta = document.createElement('div')
meta.className = 'meta'
const explainText = normalizeExplainText(r.explain)
if (explainText) {
const explain = document.createElement('div')
explain.className = 'meta'
explain.style.marginTop = '6px'
explain.style.color = '#595959'
explain.textContent = explainText
card.appendChild(explain)
}
const status = getMarketStatus(r.market_status);
let statusHtml = '';
if (status) {
const color = status.isOpen ? '#52c41a' : '#8c8c8c';
statusHtml = ` <span style="margin-left:8px;color:${color}">● ${status.text} (北时 ${status.label})</span>`;
}
meta.innerHTML = `来源 ${r.source} · 时间 ${r.captured_at || ''}${statusHtml}`
card.appendChild(header)
card.appendChild(price)
card.appendChild(meta)
box.appendChild(card)
}
}
const generateArchiveHTML = (data) => {
const escapeHTML = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
const css = `body{font-family:system-ui,Segoe UI,Arial,sans-serif;margin:0;padding:16px;box-sizing:border-box;width:100%;max-width:none} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;width:100%;margin:0} .card{border:1px solid #e8e8e8;border-radius:12px;padding:12px;display:grid;gap:8px;background:var(--card-bg,#ffffff);box-shadow:0 1px 4px rgba(0,0,0,0.08)} .card-span-2{grid-column:span 2} .card-overseas{--card-bg:#FAE5E5} .card-domestic{--card-bg:#F8E0FA} .card-enhanced{--card-bg:#FDF9E2} .card-defensive{--card-bg:#DADAF8} .card-us:not(.card-defensive){--card-bg:#EDF4FF} .card-299{--card-bg:#09D3C0} .card-error{--card-bg:#f0f0f0;border-color:#d9d9d9;opacity:0.7} .card-zhonghang-299{padding:16px;min-height:260px} .card-zhonghang-299 .price-display{font-size:15px;font-weight:500;line-height:1.7} .card-zhonghang-299 .zh299-wrap{display:grid;gap:10px} .card-zhonghang-299 .zh299-price{font-size:16px;font-weight:700;color:#003a8c} .card-zhonghang-299 .zh299-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px} .card-zhonghang-299 .zh299-block{border:1px solid rgba(0,0,0,0.08);border-radius:10px;background:rgba(255,255,255,0.72);padding:10px 12px} .card-zhonghang-299 .zh299-title{font-size:13px;font-weight:700;color:#0958d9;margin-bottom:6px} .card-zhonghang-299 .zh299-row{font-size:13px;color:#262626;line-height:1.7} .meta{font-size:12px;color:#8c8c8c} .up{color:#f5222d;font-weight:600} .down{color:#52c41a;font-weight:600} .card-alert{border-color:#ff4d4f} .alert-flag{display:inline-block;padding:2px 8px;font-size:12px;border-radius:12px;background:#ff4d4f;color:#fff;margin-left:8px} a{text-decoration:none} @media (max-width:768px){.card-span-2{grid-column:span 1} .card-zhonghang-299{padding:12px;min-height:auto} .card-zhonghang-299 .zh299-grid{grid-template-columns:1fr;gap:10px}}`;
let cardsHTML = '';
const sorted = [...data].sort((a, b) => {
const ao = Number.isFinite(a.card_order) ? a.card_order : 9999
const bo = Number.isFinite(b.card_order) ? b.card_order : 9999
if (ao !== bo) return ao - bo
const aKey = `${a.label || ''}${a.name || ''}${a.symbol || ''}`
const bKey = `${b.label || ''}${b.name || ''}${b.symbol || ''}`
return aKey.localeCompare(bKey)
})
for (const r of sorted) {
let cardCls = 'card';
let isAlert = false;
if (r.card_type === 'overseas') cardCls += ' card-overseas';
if (r.card_type === 'domestic') cardCls += ' card-domestic';
if (r.card_type === 'enhanced') cardCls += ' card-enhanced';
if (r.card_type === 'defensive') cardCls += ' card-defensive';
if (r.card_type === '299') cardCls += ' card-299';
if (isUSRelatedCard(r)) cardCls += ' card-us';
if (Number.isFinite(r.card_span) && r.card_span >= 2) cardCls += ' card-span-2';
cardCls = addCustomCardClass(cardCls, r.card_custom_class);
// ── Error / Placeholder Card for Archive ──
if (r._error || r.source === 'error') {
const symbolDisplay = r.displaySymbol || r.symbol;
const linkHtml = r.url ? `<a href="${r.url}" target="_blank" title="查看详情" style="margin-left:8px">🏠</a>` : '';
cardsHTML += `
<div class="${cardCls} card-error" style="opacity:0.7">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>${escapeHTML(r.label || r.name)} (${escapeHTML(symbolDisplay)})</div>
${linkHtml}
</div>
<div class="price-display" style="font-size:16px;font-weight:600;color:#8c8c8c;display:grid;gap:8px;padding:12px 0">
<div>— 暂无数据 —</div>
<div style="font-size:13px;font-weight:400;color:#bfbfbf">${escapeHTML(r._error || '数据源异常')}</div>
</div>
<div class="meta">来源 ${escapeHTML(r.source)} · 时间 ${r.captured_at || ''}</div>
</div>
`;
continue;
}
const th = el('th').value;
const tp = th ? parseFloat(th) : null;
const move = normalizeMove(r);
if (tp !== null && move.changePercent <= tp) {
cardCls += ' card-alert';
isAlert = true;
}
const changePercent = move.changePercent;
const changeClass = move.sign >= 0 ? 'up' : 'down';
// Status Logic (Independent implementation)
let statusHtml = '';
const status = getMarketStatus(r.market_status);
if (status) {
const color = status.isOpen ? '#52c41a' : '#8c8c8c';
statusHtml = ` <span style="margin-left:8px;color:${color}">● ${status.text} (北时 ${status.label})</span>`;
}
// Link Logic
const symbolDisplay = r.displaySymbol || r.symbol;
const linkUrl = r.url || getSymbolUrl(symbolDisplay);
const linkHtml = `<a href="${linkUrl}" target="_blank" title="查看详情" style="margin-left:8px">🏠</a>`;
// Alert Flag
const alertHtml = isAlert ? `<span class="alert-flag">⚠ 告警</span>` : '';
let contentHtml = '';
if (r.render_type === 'gold_silver_nav') {
const fmt = v => Number.isFinite(v) ? v.toFixed(4) : '—';
const fmtPct = v => Number.isFinite(v) ? `${v >= 0 ? '+' : ''}${v.toFixed(2)}%` : '—';
contentHtml = `
<div style="display:grid;gap:6px">
<div>${r.label} · ${r.name} (${symbolDisplay}) ${alertHtml}</div>
<div>黄金:净值 ${fmt(r.gold_nav)} | 价格 ${fmt(r.gold_price)} | 溢价率 ${fmtPct(r.gold_premium)} | ${r.gold_tip || '—'}</div>
<div>白银:净值 ${fmt(r.silver_nav)} | 价格 ${fmt(r.silver_price)} | 溢价率 ${fmtPct(r.silver_premium)} | ${r.silver_tip || '—'}</div>
</div>
`;
} else if ((r.render_type === 'html' || r.render_type === 'zhonghang_299_html') && r.html) {
contentHtml = `
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>${r.label} · ${r.name} (${symbolDisplay}) ${alertHtml}</div>
${linkHtml}
</div>
<div class="price-display" style="font-size:16px;font-weight:600">
${r.html}
</div>
`;
} else if (r.isMaCard) {
const isBull = parseFloat(r.ma120) > parseFloat(r.ma250);
contentHtml = `
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>${r.label} · MA120: ${r.ma120} | MA250: ${r.ma250} (${symbolDisplay}) ${alertHtml}</div>
${linkHtml}
</div>
<div class="price-display" style="font-size:16px;font-weight:600;margin-top:8px">
MA120 ${isBull ? '>' : '<'} MA250 :结论为“ <span style="font-weight:bold; color:${isBull ? '#f5222d' : '#52c41a'}">${r.conclusion}</span> ”
</div>
`;
} else {
contentHtml = `
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>${r.label} · ${r.name} (${symbolDisplay}) ${alertHtml}</div>
${linkHtml}
</div>
<div class="price-display" style="font-size:16px;font-weight:600">
价格 <span style="font-weight:700">${r.price}</span>
涨跌 <span class="${changeClass}">${fmtSign(move.change)}</span>
(<span class="${changeClass}" style="padding:2px 6px;border-radius:4px;background:${move.sign>=0?'#fff2f0':'#f6ffed'};border:1px solid ${move.sign>=0?'#ffccc7':'#b7eb8f'}">${fmtSign(changePercent)}%</span>)
</div>
`;
}
const explainText = normalizeExplainText(r.explain);
const explainHtml = explainText ? `<div class="meta" style="margin-top:6px;color:#595959">${escapeHTML(explainText)}</div>` : '';
cardsHTML += `
<div class="${cardCls}">
${contentHtml}
${explainHtml}
<div class="meta">来源 ${r.source} · 时间 ${r.captured_at || ''}${statusHtml}</div>
</div>
`;
}
const time = window.fmtCNDateTime ? window.fmtCNDateTime(new Date()) : new Date().toLocaleString('zh-CN');
const date = (el('rt-date').value || todayLocal()).replace(/\//g,'-');
const title = `市场封存 · ${date}`;
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${title}</title><style>${css}</style></head><body><h3>${title}</h3><div class="grid">${cardsHTML}</div><div class="meta" style="margin-top:16px">生成时间 ${time}</div></body></html>`;
};
const loadArchive = async () => {
const dayRaw = el('arch-date').value || todayLocal()
const day = dayRaw.replace(/\//g,'-')
const box = el('archive-view')
const status = el('archive-status')
try { await fetch('/api/debug/event', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ source:'markets', action:'archive.get', day }) }) } catch {}
if (status) { status.style.display = ''; status.textContent = `正在读取 ${day} 的封存页面...` }
let data
try {
const res = await fetch(`/api/markets/archive/get?day=${encodeURIComponent(day)}`)
data = await res.json()
} catch (e) {
box.innerHTML = ''
if (status) { status.style.display = ''; status.textContent = `读取失败:${String(e.message || e)}` }
logLocal(`markets.archive.error ${JSON.stringify({ day, error: String(e.message || e) })}`)
return
}
if (data.ok && data.html) {
const iframe = document.createElement('iframe')
// Inject override CSS for historical snapshots to fix width issues
let html = data.html;
const overrideCss = `<style>
body { margin: 0 !important; padding: 16px !important; box-sizing: border-box !important; width: 100% !important; max-width: none !important; }
.grid { width: 100% !important; margin: 0 !important; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)) !important; gap: 12px !important; }
.card { box-shadow: 0 1px 4px rgba(0,0,0,0.08) !important; }
.card-span-2 { grid-column: span 2 !important; }
.card-zhonghang-299 { padding: 16px !important; min-height: 260px !important; }
.card-zhonghang-299 .zh299-grid { display: grid !important; grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 12px !important; }
@media (max-width: 768px) { .card-span-2 { grid-column: span 1 !important; } }
@media (max-width: 768px) { .card-zhonghang-299 { padding: 12px !important; min-height: auto !important; } .card-zhonghang-299 .zh299-grid { grid-template-columns: 1fr !important; gap: 10px !important; } }
</style>`;
if (html.includes('</head>')) {
html = html.replace('</head>', overrideCss + '</head>');
} else {
html += overrideCss;
}
iframe.setAttribute('srcdoc', html)
iframe.style.width = '100%'
iframe.style.border = '0'
iframe.addEventListener('load', () => {
fitArchiveIframeHeight(iframe)
setTimeout(() => fitArchiveIframeHeight(iframe), 120)
})
box.innerHTML = ''
box.appendChild(iframe)
fitArchiveIframeHeight(iframe)
if (status) { status.style.display = 'none'; status.textContent = '' }
logLocal(`markets.archive.loaded ${JSON.stringify({ day, html_len: data.html.length })}`)
} else {
box.innerHTML = ''
if (status) { status.style.display = ''; status.textContent = `没有找到 ${day} 的封存页面` }
logLocal(`markets.archive.empty ${JSON.stringify({ day })}`)
}
}
const saveArchive = async () => {
const data = window.lastMarketData;
if (!data || !data.length) {
alert('当前没有可封存的数据,请先刷新获取数据');
return;
}
const dayRaw = el('rt-date').value || todayLocal()
const day = dayRaw.replace(/\//g,'-')
const status = el('m-status')
try { await fetch('/api/debug/event', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ source:'markets', action:'archive.save.click', day }) }) } catch {}
const existed = await fetch(`/api/markets/archive/get?day=${encodeURIComponent(day)}`)
const existedData = await existed.json()
if (existedData.ok && existedData.html) {
const ok = window.confirm(`当天已存在封存页面,是否覆盖 ${day} ?`)
if (!ok) return
}
const html = generateArchiveHTML(data); // Use independent generator
const res = await fetch('/api/markets/archive/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ day, html })
})
const result = await res.json()
if (result.ok) {
if (status) {
status.style.display = ''
status.textContent = `封存成功 (${day})`
setTimeout(() => status.style.display = 'none', 3000)
}
// Auto refresh archive view if looking at same day
const archDate = el('arch-date')
if (archDate && archDate.value === dayRaw) {
loadArchive()
}
} else {
alert('封存失败: ' + (result.error || '未知错误'))
}
}
const navigateDayArchive = d => {
const cur = el('arch-date').value || todayLocal()
const next = offsetLocal(cur, d)
const input = el('arch-date')
if (input) input.value = next
loadArchive()
}
const navigateDayRealtime = d => {
const cur = el('rt-date').value || todayLocal()
const next = offsetLocal(cur, d)
const input = el('rt-date')
if (input) input.value = next
const status = el('m-status')
if (status) { status.style.display = ''; status.textContent = `保存目标日期:${next}` }
logLocal(`frontend.event ${JSON.stringify({ source: 'markets', action: 'rt.navigate', delta: d, to: next })}`)
try { fetch('/api/debug/event', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ source:'markets', action:'rt.navigate', delta:d, to: next }) }) } catch {}
}
const getCalendarStatusClass = day => {
if (!day) return 'markets-calendar-pill-muted'
if (day.ipo_count > 0) return day.is_trading_day ? 'markets-calendar-pill-ipo' : 'markets-calendar-pill-closed'
if (day.is_trading_day) return 'markets-calendar-pill-open'
return 'markets-calendar-pill-closed'
}
const getCalendarStatusText = day => {
if (!day) return '未知'
if (day.ipo_count > 0) return `新股申购 ${day.ipo_count} 项`
return day.day_type_label || (day.is_trading_day ? '交易日' : '休市日')
}
const getCalendarAiReviewLabel = review => {
if (!review || !review.verify_status) return '未复核'
if (review.verify_status === 'verified_ok') return 'AI未提示异常'
if (review.verify_status === 'conflict') return 'AI存疑'
if (review.verify_status === 'needs_review') return 'AI待复核'
return '未复核'
}
const getCalendarAiReviewClass = review => {
if (!review || !review.verify_status) return 'markets-calendar-pill-muted'
if (review.verify_status === 'verified_ok') return 'markets-calendar-pill-ai-ok'
if (review.verify_status === 'conflict') return 'markets-calendar-pill-ai-risk'
if (review.verify_status === 'needs_review') return 'markets-calendar-pill-ai-warn'
return 'markets-calendar-pill-muted'
}
const renderCalendarSummary = () => {
const box = el('calendar-summary')
if (!box) return
const summary = calendarState.summary || {}
const sync = calendarState.syncStatus || {}
const holiday = sync.holiday || {}
const ipo = sync.ipo || {}
const ai = sync.ai_verify || {}
const note = sync.note || ''
const overviewText = `已加载 ${calendarState.month || ''} | 交易日 ${summary.trading_day_count || 0} 天 | 新股申购 ${summary.ipo_day_count || 0} 天`
const holidayText = holiday.ok ? `节假日已同步 ${String(holiday.synced_at || '').replace('T', ' ').slice(0, 19)}` : '节假日使用本地回退规则'
const ipoText = ipo.ok ? `新股已同步 ${String(ipo.synced_at || '').replace('T', ' ').slice(0, 19)}` : '新股使用本地缓存/待补同步'
const aiText = ai.ok
? `AI 已复核 ${String(ai.verified_at || '').replace('T', ' ').slice(0, 19)} | ${ai.summary || '未发现明显冲突'}`
: (ai && ai.overall_status === 'error'
? `AI 复核失败:${ai.error || ai.summary || '继续使用主数据源'}`
: 'AI 尚未完成本周复核')
const syncText = [holidayText, ipoText, aiText, note].filter(Boolean).join(' | ')
box.innerHTML = `
<div class="markets-calendar-overview markets-calendar-overview-main">
<div class="markets-calendar-overview-line">${overviewText}</div>
</div>
<div class="markets-calendar-overview markets-calendar-overview-stats">
<div class="markets-calendar-stat">
<div class="markets-calendar-stat-label">交易日</div>
<div class="markets-calendar-stat-value">${summary.trading_day_count || 0}</div>
</div>
<div class="markets-calendar-stat">
<div class="markets-calendar-stat-label">休市日</div>
<div class="markets-calendar-stat-value">${summary.market_closed_count || 0}</div>
</div>
<div class="markets-calendar-stat">
<div class="markets-calendar-stat-label">新股申购日</div>
<div class="markets-calendar-stat-value">${summary.ipo_day_count || 0}</div>
</div>
<div class="markets-calendar-stat">
<div class="markets-calendar-stat-label">AI待复核</div>
<div class="markets-calendar-stat-value">${summary.ai_issue_count || 0}</div>
</div>
</div>
<div class="markets-calendar-overview markets-calendar-overview-sync">
<div class="markets-calendar-sync-content">
<span class="markets-calendar-sync-title">同步状态</span>
<span class="markets-calendar-stat-note markets-calendar-sync-text">${syncText}</span>
</div>
</div>
`
}
const renderCalendarDetail = date => {
const box = el('calendar-detail')
if (!box) return
const day = (calendarState.days || []).find(item => item.date === date) || null
if (!day) {
box.innerHTML = `
<div class="markets-calendar-detail-empty">
选择左侧日期后,这里会显示交易状态、节假日说明和新股申购明细。
</div>
`
return
}
const holidayHtml = day.holiday_name
? `<div class="markets-calendar-detail-row"><span>节假日</span><strong>${day.holiday_name}</strong></div>`
: ''
const ipoHtml = (day.ipo_events || []).length
? day.ipo_events.map(item => `
<div class="markets-calendar-ipo-item">
<div class="markets-calendar-ipo-title">${item.stock_name} <span>${item.stock_code}</span></div>
<div class="markets-calendar-ipo-meta">市场:${item.market || 'A股'} | 顶格申购:${Number.isFinite(Number(item.apply_limit_wan)) ? Number(item.apply_limit_wan).toFixed(2) : '—'} 万股</div>
<div class="markets-calendar-ipo-meta">发行价:${Number.isFinite(Number(item.issue_price)) ? Number(item.issue_price).toFixed(2) + ' 元' : '—'} | 市盈率:${Number.isFinite(Number(item.pe_ratio)) ? Number(item.pe_ratio).toFixed(2) : '—'}</div>
<div class="markets-calendar-ipo-meta">状态:${item.status || '已收录'}</div>
</div>
`).join('')
: '<div class="markets-calendar-detail-empty">当天没有新股申购安排。</div>'
const aiReview = day.ai_review || null
const aiReviewHtml = aiReview
? `
<div class="markets-calendar-detail-row"><span>AI状态</span><strong>${getCalendarAiReviewLabel(aiReview)}</strong></div>
<div class="markets-calendar-detail-row"><span>AI结论</span><strong>${aiReview.title || aiReview.suggested_status || '未提示异常'}</strong></div>
<div class="markets-calendar-detail-row"><span>AI原因</span><strong>${aiReview.reason || 'AI 本轮未提示明显冲突,但不代表替代主数据源。'}</strong></div>
`
: '<div class="markets-calendar-detail-empty">该日期尚未进入 AI 复核窗口,或还未执行本周复核。</div>'
box.innerHTML = `
<div class="markets-calendar-detail-head">
<div>
<div class="markets-calendar-detail-date">${day.date}</div>
<div class="markets-calendar-detail-weekday">${day.weekday_label || ''}</div>
</div>
<span class="markets-calendar-pill ${getCalendarStatusClass(day)}">${getCalendarStatusText(day)}</span>
</div>
<div class="markets-calendar-detail-group">
<div class="markets-calendar-detail-row"><span>证券状态</span><strong>${day.day_type_label || (day.is_trading_day ? '交易日' : '休市日')}</strong></div>
${holidayHtml}
<div class="markets-calendar-detail-row"><span>备注</span><strong>${day.notes || '—'}</strong></div>
</div>
<div class="markets-calendar-detail-section-title">AI复核</div>
<div class="markets-calendar-detail-group">${aiReviewHtml}</div>
<div class="markets-calendar-detail-section-title">新股申购</div>
<div class="markets-calendar-ipo-list">${ipoHtml}</div>
`
}
const renderCalendarGrid = () => {
const box = el('calendar-grid')
if (!box) return
const days = Array.isArray(calendarState.days) ? calendarState.days : []
const weekdayHeaders = ['一', '二', '三', '四', '五', '六', '日']
if (!days.length) {
box.innerHTML = '<div class="markets-calendar-empty">当前月份暂无日历数据。</div>'
return
}
const first = days[0]
const firstJsDay = new Date(`${first.date}T00:00:00`).getDay()
const leadCount = (firstJsDay + 6) % 7
const nodes = []
weekdayHeaders.forEach(label => {
nodes.push(`<div class="markets-calendar-weekday">${label}</div>`)
})
for (let i = 0; i < leadCount; i += 1) nodes.push('<div class="markets-calendar-cell markets-calendar-cell-blank"></div>')
for (const day of days) {
const selected = calendarState.selectedDate === day.date
const classes = ['markets-calendar-cell']
if (day.is_today) classes.push('is-today')
if (selected) classes.push('is-selected')
if (!day.is_trading_day) classes.push('is-closed')
if (day.ipo_count > 0) classes.push('has-ipo')
nodes.push(`
<button class="${classes.join(' ')}" type="button" data-date="${day.date}">
<div class="markets-calendar-cell-top">
<span class="markets-calendar-day-no">${day.day}</span>
${day.holiday_name ? `<span class="markets-calendar-mini-tag">${day.holiday_name}</span>` : ''}
</div>
<div class="markets-calendar-cell-body">
<span class="markets-calendar-pill ${getCalendarStatusClass(day)}">${day.is_trading_day ? '交易日' : '休市'}</span>
${day.ipo_count > 0 ? `<span class="markets-calendar-pill markets-calendar-pill-ipo">申购 ${day.ipo_count}</span>` : ''}
${day.ai_review ? `<span class="markets-calendar-pill ${getCalendarAiReviewClass(day.ai_review)}">${getCalendarAiReviewLabel(day.ai_review)}</span>` : ''}
</div>
</button>
`)
}
box.innerHTML = nodes.join('')
box.querySelectorAll('[data-date]').forEach(node => {
node.addEventListener('click', () => {
calendarState.selectedDate = node.getAttribute('data-date') || ''
renderCalendarGrid()
renderCalendarDetail(calendarState.selectedDate)
})
})
}
const runSecurityCalendarAiVerify = async () => {
const status = el('calendar-status')
if (status) {
status.style.display = ''
status.innerHTML = '<span class="loading-spinner"></span>正在调用 Qwen 复核当前周、下周和下下周...'
}
try {
const data = await runTrackedInteraction({
title: '证券日历 AI 复核',
action: '证券日历 AI 复核',
button: el('cal-btn-ai-verify'),
loadingText: 'AI复核中…',
request: { force_refresh: true },
summary: '正在调用 Qwen 复核证券日历...',
successText: result => `AI 复核完成:${((result || {}).result || {}).summary || '未发现明显冲突'}`
}, async ctx => {
const result = await ctx.fetchJson('/api/markets/security_calendar/ai_verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force_refresh: true })
}, { api: '/api/markets/security_calendar/ai_verify' })
ctx.log('AI 复核结果', result && result.result ? result.result : result)
await loadSecurityCalendar(false)
return result
})
if (status) {
const summary = data.result && data.result.summary ? data.result.summary : 'AI 复核完成'
status.style.display = ''
status.textContent = `AI 复核完成:${summary}`
}
logLocal(`markets.security_calendar.ai_verify ${JSON.stringify({ ok: true, summary: data.result && data.result.summary ? data.result.summary : '' })}`)
} catch (e) {
if (status) {
status.style.display = ''
status.textContent = `AI 复核失败:${String(e.message || e)}`
}
logLocal(`markets.security_calendar.ai_verify.error ${JSON.stringify({ error: String(e.message || e) })}`)
}
}
const loadSecurityCalendar = async (forceRefresh, options = {}) => {
const monthInput = el('cal-month')
const status = el('calendar-status')
const month = monthInput && monthInput.value ? monthInput.value : todayMonthLocal()
const tracked = options && options.tracked === true
if (monthInput && !monthInput.value) monthInput.value = month
if (status) {
status.style.display = ''
status.innerHTML = `<span class="loading-spinner"></span>正在加载 ${month} 的证券日历...`
}
const execute = async ctx => {
const url = `/api/markets/security_calendar?month=${encodeURIComponent(month)}${forceRefresh ? '&refresh=1' : ''}`
const data = ctx
? await ctx.fetchJson(url, {}, { api: '/api/markets/security_calendar', month, refresh: !!forceRefresh })
: await (async () => {
const res = await fetch(url)
const result = await res.json()
if (!result.ok) throw new Error(result.error || '证券日历读取失败')
return result
})()
if (ctx) {
ctx.log('证券日历汇总', {
month: data.month || month,
day_count: Array.isArray(data.days) ? data.days.length : 0,
sync_status: data.sync_status || null
})
}
calendarState.month = data.month || month
calendarState.days = Array.isArray(data.days) ? data.days : []
calendarState.summary = data.summary || null
calendarState.syncStatus = data.sync_status || null
calendarState.selectedDate = calendarState.selectedDate && calendarState.days.find(item => item.date === calendarState.selectedDate)
? calendarState.selectedDate
: ((calendarState.days.find(item => item.is_today) || calendarState.days[0] || {}).date || '')
if (calendarState.pendingDateFromQuery && calendarState.days.find(item => item.date === calendarState.pendingDateFromQuery)) {
calendarState.selectedDate = calendarState.pendingDateFromQuery
calendarState.pendingDateFromQuery = ''
}
renderCalendarSummary()
renderCalendarGrid()
renderCalendarDetail(calendarState.selectedDate)
if (status) {
status.style.display = 'none'
status.textContent = ''
}
logLocal(`markets.security_calendar.loaded ${JSON.stringify({ month: calendarState.month, day_count: calendarState.days.length, force_refresh: !!forceRefresh })}`)
return data
}
try {
if (tracked) {
await runTrackedInteraction({
title: forceRefresh ? '证券日历刷新' : '证券日历加载',
action: forceRefresh ? '刷新证券日历' : '加载证券日历',
button: el(forceRefresh ? 'cal-btn-refresh' : 'cal-btn-load'),
loadingText: forceRefresh ? '刷新中…' : '加载中…',
request: { month, refresh: !!forceRefresh },
summary: '正在同步证券日历、节假日、新股申购与 AI 复核状态...',
successText: result => `证券日历已加载:${(result && result.month) || month},共 ${((result && result.days) || []).length} 天`
}, execute)
} else {
await execute(null)
}
} catch (e) {
if (status) {
status.style.display = ''
status.textContent = `证券日历加载失败:${String(e.message || e)}`
}
logLocal(`markets.security_calendar.error ${JSON.stringify({ month, error: String(e.message || e) })}`)
}
}
const navigateMonthCalendar = d => {
const input = el('cal-month')
const next = offsetMonthLocal(input && input.value ? input.value : todayMonthLocal(), d)
if (input) input.value = next
loadSecurityCalendar(false)
}
const renderWeeklySummary = () => {
const box = el('weekly-summary')
if (!box) return
const summary = weeklyState.summary || {}
const activeFilter = String(weeklyState.activeFilter || 'all')
const statClass = (key, tone) => {
const classes = ['markets-weekly-stat', 'markets-weekly-stat-action']
if (tone) classes.push(`markets-weekly-stat-${tone}`)
if (activeFilter === key) classes.push('is-active')
return classes.join(' ')
}
box.innerHTML = `
<button type="button" class="${statClass('all', 'all')}" data-weekly-filter="all">
<div class="markets-weekly-stat-label">周区间</div>
<div class="markets-weekly-stat-value markets-weekly-stat-range">${fmtWeekRange(weeklyState.weekStart, weeklyState.weekEnd)}</div>
</button>
<button type="button" class="${statClass('available', 'available')}" data-weekly-filter="available">
<div class="markets-weekly-stat-label">可用卡片</div>
<div class="markets-weekly-stat-value">${summary.available_count || 0}</div>
</button>
<button type="button" class="${statClass('positive', 'positive')}" data-weekly-filter="positive">
<div class="markets-weekly-stat-label">上涨</div>
<div class="markets-weekly-stat-value">${summary.positive_count || 0}</div>
</button>
<button type="button" class="${statClass('negative', 'negative')}" data-weekly-filter="negative">
<div class="markets-weekly-stat-label">下跌</div>
<div class="markets-weekly-stat-value">${summary.negative_count || 0}</div>
</button>
<button type="button" class="${statClass('missing', 'missing')}" data-weekly-filter="missing">
<div class="markets-weekly-stat-label">待补数据</div>
<div class="markets-weekly-stat-value">${summary.missing_count || 0}</div>
</button>
`
box.querySelectorAll('[data-weekly-filter]').forEach(node => {
node.addEventListener('click', () => setWeeklyFilter(node.getAttribute('data-weekly-filter') || 'all'))
})
}
const renderWeeklyGrid = () => {
const box = el('weekly-grid')
if (!box) return
const rows = getWeeklyFilterRows()
if (!rows.length) {
box.innerHTML = `<div class="markets-weekly-empty">${getWeeklyFilterLabel(weeklyState.activeFilter)}暂无可显示卡片。</div>`
return
}
box.innerHTML = rows.map(row => {
const moveClass = getWeeklyMoveClass(row)
const moveCellClass = moveClass === 'up' ? 'markets-weekly-cell-up' : 'markets-weekly-cell-down'
const movePct = fmtWeekPct(row.change_percent)
const moveValue = fmtWeekSigned(row.change_value)
const source = row.data_source || 'unknown'
const statusText = row.week_status === 'future'
? '未来周'
: (row.week_status === 'missing' || row.week_status === 'error'
? '待补数据'
: (row.week_status === 'current' ? '本周滚动' : '历史周报'))
const note = row.data_note || '按北京时间聚合'
const detailLink = row.detail_url ? `<a class="markets-weekly-link" href="${row.detail_url}" target="_blank" rel="noreferrer">东方财富</a>` : ''
return `
<article class="markets-weekly-card">
<div class="markets-weekly-card-head">
<div>
<div class="markets-weekly-card-title">${row.label || row.symbol}</div>
<div class="markets-weekly-card-subtitle">${row.symbol || ''} | ${row.market_group || '指数'} | 北时 ${fmtWeeklyBeijingTime(row)}</div>
</div>
<div class="markets-weekly-card-links">${detailLink}</div>
</div>
<div class="markets-weekly-card-range">${fmtWeekRange(row.week_start, row.week_end)}</div>
<div class="markets-weekly-card-grid">
<div><span>开盘</span><strong>${fmtWeekPrice(row.price_open)}</strong></div>
<div><span>最高</span><strong>${fmtWeekPrice(row.price_high)}</strong></div>
<div><span>最低</span><strong>${fmtWeekPrice(row.price_low)}</strong></div>
<div><span>收盘</span><strong>${fmtWeekPrice(row.price_close)}</strong></div>
<div class="${moveCellClass}"><span>涨跌幅</span><strong class="${moveClass}">${movePct}</strong></div>
<div class="${moveCellClass}"><span>涨跌额</span><strong class="${moveClass}">${moveValue}</strong></div>
<div><span>成交额</span><strong>${fmtWeekAmount(row.amount_value, row.currency_label)}</strong></div>
<div><span>货币</span><strong>${row.currency_label || row.currency_code || '—'}</strong></div>
</div>
<div class="markets-weekly-card-meta">状态:${statusText} | 来源:${source} | 最后交易日:${row.last_trade_date || '—'} | 北时:${fmtWeeklyBeijingTime(row)}</div>
<div class="markets-weekly-card-note">${note}</div>
</article>
`
}).join('')
}
const setWeeklyIdleState = (message, options = {}) => {
const status = el('weekly-status')
const clearData = !(options && options.clearData === false)
if (clearData) {
weeklyState.weekStart = ''
weeklyState.weekEnd = ''
weeklyState.rows = []
weeklyState.summary = null
weeklyState.weekType = ''
weeklyState.activeFilter = 'all'
renderWeeklySummary()
renderWeeklyGrid()
}
if (status) {
status.style.display = ''
status.textContent = message || '请选择周后点击“加载”或“刷新并补存”。'
}
}
const loadWeeklyReport = async (forceRefresh, options = {}) => {
const input = el('weekly-week')
const status = el('weekly-status')
const week = mondayLocal(input && input.value ? input.value : todayLocal())
const tracked = options && options.tracked === true
if (input) input.value = week
if (status) {
status.style.display = ''
status.innerHTML = `<span class="loading-spinner"></span>正在加载 ${week} 这一周的指数周报...`
}
const execute = async ctx => {
const url = `/api/markets/weekly_report?week=${encodeURIComponent(week)}${forceRefresh ? '&refresh=1' : ''}`
const data = ctx
? await ctx.fetchJson(url, {}, { api: '/api/markets/weekly_report', week, refresh: !!forceRefresh })
: await (async () => {
const res = await fetch(url)
const result = await res.json()
if (!result.ok) throw new Error(result.error || '指数周报读取失败')
return result
})()
if (ctx) {
ctx.log('周报汇总', {
week_start: data.week_start,
week_end: data.week_end,
row_count: Array.isArray(data.rows) ? data.rows.length : 0,
week_type: data.week_type || ''
})
}
weeklyState.weekStart = data.week_start || week
weeklyState.weekEnd = data.week_end || week
weeklyState.rows = Array.isArray(data.rows) ? data.rows : []
weeklyState.summary = data.summary || null
weeklyState.weekType = data.week_type || ''
weeklyState.activeFilter = 'all'
renderWeeklySummary()
renderWeeklyGrid()
updateWeeklyStatus()
logLocal(`markets.weekly_report.loaded ${JSON.stringify({ week_start: weeklyState.weekStart, row_count: weeklyState.rows.length, force_refresh: !!forceRefresh })}`)
return data
}
try {
if (tracked) {
await runTrackedInteraction({
title: forceRefresh ? '指数周报刷新并补存' : '指数周报加载',
action: forceRefresh ? '刷新指数周报' : '加载指数周报',
button: el(forceRefresh ? 'weekly-btn-refresh' : 'weekly-btn-load'),
loadingText: forceRefresh ? '刷新中…' : '加载中…',
request: { week, refresh: !!forceRefresh },
summary: '正在聚合周线数据,并按北京时间整理周报...',
successText: result => `周报已完成:${(result && result.week_start) || week} 至 ${(result && result.week_end) || week},共 ${((result && result.rows) || []).length} 张卡`
}, execute)
} else {
await execute(null)
}
} catch (e) {
if (status) {
status.style.display = ''
status.textContent = `指数周报加载失败:${String(e.message || e)}`
}
logLocal(`markets.weekly_report.error ${JSON.stringify({ week, error: String(e.message || e) })}`)
}
}
const navigateWeeklyReport = d => {
const input = el('weekly-week')
const next = offsetWeekLocal(input && input.value ? input.value : todayLocal(), d)
if (input) input.value = next
setWeeklyIdleState(`已切换到 ${next} 这一周,请点击“加载”或“刷新并补存”。`)
}
const loadDay = async () => {
const status = el('m-status')
const dateRaw = el('date').value || todayISO()
const date = dateRaw.replace(/\//g, '-')
logLocal(`frontend.event ${JSON.stringify({ source: 'markets', action: 'load.day', date })}`)
try { await fetch('/api/debug/event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source: 'markets', action: 'load.day', date }) }) } catch {}
if (status) { status.style.display = ''; status.innerHTML = `<span class="loading-spinner"></span>正在加载 ${date} 的记录...` }
const res = await fetch(`/api/markets/day?date=${encodeURIComponent(date)}`)
const data = await res.json()
if (data.ok) {
render(data.rows)
logLocal(`markets.day ${JSON.stringify({ date, rows: (data.rows || []).length })}`)
if (status) { status.style.display = 'none'; status.textContent = '' }
} else {
logLocal(`markets.day.error ${JSON.stringify({ date, error: data.error || '' })}`)
if (status) { status.style.display = ''; status.textContent = `加载失败:${data.error || ''}` }
}
}
const doFetch = async () => {
const status = el('m-status')
const thLabel = el('threshold-label')
logLocal(`frontend.event ${JSON.stringify({ source: 'markets', action: 'fetch' })}`)
try { await fetch('/api/debug/event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source: 'markets', action: 'fetch' }) }) } catch {}
if (status) { status.style.display = ''; status.textContent = '正在刷新最新行情...' }
try {
const data = await runTrackedInteraction({
title: '实时行情刷新',
action: '刷新最新行情',
button: el('btn-fetch'),
loadingText: '刷新中…',
request: { notify: 1 },
summary: '正在汇总腾讯 / 新浪 / Yahoo 探针 / skills 数据...',
successText: result => `实时行情刷新完成:共 ${(result && result.quotes && result.quotes.length) || 0} 张卡片`
}, async ctx => {
const result = await ctx.fetchJson('/api/markets/fetch?notify=1', {}, { api: '/api/markets/fetch', notify: 1 })
ctx.log('行情返回统计', {
quote_count: Array.isArray(result && result.quotes) ? result.quotes.length : 0,
threshold_percent: result && result.threshold_percent
})
return result
})
if (thLabel && typeof data.threshold_percent === 'number') {
thLabel.textContent = `阈值: ${data.threshold_percent.toFixed(2)}%`
}
render(data.quotes, data.threshold_percent)
logLocal(`markets.fetch.done ${JSON.stringify({ count: (data.quotes || []).length, threshold_percent: data.threshold_percent })}`)
if (status) { status.style.display = 'none'; status.textContent = '' }
} catch (e) {
logLocal(`markets.fetch.error ${JSON.stringify({ error: String(e.message || e) })}`)
if (status) { status.style.display = ''; status.textContent = `刷新失败:${String(e.message || e)}` }
}
}
const openSettings = async () => {
let data
try {
const res = await fetch('/api/markets/config')
data = await res.json()
} catch (e) {
logLocal(`markets.config.load.error ${JSON.stringify({ error: String(e.message || e) })}`)
const status = el('m-status')
if (status) { status.style.display = ''; status.textContent = `加载配置失败:${String(e.message || e)}` }
return
}
if (data.ok && data.config) {
const cfg = data.config
el('wl').value = JSON.stringify(cfg.watchlist)
el('th').value = cfg.threshold_percent
el('mail').value = JSON.stringify(cfg.email)
}
el('settings-modal').classList.remove('hidden')
}
const saveSettings = async () => {
let wl = []
let mail = {}
const wlText = el('wl').value.trim()
const mailText = el('mail').value.trim()
try {
wl = wlText ? JSON.parse(wlText) : []
} catch (e) {
window.alert('监控列表 JSON 格式错误,请检查后再保存')
return
}
try {
mail = mailText ? JSON.parse(mailText) : {}
} catch (e) {
window.alert('邮件配置 JSON 格式错误,请检查后再保存')
return
}
const thRaw = el('th').value
const thNum = parseFloat(thRaw)
const threshold = Number.isFinite(thNum) ? thNum : -2
const body = { watchlist: wl, threshold_percent: threshold, email: mail }
try {
await fetch('/api/markets/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
el('settings-modal').classList.add('hidden')
} catch (e) {
window.alert(`保存失败:${String(e.message || e)}`)
}
}
const formatSettingsJSON = () => {
const wlEl = el('wl')
const mailEl = el('mail')
if (wlEl) {
const txt = wlEl.value.trim()
if (txt) {
try {
const obj = JSON.parse(txt)
wlEl.value = JSON.stringify(obj, null, 2)
} catch (e) {
window.alert('监控列表 JSON 格式错误,无法格式化')
}
}
}
if (mailEl) {
const txt = mailEl.value.trim()
if (txt) {
try {
const obj = JSON.parse(txt)
mailEl.value = JSON.stringify(obj, null, 2)
} catch (e) {
window.alert('邮件配置 JSON 格式错误,无法格式化')
}
}
}
}
const switchPanel = which => {
const rtTab = el('m-tab-realtime')
const arTab = el('m-tab-archive')
const calTab = el('m-tab-calendar')
const wkTab = el('m-tab-weekly')
const newsTab = el('m-tab-news')
const wisdomTab = el('m-tab-wisdom')
const rt = el('m-panel-realtime')
const ar = el('m-panel-archive')
const cal = el('m-panel-calendar')
const wk = el('m-panel-weekly')
const news = el('m-panel-news')
const wisdom = el('m-panel-wisdom')
if (!rtTab || !arTab || !calTab || !wkTab || !rt || !ar || !cal || !wk) return
if (which === 'archive') {
rtTab.classList.remove('active'); arTab.classList.add('active'); calTab.classList.remove('active'); wkTab.classList.remove('active'); if (newsTab) newsTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active')
rt.style.display = 'none'; ar.style.display = ''; cal.style.display = 'none'; wk.style.display = 'none'; if (news) news.style.display = 'none'; if (wisdom) wisdom.style.display = 'none'
loadArchive()
} else if (which === 'calendar') {
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.add('active'); wkTab.classList.remove('active'); if (newsTab) newsTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active')
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = ''; wk.style.display = 'none'; if (news) news.style.display = 'none'; if (wisdom) wisdom.style.display = 'none'
if (!calendarState.month || !Array.isArray(calendarState.days) || calendarState.days.length === 0) loadSecurityCalendar(false)
} else if (which === 'weekly') {
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.add('active'); if (newsTab) newsTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active')
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = ''; if (news) news.style.display = 'none'; if (wisdom) wisdom.style.display = 'none'
if (!weeklyState.weekStart || !Array.isArray(weeklyState.rows) || weeklyState.rows.length === 0) {
setWeeklyIdleState('请选择周后点击“加载”读取历史,或点“刷新并补存”主动更新。', { clearData: false })
}
} else if (which === 'news') {
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active'); if (wisdomTab) wisdomTab.classList.remove('active')
if (newsTab) newsTab.classList.add('active')
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = 'none'; if (news) news.style.display = ''; if (wisdom) wisdom.style.display = 'none'
loadGlobalNews(false)
} else if (which === 'wisdom') {
rtTab.classList.remove('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active')
if (newsTab) newsTab.classList.remove('active')
if (wisdomTab) wisdomTab.classList.add('active')
rt.style.display = 'none'; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = 'none'
if (news) news.style.display = 'none'
if (wisdom) wisdom.style.display = ''
} else {
rtTab.classList.add('active'); arTab.classList.remove('active'); calTab.classList.remove('active'); wkTab.classList.remove('active'); if (newsTab) newsTab.classList.remove('active')
if (wisdomTab) wisdomTab.classList.remove('active')
rt.style.display = ''; ar.style.display = 'none'; cal.style.display = 'none'; wk.style.display = 'none'; if (news) news.style.display = 'none'
if (wisdom) wisdom.style.display = 'none'
}
}
document.addEventListener('DOMContentLoaded', () => {
try {
logLocal('frontend.event {"source":"markets","action":"dom.ready"}')
loadMarketsUiMode()
const query = getQueryParams()
const rt = el('rt-date'); if (rt) rt.value = todayLocal()
const ad = el('arch-date'); if (ad) ad.value = todayLocal()
const cm = el('cal-month'); if (cm) cm.value = query.month || todayMonthLocal()
const ww = el('weekly-week'); if (ww) ww.value = mondayLocal(query.week || todayLocal())
if (query.date) calendarState.pendingDateFromQuery = query.date
const bl = el('arch-btn-load'); if (bl) bl.addEventListener('click', loadArchive)
const bf = el('btn-fetch'); if (bf) bf.addEventListener('click', doFetch)
const bp = el('arch-btn-prev'); if (bp) bp.addEventListener('click', () => navigateDayArchive(-1))
const btoday = el('arch-btn-today'); if (btoday) btoday.addEventListener('click', () => { const i = el('arch-date'); if (i) i.value = todayLocal(); loadArchive() })
const bn = el('arch-btn-next'); if (bn) bn.addEventListener('click', () => navigateDayArchive(1))
const bsa = el('rt-btn-save-archive'); if (bsa) bsa.addEventListener('click', saveArchive)
const rtp = el('rt-btn-prev'); if (rtp) rtp.addEventListener('click', () => navigateDayRealtime(-1))
const rtt = el('rt-btn-today'); if (rtt) rtt.addEventListener('click', () => { const i = el('rt-date'); const v = todayLocal(); if (i) i.value = v; const status = el('m-status'); if (status) { status.style.display = ''; status.textContent = `保存目标日期:${v}` }; logLocal(`frontend.event ${JSON.stringify({ source: 'markets', action: 'rt.today', to: v })}`); try { fetch('/api/debug/event', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ source:'markets', action:'rt.today', to: v }) }) } catch {} })
const rtn = el('rt-btn-next'); if (rtn) rtn.addEventListener('click', () => navigateDayRealtime(1))
const stRt = el('m-tab-realtime'); if (stRt) stRt.addEventListener('click', () => switchPanel('realtime'))
const stAr = el('m-tab-archive'); if (stAr) stAr.addEventListener('click', () => switchPanel('archive'))
const stCal = el('m-tab-calendar'); if (stCal) stCal.addEventListener('click', () => switchPanel('calendar'))
const stWk = el('m-tab-weekly'); if (stWk) stWk.addEventListener('click', () => switchPanel('weekly'))
const stNews = el('m-tab-news'); if (stNews) stNews.addEventListener('click', () => switchPanel('news'))
const stWisdom = el('m-tab-wisdom'); if (stWisdom) stWisdom.addEventListener('click', () => switchPanel('wisdom'))
const cbp = el('cal-btn-prev'); if (cbp) cbp.addEventListener('click', () => navigateMonthCalendar(-1))
const cbc = el('cal-btn-current'); if (cbc) cbc.addEventListener('click', () => { const i = el('cal-month'); if (i) i.value = todayMonthLocal(); loadSecurityCalendar(false) })
const cbn = el('cal-btn-next'); if (cbn) cbn.addEventListener('click', () => navigateMonthCalendar(1))
const cbl = el('cal-btn-load'); if (cbl) cbl.addEventListener('click', () => loadSecurityCalendar(false, { tracked: true }))
const cbr = el('cal-btn-refresh'); if (cbr) cbr.addEventListener('click', () => loadSecurityCalendar(true, { tracked: true }))
const cbai = el('cal-btn-ai-verify'); if (cbai) cbai.addEventListener('click', runSecurityCalendarAiVerify)
const wbp = el('weekly-btn-prev'); if (wbp) wbp.addEventListener('click', () => navigateWeeklyReport(-1))
const wbc = el('weekly-btn-current'); if (wbc) wbc.addEventListener('click', () => { const i = el('weekly-week'); const week = mondayLocal(todayLocal()); if (i) i.value = week; setWeeklyIdleState(`已切换到 ${week} 这一周,请点击“加载”或“刷新并补存”。`) })
const wbn = el('weekly-btn-next'); if (wbn) wbn.addEventListener('click', () => navigateWeeklyReport(1))
const wbl = el('weekly-btn-load'); if (wbl) wbl.addEventListener('click', () => loadWeeklyReport(false, { tracked: true }))
const wbr = el('weekly-btn-refresh'); if (wbr) wbr.addEventListener('click', () => loadWeeklyReport(true, { tracked: true }))
const wbm = el('btn-weekly-meta'); if (wbm) wbm.addEventListener('click', loadWeeklyMeta)
const wbmc = el('btn-weekly-meta-close'); if (wbmc) wbmc.addEventListener('click', () => el('weekly-meta-modal').classList.add('hidden'))
const bt = document.getElementById('btn-test'); if (bt) bt.addEventListener('click', async () => {
logLocal('frontend.event {"source":"markets","action":"email.test"}')
const status = el('m-status'); if (status) { status.style.display = ''; status.textContent = '正在发送测试邮件...' }
try {
const res = await fetch('/api/markets/test-email', { method: 'POST' })
const data = await res.json()
if (data.ok) { if (status) { status.style.display = ''; status.textContent = '测试邮件已发送' } }
else { if (status) { status.style.display = ''; status.textContent = `发送失败:${data.error || ''}` } }
} catch (e) {
if (status) { status.style.display = ''; status.textContent = `发送失败:${String(e.message || e)}` }
}
loadLogs()
})
const bs = el('btn-settings'); if (bs) bs.addEventListener('click', openSettings)
const bsv = el('btn-save'); if (bsv) bsv.addEventListener('click', saveSettings)
const bfj = el('btn-format-json'); if (bfj) bfj.addEventListener('click', formatSettingsJSON)
const bc = el('btn-close'); if (bc) bc.addEventListener('click', () => el('settings-modal').classList.add('hidden'))
const bsm = el('btn-skills-meta'); if (bsm) bsm.addEventListener('click', loadSkillsMeta)
const bsmc = el('btn-skills-meta-close'); if (bsmc) bsmc.addEventListener('click', () => el('skills-meta-modal').classList.add('hidden'))
const bel = el('btn-email-log'); if (bel) bel.addEventListener('click', loadEmailLog)
const belc = el('btn-email-log-close'); if (belc) belc.addEventListener('click', () => el('email-log-modal').classList.add('hidden'))
const belr = el('btn-email-log-refresh'); if (belr) belr.addEventListener('click', loadEmailLog)
setWeeklyIdleState('请选择周后点击“加载”读取历史,或点“刷新并补存”主动更新。', { clearData: false })
if (mBtnCopy) mBtnCopy.addEventListener('click', async () => { try { await navigator.clipboard.writeText(mOut.textContent || '') } catch {} })
if (mBtnRefresh) mBtnRefresh.addEventListener('click', loadLogs)
if (mBtnClear) mBtnClear.addEventListener('click', async () => { try { await fetch('/api/debug/clear?channel=markets', { method: 'POST' }) } catch {}; loadLogs() })
const interactionCopy = el('interaction-copy'); if (interactionCopy) interactionCopy.addEventListener('click', async () => { try { await navigator.clipboard.writeText(marketsUiState.interaction.lines.join('\n\n')) } catch {} })
const interactionClose = el('interaction-close'); if (interactionClose) interactionClose.addEventListener('click', closeInteractionDialog)
setInterval(loadLogs, 5000)
const nd = el('news-date'); if (nd) { nd.value = yesterdayLocal(); nd.addEventListener('change', () => { if (nd.value) loadGlobalNews(false) }) }
const nPrev = el('news-btn-prev'); if (nPrev) nPrev.addEventListener('click', () => { const i = el('news-date'); if (i) { i.value = addDays(i.value, -1); loadGlobalNews(false) } })
const nToday = el('news-btn-today'); if (nToday) nToday.addEventListener('click', () => { const i = el('news-date'); if (i) i.value = yesterdayLocal(); loadGlobalNews(false) })
const nCurrent = el('news-btn-current'); if (nCurrent) nCurrent.addEventListener('click', () => { const i = el('news-date'); if (i) i.value = todayLocal(); loadGlobalNews(false) })
const nNext = el('news-btn-next'); if (nNext) nNext.addEventListener('click', () => { const i = el('news-date'); if (i) { i.value = addDays(i.value, 1); loadGlobalNews(false) } })
const nFetch = el('news-btn-fetch'); if (nFetch) nFetch.addEventListener('click', () => loadGlobalNews(true, { tracked: true }))
const nTranslate = el('news-btn-translate'); if (nTranslate) nTranslate.addEventListener('click', translateGlobalNews)
const nAnalyze = el('news-btn-analyze'); if (nAnalyze) nAnalyze.addEventListener('click', analyzeGlobalNews)
const nLogsBtn = el('news-btn-logs'); if (nLogsBtn) nLogsBtn.addEventListener('click', async () => {
const modal = el('news-logs-modal')
const content = el('news-logs-content')
const count = el('news-logs-count')
if (modal) modal.classList.remove('hidden')
if (content) content.textContent = '正在加载日志...'
if (count) count.textContent = ''
try {
const r = await fetch('/api/global_news/logs')
const d = await r.json()
if (d.ok && Array.isArray(d.logs)) {
if (d.logs.length === 0) {
if (content) content.textContent = '暂无日志记录'
} else {
const logText = d.logs.map(l => {
const icon = l.level === 'ERROR' ? '❌' : l.level === 'WARN' ? '⚠️' : 'ℹ️'
return `${icon} [${l.time}] ${l.msg}${l.detail ? ' | ' + l.detail : ''}`
}).join('\n')
if (content) content.textContent = logText
if (count) count.textContent = `共 ${d.logs.length} 条`
if (content) content.scrollTop = content.scrollHeight
}
} else {
if (content) content.textContent = `加载失败: ${d.error || '未知错误'}`
}
} catch (e) {
if (content) content.textContent = `请求失败: ${e.message}`
}
})
const nLogsClose = el('news-btn-logs-close'); if (nLogsClose) nLogsClose.addEventListener('click', () => {
const modal = el('news-logs-modal'); if (modal) modal.classList.add('hidden')
})
const nCopyLogs = el('news-btn-copy-logs'); if (nCopyLogs) nCopyLogs.addEventListener('click', () => {
const content = el('news-logs-content')
const text = content ? content.textContent : ''
if (text) {
navigator.clipboard.writeText(text).then(() => {
const orig = nCopyLogs.textContent
nCopyLogs.textContent = '✅ 已复制'
setTimeout(() => { nCopyLogs.textContent = orig }, 2000)
}).catch(() => {
nCopyLogs.textContent = '❌ 复制失败'
setTimeout(() => { nCopyLogs.textContent = '复制全部' }, 2000)
})
}
})
const nClearLogs = el('news-btn-clear-logs'); if (nClearLogs) nClearLogs.addEventListener('click', async () => {
try {
await fetch('/api/global_news/clear_logs', { method: 'POST' })
const content = el('news-logs-content')
if (content) content.textContent = '日志已清空'
const count = el('news-logs-count')
if (count) count.textContent = ''
} catch (e) {}
})
const newsList = el('news-list'); if (newsList) {
newsList.addEventListener('click', (e) => {
const toggle = e.target.closest('.expandable-toggle')
if (!toggle) return
const td = toggle.closest('.expandable')
if (!td) return
const expanded = td.classList.toggle('expanded')
toggle.textContent = expanded ? '👆收缩' : '👇展开'
})
}
loadLogs()
switchPanel(query.tab === 'calendar' ? 'calendar' : (query.tab === 'weekly' ? 'weekly' : 'realtime'))
} catch (e) {
logLocal(`frontend.error ${String(e.message || e)}`)
}
})
const loadSkillsMeta = async () => {
const modal = el('skills-meta-modal')
const stats = el('skills-meta-stats')
const tbody = el('skills-meta-tbody')
const structure = el('skills-meta-structure')
const anomalies = el('skills-meta-anomalies')
const heartbeats = el('skills-meta-heartbeats')
modal.classList.remove('hidden')
stats.innerHTML = '<span>加载中...</span>'
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:20px;color:#8c8c8c">正在拉取数元数据...</td></tr>'
structure.innerHTML = ''
anomalies.innerHTML = ''
heartbeats.innerHTML = ''
try {
const res = await fetch('/api/markets/skills-meta')
const data = await res.json()
if (!data.ok) throw new Error(data.error || '未知错误')
stats.innerHTML = `
<span>总计 ${data.total_skills} 卡</span>
<span>健康 ${data.healthy_count}</span>
<span>生成: ${new Date(data.generated_at).toLocaleString('zh-CN')}</span>
`
tbody.innerHTML = data.skills.map(s => {
let healthCls = 'cell-health-ok'
if (s.health === '❌') healthCls = 'cell-health-err'
else if (s.health === '⚠️') healthCls = 'cell-health-warn'
return `<tr>
<td>${s.name}</td>
<td style="font-family:monospace;font-size:12px">${s.symbol}</td>
<td>${s.main_source}</td>
<td style="font-size:12px">${s.backup_source}</td>
<td>${s.schedule}</td>
<td style="font-size:12px">${s.last_data_at === '—' ? '—' : s.last_data_at.replace('T',' ').slice(0,19)}</td>
<td class="${healthCls}" title="${escAttr(s.health_note)}">${s.health}</td>
</tr>`
}).join('')
structure.innerHTML = data.skills.map(s => {
const tags = []
tags.push(s.is_shared
? `<span class="tag tag-shared">共享: ${s.shared_from || 'germany_etf'}</span>`
: `<span class="tag tag-independent">独立实现</span>`)
if (s.has_backup) tags.push(`<span class="tag tag-backup">有备用源</span>`)
return `<div class="skills-meta-structure-item">
<strong>${s.name}</strong> <span style="font-size:12px;color:#64748b">(${s.symbol})</span> ${tags.join(' ')}<br/>
主源: ${s.main_source}${s.backup_source !== '—' ? ' | 备源: ' + s.backup_source : ''} | 刷新: ${s.schedule}
</div>`
}).join('')
if (data.anomalies && data.anomalies.length > 0) {
anomalies.innerHTML = data.anomalies.map(a =>
`<div class="skills-meta-anomaly ${a.level}">
<strong>${a.source}</strong>: ${a.detail}
</div>`
).join('')
} else {
anomalies.innerHTML = '<div class="skills-meta-anomaly" style="background:#f0fdf4;border:1px solid #bbf7d0;color:#15803d">✅ 未检测到异常</div>'
}
if (data.heartbeats && data.heartbeats.length > 0) {
heartbeats.innerHTML = data.heartbeats.map(hb => {
const cls = hb.ok ? 'ok' : 'fail'
const icon = hb.ok ? '✅' : '❌'
return `<div class="skills-meta-hb-item ${cls}">${icon} ${hb.label}${!hb.ok ? ' (' + hb.status + ')' : ''}</div>`
}).join('')
}
} catch (e) {
stats.innerHTML = '<span style="color:#dc2626">加载失败</span>'
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:20px;color:#dc2626">加载失败: ' + escHtml(String(e.message || e)) + '</td></tr>'
}
}
const escAttr = s => String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;')
const loadEmailLog = async () => {
const modal = el('email-log-modal')
const tbody = el('email-log-tbody')
const countEl = el('email-log-count')
modal.classList.remove('hidden')
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:20px;color:#8c8c8c">加载中...</td></tr>'
try {
const res = await fetch('/api/markets/email-log?limit=200')
const data = await res.json()
if (!data.ok) throw new Error(data.error || '未知错误')
countEl.textContent = `共 ${data.total} 条`
if (data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:30px;color:#8c8c8c">暂无邮件发送记录</td></tr>'
return
}
const statusMap = {
sending: { label: '发送中', cls: 'email-status-pending' },
sent: { label: '已发送', cls: 'email-status-ok' },
failed: { label: '失败', cls: 'email-status-err' },
retry_pending: { label: '等待重试', cls: 'email-status-warn' },
retry_attempt: { label: '重试中', cls: 'email-status-warn' },
retry_exhausted: { label: '放弃', cls: 'email-status-err' }
}
tbody.innerHTML = data.rows.map(r => {
const st = statusMap[r.status] || { label: r.status, cls: '' }
return `<tr>
<td style="font-family:monospace;font-size:12px;color:#64748b">${r.id}</td>
<td style="font-size:12px;white-space:nowrap">${r.created_at}</td>
<td style="font-size:12px">${escHtml(r.skill)}</td>
<td style="font-family:monospace;font-size:11px;max-width:170px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escAttr(r.alert_id)}">${escHtml(r.alert_id)}</td>
<td style="font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escAttr(r.subject)}">${escHtml(r.subject)}</td>
<td><span class="${st.cls}">${st.label}</span></td>
<td style="font-size:11px;color:#64748b;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escAttr(r.error_message)}">${escHtml(r.error_message || '—')}</td>
</tr>`
}).join('')
} catch (e) {
countEl.textContent = '加载失败'
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:20px;color:#dc2626">加载失败: ' + escHtml(String(e.message || e)) + '</td></tr>'
}
}
const loadWeeklyMeta = async () => {
const modal = el('weekly-meta-modal')
const stats = el('weekly-meta-stats')
const tbody = el('weekly-meta-tbody')
const heartbeats = el('weekly-meta-heartbeats')
const diagnosis = el('weekly-meta-diagnosis')
modal.classList.remove('hidden')
stats.innerHTML = '<span>加载中...</span>'
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:20px;color:#8c8c8c">正在拉取周报数据源诊断...</td></tr>'
heartbeats.innerHTML = ''
diagnosis.innerHTML = ''
try {
const res = await fetch('/api/markets/weekly_report_health')
const data = await res.json()
if (!data.ok) throw new Error(data.error || '未知错误')
stats.innerHTML = `
<span>总计 ${data.total} 卡</span>
<span style="background:#fef2f2;border-color:#fecaca;color:#b91c1c">东财K线影响 ${data.eastmoney_count} 卡</span>
`
tbody.innerHTML = data.items.map(it => {
return `<tr>
<td>${it.label}</td>
<td style="font-family:monospace;font-size:12px">${it.symbol}</td>
<td>${it.main_source}</td>
<td style="font-size:12px">${it.backup_source}</td>
<td style="font-size:12px">${it.group}</td>
<td style="color:${it.reliability_color};font-weight:600">${it.reliability}</td>
</tr>`
}).join('')
if (data.heartbeats && data.heartbeats.length > 0) {
heartbeats.innerHTML = data.heartbeats.map(hb => {
const cls = hb.ok ? 'ok' : 'fail'
const icon = hb.ok ? '✅' : '❌'
return `<div class="skills-meta-hb-item ${cls}">${icon} ${hb.label}${!hb.ok ? ' (' + hb.status + ')' : ''}</div>`
}).join('')
}
if (data.diagnosis && data.diagnosis.length > 0) {
diagnosis.innerHTML = data.diagnosis.map(d =>
`<div class="skills-meta-anomaly ${d.level}">
<strong>${d.source}</strong>: ${d.detail}
</div>`
).join('')
} else {
diagnosis.innerHTML = '<div class="skills-meta-anomaly" style="background:#f0fdf4;border:1px solid #bbf7d0;color:#15803d">✅ 未检测到异常,所有数据源连通正常</div>'
}
} catch (e) {
stats.innerHTML = '<span style="color:#dc2626">加载失败</span>'
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:20px;color:#dc2626">加载失败: ' + escHtml(String(e.message || e)) + '</td></tr>'
}
}