1186 lines
55 KiB
HTML
1186 lines
55 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>MoFin · 莫荷情报</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||
* { font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif; }
|
||
body { background: #0f0f13; color: #e2e8f0; }
|
||
.tab-btn { transition: all 0.15s; }
|
||
.tab-btn.active { background: #1e293b; color: #60a5fa; border-color: #60a5fa; }
|
||
.card { background: #1a1a24; border: 1px solid #2a2a3a; border-radius: 12px; }
|
||
.badge-up { color: #22c55e; }
|
||
.badge-down { color: #ef4444; }
|
||
.pnl-badge { font-weight: 600; }
|
||
.stock-row:hover { background: #22222e; cursor: pointer; }
|
||
.loading { opacity: 0.5; pointer-events: none; }
|
||
.modal-overlay { background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); }
|
||
::-webkit-scrollbar { width: 6px; }
|
||
::-webkit-scrollbar-track { background: #0f0f13; }
|
||
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
|
||
</style>
|
||
</head>
|
||
<body class="min-h-screen">
|
||
|
||
<div class="max-w-7xl mx-auto px-4 py-4">
|
||
<!-- Header -->
|
||
<div class="flex items-center justify-between mb-6">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-2xl">📊</span>
|
||
<h1 class="text-xl font-bold text-white tracking-tight">MoFin</h1>
|
||
<span class="text-xs text-slate-500 bg-slate-800 px-2 py-0.5 rounded-full">知微</span>
|
||
</div>
|
||
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||
<span id="updateTime">—</span>
|
||
<button onclick="refreshAll()" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded-lg transition">↻</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tabs -->
|
||
<div class="flex gap-1 mb-6 bg-slate-900/50 rounded-xl p-1 border border-slate-800/50 overflow-x-auto" id="tabBar">
|
||
<button class="tab-btn active px-4 py-2 text-sm rounded-lg" data-tab="overview">📈 概览</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="holdings">💼 持仓</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="watchlist">⭐ 自选</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="reports">📋 报告</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="decisions">📝 决策库</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="evaluation">📊 评估</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="prompts">📝 提示词</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="market">🌐 市场</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="signals">🔍 信号</button>
|
||
<a href="/upload" class="tab-btn px-4 py-2 text-sm rounded-lg" style="text-decoration:none;color:#fbbf24;border-color:#fbbf24">📸 上传</a>
|
||
</div>
|
||
|
||
<!-- Tab Content -->
|
||
<div id="tab-overview" class="tab-content"></div>
|
||
<div id="tab-holdings" class="tab-content hidden"></div>
|
||
<div id="tab-watchlist" class="tab-content hidden"></div>
|
||
<div id="tab-reports" class="tab-content hidden"></div>
|
||
<div id="tab-decisions" class="tab-content hidden"></div>
|
||
<div id="tab-evaluation" class="tab-content hidden"></div>
|
||
<div id="tab-prompts" class="tab-content hidden"></div>
|
||
<div id="tab-market" class="tab-content hidden"></div>
|
||
<div id="tab-signals" class="tab-content hidden"></div>
|
||
</div>
|
||
|
||
<!-- Stock Detail Modal -->
|
||
<div id="stockModal" class="fixed inset-0 modal-overlay hidden items-center justify-center z-50" onclick="if(event.target===this)closeStock()">
|
||
<div class="bg-[#1a1a24] border border-slate-700 rounded-2xl w-full max-w-2xl max-h-[85vh] overflow-y-auto mx-4 p-6" onclick="event.stopPropagation()">
|
||
<div class="flex justify-between items-start mb-4">
|
||
<div>
|
||
<h2 id="modalTitle" class="text-lg font-bold text-white">—</h2>
|
||
<p id="modalMeta" class="text-xs text-slate-500 mt-1">—</p>
|
||
</div>
|
||
<button onclick="closeStock()" class="text-slate-500 hover:text-white text-xl">×</button>
|
||
</div>
|
||
<div id="modalInfo" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4"></div>
|
||
<div id="modalChart" class="h-48 mb-4 bg-slate-900 rounded-xl p-2"></div>
|
||
<div>
|
||
<h3 class="text-sm font-semibold text-slate-300 mb-2">📜 操作建议历史</h3>
|
||
<div id="modalHistory" class="space-y-2"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 提示词内容弹窗(独立,避免与stockModal冲突) -->
|
||
<div id="promptModal" class="fixed inset-0 modal-overlay hidden items-center justify-center z-50" onclick="if(event.target===this)closePromptModal()">
|
||
<div class="bg-[#1a1a24] border border-slate-700 rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto mx-4 p-6" onclick="event.stopPropagation()">
|
||
<div class="flex justify-between items-start mb-4">
|
||
<div>
|
||
<h2 id="promptModalTitle" class="text-lg font-bold text-white">—</h2>
|
||
</div>
|
||
<button onclick="closePromptModal()" class="text-slate-500 hover:text-white text-xl">×</button>
|
||
</div>
|
||
<div id="promptModalBody" class="text-sm text-slate-300 leading-relaxed"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ── State ──
|
||
let portfolioData = { holdings: [] };
|
||
let watchlistData = { stocks: [] };
|
||
let reportsData = [];
|
||
|
||
// ── Tab Switching ──
|
||
// 事件委托:点击 tabBar 内的按钮自动切换
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const tabBar = document.getElementById('tabBar');
|
||
if (tabBar) {
|
||
tabBar.addEventListener('click', function(e) {
|
||
const btn = e.target.closest('.tab-btn');
|
||
if (!btn) return;
|
||
if (btn.tagName === 'A') return; // 上传链接保持原行为
|
||
const name = btn.dataset.tab;
|
||
if (!name) return;
|
||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
|
||
document.getElementById('tab-' + name).classList.remove('hidden');
|
||
btn.classList.add('active');
|
||
renderTab(name);
|
||
});
|
||
}
|
||
refreshAll();
|
||
setInterval(refreshAll, 120000);
|
||
});
|
||
|
||
function switchTab(name) {
|
||
const btn = document.querySelector(`[data-tab="${name}"]`);
|
||
if (btn) {
|
||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
|
||
document.getElementById('tab-' + name).classList.remove('hidden');
|
||
btn.classList.add('active');
|
||
renderTab(name);
|
||
}
|
||
}
|
||
|
||
function renderTab(name) {
|
||
if (name === 'overview') renderOverview();
|
||
else if (name === 'holdings') renderHoldings();
|
||
else if (name === 'watchlist') renderWatchlist();
|
||
else if (name === 'reports') renderReports();
|
||
else if (name === 'decisions') renderDecisions();
|
||
else if (name === 'evaluation') renderEvaluation();
|
||
else if (name === 'prompts') renderPrompts();
|
||
else if (name === 'market') renderMarket();
|
||
else if (name === 'signals') renderSignals();
|
||
}
|
||
|
||
// ── Data Fetching ──
|
||
async function fetchJSON(url) {
|
||
try {
|
||
const r = await fetch(url);
|
||
return await r.json();
|
||
} catch(e) { return {}; }
|
||
}
|
||
|
||
async function refreshAll() {
|
||
document.getElementById('tab-overview').classList.add('loading');
|
||
const [overview, portfolio, watchlist, reports] = await Promise.all([
|
||
fetchJSON('/api/overview'),
|
||
fetchJSON('/api/portfolio'),
|
||
fetchJSON('/api/watchlist'),
|
||
fetchJSON('/api/reports'),
|
||
]);
|
||
portfolioData = portfolio;
|
||
watchlistData = watchlist;
|
||
reportsData = Array.isArray(reports) ? reports : [];
|
||
document.getElementById('updateTime').textContent = '更新 ' + new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit'});
|
||
document.getElementById('tab-overview').classList.remove('loading');
|
||
renderTab(document.querySelector('.tab-btn.active')?.dataset?.tab || 'overview');
|
||
}
|
||
|
||
// ── Overview Tab ──
|
||
function renderOverview() {
|
||
const el = document.getElementById('tab-overview');
|
||
fetchJSON('/api/overview').then(d => {
|
||
const h = d.top_movers || [];
|
||
const alertHtml = h.length ? h.map(s => `
|
||
<div class="flex items-center justify-between p-2 bg-slate-800/50 rounded-lg">
|
||
<span class="text-sm">${s.name||s.code}</span>
|
||
<span class="text-sm font-mono ${(s.change_pct||0)>=0?'badge-up':'badge-down'}">
|
||
${(s.change_pct||0)>=0?'+':''}${(s.change_pct||0).toFixed(2)}%
|
||
</span>
|
||
</div>
|
||
`).join('') : '<div class="text-xs text-slate-500">暂无大幅异动</div>';
|
||
|
||
el.innerHTML = `
|
||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||
${statCard('总资产', formatMoney(d.total_assets), '💰')}
|
||
${statCard('股票市值', formatMoney(d.stock_value), '📈')}
|
||
${statCard('仓位', (d.position_pct||0).toFixed(1)+'%', '📊')}
|
||
${statCard('总盈亏', formatMoney(d.total_pnl, true), (d.total_pnl||0)>=0?'🟢':'🔴')}
|
||
</div>
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
<div class="card p-4">
|
||
<h3 class="text-sm font-semibold mb-3">⚡ 大幅异动</h3>
|
||
<div class="space-y-2">${alertHtml}</div>
|
||
</div>
|
||
<div class="card p-4">
|
||
<h3 class="text-sm font-semibold mb-3">📋 最新动态</h3>
|
||
<div id="latestReports" class="text-xs space-y-2">加载中...</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
// Load latest 3 reports
|
||
if (reportsData.length) {
|
||
document.getElementById('latestReports').innerHTML = reportsData.slice(0,3).map(r =>
|
||
`<div class="flex justify-between p-2 bg-slate-800/30 rounded-lg">
|
||
<span>${r.title||r.id}</span>
|
||
<span class="text-slate-500">${r.created_at?.slice(5,16)||''}</span>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
});
|
||
}
|
||
|
||
function statCard(label, value, icon) {
|
||
return `<div class="card p-4">
|
||
<div class="text-xs text-slate-500 mb-1">${icon} ${label}</div>
|
||
<div class="text-xl font-bold font-mono text-white">${value}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function formatMoney(v, signed=false) {
|
||
if (!v && v!==0) return '—';
|
||
const n = Number(v).toFixed(2);
|
||
if (signed) return n>=0 ? '+¥'+n : '-¥'+Math.abs(n).toFixed(2);
|
||
return '¥'+n.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||
}
|
||
|
||
// ── Holdings Tab ──
|
||
function renderHoldings() {
|
||
const el = document.getElementById('tab-holdings');
|
||
const holdings = portfolioData.holdings || [];
|
||
el.innerHTML = `
|
||
<div class="card overflow-hidden">
|
||
<div class="p-3 border-b border-slate-800/50 flex justify-between items-center">
|
||
<span class="text-sm font-semibold">持仓 · ${holdings.length} 只</span>
|
||
<span class="text-xs text-slate-500">点击查看详情</span>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-xs">
|
||
<thead><tr class="text-slate-500 border-b border-slate-800/50">
|
||
<th class="text-left p-3">股票</th><th class="text-right p-3">仓位%</th>
|
||
<th class="text-right p-3">成本</th><th class="text-right p-3">现价</th>
|
||
<th class="text-right p-3">涨跌%</th><th class="text-right p-3">盈亏</th>
|
||
<th class="text-left p-3">操作建议</th>
|
||
</tr></thead>
|
||
<tbody>${holdings.map(s => {
|
||
const pnl = ((s.price||0) - (s.cost||0)) * (s.shares||0);
|
||
return `<tr class="stock-row border-b border-slate-800/30" onclick="openStock('${s.code}')">
|
||
<td class="p-3"><div class="font-medium text-white">${s.name}</div><div class="text-slate-500">${s.code}</div></td>
|
||
<td class="p-3 text-right font-mono">${(s.position_pct||0).toFixed(2)}</td>
|
||
<td class="p-3 text-right font-mono">${(s.cost||0).toFixed(2)}</td>
|
||
<td class="p-3 text-right font-mono">${(s.price||0).toFixed(2)}</td>
|
||
<td class="p-3 text-right font-mono ${(s.change_pct||0)>=0?'badge-up':'badge-down'}">
|
||
${(s.change_pct||0)>=0?'+':''}${(s.change_pct||0).toFixed(2)}
|
||
</td>
|
||
<td class="p-3 text-right font-mono ${pnl>=0?'badge-up':'badge-down'}">
|
||
${pnl>=0?'+':''}${pnl.toFixed(0)}
|
||
</td>
|
||
<td class="p-3">${renderRec(s)}</td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderRec(s) {
|
||
const a = s.analysis || {};
|
||
const parts = [];
|
||
if (a.stop_loss) parts.push(`<span class="text-red-400">止损${a.stop_loss}</span>`);
|
||
if (a.take_profit) parts.push(`<span class="text-green-400">止盈${a.take_profit}</span>`);
|
||
if (a.suggestion) parts.push(`<span class="text-blue-400">${a.suggestion}</span>`);
|
||
if (a.buy_zone_low && a.buy_zone_high) parts.push(`<span class="text-yellow-400">补仓${a.buy_zone_low}~${a.buy_zone_high}</span>`);
|
||
if (a.reason) parts.push(`<span class="text-slate-400 text-[11px]">${a.reason}</span>`);
|
||
return parts.length ? parts.join(' · ') : `<span class="text-slate-500">待分析</span>`;
|
||
}
|
||
|
||
// ── Watchlist Tab ──
|
||
function renderWatchlist() {
|
||
const el = document.getElementById('tab-watchlist');
|
||
const stocks = watchlistData.stocks || [];
|
||
el.innerHTML = `
|
||
<div class="card overflow-hidden">
|
||
<div class="p-3 border-b border-slate-800/50 flex justify-between items-center">
|
||
<span class="text-sm font-semibold">自选 · ${stocks.length} 只</span>
|
||
<span class="text-xs text-slate-500">按推荐价值排序</span>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-xs">
|
||
<thead><tr class="text-slate-500 border-b border-slate-800/50">
|
||
<th class="text-left p-3">股票</th><th class="text-right p-3">现价</th>
|
||
<th class="text-right p-3">涨跌%</th><th class="text-right p-3">买入区间</th>
|
||
<th class="text-right p-3">建议仓位</th><th class="text-left p-3">推荐理由</th>
|
||
</tr></thead>
|
||
<tbody>${stocks.map(s => `
|
||
<tr class="stock-row border-b border-slate-800/30" onclick="openStock('${s.code}')">
|
||
<td class="p-3"><div class="font-medium text-white">${s.name}</div><div class="text-slate-500">${s.code}</div></td>
|
||
<td class="p-3 text-right font-mono">${(s.price||'—')}</td>
|
||
<td class="p-3 text-right font-mono ${(s.change_pct||0)>=0?'badge-up':'badge-down'}">
|
||
${(s.change_pct||0)>=0?'+':''}${(s.change_pct||0).toFixed(2)}
|
||
</td>
|
||
<td class="p-3 text-right font-mono">${s.analysis?.buy_low||'—'}~${s.analysis?.buy_high||'—'}</td>
|
||
<td class="p-3 text-right font-mono">${s.analysis?.position_recommend||'—'}</td>
|
||
<td class="p-3 text-slate-400">${s.analysis?.reason||''}</td>
|
||
</tr>
|
||
`).join('')}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ── Reports Tab ──
|
||
function renderReports() {
|
||
const el = document.getElementById('tab-reports');
|
||
if (!reportsData.length) {
|
||
el.innerHTML = '<div class="card p-8 text-center text-slate-500">暂无报告</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = `
|
||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||
${reportsData.map(r => `
|
||
<div class="card p-4 hover:border-slate-600 transition" onclick="viewReport('${r.id}')">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<span class="text-xs px-2 py-0.5 rounded-full ${r.type==='盘中'?'bg-blue-900/50 text-blue-300':r.type==='盘后'?'bg-purple-900/50 text-purple-300':'bg-slate-800 text-slate-400'}">${r.type||'其他'}</span>
|
||
<span class="text-xs text-slate-500">${r.created_at?.slice(5,16)||''}</span>
|
||
</div>
|
||
<div class="text-sm font-medium text-white">${r.title||r.id}</div>
|
||
<div class="text-xs text-slate-400 mt-1 line-clamp-2">${r.summary||''}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function viewReport(id) {
|
||
const d = await fetchJSON(`/api/report/${id}`);
|
||
if (d.error) return;
|
||
const el = document.getElementById('tab-reports');
|
||
el.innerHTML = `
|
||
<div class="card p-6">
|
||
<div class="flex items-center gap-2 mb-4">
|
||
<button onclick="renderReports()" class="text-xs text-blue-400 hover:text-blue-300">← 返回报告列表</button>
|
||
<span class="text-xs text-slate-500">${d.created_at||''}</span>
|
||
</div>
|
||
<div class="text-lg font-bold text-white mb-4">${d.title||'报告'}</div>
|
||
<div class="text-sm text-slate-300 leading-relaxed whitespace-pre-wrap">${d.content||d.summary||'(无内容)'}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ── Market Tab ──
|
||
let marketRefreshInterval = null;
|
||
|
||
function refreshMarketData() {
|
||
const el = document.getElementById('tab-market');
|
||
if (!el || el.classList.contains('hidden')) return;
|
||
Promise.all([
|
||
fetchJSON('/api/market'),
|
||
fetchJSON('/api/signals'),
|
||
]).then(([mkt, signals]) => {
|
||
const sectors = mkt.sectors || [];
|
||
const sigs = signals || [];
|
||
const xiaoguo = sigs.filter(s => s.source === 'xiaoguo');
|
||
const trend = sigs.filter(s => s.source !== 'xiaoguo');
|
||
el.innerHTML = `
|
||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-4">
|
||
<div class="card p-4">
|
||
<h3 class="text-sm font-semibold mb-2">🏭 行业热点</h3>
|
||
${sectors.length ? sectors.slice(0,8).map(s => `
|
||
<div class="flex justify-between items-center p-1.5 ${s.change>=0?'':'bg-slate-800/20'} rounded mb-0.5">
|
||
<span class="text-xs">${s.name}</span>
|
||
<span class="text-xs font-mono ${(s.change||0)>=0?'badge-up':'badge-down'}">${(s.change||0)>=0?'+':''}${(s.change||0).toFixed(1)}%</span>
|
||
</div>
|
||
`).join('') : '<div class="text-xs text-slate-500">暂无</div>'}
|
||
</div>
|
||
<div class="card p-4">
|
||
<h3 class="text-sm font-semibold mb-2">📡 趋势信号 (${trend.length})</h3>
|
||
${trend.length ? trend.slice(0,6).map(s => `
|
||
<div class="p-1.5 bg-slate-800/30 rounded mb-0.5 text-xs">
|
||
<span class="font-semibold ${s.overall_sentiment==='利好'?'text-green-400':s.overall_sentiment==='利空'?'text-red-400':'text-slate-400'}">${s.overall_sentiment||'中性'}</span>
|
||
${s.sector} ${(s.summary||'').slice(0,60)}
|
||
</div>
|
||
`).join('') : '<div class="text-xs text-slate-500">暂无</div>'}
|
||
</div>
|
||
<div class="card p-4">
|
||
<h3 class="text-sm font-semibold mb-2">🔍 小果扫描 (${xiaoguo.length})</h3>
|
||
${xiaoguo.length ? xiaoguo.slice(0,6).map(s => `
|
||
<div class="p-1.5 bg-slate-800/30 rounded mb-0.5 text-xs">
|
||
<span class="font-semibold ${s.overall_sentiment==='利好'?'text-green-400':s.overall_sentiment==='利空'?'text-red-400':'text-slate-400'}">${s.overall_sentiment||'中性'}</span>
|
||
${s.sector} ${(s.summary||'').slice(0,60)}
|
||
</div>
|
||
`).join('') : '<div class="text-xs text-slate-500">暂无</div>'}
|
||
</div>
|
||
</div>
|
||
<div class="card p-4">
|
||
<h3 class="text-sm font-semibold mb-2">📋 全部板块排行(${sectors.length})</h3>
|
||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1 max-h-48 overflow-y-auto">
|
||
${sectors.map(s => `
|
||
<div class="flex justify-between p-1 bg-slate-800/20 rounded text-xs">
|
||
<span>${s.name}</span>
|
||
<span class="font-mono ${(s.change||0)>=0?'badge-up':'badge-down'}">${(s.change||0)>=0?'+':''}${(s.change||0).toFixed(1)}%</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
// 市场页自动刷新(30秒)
|
||
function renderMarket() {
|
||
if (marketRefreshInterval) clearInterval(marketRefreshInterval);
|
||
refreshMarketData();
|
||
marketRefreshInterval = setInterval(refreshMarketData, 30000);
|
||
}
|
||
|
||
// ── Decisions Tab ──
|
||
let decisionsIsComposing = false;
|
||
let decisionsSearchTerm = '';
|
||
let decisionsRecommendOnly = false;
|
||
let decisionsTagFilter = ''; // '' = all tags
|
||
|
||
function decisionsFilter() {
|
||
if (decisionsIsComposing) return; // 跳过IME输入法组合中的中间值
|
||
const el = document.getElementById('decisionsSearch');
|
||
if (!el) return;
|
||
decisionsSearchTerm = el.value.trim().toLowerCase();
|
||
renderDecisionsFiltered();
|
||
}
|
||
|
||
function toggleRecommendFilter() {
|
||
decisionsRecommendOnly = !decisionsRecommendOnly;
|
||
const btn = document.getElementById('recommendFilterBtn');
|
||
if (btn) {
|
||
btn.classList.toggle('bg-yellow-900/30', decisionsRecommendOnly);
|
||
btn.classList.toggle('border-yellow-400', decisionsRecommendOnly);
|
||
btn.textContent = decisionsRecommendOnly ? '⭐ 推荐中' : '⭐ 当前推荐';
|
||
}
|
||
renderDecisionsFiltered();
|
||
}
|
||
|
||
function decisionsTagFilterSet(tag) {
|
||
decisionsTagFilter = (tag === decisionsTagFilter) ? '' : tag;
|
||
renderDecisionsFiltered();
|
||
// 重新聚焦搜索框
|
||
document.getElementById('decisionsSearch')?.focus();
|
||
}
|
||
|
||
/** 计算"距操作区距离"(越小越近),百分比 */
|
||
function calcProximity(d) {
|
||
const price = d.price || 0;
|
||
const sl = d.stop_loss || 0;
|
||
const tp = d.take_profit || 0;
|
||
const el = d.entry_low || 0;
|
||
const eh = d.entry_high || 0;
|
||
if (!price) return 999;
|
||
|
||
let minDist = 999;
|
||
|
||
// 距止损的距离(百分比)
|
||
if (sl > 0) {
|
||
const dist = Math.abs((price - sl) / sl) * 100;
|
||
minDist = Math.min(minDist, dist);
|
||
}
|
||
|
||
// 距止盈的距离(百分比)
|
||
if (tp > 0) {
|
||
const dist = Math.abs((tp - price) / price) * 100;
|
||
minDist = Math.min(minDist, dist);
|
||
}
|
||
|
||
// 距买入区的距离
|
||
if (el > 0 && eh > 0) {
|
||
if (price >= el && price <= eh) {
|
||
// 在买入区内,距边界距离
|
||
const distToLow = (price - el) / (eh - el) * 100;
|
||
const distToHigh = (eh - price) / (eh - el) * 100;
|
||
minDist = Math.min(minDist, Math.min(distToLow, distToHigh));
|
||
} else if (price < el) {
|
||
const dist = (el - price) / el * 100;
|
||
minDist = Math.min(minDist, dist);
|
||
} else {
|
||
const dist = (price - eh) / eh * 100;
|
||
minDist = Math.min(minDist, dist);
|
||
}
|
||
}
|
||
|
||
return minDist;
|
||
}
|
||
|
||
function renderDecisionsFiltered() {
|
||
const el = document.getElementById('tab-decisions');
|
||
const items = decisionsCache;
|
||
const term = decisionsSearchTerm;
|
||
const recOnly = decisionsRecommendOnly;
|
||
const tagFilter = decisionsTagFilter;
|
||
|
||
// 搜索过滤
|
||
let filtered = term ? items.filter(x => {
|
||
const name = (x.name || '').toLowerCase();
|
||
const code = (x.code || '').toLowerCase();
|
||
const action = (x._raw_action || x.action || '').toLowerCase();
|
||
const note = (x.updated_reason || x.note || '').toLowerCase();
|
||
return name.includes(term) || code.includes(term) || action.includes(term) || note.includes(term);
|
||
}) : items;
|
||
|
||
// 当前推荐筛选(点击⭐按钮时启用)
|
||
if (recOnly) {
|
||
filtered = filtered.filter(x => x.tag === 'current_recommend' || x.tag === 'active_manual');
|
||
}
|
||
|
||
// 标签过滤
|
||
if (tagFilter) {
|
||
filtered = filtered.filter(x => x.tag === tagFilter);
|
||
}
|
||
|
||
// 只显示有策略的条目
|
||
const withStrategy = filtered.filter(x => {
|
||
const tg = x.trigger || {};
|
||
return !!(x.stop_loss || tg.stop_loss || x.take_profit || tg.take_profit || x.entry_low || tg.entry_zone || x._raw_action);
|
||
});
|
||
|
||
// 给每个条目算距离
|
||
withStrategy.forEach(x => { x._proximity = calcProximity(x); });
|
||
|
||
// 分组(按优先级从高到低)
|
||
// 1. 执行中(已建仓+部分退出,按距操作区距离排序)
|
||
const executing = withStrategy.filter(x => x.execution?.status === 'executing' || x.execution?.status === 'partial_exit')
|
||
.sort((a, b) => a._proximity - b._proximity);
|
||
|
||
// 2. 接近操作区(距操作区 < 5%,不论状态)
|
||
const proxThreshold = 5;
|
||
const proxKeys = new Set(executing.map(x => x.code));
|
||
const nearZone = withStrategy.filter(x =>
|
||
!proxKeys.has(x.code) && x._proximity < proxThreshold &&
|
||
x.execution?.status !== 'observing'
|
||
).sort((a, b) => a._proximity - b._proximity);
|
||
nearZone.forEach(x => proxKeys.add(x.code));
|
||
|
||
// 3. 观察中
|
||
const observing = withStrategy.filter(x =>
|
||
!proxKeys.has(x.code) && x.execution?.status === 'observing'
|
||
).sort((a, b) => a._proximity - b._proximity);
|
||
observing.forEach(x => proxKeys.add(x.code));
|
||
|
||
// 4. 其余
|
||
const others = withStrategy.filter(x => !proxKeys.has(x.code))
|
||
.sort((a, b) => a._proximity - b._proximity);
|
||
|
||
// 统计标签
|
||
const tagCounts = {};
|
||
withStrategy.forEach(x => {
|
||
const t = x.tag || '';
|
||
if (t) tagCounts[t] = (tagCounts[t] || 0) + 1;
|
||
});
|
||
const hasTags = Object.keys(tagCounts).length > 0;
|
||
|
||
const matched = term ? `(搜索"${term}" 匹配 ${filtered.length} 只)` : '';
|
||
const tagPills = hasTags ? `
|
||
<div class="flex gap-1 flex-wrap mb-3">
|
||
<button onclick="decisionsTagFilterSet('')"
|
||
class="text-xs px-2 py-1 rounded-full transition ${!tagFilter ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}">
|
||
全部
|
||
</button>
|
||
${Object.entries(tagCounts).map(([tag, count]) => {
|
||
const label = tag === 'active_manual' ? '当前推荐操作' :
|
||
tag === 'current_recommend' ? '知微推荐' : tag;
|
||
const active = tagFilter === tag;
|
||
const colors = (tag === 'active_manual' || tag === 'current_recommend')
|
||
? (active ? 'bg-amber-500 text-white' : 'bg-amber-900/40 text-amber-300 hover:bg-amber-800/50')
|
||
: (active ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700');
|
||
return `<button onclick="decisionsTagFilterSet('${tag}')"
|
||
class="text-xs px-2 py-1 rounded-full transition ${colors}">
|
||
${label} (${count})
|
||
</button>`;
|
||
}).join('')}
|
||
</div>
|
||
` : '';
|
||
|
||
el.innerHTML = `
|
||
${tagPills}
|
||
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
||
<span class="text-sm font-semibold">策略决策库</span>
|
||
<span class="text-xs text-slate-500">· ${withStrategy.length} 只 ${matched}</span>
|
||
<button id="recommendFilterBtn" onclick="toggleRecommendFilter()"
|
||
class="text-xs px-2 py-1 rounded-full border transition ${decisionsRecommendOnly ? 'bg-amber-900/30 border-amber-400 text-amber-300' : 'bg-slate-800 border-slate-700 text-slate-400 hover:bg-slate-700'}">
|
||
${decisionsRecommendOnly ? '⭐ 推荐中' : '⭐ 当前推荐'}
|
||
</button>
|
||
<span class="text-xs text-green-400">${executing.length} 执行中</span>
|
||
<span class="text-xs text-orange-400">${nearZone.length} 接近操作</span>
|
||
<span class="text-xs text-yellow-400">${observing.length} 观察</span>
|
||
<span class="text-xs text-slate-500">${others.length} 其他</span>
|
||
</div>
|
||
<div class="relative mb-4">
|
||
<input id="decisionsSearch" type="text" placeholder="搜索股票名或代码…" value="${term}"
|
||
class="w-full px-3 py-2 pl-8 text-xs bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||
oninput="if(!this._composing)decisionsFilter()"
|
||
oncompositionstart="this._composing=true"
|
||
oncompositionend="this._composing=false;decisionsFilter()" />
|
||
<span class="absolute left-2.5 top-2 text-slate-500 text-xs">🔍</span>
|
||
${term ? `<button onclick="document.getElementById('decisionsSearch').value='';decisionsSearchTerm='';renderDecisionsFiltered()" class="absolute right-2 top-2 text-slate-500 hover:text-white text-xs">×</button>` : ''}
|
||
</div>
|
||
|
||
${executing.length > 0 ? `
|
||
<div class="mb-4">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<span class="text-xs font-semibold text-green-400 bg-green-900/30 px-2 py-0.5 rounded-full">▶ 正在执行</span>
|
||
<span class="text-xs text-slate-500">已建仓 · 距操作区最近优先</span>
|
||
</div>
|
||
${renderDecCards(executing, true)}
|
||
</div>
|
||
` : ''}
|
||
|
||
${nearZone.length > 0 ? `
|
||
<div class="mb-4">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<span class="text-xs font-semibold text-orange-400 bg-orange-900/30 px-2 py-0.5 rounded-full">⚡ 接近操作区</span>
|
||
<span class="text-xs text-slate-500">距关键位<5%,重点关注</span>
|
||
</div>
|
||
${renderDecCards(nearZone, true)}
|
||
</div>
|
||
` : ''}
|
||
|
||
${observing.length > 0 ? `
|
||
<div class="mb-4">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<span class="text-xs font-semibold text-yellow-400 bg-yellow-900/30 px-2 py-0.5 rounded-full">◐ 观察中</span>
|
||
<span class="text-xs text-slate-500">自选关注,等待入场时机</span>
|
||
</div>
|
||
${renderDecCards(observing, false)}
|
||
</div>
|
||
` : ''}
|
||
|
||
${others.length > 0 ? `
|
||
<div class="mb-4">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<span class="text-xs font-semibold text-slate-400 bg-slate-800/50 px-2 py-0.5 rounded-full">○ 其他</span>
|
||
<span class="text-xs text-slate-500">远离操作区</span>
|
||
</div>
|
||
${renderDecCards(others, false)}
|
||
</div>
|
||
` : ''}
|
||
|
||
${withStrategy.length === 0 ? `<div class="card p-8 text-center text-slate-500">${term ? '未匹配到结果' : '暂无带策略的条目'}</div>` : ''}
|
||
`;
|
||
|
||
if (term) document.getElementById('decisionsSearch')?.focus();
|
||
}
|
||
|
||
async function renderDecisions() {
|
||
const d = await fetchJSON('/api/decisions');
|
||
decisionsCache = d.decisions || [];
|
||
renderDecisionsFiltered();
|
||
}
|
||
|
||
function renderDecCards(items, showExecution) {
|
||
return `<div class="space-y-2">${items.map(d => {
|
||
const exec = d.execution || {};
|
||
const analysis = d.analysis || {};
|
||
const timeline = d.advice_timeline || d.changelog || [];
|
||
|
||
// 策略区(兼容新旧字段名)
|
||
const tg = d.trigger || {};
|
||
const stopLoss = d.stop_loss || tg.stop_loss || '';
|
||
const takeProfit = d.take_profit || tg.take_profit || '';
|
||
const entryLow = d.entry_low || 0;
|
||
const entryHigh = d.entry_high || 0;
|
||
const techSnap = d.tech_snapshot || analysis.tech_basis || '';
|
||
const timingSignal = d.timing_signal || analysis.timing_signal || '';
|
||
const action = d.action || '';
|
||
const hasTrigger = !!(stopLoss || (entryLow && entryHigh) || takeProfit);
|
||
|
||
// 执行区(实际)
|
||
const execStatus = exec.status || 'none';
|
||
const execPnl = exec.pnl_pct || '';
|
||
const execPrice = exec.entry_price || '';
|
||
const execShares = exec.shares || '';
|
||
const execNotes = exec.notes || '';
|
||
|
||
return `<div class="card p-4">
|
||
<div class="flex items-start justify-between mb-2">
|
||
<div>
|
||
<span class="font-medium text-white">${d.name}</span>
|
||
<span class="text-xs text-slate-500 ml-2">${d.code}</span>
|
||
<span class="text-xs px-1.5 py-0.5 rounded ml-2 ${d.type?.includes('持仓')?'bg-blue-900/50 text-blue-300':'bg-purple-900/50 text-purple-300'}">${d.type||'—'}</span>
|
||
${execStatus === 'executing' ? '<span class="text-xs px-1.5 py-0.5 rounded bg-green-900/50 text-green-300 ml-1">已执行</span>' : ''}
|
||
${execStatus === 'partial_exit' ? '<span class="text-xs px-1.5 py-0.5 rounded bg-orange-900/50 text-orange-300 ml-1">部分退出</span>' : ''}
|
||
${execStatus === 'observing' ? '<span class="text-xs px-1.5 py-0.5 rounded bg-yellow-900/50 text-yellow-300 ml-1">观察中</span>' : ''}
|
||
${d.tag === 'active_manual' || d.tag === 'current_recommend' ? (() => {
|
||
let label = '⭐ ';
|
||
if (d.tag === 'active_manual') {
|
||
if (execStatus === 'executing') label += '执行中 持有';
|
||
else if (execStatus === 'partial_exit') label += '执行中 部分退出';
|
||
else label += '待买入';
|
||
} else {
|
||
if (execStatus === 'partial_exit') label += '部分退出 剩余半仓';
|
||
else if (execStatus === 'observing') label += '推荐买入';
|
||
else label += '推荐操作';
|
||
}
|
||
return `<span class="text-xs px-1.5 py-0.5 rounded bg-amber-900/50 text-amber-300 ml-1 cursor-pointer" onclick="decisionsTagFilterSet('${d.tag}')">${label}</span>`;
|
||
})() : ''}
|
||
</div>
|
||
<span class="text-xs text-slate-500">${d.timestamp?.slice(5,16)||''}</span>
|
||
</div>
|
||
|
||
<!-- 双维度:策略 vs 执行 -->
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-2">
|
||
<!-- 策略理论 -->
|
||
<div class="bg-blue-900/10 rounded-lg p-3 border border-blue-900/30">
|
||
<div class="text-xs font-semibold text-blue-400 mb-1">📐 策略理论</div>
|
||
${hasTrigger ? `
|
||
<div class="grid grid-cols-3 gap-1 text-xs">
|
||
${stopLoss ? `<div><span class="text-red-400">止损</span> ${stopLoss}</div>` : ''}
|
||
${entryLow ? `<div><span class="text-yellow-400">买入区</span> ${entryLow}~${entryHigh}</div>` : tg.entry_zone ? `<div><span class="text-yellow-400">买入区</span> ${tg.entry_zone}</div>` : ''}
|
||
${takeProfit ? `<div><span class="text-green-400">止盈</span> ${takeProfit}</div>` : ''}
|
||
</div>
|
||
${action ? `<div class="text-xs text-slate-300 mt-1 font-medium">${action}</div>` : ''}
|
||
${analysis.reasoning ? `<div class="text-xs text-slate-400 mt-1">${analysis.reasoning.slice(0,100)}</div>` : ''}
|
||
${timingSignal && !timingSignal.includes('neutral') ? `<div class="text-xs text-yellow-400 mt-1">⏱ ${timingSignal}</div>` : ''}
|
||
` : `<div class="text-xs text-slate-500">无策略</div>`}
|
||
</div>
|
||
|
||
<!-- 执行实际 -->
|
||
<div class="bg-green-900/10 rounded-lg p-3 border border-green-900/30">
|
||
<div class="text-xs font-semibold text-green-400 mb-1">⚡ 实际执行</div>
|
||
${execStatus === 'executing' || execStatus === 'partial_exit' ? `
|
||
<div class="text-xs">
|
||
<div class="flex justify-between"><span class="text-slate-400">建仓价</span><span class="font-mono text-white">¥${execPrice||'?'}</span></div>
|
||
<div class="flex justify-between"><span class="text-slate-400">持仓</span><span class="font-mono text-white">${execShares||'?'}股</span></div>
|
||
<div class="flex justify-between"><span class="text-slate-400">盈亏</span><span class="font-mono ${(execPnl||'').includes('-')?'text-red-400':'text-green-400'}">${execPnl||'?'}</span></div>
|
||
${exec.remaining_shares ? `<div class="flex justify-between"><span class="text-slate-400">剩余</span><span class="font-mono text-white">${exec.remaining_shares}股</span></div>` : ''}
|
||
${exec.realized_gain ? `<div class="flex justify-between"><span class="text-slate-400">已实现</span><span class="font-mono text-green-400">+${exec.realized_gain}</span></div>` : ''}
|
||
${exec.notes ? `<div class="text-slate-400 mt-1">${exec.notes}</div>` : ''}
|
||
</div>
|
||
` : execStatus === 'observing' ? `
|
||
<div class="text-xs text-slate-400">观察中,等待入场时机</div>
|
||
<div class="text-xs text-slate-500 mt-1">${execNotes||'关注回调至买入区'}</div>
|
||
` : `
|
||
<div class="text-xs text-slate-500">尚未执行</div>
|
||
`}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 建议记录 -->
|
||
${timeline.length ? `
|
||
<div class="border-t border-slate-800/50 pt-2 mt-1">
|
||
<div class="text-xs text-slate-500 mb-1">📜 建议记录 (${timeline.length})</div>
|
||
<div class="space-y-1">
|
||
${timeline.slice(-3).map(a =>
|
||
`<div class="text-xs text-slate-400 pl-2 border-l-2 border-blue-500/50 flex items-center gap-1">
|
||
<span class="text-slate-500">${a.date?.slice(5,16)||''}</span>
|
||
<span class="${a.direction==='买入'?'text-green-400':a.direction==='卖出'?'text-red-400':'text-slate-300'}">${a.direction||''}</span>
|
||
<span>${a.summary?.slice(0,40)||''}</span>
|
||
${a.status === 'pending' ? '<span class="text-yellow-500">⏳</span>' : a.status === 'confirmed' ? '<span class="text-green-500">✓</span>' : a.status === 'ignored' ? '<span class="text-slate-600">✗</span>' : ''}
|
||
</div>`
|
||
).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
${d.updated_reason ? `<div class="text-xs text-slate-500 mt-1">📝 ${d.updated_reason}</div>` : ''}
|
||
</div>`;
|
||
}).join('')}</div>`;
|
||
}
|
||
|
||
|
||
// ── Evaluation Tab ──
|
||
function renderEvaluation() {
|
||
const el = document.getElementById('tab-evaluation');
|
||
Promise.all([
|
||
fetchJSON('/api/evaluation'),
|
||
fetchJSON('/api/stats/accuracy'),
|
||
]).then(([evals, stats]) => {
|
||
const p1 = stats.phase1 || {};
|
||
const p2 = stats.phase2 || {};
|
||
el.innerHTML = `
|
||
<div class="flex items-center gap-2 mb-4">
|
||
<span class="text-sm font-semibold">策略双维度评估</span>
|
||
<span class="text-xs bg-blue-900/50 text-blue-300 px-2 py-0.5 rounded-full">${evals.length} 只已评估</span>
|
||
<button class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 ml-auto" onclick="triggerEval()">🔄 重新评估</button>
|
||
</div>
|
||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||
<div class="card p-3 text-center">
|
||
<div class="text-lg font-bold text-green-400">${p1.correct||0}</div>
|
||
<div class="text-xs text-slate-500">阶段一 达标</div>
|
||
</div>
|
||
<div class="card p-3 text-center">
|
||
<div class="text-lg font-bold text-red-400">${p1.wrong||0}</div>
|
||
<div class="text-xs text-slate-500">阶段一 止损</div>
|
||
</div>
|
||
<div class="card p-3 text-center">
|
||
<div class="text-lg font-bold text-yellow-400">${p1.pending||0}</div>
|
||
<div class="text-xs text-slate-500">待验证</div>
|
||
</div>
|
||
<div class="card p-3 text-center">
|
||
<div class="text-lg font-bold text-white">${p1.accuracy_pct||0}%</div>
|
||
<div class="text-xs text-slate-500">准确率</div>
|
||
</div>
|
||
</div>
|
||
<div class="card overflow-hidden">
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-xs">
|
||
<thead><tr class="text-slate-500 border-b border-slate-800/50">
|
||
<th class="text-left p-3">股票</th><th class="text-left p-3">策略</th>
|
||
<th class="text-left p-3">理论盈亏</th><th class="text-left p-3">实际盈亏</th>
|
||
<th class="text-left p-3">状态</th>
|
||
</tr></thead>
|
||
<tbody>${evals.map(x => {
|
||
const adv = (x.advice_evaluation && x.advice_evaluation[0]) || {};
|
||
const t = x.theoretical || {};
|
||
const a = x.actual || {};
|
||
const status = t.status || 'safe';
|
||
const icon = status === 'take_profit_hit' ? '🟢' : status === 'stop_loss_hit' ? '🔴' : status === 'in_entry_zone' ? '📥' : '💤';
|
||
return `<tr class="stock-row border-b border-slate-800/30">
|
||
<td class="p-3"><div class="font-medium text-white">${x.name}</div><div class="text-slate-500">${x.code}</div></td>
|
||
<td class="p-3 text-slate-300">${x.current || (x.advice_evaluation && x.advice_evaluation[0] && x.advice_evaluation[0].current_advice || '') || '—'}</td>
|
||
<td class="p-3"><span class="${(t.theoretical_pnl_pct||0)>=0?'badge-up':'badge-down'}">${(t.theoretical_pnl_pct||0).toFixed(1)}%</span></td>
|
||
<td class="p-3"><span class="${(a.actual_pnl_pct||0)>=0?'badge-up':'badge-down'}">${(a.actual_pnl_pct||0).toFixed(1)}%</span></td>
|
||
<td class="p-3">${icon} ${status.replace(/_/g,' ')}</td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="text-xs text-slate-600 mt-2">更新时间: ${stats.updated_at?.slice(0,16)||'—'}</div>
|
||
`;
|
||
// Load feedback
|
||
fetchJSON('/api/feedback').then(fb => {
|
||
const trend = fb.accuracy_trend || {};
|
||
const completed = fb.phase1_completed_count || 0;
|
||
const reassess = fb.reassess_needed_count || 0;
|
||
if (!completed && !reassess) return;
|
||
const fbEl = document.createElement('div');
|
||
fbEl.className = 'card p-4 mt-4';
|
||
fbEl.innerHTML = `
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-sm font-semibold">🔄 反馈闭环</span>
|
||
<span class="text-xs text-slate-500">趋势: ${trend.trend||'stable'}</span>
|
||
<span class="text-xs ${completed>0?'text-green-400':'text-slate-500'}">完成 ${completed}</span>
|
||
<span class="text-xs ${reassess>0?'text-yellow-400':'text-slate-500'}">重评 ${reassess}</span>
|
||
</div>
|
||
${(fb.feedback||[]).filter(f => f.adjustments?.length).slice(0,10).map(f => f.adjustments.map(a => `
|
||
<div class="flex items-start gap-2 p-2 bg-slate-800/30 rounded-lg mb-1 text-xs">
|
||
<span class="${a.type==='phase1_success'?'text-green-400':a.type==='phase1_failure'?'text-red-400':'text-yellow-400'}">${a.type==='phase1_success'?'✅':a.type==='phase1_failure'?'❌':'🔄'}</span>
|
||
<span class="text-slate-300">${a.message}</span>
|
||
</div>
|
||
`).join('')).join('')}
|
||
`;
|
||
el.appendChild(fbEl);
|
||
});
|
||
});
|
||
}
|
||
function triggerEval() {
|
||
const btn = document.querySelector('#tab-evaluation button');
|
||
btn.textContent = '⏳ 评估中...';
|
||
btn.disabled = true;
|
||
fetch('/api/evaluation/trigger', {method:'POST'}).then(r => r.json()).then(d => {
|
||
btn.textContent = '🔄 重新评估';
|
||
btn.disabled = false;
|
||
renderEvaluation();
|
||
});
|
||
}
|
||
|
||
// ── Stock Detail Modal ──
|
||
async function openStock(code) {
|
||
const el = document.getElementById('stockModal');
|
||
el.classList.remove('hidden');
|
||
el.classList.add('flex');
|
||
document.getElementById('modalTitle').textContent = '加载中...';
|
||
|
||
const [stockData] = await Promise.all([
|
||
fetchJSON(`/api/stock/${code}`),
|
||
]);
|
||
|
||
// Also find in portfolio
|
||
const inPortfolio = (portfolioData.holdings||[]).find(s => s.code === code);
|
||
const name = inPortfolio?.name || stockData.name || code;
|
||
document.getElementById('modalTitle').textContent = name;
|
||
document.getElementById('modalMeta').textContent = `${code} · ${inPortfolio?.sector||stockData.sector||''}`;
|
||
|
||
document.getElementById('modalInfo').innerHTML = inPortfolio ? `
|
||
<div><div class="text-xs text-slate-500">成本</div><div class="text-sm font-mono">${(inPortfolio.cost||0).toFixed(2)}</div></div>
|
||
<div><div class="text-xs text-slate-500">现价</div><div class="text-sm font-mono">${(inPortfolio.price||0).toFixed(2)}</div></div>
|
||
<div><div class="text-xs text-slate-500">仓位</div><div class="text-sm font-mono">${(inPortfolio.position_pct||0).toFixed(2)}%</div></div>
|
||
<div><div class="text-xs text-slate-500">盈亏</div><div class="text-sm font-mono ${((inPortfolio.price||0)-(inPortfolio.cost||0))>=0?'badge-up':'badge-down'}">
|
||
${formatMoney(((inPortfolio.price||0)-(inPortfolio.cost||0))*(inPortfolio.shares||0), true)}
|
||
</div></div>
|
||
` : `<div class="col-span-full text-xs text-slate-500">暂无持仓数据</div>`;
|
||
|
||
// Render recommendation history
|
||
const history = stockData.history || [];
|
||
document.getElementById('modalHistory').innerHTML = history.length ? history.map(h => `
|
||
<div class="flex items-start gap-2 p-2 bg-slate-800/30 rounded-lg">
|
||
<span class="text-xs text-slate-500 w-14 flex-shrink-0">${h.time?.slice(5,16)||''}</span>
|
||
<span class="text-xs font-mono w-14 flex-shrink-0">¥${h.price||'—'}</span>
|
||
<span class="text-xs flex-1">${h.recommendation||''}${h.reason?' — '+h.reason:''}</span>
|
||
</div>
|
||
`).join('') : '<div class="text-xs text-slate-500">暂无操作建议记录</div>';
|
||
|
||
// Mini chart (mock data if no real data)
|
||
const chartCanvas = document.getElementById('modalChart');
|
||
chartCanvas.innerHTML = '<canvas id="stockChart"></canvas>';
|
||
const ctx = document.getElementById('stockChart').getContext('2d');
|
||
if (stockData.price_history && stockData.price_history.length > 1) {
|
||
new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: stockData.price_history.map(p => p.date),
|
||
datasets: [{
|
||
label: '价格',
|
||
data: stockData.price_history.map(p => p.price),
|
||
borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.1)',
|
||
fill: true, tension: 0.3, pointRadius: 2,
|
||
}]
|
||
},
|
||
options: { responsive: true, maintainAspectRatio: false,
|
||
plugins: { legend: { display: false } },
|
||
scales: { x: { display: false }, y: { ticks: { font: {size:10} } } }
|
||
}
|
||
});
|
||
} else {
|
||
chartCanvas.innerHTML = '<div class="flex items-center justify-center h-full text-xs text-slate-500">暂无价格历史数据</div>';
|
||
}
|
||
}
|
||
|
||
function closeStock() {
|
||
document.getElementById('stockModal').classList.add('hidden');
|
||
}
|
||
|
||
// ── Prompts Tab ──
|
||
function renderPrompts() {
|
||
const el = document.getElementById('tab-prompts');
|
||
el.innerHTML = '<div class="text-center text-slate-500 py-8">加载中...</div>';
|
||
|
||
Promise.all([
|
||
fetch('/api/prompts').then(r => r.json()),
|
||
fetch('/api/prompts/effectiveness').then(r => r.json()),
|
||
]).then(([data, effectiveness]) => {
|
||
const prompts = data.prompts || [];
|
||
const categories = data.categories || {};
|
||
|
||
let html = '<div class="space-y-4">';
|
||
|
||
// 分类筛选按钮
|
||
const catSet = {};
|
||
prompts.forEach(p => { catSet[p.category] = true; });
|
||
html += '<div class="flex flex-wrap gap-2 mb-4">';
|
||
html += '<button class="px-3 py-1 text-xs rounded-full bg-slate-700 text-white" onclick="filterPromptTable(\'\')">全部</button>';
|
||
Object.keys(catSet).sort().forEach(cat => {
|
||
html += `<button class="px-3 py-1 text-xs rounded-full bg-slate-800 text-slate-300 hover:bg-slate-700" onclick="filterPromptTable('${cat}')">${categories[cat]||cat}</button>`;
|
||
});
|
||
html += '<button class="px-3 py-1 text-xs rounded-full bg-amber-900/50 text-amber-400 ml-auto" onclick="loadPromptReport()">📊 版本有效性报告</button>';
|
||
html += '</div>';
|
||
|
||
// 提示词列表
|
||
if (prompts.length === 0) {
|
||
html += '<div class="card p-6 text-center text-slate-500">暂无提示词记录,请先初始化注册表。</div>';
|
||
} else {
|
||
html += '<div class="card overflow-hidden"><table class="w-full text-sm">';
|
||
html += '<thead><tr class="text-left text-slate-400 border-b border-slate-700/50"><th class="p-3 font-medium">提示词</th><th class="p-3 font-medium">分类</th><th class="p-3 font-medium">当前版本</th><th class="p-3 font-medium">版本数</th><th class="p-3 font-medium">关联策略</th><th class="p-3 font-medium">成功率</th><th class="p-3 font-medium">操作</th></tr></thead><tbody>';
|
||
|
||
prompts.forEach(p => {
|
||
const verCount = (p.versions||[]).length;
|
||
const curVer = p.current_version || '-';
|
||
const eff = effectiveness[`${p.id}@${curVer}`] || {};
|
||
const sr = eff.success_rate !== undefined ? eff.success_rate + '%' : '-';
|
||
const total = eff.total || 0;
|
||
|
||
html += `<tr class="border-b border-slate-800/50 hover:bg-slate-800/30 prompt-row" data-category="${p.category}">
|
||
<td class="p-3"><strong>${p.name}</strong><br><small class="text-slate-500">${p.id}</small></td>
|
||
<td class="p-3"><span class="px-2 py-0.5 text-xs rounded-full bg-slate-700 text-slate-300">${categories[p.category]||p.category}</span></td>
|
||
<td class="p-3"><span class="px-2 py-0.5 text-xs rounded-full bg-blue-900/50 text-blue-400">${curVer}</span></td>
|
||
<td class="p-3">${verCount}</td>
|
||
<td class="p-3">${total}</td>
|
||
<td class="p-3">${sr}</td>
|
||
<td class="p-3">
|
||
<button class="px-2 py-1 text-xs bg-blue-900/50 text-blue-400 hover:bg-blue-800/50 rounded" onclick="showPromptVersions('${p.id}')">📋 版本</button>
|
||
</td>
|
||
</tr>`;
|
||
});
|
||
|
||
html += '</tbody></table></div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
el.innerHTML = html;
|
||
|
||
// 保存数据供版本弹窗使用
|
||
window._promptData = data;
|
||
}).catch(err => {
|
||
el.innerHTML = `<div class="card p-6 text-center text-red-400">加载失败: ${err.message}</div>`;
|
||
});
|
||
}
|
||
|
||
function filterPromptTable(category) {
|
||
document.querySelectorAll('.prompt-row').forEach(row => {
|
||
row.style.display = (!category || row.dataset.category === category) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function showPromptVersions(promptId) {
|
||
fetch(`/api/prompts/${promptId}`)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const prompt = data.prompt || {};
|
||
const history = data.version_history || [];
|
||
const versions = prompt.versions || [];
|
||
|
||
let html = `<p class="text-xs text-slate-400 mb-3">${prompt.description || ''}</p>`;
|
||
html += '<div class="overflow-x-auto"><table class="w-full text-sm">';
|
||
html += '<thead><tr class="text-left text-slate-400 border-b border-slate-700">';
|
||
html += '<th class="p-2">版本</th><th class="p-2">标签</th><th class="p-2">状态</th><th class="p-2">时间</th><th class="p-2">变更说明</th></tr></thead><tbody>';
|
||
|
||
// 按版本号排序
|
||
const sorted = [...history].sort((a, b) => a.version.localeCompare(b.version, undefined, {numeric: true}));
|
||
|
||
sorted.forEach(h => {
|
||
const statusText = h.is_current ? '当前' : h.status;
|
||
const statusClass = h.is_current ? 'text-green-400' : h.status==='deprecated' ? 'text-amber-400' : 'text-slate-500';
|
||
|
||
// 找到该版本的完整内容
|
||
const verData = versions.find(v => v.version === h.version);
|
||
const contentPreview = verData?.content ? verData.content.slice(0, 80) + '...' : '';
|
||
|
||
html += `<tr class="border-b border-slate-800/50 hover:bg-slate-700/50 cursor-pointer" onclick="showVersionDetail('${promptId}', '${h.version}')">
|
||
<td class="p-2 font-mono">${h.version}</td>
|
||
<td class="p-2">${h.label}</td>
|
||
<td class="p-2 ${statusClass}">${statusText}</td>
|
||
<td class="p-2 text-xs text-slate-500">${h.created_at}</td>
|
||
<td class="p-2 text-xs text-slate-400">${h.changelog}</td>
|
||
</tr>`;
|
||
});
|
||
|
||
html += '</tbody></table></div>';
|
||
html += '<p class="text-xs text-slate-500 mt-2">💡 点击任意行查看该版本的完整内容</p>';
|
||
|
||
openPromptModal(prompt.name + ' · 版本历史', html);
|
||
|
||
// 缓存数据,供 showVersionDetail 使用
|
||
window._promptVersionsCache = { promptId, versions };
|
||
});
|
||
}
|
||
|
||
function showVersionDetail(promptId, version) {
|
||
const cache = window._promptVersionsCache;
|
||
const versions = cache?.promptId === promptId ? cache.versions : [];
|
||
|
||
// 直接从API拿(保证最新)
|
||
fetch(`/api/prompts/${promptId}`)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const prompt = data.prompt || {};
|
||
const v = (prompt.versions || []).find(x => x.version === version);
|
||
if (!v) return;
|
||
|
||
const isCurrent = v.version === prompt.current_version;
|
||
const statusLabel = isCurrent ? '当前版本' : v.status;
|
||
const statusColor = isCurrent ? 'bg-blue-900/50 text-blue-400' : 'bg-slate-700 text-slate-400';
|
||
|
||
let html = `<div class="mb-3 flex items-center gap-2 flex-wrap">
|
||
<span class="font-mono font-bold text-lg">${v.version}</span>
|
||
<span class="text-sm text-slate-400">${v.label}</span>
|
||
<span class="px-2 py-0.5 text-xs rounded-full ${statusColor}">${statusLabel}</span>
|
||
<span class="text-xs text-slate-500 ml-auto">${v.created_at}</span>
|
||
</div>`;
|
||
|
||
if (v.changelog) {
|
||
html += `<div class="mb-3 text-xs text-slate-400 bg-slate-800/50 px-3 py-2 rounded-lg">🔄 ${v.changelog}</div>`;
|
||
}
|
||
|
||
html += `<pre class="text-xs bg-slate-900 p-4 rounded-lg overflow-x-auto whitespace-pre-wrap leading-relaxed">${v.content || '(无内容)'}</pre>`;
|
||
|
||
// 底部导航
|
||
const allVersions = prompt.versions || [];
|
||
const sorted = [...allVersions].sort((a, b) => a.version.localeCompare(b.version, undefined, {numeric: true}));
|
||
const idx = sorted.findIndex(x => x.version === version);
|
||
html += '<div class="flex justify-between mt-4 pt-3 border-t border-slate-700/50">';
|
||
if (idx > 0) {
|
||
html += `<button class="text-xs text-blue-400 hover:text-blue-300" onclick="showVersionDetail('${promptId}', '${sorted[idx-1].version}')">← ${sorted[idx-1].version}</button>`;
|
||
} else {
|
||
html += '<span></span>';
|
||
}
|
||
html += `<button class="text-xs text-slate-400 hover:text-slate-300" onclick="showPromptVersions('${promptId}')">↑ 返回版本列表</button>`;
|
||
if (idx < sorted.length - 1) {
|
||
html += `<button class="text-xs text-blue-400 hover:text-blue-300" onclick="showVersionDetail('${promptId}', '${sorted[idx+1].version}')">${sorted[idx+1].version} →</button>`;
|
||
} else {
|
||
html += '<span></span>';
|
||
}
|
||
html += '</div>';
|
||
|
||
openPromptModal(prompt.name + ' · ' + v.version + ' ' + v.label, html);
|
||
});
|
||
}
|
||
|
||
function loadPromptReport() {
|
||
fetch('/api/prompts/report')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
openPromptModal('📊 提示词版本有效性报告',
|
||
`<pre class="text-xs text-slate-300 whitespace-pre-wrap">${data.report||'暂无数据'}</pre>`);
|
||
});
|
||
}
|
||
|
||
function openPromptModal(title, bodyHtml) {
|
||
document.getElementById('promptModalTitle').textContent = title;
|
||
document.getElementById('promptModalBody').innerHTML = bodyHtml;
|
||
document.getElementById('promptModal').classList.remove('hidden');
|
||
document.getElementById('promptModal').classList.add('flex');
|
||
}
|
||
|
||
function closePromptModal() {
|
||
document.getElementById('promptModal').classList.add('hidden');
|
||
document.getElementById('promptModal').classList.remove('flex');
|
||
}
|
||
|
||
// ── 信号页 ──
|
||
async function renderSignals() {
|
||
const el = document.getElementById('tab-signals');
|
||
el.innerHTML = '<div class="text-slate-400 text-sm">加载中…</div>';
|
||
try {
|
||
const [signals, scan] = await Promise.all([
|
||
fetchJSON('/api/signals'),
|
||
fetchJSON('/api/xiaoguo-scan')
|
||
]);
|
||
const sourceToday = scan.source_today || {};
|
||
let html = `
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||
<div class="card p-4">
|
||
<div class="text-xs text-slate-500 mb-1">今日信号来源</div>
|
||
<div class="flex gap-4 text-sm">
|
||
<span class="text-blue-400">📡 趋势: ${sourceToday.trend || 0}</span>
|
||
<span class="text-green-400">🔍 小果扫描: ${sourceToday.xiaoguo || 0}</span>
|
||
</div>
|
||
</div>
|
||
<div class="card p-4">
|
||
<div class="text-xs text-slate-500 mb-1">小果扫描统计</div>
|
||
<div class="flex gap-4 text-sm">
|
||
<span class="text-slate-300">📋 累计扫描: ${scan.total_scanned || 0} 只</span>
|
||
<span class="text-yellow-400">🏆 发现信号: ${scan.found_signals || 0} 只</span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
if (!signals || signals.length === 0) {
|
||
html += '<div class="card p-6 text-center text-slate-500 text-sm">暂无信号</div>';
|
||
el.innerHTML = html;
|
||
return;
|
||
}
|
||
|
||
html += '<div class="space-y-3">';
|
||
for (const s of signals) {
|
||
const sev = s.severity || '-';
|
||
const sevColor = sev === 'high' ? 'text-red-400' : sev === 'medium' ? 'text-yellow-400' : 'text-green-400';
|
||
const sent = s.overall_sentiment || '-';
|
||
const sentColor = sent === '利好' ? 'text-green-400' : sent === '利空' ? 'text-red-400' : 'text-slate-400';
|
||
const source = s.source || 'trend';
|
||
const sourceIcon = source === 'xiaoguo' ? '🔍' : '📡';
|
||
html += `
|
||
<div class="card p-4">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<span class="${sevColor} text-xs font-semibold">${sev.toUpperCase()}</span>
|
||
<span class="${sentColor} text-sm font-semibold">${sent}</span>
|
||
<span class="text-slate-500 text-xs">${sourceIcon} ${source}</span>
|
||
<span class="text-slate-600 text-xs ml-auto">${s.created_at ? s.created_at.slice(5,16) : ''}</span>
|
||
</div>
|
||
<div class="text-sm text-slate-300">${s.sector || '-'}</div>
|
||
<div class="text-xs text-slate-500 mt-1">${(s.summary || '').slice(0,120)}</div>
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
el.innerHTML = html;
|
||
} catch(e) {
|
||
el.innerHTML = `<div class="text-red-400 text-sm">加载失败: ${e.message}</div>`;
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |