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, '&').replace(//g, '>').replace(/"/g, '"')
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 = `${infoParts.join(' · ')}`
}
if (rows.length === 0) {
list.innerHTML = '
| 暂无数据,请点击「手动刷新」抓取 |
'
if (status) { status.style.display = ''; status.textContent = '该日期暂无数据' }
return
}
list.innerHTML = rows.map(r => {
const titleCn = r.title_cn ? `${escHtml(r.title_cn)}
` : ''
const summaryCn = r.summary_cn ? `${escHtml(r.summary_cn)}
` : ''
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
? `${r.ai_status === 'unrelated' ? '【无关】' : `🇨🇳 ${escHtml(r.ai_domestic || '')}
🌍 ${escHtml(r.ai_overseas || '')}
🛡️ ${escHtml(r.ai_defensive || '')}`} 👇展开 | `
: '— | '
const link = String(r.link || '').trim()
const linkHtml = link
? `🔗`
: '—'
return `
| ${escHtml(r.continent)} |
${escHtml(r.country)} |
${escHtml(r.source_name)} |
${String(r.published_at || '').slice(0, 16)} |
${escHtml(r.title)}${titleCn} |
${escHtml(r.summary)}${summaryCn} |
${linkHtml} |
${insightCell}
`
}).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 = `| 加载失败: ${escHtml(d.error || '')} |
`
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 = `| 加载失败: ${escHtml(String(e.message || e))} |
`
}
}
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 = `
${r.label || r.name} (${symbolDisplay})
${r.url ? `
🏠` : ''}
— 暂无数据 —
${r._error || '数据源异常'}
来源 ${r.source} · 时间 ${r.captured_at || ''}
`
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 = `
黄金:净值 ${fmt(r.gold_nav)} | 价格 ${fmt(r.gold_price)} | 溢价率 ${fmtPct(r.gold_premium)} | ${r.gold_tip || '—'}
白银:净值 ${fmt(r.silver_nav)} | 价格 ${fmt(r.silver_price)} | 溢价率 ${fmtPct(r.silver_premium)} | ${r.silver_tip || '—'}
`
} 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 = `
MA120 ${isBull ? '>' : '<'} MA250 :结论为“ ${r.conclusion} ”
`
} else {
const changePercent = move.changePercent
const changeClass = move.sign >= 0 ? 'up' : 'down'
price.innerHTML = `
价格 ${r.price}
涨跌 ${fmtSign(move.change)}
(${fmtSign(changePercent)}%)
`
}
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 = ` ● ${status.text} (北时 ${status.label})`;
}
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, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''')
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 ? `🏠` : '';
cardsHTML += `
${escapeHTML(r.label || r.name)} (${escapeHTML(symbolDisplay)})
${linkHtml}
— 暂无数据 —
${escapeHTML(r._error || '数据源异常')}
来源 ${escapeHTML(r.source)} · 时间 ${r.captured_at || ''}
`;
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 = ` ● ${status.text} (北时 ${status.label})`;
}
// Link Logic
const symbolDisplay = r.displaySymbol || r.symbol;
const linkUrl = r.url || getSymbolUrl(symbolDisplay);
const linkHtml = `🏠`;
// Alert Flag
const alertHtml = isAlert ? `⚠ 告警` : '';
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 = `
${r.label} · ${r.name} (${symbolDisplay}) ${alertHtml}
黄金:净值 ${fmt(r.gold_nav)} | 价格 ${fmt(r.gold_price)} | 溢价率 ${fmtPct(r.gold_premium)} | ${r.gold_tip || '—'}
白银:净值 ${fmt(r.silver_nav)} | 价格 ${fmt(r.silver_price)} | 溢价率 ${fmtPct(r.silver_premium)} | ${r.silver_tip || '—'}
`;
} else if ((r.render_type === 'html' || r.render_type === 'zhonghang_299_html') && r.html) {
contentHtml = `
${r.label} · ${r.name} (${symbolDisplay}) ${alertHtml}
${linkHtml}
${r.html}
`;
} else if (r.isMaCard) {
const isBull = parseFloat(r.ma120) > parseFloat(r.ma250);
contentHtml = `
${r.label} · MA120: ${r.ma120} | MA250: ${r.ma250} (${symbolDisplay}) ${alertHtml}
${linkHtml}
MA120 ${isBull ? '>' : '<'} MA250 :结论为“ ${r.conclusion} ”
`;
} else {
contentHtml = `
${r.label} · ${r.name} (${symbolDisplay}) ${alertHtml}
${linkHtml}
价格 ${r.price}
涨跌 ${fmtSign(move.change)}
(${fmtSign(changePercent)}%)
`;
}
const explainText = normalizeExplainText(r.explain);
const explainHtml = explainText ? `${escapeHTML(explainText)}
` : '';
cardsHTML += `
${contentHtml}
${explainHtml}
来源 ${r.source} · 时间 ${r.captured_at || ''}${statusHtml}
`;
}
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 `${title}${title}
${cardsHTML}
生成时间 ${time}
`;
};
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 = ``;
if (html.includes('')) {
html = html.replace('', overrideCss + '');
} 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 = `
交易日
${summary.trading_day_count || 0}
休市日
${summary.market_closed_count || 0}
新股申购日
${summary.ipo_day_count || 0}
AI待复核
${summary.ai_issue_count || 0}
`
}
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 = `
选择左侧日期后,这里会显示交易状态、节假日说明和新股申购明细。
`
return
}
const holidayHtml = day.holiday_name
? `节假日${day.holiday_name}
`
: ''
const ipoHtml = (day.ipo_events || []).length
? day.ipo_events.map(item => `
${item.stock_name} ${item.stock_code}
市场:${item.market || 'A股'} | 顶格申购:${Number.isFinite(Number(item.apply_limit_wan)) ? Number(item.apply_limit_wan).toFixed(2) : '—'} 万股
发行价:${Number.isFinite(Number(item.issue_price)) ? Number(item.issue_price).toFixed(2) + ' 元' : '—'} | 市盈率:${Number.isFinite(Number(item.pe_ratio)) ? Number(item.pe_ratio).toFixed(2) : '—'}
状态:${item.status || '已收录'}
`).join('')
: '当天没有新股申购安排。
'
const aiReview = day.ai_review || null
const aiReviewHtml = aiReview
? `
AI状态${getCalendarAiReviewLabel(aiReview)}
AI结论${aiReview.title || aiReview.suggested_status || '未提示异常'}
AI原因${aiReview.reason || 'AI 本轮未提示明显冲突,但不代表替代主数据源。'}
`
: '该日期尚未进入 AI 复核窗口,或还未执行本周复核。
'
box.innerHTML = `
${day.date}
${day.weekday_label || ''}
${getCalendarStatusText(day)}
证券状态${day.day_type_label || (day.is_trading_day ? '交易日' : '休市日')}
${holidayHtml}
备注${day.notes || '—'}
AI复核
${aiReviewHtml}
新股申购
${ipoHtml}
`
}
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 = '当前月份暂无日历数据。
'
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(`${label}
`)
})
for (let i = 0; i < leadCount; i += 1) nodes.push('')
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(`
`)
}
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 = '正在调用 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 = `正在加载 ${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 = `
`
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 = `${getWeeklyFilterLabel(weeklyState.activeFilter)}暂无可显示卡片。
`
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 ? `东方财富` : ''
return `
${row.label || row.symbol}
${row.symbol || ''} | ${row.market_group || '指数'} | 北时 ${fmtWeeklyBeijingTime(row)}
${detailLink}
${fmtWeekRange(row.week_start, row.week_end)}
开盘${fmtWeekPrice(row.price_open)}
最高${fmtWeekPrice(row.price_high)}
最低${fmtWeekPrice(row.price_low)}
收盘${fmtWeekPrice(row.price_close)}
涨跌幅${movePct}
涨跌额${moveValue}
成交额${fmtWeekAmount(row.amount_value, row.currency_label)}
货币${row.currency_label || row.currency_code || '—'}
状态:${statusText} | 来源:${source} | 最后交易日:${row.last_trade_date || '—'} | 北时:${fmtWeeklyBeijingTime(row)}
${note}
`
}).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 = `正在加载 ${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 = `正在加载 ${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 zhiziTab = el('m-tab-zhizi')
const toolsTab = el('m-tab-tools')
const dipTab = el('m-tab-dip')
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')
const tools = el('m-panel-tools')
const dip = el('m-panel-dip')
const hideDip = () => { if (dipTab) dipTab.classList.remove('active'); if (dip) dip.style.display = 'none' }
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'); if (zhiziTab) zhiziTab.classList.remove('active'); if (toolsTab) toolsTab.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'; if (tools) tools.style.display = 'none'; hideDip()
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'); if (zhiziTab) zhiziTab.classList.remove('active'); if (toolsTab) toolsTab.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 (tools) tools.style.display = 'none'; hideDip()
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'); if (zhiziTab) zhiziTab.classList.remove('active'); if (toolsTab) toolsTab.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 (tools) tools.style.display = 'none'; hideDip()
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 (zhiziTab) zhiziTab.classList.remove('active'); if (toolsTab) toolsTab.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'; if (tools) tools.style.display = 'none'; hideDip()
loadGlobalNews(false)
} else if (which === 'wisdom') {
window.open('https://traesite.umersoft.com:8975/tools/markets/wallstreet_wisdom.html', '_blank')
return
} else if (which === 'zhizi') {
window.open('https://traesite.umersoft.com:8975/tools/zhizi_notes/index.html', '_blank')
return
} else if (which === 'tools') {
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.remove('active')
if (zhiziTab) zhiziTab.classList.remove('active')
if (toolsTab) toolsTab.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 = 'none'
if (tools) tools.style.display = ''
hideDip()
} else if (which === 'dip') {
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.remove('active')
if (zhiziTab) zhiziTab.classList.remove('active')
if (toolsTab) toolsTab.classList.remove('active')
if (dipTab) dipTab.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 = 'none'
if (tools) tools.style.display = 'none'
if (dip) dip.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')
if (zhiziTab) zhiziTab.classList.remove('active')
if (toolsTab) toolsTab.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'
if (tools) tools.style.display = 'none'
hideDip()
}
}
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 stZhizi = el('m-tab-zhizi'); if (stZhizi) stZhizi.addEventListener('click', () => switchPanel('zhizi'))
const stTools = el('m-tab-tools'); if (stTools) stTools.addEventListener('click', () => switchPanel('tools'))
const stDip = el('m-tab-dip'); if (stDip) stDip.addEventListener('click', () => switchPanel('dip'))
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' : (query.tab === 'tools' ? 'tools' : '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 = '加载中...'
tbody.innerHTML = '| 正在拉取数元数据... |
'
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 = `
总计 ${data.total_skills} 卡
健康 ${data.healthy_count}
生成: ${new Date(data.generated_at).toLocaleString('zh-CN')}
`
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 `
| ${s.name} |
${s.symbol} |
${s.main_source} |
${s.backup_source} |
${s.schedule} |
${s.last_data_at === '—' ? '—' : s.last_data_at.replace('T',' ').slice(0,19)} |
${s.health} |
`
}).join('')
structure.innerHTML = data.skills.map(s => {
const tags = []
tags.push(s.is_shared
? `共享: ${s.shared_from || 'germany_etf'}`
: `独立实现`)
if (s.has_backup) tags.push(`有备用源`)
return `
${s.name} (${s.symbol}) ${tags.join(' ')}
主源: ${s.main_source}${s.backup_source !== '—' ? ' | 备源: ' + s.backup_source : ''} | 刷新: ${s.schedule}
`
}).join('')
if (data.anomalies && data.anomalies.length > 0) {
anomalies.innerHTML = data.anomalies.map(a =>
`
${a.source}: ${a.detail}
`
).join('')
} else {
anomalies.innerHTML = '✅ 未检测到异常
'
}
if (data.heartbeats && data.heartbeats.length > 0) {
heartbeats.innerHTML = data.heartbeats.map(hb => {
const cls = hb.ok ? 'ok' : 'fail'
const icon = hb.ok ? '✅' : '❌'
return `${icon} ${hb.label}${!hb.ok ? ' (' + hb.status + ')' : ''}
`
}).join('')
}
} catch (e) {
stats.innerHTML = '加载失败'
tbody.innerHTML = '| 加载失败: ' + escHtml(String(e.message || e)) + ' |
'
}
}
const escAttr = s => String(s || '').replace(/&/g, '&').replace(/"/g, '"')
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 = '| 加载中... |
'
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 = '| 暂无邮件发送记录 |
'
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 `
| ${r.id} |
${r.created_at} |
${escHtml(r.skill)} |
${escHtml(r.alert_id)} |
${escHtml(r.subject)} |
${st.label} |
${escHtml(r.error_message || '—')} |
`
}).join('')
} catch (e) {
countEl.textContent = '加载失败'
tbody.innerHTML = '| 加载失败: ' + escHtml(String(e.message || e)) + ' |
'
}
}
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 = '加载中...'
tbody.innerHTML = '| 正在拉取周报数据源诊断... |
'
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 = `
总计 ${data.total} 卡
东财K线影响 ${data.eastmoney_count} 卡
`
tbody.innerHTML = data.items.map(it => {
return `
| ${it.label} |
${it.symbol} |
${it.main_source} |
${it.backup_source} |
${it.group} |
${it.reliability} |
`
}).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 `${icon} ${hb.label}${!hb.ok ? ' (' + hb.status + ')' : ''}
`
}).join('')
}
if (data.diagnosis && data.diagnosis.length > 0) {
diagnosis.innerHTML = data.diagnosis.map(d =>
`
${d.source}: ${d.detail}
`
).join('')
} else {
diagnosis.innerHTML = '✅ 未检测到异常,所有数据源连通正常
'
}
} catch (e) {
stats.innerHTML = '加载失败'
tbody.innerHTML = '| 加载失败: ' + escHtml(String(e.message || e)) + ' |
'
}
}