feat: 新增免费演出雷达工具与定投评估功能,优化周报提醒逻辑
1. 新增免费演出雷达工具:含秀动/猫眼/大麦平台抓取、本地代理鉴权配置 2. 新增建行定投评估模块:四大指数PE-TTM估值与定投档位建议 3. 新增推送告警白名单过滤功能,限制推送指定指数告警 4. 重构周报首页提醒逻辑,移至服务端处理并减少前端请求 5. 新增多套调试探针脚本与单元测试用例
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ===== 建行定投评估(前端逻辑) =====
|
||||
* 银行场外基金定投/赎回参考:点击「开始评估」按需抓取四大指数 PE-TTM 与近10年分位,无定时任务。
|
||||
*
|
||||
* 档位阈值与后端 src/server/markets_dip_eval.js 保持一致:
|
||||
* 沪深300:PE<12 定投200元/日 | 12~14.5 定投150元/日 | 14.5~16 定投100元/日·可止盈50% | PE>16 暂停定投+止盈清仓
|
||||
* 中证500:PE<25 定投200元/日 | 25~32 定投150元/日 | 32~36 定投100元/日·可止盈50% | PE>36 暂停定投+止盈清仓
|
||||
* 纳指100:PE<24 定投200元/日 | 24~30 定投150元/日 | 30~36 定投100元/日·可止盈50% | PE>36 暂停定投+止盈清仓
|
||||
* 标普500:PE<16 定投200元/日 | 16~21 定投150元/日 | 21~25 定投100元/日·可止盈50% | PE>25 暂停定投+止盈清仓
|
||||
*
|
||||
* 依赖 markets.js 提供的全局函数:el / runTrackedInteraction / setButtonBusy
|
||||
*/
|
||||
|
||||
// 档位 → 徽章样式(绿=定投加仓、蓝=轻定投、黄=可止盈一半、红=暂停+清仓)
|
||||
const DIP_LEVEL_STYLE = {
|
||||
buy_heavy: { bg: '#dcfce7', color: '#15803d', label: '低估档' },
|
||||
buy: { bg: '#dcfce7', color: '#16a34a', label: '定投档' },
|
||||
buy_light: { bg: '#e0f2fe', color: '#0369a1', label: '轻定投档' },
|
||||
take_half: { bg: '#fef3c7', color: '#b45309', label: '止盈档' },
|
||||
exit: { bg: '#fee2e2', color: '#b91c1c', label: '清仓档' },
|
||||
unknown: { bg: '#f1f5f9', color: '#64748b', label: '未知' }
|
||||
}
|
||||
|
||||
// HTML 转义,防止数据源返回内容注入
|
||||
const dipEscapeHtml = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]))
|
||||
|
||||
// 渲染评估结果表格
|
||||
const renderDipReport = data => {
|
||||
const list = el('dip-list')
|
||||
const status = el('dip-status')
|
||||
const errorsBox = el('dip-errors')
|
||||
if (!list) return
|
||||
const rows = Array.isArray(data.rows) ? data.rows : []
|
||||
|
||||
if (!rows.length) {
|
||||
list.innerHTML = '<tr><td colspan="8" class="muted" style="text-align:center;padding:30px">未获取到有效数据,请查看下方异常提示</td></tr>'
|
||||
} else {
|
||||
list.innerHTML = rows.map(r => {
|
||||
const st = DIP_LEVEL_STYLE[r.level] || DIP_LEVEL_STYLE.unknown
|
||||
const pe = Number.isFinite(r.pe_ttm) ? r.pe_ttm.toFixed(2) : '—'
|
||||
const pct = Number.isFinite(r.percentile) ? `${r.percentile.toFixed(1)}%` : '—'
|
||||
const note = r.note ? `<div style="margin-top:6px;font-size:12px;color:#b45309;line-height:1.6">⚠ ${dipEscapeHtml(r.note)}</div>` : ''
|
||||
return `<tr>
|
||||
<td><b>${dipEscapeHtml(r.name)}</b></td>
|
||||
<td>${dipEscapeHtml(r.code)}</td>
|
||||
<td>${dipEscapeHtml(r.market || '—')}</td>
|
||||
<td style="font-weight:700">${pe}</td>
|
||||
<td title="${dipEscapeHtml(r.percentile_note || '')}">${pct}</td>
|
||||
<td>${dipEscapeHtml(r.pe_date || '—')}</td>
|
||||
<td>${dipEscapeHtml(r.source || '—')}</td>
|
||||
<td>
|
||||
<span style="display:inline-block;padding:3px 10px;border-radius:999px;background:${st.bg};color:${st.color};font-weight:700;font-size:13px">${st.label}:${dipEscapeHtml(r.advice || '—')}</span>
|
||||
${note}
|
||||
</td>
|
||||
</tr>`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
if (status) {
|
||||
status.style.display = ''
|
||||
status.textContent = `评估时间 ${data.captured_at || '—'} | 指数 ${rows.length} 个 | 异常 ${(Array.isArray(data.errors) ? data.errors.length : 0)} 条`
|
||||
}
|
||||
|
||||
// 异常提示区:单指数失败不影响其他指数展示
|
||||
if (errorsBox) {
|
||||
const errs = Array.isArray(data.errors) ? data.errors : []
|
||||
if (errs.length) {
|
||||
errorsBox.style.display = ''
|
||||
errorsBox.innerHTML = errs.map(e => `<div style="font-size:13px;color:#b91c1c;line-height:1.9">⚠ ${dipEscapeHtml(e)}</div>`).join('')
|
||||
} else {
|
||||
errorsBox.style.display = 'none'
|
||||
errorsBox.innerHTML = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 点击「开始评估」:走 markets 统一交互详情弹窗(双模式由 runTrackedInteraction 内部处理)
|
||||
const runDipEval = async () => {
|
||||
const btn = el('dip-btn-run')
|
||||
try {
|
||||
const data = await runTrackedInteraction({
|
||||
title: '建行定投评估',
|
||||
action: '抓取四大指数 PE-TTM 与近10年分位并判档',
|
||||
summary: '按需抓取:中证指数官网 / 蛋卷基金(备源 multpl.com)...',
|
||||
button: btn,
|
||||
loadingText: '评估中…',
|
||||
request: { url: '/api/markets/dip_eval', method: 'GET' },
|
||||
successText: d => `评估完成:${(d && Array.isArray(d.rows)) ? d.rows.length : 0} 个指数,${((d && d.errors) || []).length} 条异常`
|
||||
}, async ctx => {
|
||||
return await ctx.fetchJson('/api/markets/dip_eval', {}, { 说明: '按需抓取,无定时任务' })
|
||||
})
|
||||
renderDipReport(data || {})
|
||||
} catch (e) {
|
||||
// 交互弹窗已展示错误详情(异常时窗口停留),这里给表格区一个兜底提示
|
||||
const list = el('dip-list')
|
||||
if (list) {
|
||||
list.innerHTML = `<tr><td colspan="8" style="text-align:center;padding:30px;color:#b91c1c">评估失败:${dipEscapeHtml(String((e && e.message) || e))}(网络波动或数据源不可用,可稍后重试)</td></tr>`
|
||||
}
|
||||
const status = el('dip-status')
|
||||
if (status) status.style.display = 'none'
|
||||
const errorsBox = el('dip-errors')
|
||||
if (errorsBox) { errorsBox.style.display = 'none'; errorsBox.innerHTML = '' }
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const btn = el('dip-btn-run')
|
||||
if (btn) btn.addEventListener('click', runDipEval)
|
||||
})
|
||||
Reference in New Issue
Block a user