智能荐股系统 v1.0.0:股票池/行情/新闻RAG/机构持仓/多因子评分/AI深度研报

This commit is contained in:
2026-08-19 19:36:32 +08:00
commit 90a9f9b212
29 changed files with 3332 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
/* 数据管理 */
const tableNames = {
stocks: '股票', stock_daily: '日线行情', news: '财经新闻', institutions: '机构',
inst_ratings: '机构评级', fund_holdings: '基金持仓', watchlist: '自选股',
analysis_cache: '研报缓存', market_index: '市场指数'
};
async function refreshStats() {
try {
const d = await api('/api/admin/stats');
$('#dbBox').innerHTML = Object.entries(d.tables).map(([k, v]) => `
<div class="between" style="padding:7px 0;border-bottom:1px solid #1f2630">
<span>${tableNames[k] || k}</span><span class="num"><b>${v}</b></span>
</div>`).join('');
$('#vecBox').innerHTML = `
<div class="between" style="padding:7px 0;border-bottom:1px solid #1f2630">
<span>📰 新闻向量索引 <span style="color:var(--muted)">(stock_news_v1)</span></span>
<span class="num"><b>${d.vector.news}</b> 条</span>
</div>
<div class="between" style="padding:7px 0;border-bottom:1px solid #1f2630">
<span>🏢 公司概况索引 <span style="color:var(--muted)">(stock_profiles_v1)</span></span>
<span class="num"><b>${d.vector.profiles}</b> 条</span>
</div>
<div class="between" style="padding:7px 0">
<span>数据模式</span>
<span class="tag ${d.is_mock ? 'tag-观望' : 'tag-推荐'}">${d.is_mock ? '模拟数据' : '真实数据'}</span>
</div>`;
} catch (e) {
$('#dbBox').innerHTML = '<div class="empty">加载失败</div>';
}
}
async function reseed() {
if (!confirm('将清空全部业务数据并重新生成(含向量索引重建,需 1-3 分钟),确定继续?')) return;
try {
await api('/api/admin/reseed', { method: 'POST' });
toast('重灌任务已启动');
} catch (e) {
toast('启动失败');
}
}
async function health() {
$('#healthBox').innerHTML = '<div class="loading"><span class="spin"></span> 检查中…</div>';
try {
const d = await api('/api/admin/healthcheck');
$('#healthBox').innerHTML = `<div class="kpi-grid">
${Object.entries(d).map(([k, v]) => `
<div class="kpi">
<div class="kpi-label">${k}</div>
<div class="kpi-value" style="font-size:14px;color:${v.includes('ok') ? 'var(--down)' : 'var(--up)'}">${escapeHtml(v)}</div>
</div>`).join('')}
</div>`;
} catch (e) {
$('#healthBox').innerHTML = '<div class="empty">检查失败</div>';
}
}
refreshStats();
+127
View File
@@ -0,0 +1,127 @@
/* 智能荐股系统 - 公共工具 */
const $ = (sel, el) => (el || document).querySelector(sel);
const $$ = (sel, el) => Array.from((el || document).querySelectorAll(sel));
async function api(url, opts) {
const res = await fetch(url, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok && data.error) throw new Error(data.error);
return data;
}
function fmtPct(v) {
if (v === null || v === undefined) return '--';
const n = Number(v);
return (n > 0 ? '+' : '') + n.toFixed(2) + '%';
}
function pctClass(v) {
const n = Number(v);
if (n > 0) return 'up';
if (n < 0) return 'down';
return 'flat';
}
function fmtNum(v, digits) {
if (v === null || v === undefined) return '--';
const n = Number(v);
if (Math.abs(n) >= 10000) return (n / 10000).toFixed(2) + '万';
return n.toFixed(digits === undefined ? 2 : digits);
}
function fmtAmountYi(v) {
// 万元 -> 亿元
const n = Number(v);
if (!n) return '--';
return (n / 10000).toFixed(2) + '亿';
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function mdRender(text) {
if (window.marked) {
return marked.parse(text || '');
}
return '<pre style="white-space:pre-wrap">' + escapeHtml(text) + '</pre>';
}
/* 评分环(SVG */
function scoreRing(el, score) {
const color = score >= 82 ? '#ef4444' : score >= 68 ? '#f59e0b' : score >= 55 ? '#3b82f6' : '#6e7681';
const r = 18, c = 2 * Math.PI * r;
const off = c * (1 - Math.min(100, score) / 100);
el.innerHTML = `<svg width="46" height="46">
<circle cx="23" cy="23" r="${r}" stroke="#262d3a" stroke-width="5" fill="none"/>
<circle cx="23" cy="23" r="${r}" stroke="${color}" stroke-width="5" fill="none"
stroke-linecap="round" stroke-dasharray="${c}" stroke-dashoffset="${off}"/>
</svg><span class="ring-val" style="color:${color}">${score}</span>`;
}
function scoreBars(parts) {
const map = [['trend', '趋势'], ['momentum', '动量'], ['technical', '技术'],
['volume', '量能'], ['news', '消息'], ['institutional', '机构']];
return map.map(([k, label]) => {
const v = parts ? (parts[k] || 0) : 0;
const pct = Math.min(100, v / (k === 'trend' ? 25 : k === 'momentum' ? 20 : 15) * 100);
return `<div class="score-bar">
<div class="sb-label">${label}</div>
<div class="sb-track"><div class="sb-fill" style="width:${pct}%"></div></div>
<div class="sb-val num">${v}</div>
</div>`;
}).join('');
}
/* 导航高亮 */
(function nav() {
const path = location.pathname;
$$('.nav-item').forEach(a => {
const n = a.dataset.nav;
if (n && (path === n || (n !== '/' && path.startsWith(n)))) a.classList.add('active');
});
const t = setInterval(() => {
const el = $('#sideTime');
if (!el) { clearInterval(t); return; }
el.textContent = new Date().toLocaleString('zh-CN', { hour12: false });
}, 1000);
})();
/* 弹窗 */
function openModal(html) {
let mask = $('.modal-mask');
if (!mask) {
mask = document.createElement('div');
mask.className = 'modal-mask';
mask.innerHTML = `<div class="modal"><span class="modal-close">✕</span><div class="modal-body"></div></div>`;
document.body.appendChild(mask);
$('.modal-close', mask).onclick = () => mask.classList.remove('show');
mask.onclick = e => { if (e.target === mask) mask.classList.remove('show'); };
}
$('.modal-body', mask).innerHTML = html;
mask.classList.add('show');
return mask;
}
/* toast */
function toast(msg, type) {
let box = $('#toastBox');
if (!box) {
box = document.createElement('div');
box.id = 'toastBox';
box.style.cssText = 'position:fixed;top:20px;right:20px;z-index:999;display:flex;flex-direction:column;gap:8px;';
document.body.appendChild(box);
}
const d = document.createElement('div');
d.style.cssText = 'padding:10px 16px;border-radius:8px;background:#1c2230;border:1px solid #262d3a;color:#e6edf3;font-size:13px;box-shadow:0 4px 16px rgba(0,0,0,.4);';
d.textContent = msg;
box.appendChild(d);
setTimeout(() => d.remove(), 2600);
}
function debounce(fn, ms) {
let t;
return function () { clearTimeout(t); t = setTimeout(() => fn.apply(this, arguments), ms); };
}
+102
View File
@@ -0,0 +1,102 @@
/* 仪表盘 */
async function loadOverview() {
try {
const d = await api('/api/overview');
renderIndexes(d.indexes, d.stat);
renderTop(d.top);
renderHeat(d.heat);
renderNews(d.news);
renderWatch(d.watchlist);
} catch (e) {
$('#indexBox').innerHTML = '<div class="card"><div class="empty">加载失败:' + escapeHtml(e.message) + '</div></div>';
}
}
function renderIndexes(indexes, stat) {
const cards = indexes.map(ix => {
const cls = pctClass(ix.chg);
return `<div class="card">
<div class="kpi-label" style="color:var(--muted);font-size:12px">${ix.label}</div>
<div class="num" style="font-size:26px;font-weight:800;margin:6px 0">${Number(ix.value).toFixed(2)}</div>
<div class="num ${cls}" style="font-weight:600">${fmtPct(ix.chg)}</div>
</div>`;
}).join('');
const s = stat || {};
$('#indexBox').innerHTML = cards + `<div class="card">
<div class="card-title"><span class="bar"></span>市场情绪</div>
<div class="mini-stats">
<div class="mini-stat"><div class="ms-label">上涨</div><div class="ms-value up">${s.up || 0}</div></div>
<div class="mini-stat"><div class="ms-label">下跌</div><div class="ms-value down">${s.down || 0}</div></div>
<div class="mini-stat"><div class="ms-label">涨停</div><div class="ms-value" style="color:var(--up)">${s.limit_up || 0}</div></div>
<div class="mini-stat"><div class="ms-label">跌停</div><div class="ms-value" style="color:var(--down)">${s.limit_down || 0}</div></div>
<div class="mini-stat"><div class="ms-label">成交额</div><div class="ms-value">${fmtAmountYi(s.amount_yi)}</div></div>
</div>
<div style="margin-top:14px;color:var(--muted);font-size:12px">股票池 ${s.total || 0} 只 · 综合评分由 趋势/动量/技术/量能/消息/机构 六因子加权得出</div>
</div>`;
}
function renderTop(list) {
if (!list || !list.length) { $('#topRecBox').innerHTML = '<div class="empty">暂无数据</div>'; return; }
$('#topRecBox').innerHTML = `<table>
<tr><th>排名</th><th>股票</th><th>现价</th><th>今日</th><th>评分</th><th>评级</th><th>推荐理由</th></tr>
${list.map((it, i) => `<tr class="row-link" onclick="location.href='/stock/${it.code}'">
<td class="num">${i + 1}</td>
<td><b>${it.name}</b><div style="color:var(--muted);font-size:12px">${it.code} · ${it.industry}</div></td>
<td class="num">${it.close}</td>
<td class="num ${pctClass(it.change_pct)}">${fmtPct(it.change_pct)}</td>
<td class="num"><b style="color:${it.score.total >= 68 ? '#f59e0b' : '#3b82f6'}">${it.score.total}</b></td>
<td><span class="tag tag-${it.score.rating}">${it.score.rating}</span></td>
<td style="max-width:260px;white-space:normal;color:var(--text2)">${escapeHtml((it.reasons || []).join(''))}</td>
</tr>`).join('')}
</table>`;
}
function renderHeat(heat) {
if (!heat || !heat.length) { $('#heatBox').innerHTML = '<div class="empty">暂无数据</div>'; return; }
const max = Math.max(...heat.map(h => Math.abs(h.chg)), 0.01);
$('#heatBox').innerHTML = heat.map(h => {
const w = Math.min(100, Math.abs(h.chg) / max * 100);
const cls = pctClass(h.chg);
return `<div style="margin-bottom:12px">
<div class="between">
<span>${escapeHtml(h.industry)} <span style="color:var(--muted)">(${h.cnt})</span></span>
<span class="num ${cls}">${fmtPct(h.chg)}</span>
</div>
<div style="height:7px;background:#262d3a;border-radius:4px;margin-top:5px">
<div style="height:100%;width:${w}%;border-radius:4px;background:${h.chg >= 0 ? 'var(--up)' : 'var(--down)'}"></div>
</div>
</div>`;
}).join('');
}
function renderNews(news) {
if (!news || !news.length) { $('#newsBox').innerHTML = '<div class="empty">暂无数据</div>'; return; }
$('#newsBox').innerHTML = news.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 ? '利空' : '中性'}</span>
<span>${escapeHtml(n.source)}</span><span>${n.publish_date}</span>
</div>
</div>`).join('');
}
function renderWatch(list) {
if (!list || !list.length) {
$('#watchBox').innerHTML = `<div class="empty">暂无自选股<br><a href="/stocks">去股票池添加 →</a></div>`;
return;
}
$('#watchBox').innerHTML = `<table>
<tr><th>股票</th><th>现价</th><th>今日</th><th>评分</th><th>评级</th></tr>
${list.map(it => `<tr class="row-link" onclick="location.href='/stock/${it.code}'">
<td><b>${it.name}</b><div style="color:var(--muted);font-size:12px">${it.code}</div></td>
<td class="num">${it.close}</td>
<td class="num ${pctClass(it.change_pct)}">${fmtPct(it.change_pct)}</td>
<td class="num">${it.score}</td>
<td><span class="tag tag-${it.rating}">${it.rating}</span></td>
</tr>`).join('')}
</table>`;
}
loadOverview();
+108
View File
@@ -0,0 +1,108 @@
/* 机构动向 */
let curType = '';
async function loadInsts() {
try {
const d = await api('/api/institutions?type=' + curType);
const items = d.items || [];
$('#tb').innerHTML = items.map(it => `
<tr>
<td><b>${escapeHtml(it.name)}</b></td>
<td><span class="cat-tag">${escapeHtml(it.type)}</span></td>
<td style="max-width:360px;white-space:normal;color:var(--text2)">${escapeHtml(it.description)}</td>
<td class="num" id="rc-${it.id}">…</td>
<td class="num" id="hc-${it.id}">…</td>
<td><button class="btn" onclick="showInst(${it.id})">查看详情</button></td>
</tr>`).join('');
items.forEach(it => fillCounts(it.id, it.name));
} catch (e) {
$('#tb').innerHTML = '<tr><td colspan="6" class="empty">加载失败</td></tr>';
}
}
async function fillCounts(id, name) {
try {
const d = await api('/api/ratings/upgrades');
const rc = d.items.filter(x => x.inst_name === name).length;
$('#rc-' + id).textContent = rc;
} catch (e) {}
}
async function showInst(id) {
try {
const d = await api('/api/institutions/' + id);
const inst = d.institution;
const ratings = (d.ratings || []).map(r => `
<tr class="row-link" onclick="location.href='/stock/${r.stock_code}'">
<td>${r.name}(${r.stock_code})</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('');
const holdings = (d.holdings || []).map(h => `
<tr class="row-link" onclick="location.href='/stock/${h.stock_code}'">
<td>${h.name}(${h.stock_code})</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('');
openModal(`
<h3>${escapeHtml(inst.name)} <span class="cat-tag">${escapeHtml(inst.type)}</span></h3>
<div style="color:var(--text2);margin-bottom:14px">${escapeHtml(inst.description)}</div>
<h3 style="font-size:15px">近期评级</h3>
<table>${ratings || '<tr><td class="empty">暂无</td></tr>'}</table>
<h3 style="font-size:15px;margin-top:16px">基金持仓</h3>
<table>${holdings || '<tr><td class="empty">暂无</td></tr>'}</table>
`);
} catch (e) { toast('加载失败'); }
}
async function loadMoves() {
try {
const d = await api('/api/holdings/moves');
const q = d.quarter || '';
const inc = (d.increase || []).map(h => `
<tr class="row-link" onclick="location.href='/stock/${h.stock_code}'">
<td>${h.name}</td><td>${escapeHtml(h.inst_name)}</td>
<td class="num up">${fmtPct(h.change_pct)}</td>
</tr>`).join('');
const dec = (d.decrease || []).map(h => `
<tr class="row-link" onclick="location.href='/stock/${h.stock_code}'">
<td>${h.name}</td><td>${escapeHtml(h.inst_name)}</td>
<td class="num down">${fmtPct(h.change_pct)}</td>
</tr>`).join('');
$('#holdBox').innerHTML = `<div style="font-size:12px;color:var(--muted);margin-bottom:8px">最新季度:${q}</div>
<table>
<tr><th>增持榜</th><th>机构</th><th>环比</th></tr>${inc || '<tr><td colspan="3" class="empty">暂无</td></tr>'}
<tr><td colspan="3" style="height:8px;border:none"></td></tr>
<tr><th>减持榜</th><th>机构</th><th>环比</th></tr>${dec || '<tr><td colspan="3" class="empty">暂无</td></tr>'}
</table>`;
} catch (e) {}
}
async function loadUpgrades() {
try {
const d = await api('/api/ratings/upgrades');
$('#upgradeBox').innerHTML = `<table>
<tr><th>股票</th><th>机构</th><th>评级</th><th>日期</th></tr>
${(d.items || []).slice(0, 14).map(r => `
<tr class="row-link" onclick="location.href='/stock/${r.stock_code}'">
<td><b>${r.name}</b><div style="color:var(--muted);font-size:12px">${r.stock_code}</div></td>
<td style="color:var(--text2)">${escapeHtml(r.inst_name)}</td>
<td><span class="tag tag-${r.rating}">${r.rating}</span> <span style="color:var(--muted);font-size:12px">→ 目标${r.target_price}</span></td>
<td style="color:var(--muted)">${r.rating_date}</td>
</tr>`).join('')}
</table>`;
} catch (e) {}
}
$$('#typePills .pill').forEach(p => {
p.onclick = () => {
$$('#typePills .pill').forEach(x => x.classList.remove('active'));
p.classList.add('active');
curType = p.dataset.t;
loadInsts();
};
});
loadMoves();
loadUpgrades();
loadInsts();
+57
View File
@@ -0,0 +1,57 @@
/* 财经新闻 */
let page = 1, total = 0;
async function load(p) {
if (p) page = p;
const q = new URLSearchParams({ keyword: $('#kw').value.trim(), category: $('#cat').value, page });
try {
const d = await api('/api/news?' + q.toString());
total = d.total;
render(d.items);
$('#pageInfo').textContent = `${page} / ${Math.max(1, Math.ceil(total / 15))}`;
$('#count').textContent = `${total}`;
$('#prevBtn').disabled = page <= 1;
$('#nextBtn').disabled = page * 15 >= total;
} catch (e) {
$('#listBox').innerHTML = '<div class="empty">加载失败</div>';
}
}
function render(items) {
if (!items.length) { $('#listBox').innerHTML = '<div class="empty">无匹配新闻</div>'; return; }
$('#listBox').innerHTML = items.map(n => `
<div class="news-item" onclick="showDetail(${n.id})">
<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 ? '利空' : '中性'}</span>
<span>${escapeHtml(n.source)}</span><span>${n.publish_date}</span>
</div>
<div class="news-summary">${escapeHtml(n.content)}</div>
</div>`).join('');
}
async function showDetail(id) {
try {
const d = await api('/api/news/' + id);
const n = d.news;
const stocks = (d.stocks || []).map(s => `<a href="/stock/${s.code}">${s.name}(${s.code})</a>`).join(' · ') || '无';
openModal(`
<h3>${escapeHtml(n.title)}</h3>
<div class="news-meta" style="margin-bottom:12px">
<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 style="line-height:1.9;color:var(--text)">${escapeHtml(n.content)}</div>
<div class="mt16" style="padding-top:12px;border-top:1px solid var(--border)">
<b>关联股票:</b>${stocks}
</div>
`);
} catch (e) { toast('加载失败'); }
}
$('#kw').addEventListener('keydown', e => { if (e.key === 'Enter') load(1); });
$('#kw').addEventListener('input', debounce(() => load(1), 400));
$('#cat').onchange = () => load(1);
load(1);
+49
View File
@@ -0,0 +1,49 @@
/* 荐股中心 */
let curRating = 'all';
async function load() {
try {
const d = await api('/api/recommend?rating=' + curRating + '&limit=80');
render(d.items);
} catch (e) {
$('#tb').innerHTML = '<tr><td colspan="10" class="empty">加载失败:' + escapeHtml(e.message) + '</td></tr>';
}
}
function render(items) {
if (!items.length) { $('#tb').innerHTML = '<tr><td colspan="10" class="empty">该评级暂无股票</td></tr>'; return; }
$('#tb').innerHTML = items.map((it, i) => {
const sc = it.score;
return `<tr class="row-link" onclick="location.href='/stock/${it.code}'">
<td class="num">${i + 1}</td>
<td><b>${it.name}</b><div style="color:var(--muted);font-size:12px">${it.code} · ${escapeHtml(it.industry)}</div></td>
<td class="num">${it.close}</td>
<td class="num ${pctClass(it.change_pct)}">${fmtPct(it.change_pct)}</td>
<td class="num ${pctClass(it.chg_5d)}">${fmtPct(it.chg_5d)}</td>
<td><div class="score-wrap"><div class="score-ring" data-score="${sc.total}"></div></div></td>
<td><span class="tag tag-${sc.rating}">${sc.rating}</span></td>
<td style="white-space:normal">
<div style="width:130px">${scoreBars(sc.score_parts)}</div>
</td>
<td style="max-width:280px;white-space:normal;color:var(--text2)">${escapeHtml((it.reasons || []).join(''))}</td>
<td><button class="btn btn-primary" onclick="event.stopPropagation();analyze('${it.code}','${it.name}')">🤖 AI深度分析</button></td>
</tr>`;
}).join('');
$$('.score-ring', $('#tb')).forEach(el => scoreRing(el, el.dataset.score));
}
/* AI 深度分析:跳详情页并自动触发 */
function analyze(code, name) {
location.href = `/stock/${code}?analyze=1`;
}
$$('#ratingPills .pill').forEach(p => {
p.onclick = () => {
$$('#ratingPills .pill').forEach(x => x.classList.remove('active'));
p.classList.add('active');
curRating = p.dataset.r;
load();
};
});
load();
+252
View File
@@ -0,0 +1,252 @@
/* 股票详情页 */
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) + ' 秒'}</div>`;
} 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);
}
$('#analyzeBtn').onclick = generateReport;
$('#focusInput').addEventListener('keydown', e => { if (e.key === 'Enter') generateReport(); });
load();
+72
View File
@@ -0,0 +1,72 @@
/* 股票池 */
let page = 1, total = 0, industries = [];
async function loadIndustries() {
try {
const d = await api('/api/overview');
industries = d.heat.map(h => h.industry);
const sel = $('#industry');
industries.forEach(i => {
const o = document.createElement('option');
o.textContent = i;
sel.appendChild(o);
});
} catch (e) {}
}
async function load(p) {
if (p) page = p;
const q = new URLSearchParams({
keyword: $('#kw').value.trim(),
industry: $('#industry').value,
board: $('#board').value,
rating: $('#rating').value,
sort: $('#sort').value,
order: $('#sort').value === 'score' ? 'desc' : 'desc',
page: page, per: 20
});
try {
const d = await api('/api/stocks?' + q.toString());
total = d.total;
render(d.items);
$('#pageInfo').textContent = `${page} / ${Math.max(1, Math.ceil(total / 20))} 页 · 共 ${total}`;
$('#stCount').textContent = `${total}`;
$('#prevBtn').disabled = page <= 1;
$('#nextBtn').disabled = page * 20 >= total;
} catch (e) {
$('#tb').innerHTML = '<tr><td colspan="12" class="empty">加载失败</td></tr>';
}
}
function render(items) {
if (!items.length) { $('#tb').innerHTML = '<tr><td colspan="12" class="empty">无匹配股票</td></tr>'; return; }
$('#tb').innerHTML = items.map(it => `<tr class="row-link" onclick="location.href='/stock/${it.code}'">
<td><b>${it.name}</b><div style="color:var(--muted);font-size:12px">${it.code}</div></td>
<td>${escapeHtml(it.industry)}</td>
<td style="color:var(--text2)">${it.board}</td>
<td class="num">${it.close}</td>
<td class="num ${pctClass(it.change_pct)}">${fmtPct(it.change_pct)}</td>
<td class="num ${pctClass(it.chg_5d)}">${fmtPct(it.chg_5d)}</td>
<td class="num">${it.vol_ratio}</td>
<td class="num">${fmtAmountYi(it.amount)}</td>
<td class="num">${Number(it.market_cap).toFixed(0)}亿</td>
<td class="num"><b style="color:${it.score >= 68 ? '#f59e0b' : it.score >= 55 ? '#3b82f6' : '#9da7b3'}">${it.score}</b></td>
<td><span class="tag tag-${it.rating}">${it.rating}</span></td>
<td><button class="btn" onclick="event.stopPropagation();addWatch('${it.code}')">⭐自选</button></td>
</tr>`).join('');
}
async function addWatch(code) {
try { await api('/api/watchlist/' + code, { method: 'POST' }); toast('已加入自选'); }
catch (e) { toast('操作失败'); }
}
$('#kw').addEventListener('keydown', e => { if (e.key === 'Enter') load(1); });
$('#kw').addEventListener('input', debounce(() => load(1), 400));
$('#industry').onchange = () => load(1);
$('#board').onchange = () => load(1);
$('#rating').onchange = () => load(1);
$('#sort').onchange = () => load(1);
loadIndustries();
load(1);