Files
stock-advisor/static/js/stock_detail.js
T

280 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 股票详情页 */
const CODE = location.pathname.split('/').pop();
let stock = null, curTab = 'news';
async function load() {
try {
const d = await api('/api/stock/' + CODE);
stock = d;
renderHead(d);
renderKpi(d.ind);
renderScore(d);
renderTab(curTab);
loadKline(120);
initWatch(d.stock.is_watch);
if (new URLSearchParams(location.search).get('analyze') === '1') generateReport();
} catch (e) {
document.querySelector('.content').innerHTML = '<div class="card"><div class="empty">股票不存在或加载失败</div></div>';
}
}
function renderHead(d) {
const s = d.stock, ind = d.ind;
$('#headName').textContent = s.name;
$('#stName').textContent = s.name;
$('#stCode').textContent = s.code;
$('#stIndustry').textContent = '行业:' + s.industry;
$('#stBoard').textContent = '板块:' + s.board;
$('#stCap').textContent = Number(s.market_cap).toFixed(0) + '亿';
$('#stPe').textContent = s.pe;
$('#stPb').textContent = s.pb;
const price = ind.close, chg = ind.change_pct;
const cls = pctClass(chg);
$('#stPrice').textContent = price;
$('#stPrice').className = 'num ' + cls;
$('#stChg').textContent = fmtPct(chg);
$('#stChg').className = 'num ' + cls;
$('#stChg5').textContent = '5日 ' + fmtPct(ind.chg_5d);
$('#stChg5').className = 'num ' + pctClass(ind.chg_5d);
$('#stChg20').textContent = '20日 ' + fmtPct(ind.chg_20d);
$('#stChg20').className = 'num ' + pctClass(ind.chg_20d);
scoreRing($('#ring'), d.score.total);
const r = $('#stRating');
r.textContent = d.score.rating;
r.className = 'tag tag-' + d.score.rating;
}
function renderKpi(ind) {
const kpis = [
['现价', ind.close, 'MA5 ' + ind.ma5 + ' / MA10 ' + ind.ma10],
['MA20 / MA60', ind.ma20 + ' / ' + (ind.ma60 || '--'), ind.trend_bull ? '多头排列' : (ind.close >= ind.ma20 ? '站上MA20' : '趋势偏弱')],
['RSI(14)', ind.rsi, ind.rsi >= 70 ? '超买' : ind.rsi <= 30 ? '超卖' : '健康'],
['MACD', ind.macd, 'DIF ' + ind.dif + ' / DEA ' + ind.dea],
['KDJ', ind.kdj_k + ' / ' + ind.kdj_d + ' / ' + ind.kdj_j, ''],
['量比', ind.vol_ratio, ind.vol_ratio >= 1.5 ? '放量' : ind.vol_ratio < 0.7 ? '缩量' : '平稳'],
['5日/20日涨幅', fmtPct(ind.chg_5d) + ' / ' + fmtPct(ind.chg_20d), ''],
['20日波动率', ind.volatility + '%', ''],
['近120日区间', ind.low_52w + ' ~ ' + ind.high_52w, ''],
];
$('#kpiBox').innerHTML = kpis.map(([label, val, sub]) => `
<div class="kpi">
<div class="kpi-label">${label}</div>
<div class="kpi-value num">${val}</div>
<div class="kpi-sub">${sub || ''}</div>
</div>`).join('');
}
function renderScore(d) {
$('#scoreBarsBox').innerHTML = scoreBars(d.score.score_parts);
$('#reasonBox').innerHTML = '<b style="color:var(--gold)">推荐逻辑:</b>' +
(d.reasons || []).map(r => escapeHtml(r)).join('<span style="color:var(--border)"></span>') +
`<span style="margin-left:8px;color:var(--muted);font-size:12px">机构正面评级 ${d.inst_up} 家 · 基金增持 ${d.hold_up} 家</span>`;
}
async function loadKline(days) {
try {
const d = await api(`/api/stock/${CODE}/kline?days=${days}`);
renderKline(d);
} catch (e) {}
}
let chart = null;
function renderKline(d) {
const el = $('#klineChart');
if (!window.echarts) { el.innerHTML = '<div class="empty">ECharts 加载失败(请检查网络)</div>'; return; }
if (!chart) chart = echarts.init(el);
const dates = d.dates;
const klineData = d.kline;
const volumes = d.volumes;
// 计算涨跌色
const colors = klineData.map(k => (k[1] >= k[0] ? '#ef4444' : '#22c55e'));
chart.setOption({
backgroundColor: 'transparent',
animation: false,
legend: { data: ['K线', 'MA5', 'MA10', 'MA20', 'MA60'], textStyle: { color: '#9da7b3' }, top: 0 },
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' },
formatter: params => {
const i = params[0].dataIndex;
const k = klineData[i];
let s = `<b>${dates[i]}</b><br>开 ${k[0]} / 收 ${k[1]} / 低 ${k[2]} / 高 ${k[3]}`;
params.forEach(p => { if (p.seriesName !== '成交量') s += `<br>${p.seriesName}: ${p.value}`; });
return s;
} },
axisPointer: { link: [{ xAxisIndex: 'all' }] },
grid: [{ left: 60, right: 16, top: 30, height: '58%' }, { left: 60, right: 16, top: '72%', height: '18%' }],
xAxis: [
{ type: 'category', data: dates, boundaryGap: true, axisLine: { lineStyle: { color: '#262d3a' } }, axisLabel: { color: '#6e7681' } },
{ type: 'category', gridIndex: 1, data: dates, axisLabel: { show: false }, axisLine: { lineStyle: { color: '#262d3a' } } },
],
yAxis: [
{ scale: true, splitLine: { lineStyle: { color: '#1f2630' } }, axisLabel: { color: '#6e7681' } },
{ gridIndex: 1, splitNumber: 2, axisLabel: { show: false }, splitLine: { show: false } },
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: 55, end: 100 },
{ type: 'slider', xAxisIndex: [0, 1], bottom: 2, height: 16, start: 55, end: 100,
borderColor: '#262d3a', backgroundColor: '#161b22', fillerColor: 'rgba(59,130,246,.2)' },
],
series: [
{ name: 'K线', type: 'candlestick', data: klineData,
itemStyle: { color: '#ef4444', color0: '#22c55e', borderColor: '#ef4444', borderColor0: '#22c55e' } },
{ name: 'MA5', type: 'line', data: d.ma5, smooth: true, showSymbol: false, lineStyle: { width: 1, color: '#f59e0b' } },
{ name: 'MA10', type: 'line', data: d.ma10, smooth: true, showSymbol: false, lineStyle: { width: 1, color: '#22d3ee' } },
{ name: 'MA20', type: 'line', data: d.ma20, smooth: true, showSymbol: false, lineStyle: { width: 1, color: '#a78bfa' } },
{ name: 'MA60', type: 'line', data: d.ma60, smooth: true, showSymbol: false, lineStyle: { width: 1, color: '#f472b6' } },
{ name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1, data: volumes, itemStyle: { color: c => colors[c.dataIndex] } },
],
}, true);
}
window.addEventListener('resize', () => chart && chart.resize());
/* 自选 */
function initWatch(isWatch) {
const b = $('#watchBtn');
if (isWatch) {
b.textContent = '★ 已在自选';
b.classList.add('btn-danger');
b.onclick = async () => {
await api('/api/watchlist/' + CODE, { method: 'DELETE' });
toast('已移出自选');
initWatch(false);
};
} else {
b.textContent = '⭐ 加入自选';
b.classList.remove('btn-danger');
b.onclick = async () => {
await api('/api/watchlist/' + CODE, { method: 'POST' });
toast('已加入自选');
initWatch(true);
};
}
}
/* Tab */
async function renderTab(tab) {
curTab = tab;
$$('#tabs .pill').forEach(p => p.classList.toggle('active', p.dataset.tab === tab));
$('#tabBox').innerHTML = '<div class="loading">加载中…</div>';
if (tab === 'news') {
try {
const d = await api(`/api/stock/${CODE}/news`);
$('#tabBox').innerHTML = d.items.length ? d.items.map(n => `
<div class="news-item" onclick="location.href='/news'">
<div class="news-title">${escapeHtml(n.title)}</div>
<div class="news-meta">
<span class="cat-tag ${n.category}">${n.category}</span>
<span class="tag tag-${n.sentiment > 0 ? '正' : n.sentiment < 0 ? '负' : '平'}">${n.sentiment > 0 ? '利好' : n.sentiment < 0 ? '利空' : '中性'}${Number(n.sentiment).toFixed(2)}</span>
<span>${escapeHtml(n.source)}</span><span>${n.publish_date}</span>
</div>
<div class="news-summary">${escapeHtml(n.content)}</div>
</div>`).join('') : '<div class="empty">暂无相关新闻</div>';
} catch (e) { $('#tabBox').innerHTML = '<div class="empty">加载失败</div>'; }
} else if (tab === 'ratings') {
try {
const d = await api(`/api/stock/${CODE}/institutions`);
const rows = (d.ratings || []).map(r => `
<tr><td>${escapeHtml(r.inst_name)}</td>
<td><span class="tag tag-${r.rating}">${r.rating}</span></td>
<td class="num">${r.target_price}</td><td>${r.rating_date}</td></tr>`).join('');
$('#tabBox').innerHTML = `<table>
<tr><th>机构</th><th>评级</th><th>目标价</th><th>日期</th></tr>
${rows || '<tr><td colspan="4" class="empty">暂无评级</td></tr>'}</table>`;
} catch (e) { $('#tabBox').innerHTML = '<div class="empty">加载失败</div>'; }
} else if (tab === 'holdings') {
try {
const d = await api(`/api/stock/${CODE}/institutions`);
const rows = (d.holdings || []).map(h => `
<tr><td>${escapeHtml(h.inst_name)}</td><td>${h.quarter}</td>
<td class="num">${fmtNum(h.hold_value)}</td>
<td class="num ${pctClass(h.change_pct)}">${fmtPct(h.change_pct)}</td></tr>`).join('');
$('#tabBox').innerHTML = `<table>
<tr><th>机构</th><th>季度</th><th>持仓市值</th><th>环比</th></tr>
${rows || '<tr><td colspan="4" class="empty">暂无持仓</td></tr>'}</table>`;
} catch (e) { $('#tabBox').innerHTML = '<div class="empty">加载失败</div>'; }
} else if (tab === 'profile') {
$('#tabBox').innerHTML = `<div style="line-height:2;color:var(--text2)">${escapeHtml(stock.stock.description || '暂无')}</div>`;
}
}
$$('#tabs .pill').forEach(p => p.onclick = () => renderTab(p.dataset.tab));
$$('.btn[data-days]').forEach(b => b.onclick = () => {
$$('.btn[data-days]').forEach(x => x.classList.remove('btn-primary'));
b.classList.add('btn-primary');
loadKline(+b.dataset.days);
});
/* AI 研报 */
async function generateReport() {
const btn = $('#analyzeBtn');
btn.disabled = true;
$('#reportBox').innerHTML = `<div class="loading"><span class="spin"></span> AI 正在生成研报(RAG检索+DeepSeek 推理),约需 30-90 秒…</div>`;
try {
const focus = $('#focusInput').value.trim();
await api('/api/stock/' + CODE + '/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ focus })
});
pollReport();
} catch (e) {
$('#reportBox').innerHTML = '<div class="empty">提交失败:' + escapeHtml(e.message) + '</div>';
btn.disabled = false;
}
}
async function pollReport() {
const t0 = Date.now();
const timer = setInterval(async () => {
try {
const d = await api(`/api/stock/${CODE}/analyze/status`);
if (d.status === 'done') {
clearInterval(timer);
$('#analyzeBtn').disabled = false;
$('#reportBox').innerHTML = `<div class="markdown-body">${mdRender(d.report)}</div>
<div style="color:var(--muted);font-size:12px;margin-top:12px">${d.cached ? '(最近一次分析记录,' + d.created_at + '' : '生成耗时 ' + Math.round((Date.now() - t0) / 1000) + ' 秒'}
${d.history_id ? ` · <a href="/analysis/${d.history_id}" target="_blank">查看数据源详情 ↗</a>` : ''}</div>`;
loadHistory();
} else if (d.status === 'error') {
clearInterval(timer);
$('#analyzeBtn').disabled = false;
$('#reportBox').innerHTML = '<div class="empty">生成失败:' + escapeHtml(d.error || '未知错误') + '</div>';
}
} catch (e) {
clearInterval(timer);
$('#analyzeBtn').disabled = false;
$('#reportBox').innerHTML = '<div class="empty">查询状态失败</div>';
}
}, 4000);
}
/* 历史分析记录 */
async function loadHistory() {
try {
const d = await api(`/api/stock/${CODE}/analyses`);
const items = d.items || [];
if (!items.length) {
$('#historyBox').innerHTML = '<div class="empty">暂无历史分析,点击上方「生成研报」开始</div>';
return;
}
$('#historyBox').innerHTML = `<table>
<tr><th>时间</th><th>关注点</th><th>引用资讯</th><th>报告摘要</th><th>操作</th></tr>
${items.map(h => `<tr>
<td style="color:var(--muted)">${h.created_at}</td>
<td>${escapeHtml(h.focus || '整体投资价值')}</td>
<td class="num">${h.news_count} 条</td>
<td style="max-width:300px;white-space:normal;color:var(--text2)">${escapeHtml(h.excerpt)}…</td>
<td><button class="btn btn-primary" onclick="window.open('/analysis/${h.id}','_blank')">查看详情 ↗</button></td>
</tr>`).join('')}
</table>`;
} catch (e) {
$('#historyBox').innerHTML = '<div class="empty">历史记录加载失败</div>';
}
}
$('#analyzeBtn').onclick = generateReport;
$('#focusInput').addEventListener('keydown', e => { if (e.key === 'Enter') generateReport(); });
load();
loadHistory();