fix: 前端整体重写——事件过滤按dataset.kind、connect可重入、前台重连、WS消息容错

This commit is contained in:
lookt
2026-09-16 07:37:17 +08:00
parent db98f6f084
commit 24b9bd44ff
+119 -75
View File
@@ -97,109 +97,153 @@
<div style="margin-top:6px;font-size:11px;color:#6b7280">⚠ 购入区间与预计收益为模型回测/推测口径,不构成投资建议。</div> <div style="margin-top:6px;font-size:11px;color:#6b7280">⚠ 购入区间与预计收益为模型回测/推测口径,不构成投资建议。</div>
</section> </section>
<script> <script>
const feed = document.getElementById('feed') 'use strict';
const conn = document.getElementById('conn') var feed = document.getElementById('feed')
const buf = document.getElementById('buf') var connEl = document.getElementById('conn')
let filter = '' var bufEl = document.getElementById('buf')
let ws, retryTimer var filter = ''
var ws = null
var retryTimer = null
function esc(s) { return String(s ?? '').replace(/[<>&]/g, '') } function esc(s) { return String(s == null ? '' : s).replace(/[<>&]/g, '') }
function impactCard(d) { function impactCard(d) {
if (!d || !d.stocks || !d.stocks.length) return '' if (!d || !d.stocks || !d.stocks.length) return ''
const dirCls = { '利好': 'dir-bull', '利空': 'dir-bear' }[d.direction] || 'dir-neutral' var rows = d.stocks.map(function (s) {
const rows = d.stocks.map(s => var er = Number(s.expected_return_pct) || 0
`<tr><td>${esc(s.name)}</td><td>${esc(s.code)}</td>` + var erCls = er > 0 ? 'up' : er < 0 ? 'down' : ''
`<td>${s.buy_zone[0]} ~ ${s.buy_zone[1]}</td>` + var erSign = er > 0 ? '+' : ''
`<td class="${s.expected_return_pct > 0 ? 'up' : s.expected_return_pct < 0 ? 'down' : ''}">${s.expected_return_pct > 0 ? '+' : ''}${s.expected_return_pct}%</td>` + return '<tr><td>' + esc(s.name) + '</td><td>' + esc(s.code) + '</td>' +
`<td class="stars">${'★'.repeat(s.stars)}${'☆'.repeat(5 - s.stars)}</td>` + '<td>' + s.buy_zone[0] + ' ~ ' + s.buy_zone[1] + '</td>' +
`<td>${esc(s.reason)}</td></tr>`).join('') '<td class="' + erCls + '">' + erSign + er + '%</td>' +
return `<div class="impact"><table>` + '<td class="stars">' + '★'.repeat(s.stars) + '☆'.repeat(5 - s.stars) + '</td>' +
`<tr><th>标的</th><th>代码</th><th>建议购入区间</th><th>预计收益(5日,推测)</th><th>推荐指数</th><th>简易原因</th></tr>${rows}</table>` + '<td>' + esc(s.reason) + '</td></tr>'
`<div class="disclaimer">⚠ 模型推断仅供参考,预计收益为推测值,不构成投资建议;置信度 ${d.confidence ?? '-'}%</div></div>` }).join('')
return '<div class="impact"><table>' +
'<tr><th>标的</th><th>代码</th><th>建议购入区间</th><th>预计收益(5日,推测)</th><th>推荐指数</th><th>简易原因</th></tr>' +
rows + '</table>' +
'<div class="disclaimer">⚠ 模型推断仅供参考,预计收益为推测值,不构成投资建议;置信度 ' +
(d.confidence == null ? '-' : d.confidence) + '%</div></div>'
} }
function render(e, fresh) { function render(e, fresh) {
if (filter && e.kind !== filter) return if (filter && e.kind !== filter) return
const empty = feed.querySelector('.empty') var emptyLi = feed.querySelector('.empty')
if (empty) empty.remove() if (emptyLi) emptyLi.remove()
const li = document.createElement('li') var li = document.createElement('li')
li.className = 'evt' + (fresh ? ' fresh' : '') li.className = 'evt' + (fresh ? ' fresh' : '')
const d = e.data || {} var d = e.data || {}
if (e.kind === '事件影响分析') { if (e.kind === '事件影响分析') {
const dirCls = { '利好': 'dir-bull', '利空': 'dir-bear', '中性': 'dir-neutral' }[d.direction] || '' var dirCls = d.direction === '利好' ? 'dir-bull' : d.direction === '利空' ? 'dir-bear' : 'dir-neutral'
const sectors = (d.sectors || []).map(s => `<span class="stars">${esc(s)}</span>`).join(' / ') var sectors = (d.sectors || []).map(esc).join(' / ')
li.innerHTML = `<span class="t">${e.ts}</span>` + li.innerHTML = '<span class="t">' + esc(e.ts) + '</span>' +
`<span class="k ${e.kind}">事件影响</span>` + '<span class="k">事件影响</span>' +
`<span class="d"><b>${esc(d.event_summary)}</b><br>` + '<span class="d"><b>' + esc(d.event_summary) + '</b><br>' +
`方向:<span class="${dirCls}">${esc(d.direction)}</span> 板块:${sectors || '—'} 置信度 ${d.confidence ?? '-'}%<br>` + '方向:<span class="' + dirCls + '">' + esc(d.direction) + '</span> 板块:' + sectors +
`${esc(d.reasoning || '')}${impactCard(d)}</span>` ' 置信度 ' + (d.confidence == null ? '-' : d.confidence) + '%<br>' +
esc(d.reasoning || '') + impactCard(d) + '</span>'
} else if (e.kind === '量化推荐') { } else if (e.kind === '量化推荐') {
renderQuant(e.data) renderQuant(e.data)
const txt = '自适应模型推送 ' + ((d.list || []).length) + ' 只推荐' var n = (d.list || []).length
li.innerHTML = `<span class="t">${e.ts}</span><span class="k ${e.kind}">${e.kind}</span><span class="d">${esc(txt)}</span>` li.innerHTML = '<span class="t">' + esc(e.ts) + '</span><span class="k">量化推荐</span>' +
'<span class="d">自适应模型推送 ' + n + ' 只推荐</span>'
} else { } else {
const txt = typeof d === 'object' ? JSON.stringify(d) : String(d) var txt = typeof d === 'object' ? JSON.stringify(d) : String(d)
li.innerHTML = `<span class="t">${e.ts}</span><span class="k ${e.kind}">${e.kind}</span><span class="d">${esc(txt)}</span>` li.innerHTML = '<span class="t">' + esc(e.ts) + '</span><span class="k">' + esc(e.kind) +
'</span><span class="d">' + esc(txt) + '</span>'
} }
feed.prepend(li) feed.insertBefore(li, feed.firstChild)
while (feed.children.length > 300) feed.lastChild.remove() while (feed.children.length > 300) feed.removeChild(feed.lastChild)
} }
function applyRecent(events) { function renderQuant(d) {
[...events].reverse().forEach(e => render(e, false)) var tb = document.querySelector('#qtable tbody')
if (!tb || !d || !d.list) return
tb.innerHTML = d.list.map(function (r) {
return '<tr><td>' + esc(r.code) + '</td><td>' + esc(r.name) + '</td>' +
'<td>' + r.price + '</td><td>' + r.score + '</td>' +
'<td>' + r.buy_low + ' ~ ' + r.buy_high + '</td>' +
'<td>' + (r.expected_return_pct == null ? '—' : r.expected_return_pct + '%') + '</td>' +
'<td class="stars">' + '★'.repeat(r.stars) + '☆'.repeat(5 - r.stars) + '</td>' +
'<td>' + esc(r.reason) + '</td></tr>'
}).join('')
} }
function function renderQuant(d) {
const tb = document.querySelector('#qtable tbody')
if (!tb) return
tb.innerHTML = (d.list || []).map(r =>
'<tr><td style="padding:6px 10px">' + esc(r.code) + '</td>' +
'<td style="padding:6px 10px">' + esc(r.name) + '</td>' +
'<td style="padding:6px 10px">' + r.price + '</td>' +
'<td style="padding:6px 10px">' + r.score + '</td>' +
'<td style="padding:6px 10px">' + r.buy_low + ' ~ ' + r.buy_high + '</td>' +
'<td style="padding:6px 10px">' + (r.expected_return_pct == null ? '—' : r.expected_return_pct + '%') + '</td>' +
'<td style="padding:6px 10px" class="stars">' + '★'.repeat(r.stars) + '</td>' +
'<td style="padding:6px 10px">' + esc(r.reason) + '</td></tr>').join('')
}
function loadQuant() { function loadQuant() {
fetch('/api/quant/recommendations').then(r => r.json()).then(renderQuant).catch(() => {}) fetch('/api/quant/recommendations').then(function (r) { return r.json() })
fetch('/api/quant/weights').then(r => r.json()).then(w => { .then(renderQuant).catch(function () {})
document.getElementById('qw').textContent = fetch('/api/quant/weights').then(function (r) { return r.json() }).then(function (w) {
Object.entries(w || {}).map(([k, v]) => k + '=' + Number(v).toFixed(2)).join(' ') || '—' var el = document.getElementById('qw')
}).catch(() => {}) if (el) el.textContent = Object.keys(w || {}).map(function (k) {
return k + '=' + Number(w[k]).toFixed(2)
}).join(' ') || '—'
}).catch(function () {})
} }
loadQuant()
setInterval(loadQuant, 60000) function setConn(connected) {
connect() { connEl.textContent = connected ? '已连接' : '已断开,重连中…'
const proto = location.protocol === 'https:' ? 'wss' : 'ws' connEl.className = 'conn ' + (connected ? 'on' : 'off')
ws = new WebSocket(`${proto}://${location.host}/ws`) }
ws.onopen = () => { conn.textContent = '已连接'; conn.className = 'conn on' }
ws.onclose = () => { function connect() {
conn.textContent = '已断开,重连中…'; conn.className = 'conn off' if (ws && (ws.readyState === 0 || ws.readyState === 1)) return
var proto = location.protocol === 'https:' ? 'wss' : 'ws'
ws = new WebSocket(proto + '://' + location.host + '/ws')
ws.onopen = function () {
setConn(true)
fetch('/api/recent').then(function (r) { return r.json() })
.then(function (d) {
(d.events || []).slice().reverse().forEach(function (e) { render(e, false) })
}).catch(function () {})
}
ws.onclose = function () {
setConn(false)
if (retryTimer) clearTimeout(retryTimer)
retryTimer = setTimeout(connect, 3000) retryTimer = setTimeout(connect, 3000)
} }
ws.onmessage = m => { ws.onmessage = function (m) {
try { render(JSON.parse(m.data), true) } catch (e) {} try { render(JSON.parse(m.data), true) } catch (e) { /* 忽略单条解析失败 */ }
} }
} }
document.getElementById('filters').addEventListener('click', ev => { document.getElementById('filters').addEventListener('click', function (ev) {
if (ev.target.dataset.f === undefined) return var f = ev.target.dataset ? ev.target.dataset.f : undefined
filter = ev.target.dataset.f if (f === undefined) return
document.querySelectorAll('.filters button').forEach(b => b.classList.toggle('active', b === ev.target)) filter = f
fetch(`/api/recent`).then(r => r.json()).then(d => { var btns = document.querySelectorAll('#filters button')
feed.innerHTML = '' for (var i = 0; i < btns.length; i++) {
;[...(d.events || [])].reverse().forEach(e => render(e, false)) btns[i].className = btns[i].dataset.f === filter ? 'active' : ''
}) }
var rows = feed.children
for (var j = rows.length - 1; j >= 0; j--) {
var show = !filter || rows[j].dataset.kind === filter
rows[j].style.display = show ? '' : 'none'
}
}) })
fetch('/api/stats').then(r => r.json()).then(s => buf.textContent = s.buffered) function refreshData() {
fetch('/api/recent').then(r => r.json()).then(d => { fetch('/api/recent').then(function (r) { return r.json() })
;[...(d.events || [])].reverse().forEach(e => render(e, false)) .then(function (d) {
var evs = d.events || []
for (var i = evs.length - 1; i >= 0; i--) render(evs[i], false)
}).catch(function () {})
loadQuant()
fetch('/api/stats').then(function (r) { return r.json() })
.then(function (s) { bufEl.textContent = s.buffered }).catch(function () {})
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
connect()
refreshData()
}
}) })
setInterval(() => fetch('/api/stats').then(r => r.json()).then(s => buf.textContent = s.buffered), 10000) window.addEventListener('online', function () { location.reload() })
refreshData()
setInterval(refreshData, 10000)
loadQuant()
setInterval(loadQuant, 60000)
connect() connect()
</script> </script>
</body> </body>