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>
</section>
<script>
const feed = document.getElementById('feed')
const conn = document.getElementById('conn')
const buf = document.getElementById('buf')
let filter = ''
let ws, retryTimer
'use strict';
var feed = document.getElementById('feed')
var connEl = document.getElementById('conn')
var bufEl = document.getElementById('buf')
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) {
if (!d || !d.stocks || !d.stocks.length) return ''
const dirCls = { '利好': 'dir-bull', '利空': 'dir-bear' }[d.direction] || 'dir-neutral'
const rows = d.stocks.map(s =>
`<tr><td>${esc(s.name)}</td><td>${esc(s.code)}</td>` +
`<td>${s.buy_zone[0]} ~ ${s.buy_zone[1]}</td>` +
`<td class="${s.expected_return_pct > 0 ? 'up' : s.expected_return_pct < 0 ? 'down' : ''}">${s.expected_return_pct > 0 ? '+' : ''}${s.expected_return_pct}%</td>` +
`<td class="stars">${'★'.repeat(s.stars)}${'☆'.repeat(5 - s.stars)}</td>` +
`<td>${esc(s.reason)}</td></tr>`).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 ?? '-'}%</div></div>`
var rows = d.stocks.map(function (s) {
var er = Number(s.expected_return_pct) || 0
var erCls = er > 0 ? 'up' : er < 0 ? 'down' : ''
var erSign = er > 0 ? '+' : ''
return '<tr><td>' + esc(s.name) + '</td><td>' + esc(s.code) + '</td>' +
'<td>' + s.buy_zone[0] + ' ~ ' + s.buy_zone[1] + '</td>' +
'<td class="' + erCls + '">' + erSign + er + '%</td>' +
'<td class="stars">' + '★'.repeat(s.stars) + '☆'.repeat(5 - s.stars) + '</td>' +
'<td>' + esc(s.reason) + '</td></tr>'
}).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) {
if (filter && e.kind !== filter) return
const empty = feed.querySelector('.empty')
if (empty) empty.remove()
const li = document.createElement('li')
var emptyLi = feed.querySelector('.empty')
if (emptyLi) emptyLi.remove()
var li = document.createElement('li')
li.className = 'evt' + (fresh ? ' fresh' : '')
const d = e.data || {}
var d = e.data || {}
if (e.kind === '事件影响分析') {
const dirCls = { '利好': 'dir-bull', '利空': 'dir-bear', '中性': 'dir-neutral' }[d.direction] || ''
const sectors = (d.sectors || []).map(s => `<span class="stars">${esc(s)}</span>`).join(' / ')
li.innerHTML = `<span class="t">${e.ts}</span>` +
`<span class="k ${e.kind}">事件影响</span>` +
`<span class="d"><b>${esc(d.event_summary)}</b><br>` +
`方向:<span class="${dirCls}">${esc(d.direction)}</span> 板块:${sectors || '—'} 置信度 ${d.confidence ?? '-'}%<br>` +
`${esc(d.reasoning || '')}${impactCard(d)}</span>`
var dirCls = d.direction === '利好' ? 'dir-bull' : d.direction === '利空' ? 'dir-bear' : 'dir-neutral'
var sectors = (d.sectors || []).map(esc).join(' / ')
li.innerHTML = '<span class="t">' + esc(e.ts) + '</span>' +
'<span class="k">事件影响</span>' +
'<span class="d"><b>' + esc(d.event_summary) + '</b><br>' +
'方向:<span class="' + dirCls + '">' + esc(d.direction) + '</span> 板块:' + sectors +
' 置信度 ' + (d.confidence == null ? '-' : d.confidence) + '%<br>' +
esc(d.reasoning || '') + impactCard(d) + '</span>'
} else if (e.kind === '量化推荐') {
renderQuant(e.data)
const txt = '自适应模型推送 ' + ((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>`
var n = (d.list || []).length
li.innerHTML = '<span class="t">' + esc(e.ts) + '</span><span class="k">量化推荐</span>' +
'<span class="d">自适应模型推送 ' + n + ' 只推荐</span>'
} else {
const 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>`
var txt = typeof d === 'object' ? JSON.stringify(d) : String(d)
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)
while (feed.children.length > 300) feed.lastChild.remove()
feed.insertBefore(li, feed.firstChild)
while (feed.children.length > 300) feed.removeChild(feed.lastChild)
}
function applyRecent(events) {
[...events].reverse().forEach(e => render(e, false))
function renderQuant(d) {
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() {
fetch('/api/quant/recommendations').then(r => r.json()).then(renderQuant).catch(() => {})
fetch('/api/quant/weights').then(r => r.json()).then(w => {
document.getElementById('qw').textContent =
Object.entries(w || {}).map(([k, v]) => k + '=' + Number(v).toFixed(2)).join(' ') || '—'
}).catch(() => {})
fetch('/api/quant/recommendations').then(function (r) { return r.json() })
.then(renderQuant).catch(function () {})
fetch('/api/quant/weights').then(function (r) { return r.json() }).then(function (w) {
var el = document.getElementById('qw')
if (el) el.textContent = Object.keys(w || {}).map(function (k) {
return k + '=' + Number(w[k]).toFixed(2)
}).join(' ') || '—'
}).catch(function () {})
}
loadQuant()
setInterval(loadQuant, 60000)
connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
ws = new WebSocket(`${proto}://${location.host}/ws`)
ws.onopen = () => { conn.textContent = '已连接'; conn.className = 'conn on' }
ws.onclose = () => {
conn.textContent = '已断开,重连中…'; conn.className = 'conn off'
function setConn(connected) {
connEl.textContent = connected ? '已连接' : '已断开,重连中…'
connEl.className = 'conn ' + (connected ? 'on' : 'off')
}
function connect() {
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)
}
ws.onmessage = m => {
try { render(JSON.parse(m.data), true) } catch (e) {}
ws.onmessage = function (m) {
try { render(JSON.parse(m.data), true) } catch (e) { /* 忽略单条解析失败 */ }
}
}
document.getElementById('filters').addEventListener('click', ev => {
if (ev.target.dataset.f === undefined) return
filter = ev.target.dataset.f
document.querySelectorAll('.filters button').forEach(b => b.classList.toggle('active', b === ev.target))
fetch(`/api/recent`).then(r => r.json()).then(d => {
feed.innerHTML = ''
;[...(d.events || [])].reverse().forEach(e => render(e, false))
})
document.getElementById('filters').addEventListener('click', function (ev) {
var f = ev.target.dataset ? ev.target.dataset.f : undefined
if (f === undefined) return
filter = f
var btns = document.querySelectorAll('#filters button')
for (var i = 0; i < btns.length; i++) {
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)
fetch('/api/recent').then(r => r.json()).then(d => {
;[...(d.events || [])].reverse().forEach(e => render(e, false))
function refreshData() {
fetch('/api/recent').then(function (r) { return r.json() })
.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()
</script>
</body>