智能荐股系统 v1.0.0:股票池/行情/新闻RAG/机构持仓/多因子评分/AI深度研报
This commit is contained in:
@@ -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();
|
||||
@@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[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); };
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user