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 = `
${overviewText}
交易日
${summary.trading_day_count || 0}
休市日
${summary.market_closed_count || 0}
新股申购日
${summary.ipo_day_count || 0}
AI待复核
${summary.ai_issue_count || 0}
同步状态 ${syncText}
` } 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)}
${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)) + '' } }