/* 股票详情页 */ 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 = '
股票不存在或加载失败
'; } } 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]) => `
${label}
${val}
${sub || ''}
`).join(''); } function renderScore(d) { $('#scoreBarsBox').innerHTML = scoreBars(d.score.score_parts); $('#reasonBox').innerHTML = '推荐逻辑:' + (d.reasons || []).map(r => escapeHtml(r)).join('') + `机构正面评级 ${d.inst_up} 家 · 基金增持 ${d.hold_up} 家`; } 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 = '
ECharts 加载失败(请检查网络)
'; 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 = `${dates[i]}
开 ${k[0]} / 收 ${k[1]} / 低 ${k[2]} / 高 ${k[3]}`; params.forEach(p => { if (p.seriesName !== '成交量') s += `
${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 = '
加载中…
'; if (tab === 'news') { try { const d = await api(`/api/stock/${CODE}/news`); $('#tabBox').innerHTML = d.items.length ? d.items.map(n => `
${escapeHtml(n.title)}
${n.category} ${n.sentiment > 0 ? '利好' : n.sentiment < 0 ? '利空' : '中性'}(${Number(n.sentiment).toFixed(2)}) ${escapeHtml(n.source)}${n.publish_date}
${escapeHtml(n.content)}
`).join('') : '
暂无相关新闻
'; } catch (e) { $('#tabBox').innerHTML = '
加载失败
'; } } else if (tab === 'ratings') { try { const d = await api(`/api/stock/${CODE}/institutions`); const rows = (d.ratings || []).map(r => ` ${escapeHtml(r.inst_name)} ${r.rating} ${r.target_price}${r.rating_date}`).join(''); $('#tabBox').innerHTML = ` ${rows || ''}
机构评级目标价日期
暂无评级
`; } catch (e) { $('#tabBox').innerHTML = '
加载失败
'; } } else if (tab === 'holdings') { try { const d = await api(`/api/stock/${CODE}/institutions`); const rows = (d.holdings || []).map(h => ` ${escapeHtml(h.inst_name)}${h.quarter} ${fmtNum(h.hold_value)} ${fmtPct(h.change_pct)}`).join(''); $('#tabBox').innerHTML = ` ${rows || ''}
机构季度持仓市值环比
暂无持仓
`; } catch (e) { $('#tabBox').innerHTML = '
加载失败
'; } } else if (tab === 'profile') { $('#tabBox').innerHTML = `
${escapeHtml(stock.stock.description || '暂无')}
`; } } $$('#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 = `
AI 正在生成研报(RAG检索+DeepSeek 推理),约需 30-90 秒…
`; 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 = '
提交失败:' + escapeHtml(e.message) + '
'; 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 = `
${mdRender(d.report)}
${d.cached ? '(最近一次分析记录,' + d.created_at + ')' : '生成耗时 ' + Math.round((Date.now() - t0) / 1000) + ' 秒'} ${d.history_id ? ` · 查看数据源详情 ↗` : ''}
`; loadHistory(); } else if (d.status === 'error') { clearInterval(timer); $('#analyzeBtn').disabled = false; $('#reportBox').innerHTML = '
生成失败:' + escapeHtml(d.error || '未知错误') + '
'; } } catch (e) { clearInterval(timer); $('#analyzeBtn').disabled = false; $('#reportBox').innerHTML = '
查询状态失败
'; } }, 4000); } /* 历史分析记录 */ async function loadHistory() { try { const d = await api(`/api/stock/${CODE}/analyses`); const items = d.items || []; if (!items.length) { $('#historyBox').innerHTML = '
暂无历史分析,点击上方「生成研报」开始
'; return; } $('#historyBox').innerHTML = ` ${items.map(h => ``).join('')}
时间关注点引用资讯报告摘要操作
${h.created_at} ${escapeHtml(h.focus || '整体投资价值')} ${h.news_count} 条 ${escapeHtml(h.excerpt)}…
`; } catch (e) { $('#historyBox').innerHTML = '
历史记录加载失败
'; } } $('#analyzeBtn').onclick = generateReport; $('#focusInput').addEventListener('keydown', e => { if (e.key === 'Enter') generateReport(); }); load(); loadHistory();