280 lines
12 KiB
JavaScript
280 lines
12 KiB
JavaScript
/* 股票详情页 */
|
||
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();
|