fix: 前端整体重写——事件过滤按dataset.kind、connect可重入、前台重连、WS消息容错
This commit is contained in:
+119
-75
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user