Files
MoFin/static/index.html
T

2012 lines
107 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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; }
.princ-btn { transition: all 0.15s; color: #8b949e; }
.princ-btn.active { background: #1e293b; 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); }
.spec-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: 50%;
font-size: 11px; font-weight: 700; line-height: 1;
cursor: pointer; transition: background 0.15s; user-select: none;
}
.spec-btn.help { color: #58a6ff; background: rgba(88,166,255,0.1); }
.spec-btn.help:hover { background: rgba(88,166,255,0.25); }
.spec-btn.ai { color: #d29922; background: rgba(210,153,34,0.1); }
.spec-btn.ai:hover { background: rgba(210,153,34,0.25); }
.spec-btns { display: inline-flex; gap: 4px; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0f0f13; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
@keyframes pulse-warn {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.pulse-warn { animation: pulse-warn 1.2s ease-in-out infinite; }
</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 blink-tab" data-tab="watch" style="color:#f97316;border-color:#f97316">👁️ 盯盘</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="evaluation">📊 评估</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>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="health">🏥 健康</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="principles">📐 开发原则</button>
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="research">📋 研究</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-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 id="tab-health" class="tab-content hidden"></div>
<div id="tab-research" class="tab-content hidden"></div>
<div id="tab-principles" class="tab-content hidden">
<div class="flex gap-1 mb-4 bg-slate-900/50 rounded-xl p-1 border border-slate-800/50" id="princBar">
<button class="princ-btn active px-4 py-1.5 text-sm rounded-lg" data-princ="spec">G 规范</button>
<button class="princ-btn px-4 py-1.5 text-sm rounded-lg" data-princ="tests">K 测试</button>
<button class="princ-btn px-4 py-1.5 text-sm rounded-lg" data-princ="health">F 健康</button>
<button class="princ-btn px-4 py-1.5 text-sm rounded-lg" data-princ="prd">H 需求</button>
</div>
<div id="princ-spec" class="princ-pane"></div>
<div id="princ-tests" class="princ-pane hidden"></div>
<div id="princ-health" class="princ-pane hidden"></div>
<div id="princ-prd" class="princ-pane hidden"></div>
</div>
<div id="tab-watch" class="tab-content hidden">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-slate-200">👁️ 盯盘 · 全部策略</h2>
<span class="text-xs text-slate-500"><span id="watchRefreshTime"></span> <span id="watchCount" class="text-yellow-400 font-mono">0</span></span>
</div>
<!-- 筛选/排序控制条 -->
<div class="mb-3 p-2 rounded-lg bg-slate-800/40 border border-slate-700/50 text-xs space-y-2">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-slate-500 mr-1">信号:</span>
<span id="watchSigChips"></span>
<button onclick="watchSigAll(true)" class="px-1.5 py-0.5 rounded text-slate-400 hover:text-white border border-slate-600">全选</button>
<button onclick="watchSigAll(false)" class="px-1.5 py-0.5 rounded text-slate-400 hover:text-white border border-slate-600">清空</button>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-slate-500">排序:</span>
<select id="watchSortMode" onchange="renderWatch()" class="bg-slate-900 border border-slate-600 rounded px-1.5 py-0.5 text-slate-300">
<option value="default">默认(分组)</option>
<option value="rr_desc">RR 从高到低</option>
<option value="rr_asc">RR 从低到高</option>
<option value="chg_desc">涨幅从高到低</option>
<option value="chg_asc">跌幅从高到低</option>
</select>
<span class="text-slate-500 ml-2">RR ≥</span>
<input id="watchMinRR" type="number" step="0.1" min="0" placeholder="0" onchange="renderWatch()" class="w-16 bg-slate-900 border border-slate-600 rounded px-1.5 py-0.5 text-slate-300">
<button onclick="watchResetFilters()" class="px-2 py-0.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600 ml-1">重置</button>
</div>
</div>
<div id="watchContent" class="card overflow-hidden"></div>
</div>
</div>
<!-- Strategy History Modal -->
<div id="strategyModal" class="fixed inset-0 modal-overlay hidden items-center justify-center z-50" onclick="if(event.target===this)closeStrategyModal()">
<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="strategyModalTitle" class="text-lg font-bold text-white"></h2>
<p id="strategyModalMeta" class="text-xs text-slate-500 mt-1"></p>
</div>
<button onclick="closeStrategyModal()" class="text-slate-500 hover:text-white text-xl">&times;</button>
</div>
<div id="strategyModalBody" class="space-y-3"></div>
</div>
</div>
<!-- Spec Modal -->
<div id="specModal" class="fixed inset-0 modal-overlay hidden items-center justify-center z-50" onclick="if(event.target===this)closeSpecModal()">
<div class="bg-[#161b22] border border-[#30363d] rounded-2xl w-full max-w-[700px] max-h-[80vh] overflow-y-auto mx-4 p-6" onclick="event.stopPropagation()">
<div class="flex justify-between items-start mb-4">
<div>
<h2 id="specModalTitle" class="text-lg font-bold text-[#c9d1d9]"></h2>
<p id="specModalSubtitle" class="text-xs text-[#8b949e] mt-1"></p>
</div>
<button onclick="closeSpecModal()" class="text-[#8b949e] hover:text-white text-xl">&times;</button>
</div>
<div id="specModalBody" class="text-sm text-[#c9d1d9] leading-relaxed"></div>
</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">&times;</button>
</div>
<div id="modalInfo" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4"></div>
<div id="modalStrategy" class="mb-4"></div>
<div id="modalChart" class="h-48 mb-4 bg-slate-900 rounded-xl p-2"></div>
<div id="modalAnalysis" class="mb-4"></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">&times;</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;
// 切走 health 时清理定时器
if (name !== 'health' && healthRefreshInterval) {
clearInterval(healthRefreshInterval);
healthRefreshInterval = null;
const he = document.getElementById('tab-health');
if (he) delete he.dataset.rendered;
}
if (name === 'health' && !healthRefreshInterval) {
if (!healthRefreshInterval) healthRefreshInterval = setInterval(refreshHealth, 10000);
}
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) {
// 切走 health 时清理定时器
if (name !== 'health' && healthRefreshInterval) {
clearInterval(healthRefreshInterval);
healthRefreshInterval = null;
const he = document.getElementById('tab-health');
if (he) delete he.dataset.rendered;
}
// 进入 health 时确保定时器运行
if (name === 'health' && !healthRefreshInterval) {
healthRefreshInterval = setInterval(refreshHealth, 10000);
}
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 === 'evaluation') renderEvaluation();
else if (name === 'prompts') renderPrompts();
else if (name === 'market') renderMarket();
else if (name === 'signals') renderSignals();
else if (name === 'health') renderHealth();
else if (name === 'principles') renderPrinciples();
else if (name === 'watch') renderWatch();
else if (name === 'research') renderResearch();
}
// ── 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, watchData] = await Promise.all([
fetchJSON('/api/overview'),
fetchJSON('/api/portfolio'),
fetchJSON('/api/watchlist'),
fetchJSON('/api/reports'),
fetchJSON('/api/watch'),
]);
portfolioData = portfolio;
watchlistData = watchlist;
reportsData = Array.isArray(reports) ? reports : [];
// 右上角时间显示:来源时间说明
let timeParts = ['⏱页面 ' + new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit'})];
if (watchData && watchData.data_time) {
const dt = watchData.data_time.slice(5,16);
timeParts.unshift('📡价格 ' + dt);
// 开盘期间检测 price_monitor 是否存(数据>5分钟=可能挂了)
const now = new Date();
const h = now.getHours(), m = now.getMinutes(), d = now.getDay();
const isMarketHours = d >= 1 && d <= 5 && ((h === 9 && m >= 30) || (h >= 10 && h < 15) || (h === 15 && m === 0));
if (isMarketHours) {
const dataDate = new Date(watchData.data_time);
const ageMin = (now - dataDate) / 60000;
if (ageMin > 5) {
timeParts[0] = '🚨价格 ' + dt + '' + Math.round(ageMin) + '分钟未更新)';
}
}
}
document.getElementById('updateTime').innerHTML = timeParts.join(' | ');
// 如果有告警、整行变红闪烁
if (timeParts[0].startsWith('🚨')) {
const el = document.getElementById('updateTime');
el.style.color = '#e53935';
el.style.fontWeight = 'bold';
el.className = 'pulse-warn';
} else {
document.getElementById('updateTime').style.color = '';
document.getElementById('updateTime').style.fontWeight = '';
document.getElementById('updateTime').className = '';
}
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">⚡ 大幅异动 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('portfolio','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('portfolio','ai')">§</span></span></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="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('portfolio','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('portfolio','ai')">§</span></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 showFullAnalysis(code) {
const text = window._fullAnalysisMap[code] || '暂无完整分析';
const modal = document.createElement('div');
modal.id = 'analysisModal';
modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center';
modal.innerHTML = `<div style="background:#1e293b;border:1px solid #334155;border-radius:12px;max-width:700px;width:90%;max-height:80vh;overflow-y:auto;padding:24px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<span style="font-size:16px;font-weight:bold;color:#e2e8f0">📋 12维分析</span>
<span onclick="this.closest('#analysisModal').remove()" style="cursor:pointer;font-size:24px;color:#94a3b8">&times;</span>
</div>
<pre style="font-size:12px;color:#cbd5e1;line-height:1.6;white-space:pre-wrap;font-family:inherit;margin:0">${text}</pre>
</div>`;
modal.addEventListener('click', function(e) { if (e.target === this) this.remove(); });
document.body.appendChild(modal);
}
function renderWatchlist() {
const el = document.getElementById('tab-watchlist');
const stocks = watchlistData.stocks || [];
// 构建 code→full_analysis 映射
window._fullAnalysisMap = {};
stocks.forEach(s => { window._fullAnalysisMap[s.code] = s.full_analysis || ''; });
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="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('watchlist','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('watchlist','ai')">§</span></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">RR</th><th class="text-right p-3">信号</th><th class="text-right p-3">仓位</th>
</tr></thead>
<tbody>${stocks.map(s => {
const el = s.entry_low||0, eh = s.entry_high||0;
const mid = (el > 0 && eh > el) ? (' <b class="text-amber-300">' + ((el+eh)/2).toFixed(2) + '</b> ') : '~';
const buyZone = el + mid + eh;
const rr = s.rr_ratio || 0;
const sl = s.stop_loss || 0;
const tp = s.take_profit || 0;
const signal = s.timing_signal || '';
const action = (s.action||'');
const tech = s.tech_snapshot || '';
const sector = s.sector_context || '';
const price = s.price || 0;
const chg = s.change_pct || 0;
const priceDisplay = price ? price.toFixed(2) : '—';
// 构建完整策略显示
let detailLines = [];
if (action) detailLines.push(action);
if (tech) detailLines.push('📊 ' + tech);
if (sector) detailLines.push('🏭 ' + sector);
const detailText = detailLines.join(' | ');
// 完整分析(点击展开)
const fullAnalysis = s.full_analysis || '';
const detailDisplay = fullAnalysis ?
detailText + '<br><span class="text-blue-400 cursor-pointer" onclick="showFullAnalysis(\'' + s.code + '\')">📋 查看完整12维分析</span>' :
detailText;
return `
<tr class="border-b border-slate-800/30 hover:bg-slate-800/20 cursor-pointer" onclick="openStock('${s.code}')">
<td class="p-3"><div class="font-medium text-white">${s.name}</div><div class="text-slate-500 text-[10px]">${s.code}</div></td>
<td class="p-3 text-right font-mono text-sm">${priceDisplay}</td>
<td class="p-3 text-right font-mono text-sm ${chg>=0?'text-green-400':'text-red-400'}">${chg>=0?'+':''}${chg.toFixed(2)}</td>
<td class="p-3 text-right font-mono text-xs text-slate-300">${buyZone}<br><span class="text-slate-500">损${sl}${tp}</span></td>
<td class="p-3 text-right font-mono text-sm ${rr>=1.5?'text-green-400':rr>0?'text-yellow-400':'text-slate-500'}">${rr>0?rr.toFixed(2):'—'}${(s.rr_high>0)?'<br><span class="text-[10px] text-slate-500">'+(s.rr_low||0).toFixed(2)+'~'+s.rr_high.toFixed(2)+'</span>':''}</td>
<td class="p-3 text-right"><span class="text-xs px-2 py-1 rounded ${signal.includes('买入')?'bg-green-900/50 text-green-300':signal.includes('关注')?'bg-yellow-900/50 text-yellow-300':'bg-slate-800 text-slate-400'}">${signal||'—'}</span></td>
<td class="p-3 text-right text-xs text-slate-400">${s.position_advice || '—'}</td>
</tr>
<tr class="border-b border-slate-800/20 hover:bg-slate-800/10 cursor-pointer" onclick="openStock('${s.code}')">
<td colspan="7" class="p-2 pl-3 text-xs text-slate-400 leading-relaxed">${detailDisplay || '—'}</td>
</tr>`;
}).join('')}</tbody>
</table>
</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'),
fetchJSON('/api/candidates'),
]).then(([mkt, signals, candidates]) => {
const sectors = mkt.sectors || [];
const sigs = signals || [];
const cands = candidates || [];
const trend = sigs.filter(s => s.source && s.source !== 'xiaoguo').concat(sigs.filter(s => s.source === 'market_scanner'));
el.innerHTML = `
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-semibold">🌐 市场全景 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('market','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('market','ai')">§</span><span class="spec-btn help" onclick="showModuleHelp('signals','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('signals','ai')">§</span></span></span>
</div>
<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">📊 信号汇总 (${sigs.length})</h3>
${sigs.length ? sigs.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 mb-4">
<h3 class="text-sm font-semibold mb-2">🎯 主力建仓候选 (${cands.length})</h3>
${cands.length ? `
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead><tr class="text-slate-400">
<th class="text-left p-1">代码</th><th class="text-left p-1">名称</th>
<th class="text-center p-1">S2</th><th class="text-center p-1">S3</th>
<th class="text-center p-1">S4</th><th class="text-center p-1">S5</th>
<th class="text-center p-1">终评</th><th class="text-left p-1">理由</th>
<th class="text-left p-1">状态</th>
</tr></thead>
<tbody>${cands.slice(0,15).map(c => {
const s2 = c.pass_s2 ? '✅' : c.score_2nd ? '❌' : '⏳';
const s3 = c.pass_s3 ? '✅' : c.score_3rd ? '❌' : '⏳';
const s4 = c.pass_s4 ? '✅' : c.score_4th ? '❌' : '⏳';
const s5 = c.pass_s5 ? '✅' : c.score_5th ? '❌' : '⏳';
const status = c.promoted ? '⭐已入自选' : '📋候选';
const log = c.log ? JSON.parse(c.log) : [];
const logSummary = log.slice(-2).map(function(l){ return 'S' + l.stage + ':' + (l.passed ? '✅' : '❌'); }).join(' ');
return '<tr class="border-b border-slate-800/50 hover:bg-slate-800/20">' +
'<td class="p-1 font-mono">' + c.code + '</td>' +
'<td class="p-1">' + c.name + '</td>' +
'<td class="p-1 text-center">' + s2 + '</td>' +
'<td class="p-1 text-center">' + s3 + '</td>' +
'<td class="p-1 text-center">' + s4 + '</td>' +
'<td class="p-1 text-center">' + s5 + '</td>' +
'<td class="p-1 text-center font-bold">' + (c.score_final||'-') + '</td>' +
'<td class="p-1 text-slate-400 max-w-[200px] truncate">' + (c.reason||'').slice(0,50) + '</td>' +
'<td class="p-1"><span class="' + (c.promoted ? 'text-yellow-400' : 'text-slate-400') + '">' + status + '</span>' +
'<span class="text-slate-600 block text-[10px]">' + logSummary + '</span></td>' +
'</tr>';
}).join('')}</tbody>
</table>
</div>
` : '<div class="text-xs text-slate-500">暂无候选(开盘后自动扫描)</div>'}
</div>
<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);
}
// ── Evaluation Tab ──
let evalTimer = null;
let evalBusy = false;
function renderEvaluation() {
if (evalBusy) return;
evalBusy = true;
const el = document.getElementById('tab-evaluation');
if (evalTimer) { clearInterval(evalTimer); evalTimer = null; }
fetchJSON('/api/tracking').then(data => {
const tracks = data.tracks || [];
const stats = data.stats || {};
const statusBadge = s => {
const m = {active:'🟡进行中',hit_tp:'🟢止盈',hit_sl:'🔴止损',expired:'⚫已过期',manual_close:'🔵手动平仓'};
return m[s] || s;
};
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">${stats.total||0} 条记录</span>
<span class="text-xs text-green-400">止盈 ${stats.hit_tp||0}</span>
<span class="text-xs text-red-400">止损 ${stats.hit_sl||0}</span>
<span class="text-xs text-yellow-400">进行中 ${stats.active||0}</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-2">股票</th>
<th class="text-left p-2">推荐时间</th>
<th class="text-right p-2">评分</th>
<th class="text-right p-2">RR</th>
<th class="text-right p-2">买入区(中值)</th>
<th class="text-right p-2">止损/止盈</th>
<th class="text-right p-2">推荐价</th>
<th class="text-right p-2">推荐仓位</th>
<th class="text-right p-2">理论盈亏</th>
<th class="text-right p-2">实际盈亏</th>
<th class="text-left p-2">状态</th>
</tr></thead>
<tbody>${tracks.length === 0 ? '<tr><td colspan="12" class="p-4 text-center text-slate-500">暂无追踪记录</td></tr>' : tracks.map(t => {
const ccy = (t.code||'').startsWith('0') ? 'HK$' : '¥';
const fmt = v => v ? ccy + Number(v).toFixed(2) : '—';
const mid = t.entry_mid || 0;
const sl = t.stop_loss || 0;
const tp = t.take_profit || 0;
const cp = t.current_price || 0;
const hasActual = !!(t.actual_action);
const rowBg = t.status === 'hit_tp' ? 'bg-green-900/10' : t.status === 'hit_sl' ? 'bg-red-900/10' : hasActual ? 'bg-blue-900/5' : '';
// ── 理论盈亏 ──
const tPnl = t.theoretical_pnl != null ? '<span class="' + (t.theoretical_pnl >= 0 ? 'text-red-400' : 'text-green-400') + '">' + (t.theoretical_pnl >= 0 ? '+' : '') + t.theoretical_pnl + '%</span>' : '—';
// ── 实际盈亏 ──
let actualCell = '—';
if (hasActual) {
const fp = t.floating_pnl != null ? (t.floating_pnl >= 0 ? '+' : '') + t.floating_pnl + '%' : '';
const fa = t.floating_amount != null ? ccy + (t.floating_amount >= 0 ? '+' : '') + Number(t.floating_amount).toFixed(0) : '';
actualCell = '<span class="' + (t.floating_pnl >= 0 ? 'text-red-400' : 'text-green-400') + '">' + fp + '</span><br><span class="text-slate-500" style="font-size:10px">' + fa + '</span>';
}
// ── 推荐时间 ──
let timeCell = (t.tracked_at||'').slice(0,16);
if (hasActual && t.actual_action) {
const m = t.actual_action.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})/);
if (m) timeCell += '<br><span class="text-blue-400">实操 ' + m[1] + '</span>';
}
// ── 买入区:低→高排列(策略区下/中/上 + 实操价)──
let zonePrices = [];
if (t.entry_low) zonePrices.push({v:t.entry_low, label:'下', cls:'text-slate-300'});
if (mid) zonePrices.push({v:mid, label:'中', cls:'text-amber-300'});
if (t.entry_high) zonePrices.push({v:t.entry_high, label:'上', cls:'text-slate-300'});
if (hasActual && t.actual_entry) zonePrices.push({v:t.actual_entry, label:'实', cls:'text-blue-400'});
zonePrices.sort((a,b) => a.v - b.v);
let zoneCell = zonePrices.map(p => '<span class="' + p.cls + '">' + fmt(p.v) + '</span>').join('<br>');
// ── 止损/止盈:低→高排列 + 当前价 ──
let sltpPrices = [];
if (sl) sltpPrices.push({v:sl, label:'损', cls:'text-red-400'});
if (cp) sltpPrices.push({v:cp, label:'现', cls:'text-white'});
if (tp) sltpPrices.push({v:tp, label:'盈', cls:'text-green-400'});
sltpPrices.sort((a,b) => a.v - b.v);
let sltpCell = sltpPrices.map(p => '<span class="' + p.cls + '">' + p.label + fmt(p.v) + '</span>').join('<br>');
// ── 仓位 ──
let posCell = t.position_advice ? String(t.position_advice).replace(/(系统按.*/,'') : '—';
if (hasActual && t.actual_shares) posCell += '<br><span class="text-blue-400">实操 ' + t.actual_shares + '股</span>';
return '<tr class="border-b border-slate-800/30 ' + rowBg + '">' +
'<td class="p-2"><span class="font-medium text-white">' + (t.name||'') + '</span><br><span class="text-slate-500">' + t.code + '</span></td>' +
'<td class="p-2 text-slate-400">' + timeCell + '</td>' +
'<td class="p-2 text-right font-mono ' + (t.rec_score>=70?'text-green-400':'text-amber-300') + '">' + (t.rec_score||'—') + '</td>' +
'<td class="p-2 text-right font-mono">' + (t.rr_ratio ? t.rr_ratio.toFixed(2) : '—') + '</td>' +
'<td class="p-2 text-right font-mono text-xs">' + zoneCell + '</td>' +
'<td class="p-2 text-right font-mono text-xs">' + sltpCell + '</td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + fmt(t.price_at_track) + '</td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + posCell + '</td>' +
'<td class="p-2 text-right font-mono">' + tPnl + '</td>' +
'<td class="p-2 text-right font-mono">' + actualCell + '</td>' +
'<td class="p-2">' + statusBadge(t.status) + (t.close_reason ? '<br><span class="text-slate-500">' + t.close_reason + '</span>' : '') + '</td>' +
'</tr>';
}).join('')}</tbody>
</table>
</div>`;
if (!evalTimer) evalTimer = setInterval(renderEvaluation, 30000);
evalBusy = false;
}).catch(() => { evalBusy = false; });
}
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>`;
// ── 当前策略卡(12维参数 + 三值RR + 操作建议)──
const sd = stockData || {};
const hasStrategy = sd.action || sd.entry_low || sd.timing_signal;
if (hasStrategy) {
const rrMid = sd.rr_ratio || 0, rrLo = sd.rr_low || 0, rrHi = sd.rr_high || 0;
const rrTxt = rrMid ? `${rrMid.toFixed(2)}${(rrHi>0)?` <span class="text-slate-500 text-[11px]">(下沿${rrLo.toFixed(2)} / 上沿${rrHi.toFixed(2)})</span>`:''}` : '—';
document.getElementById('modalStrategy').innerHTML = `
<h3 class="text-sm font-semibold text-slate-300 mb-2">🎯 当前策略 <span class="text-xs text-slate-500 font-normal">${(sd.reassessed_at||'').slice(0,16)}</span></h3>
<div class="bg-slate-800/40 rounded-lg p-3 space-y-1.5 text-xs">
<div class="flex flex-wrap gap-x-4 gap-y-1 font-mono">
${sd.timing_signal?`<span>信号 <b class="${sd.timing_signal.includes('买入')?'text-green-400':sd.timing_signal.includes('卖出')?'text-red-400':'text-yellow-400'}">${sd.timing_signal}</b></span>`:''}
${sd.entry_low?`<span class="text-slate-300">区间 ${sd.entry_low} <b class="text-amber-300">${((sd.entry_low+sd.entry_high)/2).toFixed(2)}</b> ${sd.entry_high}</span>`:''}
${sd.stop_loss?`<span class="text-red-400">损 ${sd.stop_loss}</span>`:''}
${sd.take_profit?`<span class="text-green-400">盈 ${sd.take_profit}</span>`:''}
<span class="${rrMid>=1.5?'text-green-400':'text-yellow-400'}">RR ${rrTxt}</span>
${sd.position_advice?`<span class="text-blue-400">仓位 ${sd.position_advice}</span>`:''}
</div>
${sd.action?`<div class="text-slate-200 leading-relaxed border-t border-slate-700/50 pt-1.5">${sd.action}</div>`:''}
</div>`;
} else {
document.getElementById('modalStrategy').innerHTML = '';
}
// ── 12维完整分析(折叠)──
if (sd.full_analysis) {
document.getElementById('modalAnalysis').innerHTML = `
<details class="bg-slate-800/30 rounded-lg">
<summary class="text-sm font-semibold text-slate-300 p-3 cursor-pointer select-none">📋 12维完整分析(点击展开)</summary>
<pre class="text-xs text-slate-300 leading-relaxed whitespace-pre-wrap p-3 pt-0 font-sans">${sd.full_analysis}</pre>
</details>`;
} else {
document.getElementById('modalAnalysis').innerHTML = '';
}
// 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 (window._stockChart) { try { window._stockChart.destroy(); } catch(e) {} window._stockChart = null; }
if (stockData.price_history && stockData.price_history.length > 1) {
window._stockChart = 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">';
html += '<div class="flex items-center justify-between mb-2"><span class="text-sm font-semibold">📝 提示词管理 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp(\'prompts\',\'human\')">?</span><span class="spec-btn ai" onclick="showModuleHelp(\'prompts\',\'ai\')">§</span></span></span></div>';
// 分类筛选按钮
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">' +
'<div class="text-slate-400 mb-2">⚠️ 提示词注册表未初始化</div>' +
'<div class="text-xs text-slate-500">请在服务器运行 prompt_manager/init_registry.py 初始化注册表。</div>' +
'<div class="text-xs text-slate-600 mt-2">路径: /home/hmo/MoFin/prompt_manager/init_registry.py</div>' +
'</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">' +
'<div class="text-amber-400 mb-2">⚠️ 提示词注册表加载失败</div>' +
'<div class="text-xs text-slate-400">错误: ' + (err.message || 'unknown') + '</div>' +
'<div class="text-xs text-slate-500 mt-2">请确认服务器已运行 prompt_manager/init_registry.py 初始化注册表。</div>' +
'</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, candidates] = await Promise.all([
fetchJSON('/api/signals'),
fetchJSON('/api/candidates')
]);
let html = `
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-semibold">🔍 信号 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('signals','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('signals','ai')">§</span></span></span>
</div>
<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">📡 趋势信号: ${signals.length || 0} 条</span>
<span class="text-green-400">🎯 候选股: ${candidates.length || 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">📋 候选池: ${candidates.length || 0} 只</span>
<span class="text-yellow-400">🏆 已入自选: ${candidates.filter(c => c.promoted).length || 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 === 'market_scanner' ? '🎯' : source === 'macro' ? '🌐' : '📡';
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>`;
}
}
// ── Spec System ──
let _specCache = {};
function closeSpecModal() {
const el = document.getElementById('specModal');
if (el) { el.classList.add('hidden'); el.classList.remove('flex'); }
}
function showModuleHelp(module, type) {
const cacheKey = module + ':' + type;
const cached = _specCache[cacheKey];
const modal = document.getElementById('specModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
// Show loading
document.getElementById('specModalTitle').textContent = '加载中...';
document.getElementById('specModalSubtitle').textContent = '';
document.getElementById('specModalBody').innerHTML = '<div class="text-slate-500">正在获取规范...</div>';
const url = `/api/module-spec/${module}`;
const doRender = function(spec) {
if (spec.error) {
document.getElementById('specModalBody').innerHTML = `<div class="text-red-400">获取失败: ${spec.error}</div>`;
return;
}
_specCache[cacheKey] = spec;
_renderSpecModal(spec, type);
};
if (cached) {
doRender(cached);
} else {
fetchJSON(url).then(spec => {
if (spec && typeof spec === 'object') {
_specCache[cacheKey] = spec;
doRender(spec);
} else {
document.getElementById('specModalBody').innerHTML = '<div class="text-slate-500">无规范数据</div>';
}
});
}
}
function _renderSpecModal(spec, type) {
const titleEl = document.getElementById('specModalTitle');
const subEl = document.getElementById('specModalSubtitle');
const bodyEl = document.getElementById('specModalBody');
let html = '';
if (type === 'human') {
const h = spec.human_help || {};
titleEl.textContent = h.title || spec.module || module;
subEl.textContent = 'Human Help · 使用指南';
if (h.description && h.description.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#58a6ff] mb-1">说明</div><p class="text-[#c9d1d9]">' + h.description.join('<br>') + '</p></div>';
}
if (h.usage && h.usage.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#3fb950] mb-1">用法</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
h.usage.forEach(u => { html += '<li>' + u + '</li>'; });
html += '</ul></div>';
}
if (h.troubleshooting && h.troubleshooting.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#d29922] mb-1">故障排除</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
h.troubleshooting.forEach(t => { html += '<li>' + t + '</li>'; });
html += '</ul></div>';
}
} else {
const a = spec.ai_spec || {};
titleEl.textContent = spec.module || module;
subEl.textContent = 'AI Spec · 模块规范';
if (a.apis && a.apis.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#58a6ff] mb-1">APIs</div>';
a.apis.forEach(api => {
const method = api.method || 'GET';
const color = method === 'GET' ? 'text-[#3fb950]' : method === 'POST' ? 'text-[#d29922]' : method === 'DELETE' ? 'text-[#f85149]' : 'text-[#58a6ff]';
html += '<div class="bg-[#0d1117] rounded p-2 mb-1 text-xs"><span class="font-mono font-bold ' + color + '">' + method + '</span> <span class="font-mono text-[#c9d1d9]">' + (api.path || '') + '</span>';
if (api.description) html += '<div class="text-[#8b949e] mt-0.5">' + api.description + '</div>';
html += '</div>';
});
html += '</div>';
}
if (a.dependencies && a.dependencies.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#3fb950] mb-1">依赖</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
a.dependencies.forEach(d => { html += '<li>' + d + '</li>'; });
html += '</ul></div>';
}
if (a.constraints && a.constraints.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#d29922] mb-1">约束</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
a.constraints.forEach(c => { html += '<li>' + c + '</li>'; });
html += '</ul></div>';
}
if (a.must_not && a.must_not.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#f85149] mb-1">禁止</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
a.must_not.forEach(m => { html += '<li>' + m + '</li>'; });
html += '</ul></div>';
}
if (a.related_files && a.related_files.length) {
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#58a6ff] mb-1">相关文件</div><ul class="list-disc list-inside text-xs font-mono text-[#8b949e] space-y-1">';
a.related_files.forEach(f => { html += '<li>' + f + '</li>'; });
html += '</ul></div>';
}
}
if (!html) {
html = '<div class="text-slate-500">该模块暂无规范数据</div>';
}
bodyEl.innerHTML = html;
}
// ── Health Tab (native) ──
let healthRefreshInterval = null;
async function renderHealth() {
const el = document.getElementById('tab-health');
el.innerHTML = '<div class="text-slate-500 py-8">加载中...</div>';
try {
const [services, monitor] = await Promise.all([
fetchJSON('/api/services'),
fetchJSON('/api/monitor')
]);
// XMPP 通道健康(独立 fetch,不阻塞主流程)
let xmpp = null;
try { xmpp = await fetchJSON('/api/xmpp/health'); } catch(e) {}
// 最近 XMPP 对话日志(bot 挂钩 log_xmpp 后的真实聊天记录)
let xmppMsgs = [];
try { const md = await fetchJSON('/api/xmpp/messages'); xmppMsgs = md.messages || []; } catch(e) {}
const svcs = services.services || [];
const summary = services.summary || { ok: 0, total: 0 };
const tasks = monitor.tasks || [];
// Summary cards — 包含 XMPP 状态
let html = '<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">';
html += '<div class="card p-3 text-center"><div class="text-lg font-bold text-[#3fb950] font-mono" id="hSvcOk">' + (summary.ok || 0) + '</div><div class="text-xs text-slate-500">正常服务</div></div>';
html += '<div class="card p-3 text-center"><div class="text-lg font-bold text-white font-mono" id="hSvcTotal">' + (summary.total || 0) + '</div><div class="text-xs text-slate-500">总服务数</div></div>';
const xmppStatus = xmpp ? xmpp.status : 'unknown';
const xmppColor = xmppStatus === 'ok' ? '#3fb950' : xmppStatus === 'degraded' ? '#d29922' : '#f85149';
const xmppLabel = xmppStatus === 'ok' ? 'XMPP 正常' : xmppStatus === 'degraded' ? 'XMPP 降级' : xmppStatus === 'critical' ? 'XMPP 断联' : 'XMPP 无数据';
html += '<div class="card p-3 text-center"><div class="text-lg font-bold font-mono" id="hXmppAge" style="color:' + xmppColor + '">' + (xmpp ? (xmpp.last_message_age_sec > 0 ? Math.round(xmpp.last_message_age_sec/60) + 'm' : '--') : '--') + '</div><div class="text-xs text-slate-500" id="hXmppLabel">' + xmppLabel + '</div></div>';
html += '<div class="card p-3 text-center"><div class="text-lg font-bold text-[#d29922] font-mono" id="hXmppErr">' + (xmpp ? xmpp.error_rate_1h + '%' : '--') + '</div><div class="text-xs text-slate-500">XMPP 错误率(1h)</div></div>';
html += '</div>';
// Services by layer
const layers = {};
svcs.forEach(s => {
const layer = s.layer || 'other';
if (!layers[layer]) layers[layer] = [];
layers[layer].push(s);
});
html += '<div class="card overflow-hidden mb-4"><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></tr></thead><tbody>';
Object.keys(layers).sort().forEach(layer => {
layers[layer].forEach((s, i) => {
const ok = s.health?.ok;
const statusIcon = ok ? '🟢' : (ok === false ? '🔴' : '🟡');
const statusColor = ok ? 'text-[#3fb950]' : (ok === false ? 'text-[#f85149]' : 'text-[#d29922]');
const label = s.label || s.name || s.type || '';
const port = s.port || '-';
if (i === 0) {
html += '<tr class="border-b border-slate-800/30"><td class="p-3 font-semibold text-slate-300" rowspan="' + layers[layer].length + '">' + layer + '</td><td class="p-3">' + s.name + ' <span class="text-slate-500">' + label + '</span></td><td class="p-3 font-mono">' + port + '</td><td class="p-3 ' + statusColor + '" id="hSvc_' + s.name + '">' + statusIcon + (ok ? ' 正常' : (ok === false ? ' 异常' : ' 未知')) + '</td></tr>';
} else {
html += '<tr class="border-b border-slate-800/30"><td class="p-3">' + s.name + ' <span class="text-slate-500">' + label + '</span></td><td class="p-3 font-mono">' + port + '</td><td class="p-3 ' + statusColor + '" id="hSvc_' + s.name + '">' + statusIcon + (ok ? ' 正常' : (ok === false ? ' 异常' : ' 未知')) + '</td></tr>';
}
});
});
html += '</tbody></table></div>';
// Task status
if (tasks.length) {
html += '<div class="card overflow-hidden"><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></tr></thead><tbody>';
tasks.forEach(t => {
const st = t.status || '';
const sc = st === 'ok' || st === 'running' ? 'text-[#3fb950]' : st === 'error' ? 'text-[#f85149]' : st === 'pending' ? 'text-[#d29922]' : 'text-slate-500';
html += '<tr class="border-b border-slate-800/30"><td class="p-3">' + (t.name || '') + '</td><td class="p-3 ' + sc + '">' + st + '</td><td class="p-3 text-slate-500">' + (t.detail || '') + '</td></tr>';
});
html += '</tbody></table></div>';
}
html += '<div class="text-xs text-slate-600 mt-2" id="hUpdated">更新于 ' + (monitor.generated_at || new Date().toLocaleTimeString()) + '</div>';
// XMPP 通道详情
if (xmpp) {
html += '<div class="card p-4 mt-4"><h3 class="text-sm font-semibold text-[#58a6ff] mb-2">📡 XMPP 通道</h3>';
html += '<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">';
html += '<div><span class="text-slate-500">最后消息</span><br><span class="font-mono" id="hXmppDetail_age">' + (xmpp.last_message_age_sec > 0 ? Math.round(xmpp.last_message_age_sec) + '秒前' : '无数据') + '</span></div>';
html += '<div><span class="text-slate-500">1h错误率</span><br><span class="font-mono" id="hXmppDetail_err">' + xmpp.error_rate_1h + '%</span></div>';
html += '<div><span class="text-slate-500">ejabberd</span><br><span class="font-mono" id="hXmppDetail_eja">' + (xmpp.ejabberd || '?') + '</span></div>';
html += '<div><span class="text-slate-500">状态</span><br><span class="font-mono" id="hXmppDetail_st">' + xmpp.status + '</span></div>';
html += '</div>';
// Bot 活动
const ba = xmpp.bot_activity || {};
html += '<div class="grid grid-cols-3 gap-2 text-xs mt-3 pt-3 border-t border-slate-800/50">';
html += '<div><span class="text-slate-500">📩 入站</span><br><span class="font-mono text-[#58a6ff]" id="hBot_in">' + (ba.inbound || 0) + '</span></div>';
html += '<div><span class="text-slate-500">📤 出站</span><br><span class="font-mono ' + (ba.outbound ? 'text-[#3fb950]' : (ba.inbound ? 'text-[#f85149]' : 'text-slate-500')) + '" id="hBot_out">' + (ba.outbound || 0) + '</span></div>';
html += '<div><span class="text-slate-500">⚠️ 错误</span><br><span class="font-mono ' + (ba.errors ? 'text-[#f85149]' : 'text-slate-500') + '" id="hBot_err">' + (ba.errors || 0) + '</span></div>';
html += '</div>';
if (ba.last_error) {
const errAge = ba.last_error_age_sec >= 0 ? Math.round(ba.last_error_age_sec/60) + '分钟前' : '';
if (ba.last_error_resolved) {
html += '<div class="text-xs text-slate-500 mt-2 p-2 bg-slate-800/30 rounded" id="hBot_lastErr">⚠️ 历史错误(' + errAge + ',已恢复): ' + ba.last_error + '</div>';
} else {
html += '<div class="text-xs text-[#f85149] mt-2 p-2 bg-red-900/20 rounded" id="hBot_lastErr">⚠️ ' + errAge + ': ' + ba.last_error + '</div>';
}
}
// Hermes Gateway + LLM Provider
const gw = xmpp.gateways || {};
const llm = xmpp.llm_provider || {};
html += '<div class="grid grid-cols-2 gap-2 text-xs mt-3 pt-3 border-t border-slate-800/50">';
html += '<div><span class="text-slate-500">🔌 Hermes Gateway</span><br>';
Object.entries(gw).forEach(([k,v]) => {
html += '<span class="font-mono ' + (v.alive ? 'text-[#3fb950]' : 'text-[#f85149]') + '">' + k + ':' + v.port + (v.alive ? ' ✅' : ' ❌') + '</span> ';
});
html += '</div>';
html += '<div><span class="text-slate-500">🧠 LLM Provider</span><br><span class="font-mono ' + (llm.status === 'ok' ? 'text-[#3fb950]' : 'text-[#f85149]') + '" id="hLlmStatus">' + (llm.status || '?') + '</span>';
if (llm.error) html += '<br><span class="text-[#f85149]" id="hLlmErr">' + llm.error + '</span>';
html += '</div></div>';
// ── 最近 XMPP 对话日志 ──
html += '<div class="mt-3 pt-3 border-t border-slate-800/50">';
html += '<div class="text-xs text-slate-500 mb-2">💬 最近对话(' + xmppMsgs.length + ' 条)</div>';
if (xmppMsgs.length === 0) {
html += '<div class="text-xs text-slate-600 p-2">暂无记录 — bot 重启后对话开始采集</div>';
} else {
html += '<div class="space-y-1 max-h-64 overflow-y-auto" id="hXmppMsgs">';
xmppMsgs.slice(0, 10).forEach(m => {
const isIn = m.direction === 'in';
const arrow = isIn ? '📩' : '📤';
const dirColor = isIn ? 'text-[#58a6ff]' : 'text-[#3fb950]';
const statusMark = m.status === 'ok' ? '' : ' <span class="text-[#f85149]">[' + m.status + ']</span>';
const who = isIn ? (m.from || '').split('@')[0] : '知微';
const t = (m.timestamp || '').slice(11, 16);
const preview = (m.body_preview || '').replace(/</g, '&lt;').slice(0, 80);
html += '<div class="text-xs p-1.5 rounded bg-slate-800/40 flex gap-2"><span class="' + dirColor + ' shrink-0">' + arrow + ' ' + who + '</span><span class="text-slate-400 flex-1 break-all">' + preview + statusMark + '</span><span class="text-slate-600 shrink-0 font-mono">' + t + '</span></div>';
});
html += '</div>';
}
html += '</div>';
html += '</div>';
}
// ── 原有健康监控(功能树/Cron/数据实体/数据流)—— 恢复自 mofin_health.html ──
html += '<div class="card p-4 mt-4">';
html += '<div class="text-sm font-semibold text-slate-300 mb-2">📊 系统健康监控(功能树 · Cron · 数据实体 · 数据流)</div>';
html += '<iframe src="/mofin_health.html" style="width:100%;height:80vh;border:none;background:#0d1117;border-radius:8px;"></iframe>';
html += '</div>';
el.innerHTML = html;
el.dataset.rendered = '1'; // 标记已首渲
} catch(e) {
el.innerHTML = '<div class="text-red-400">加载健康状态失败: ' + e.message + '</div>';
}
}
// 增量刷新健康数据(不重建 DOM,无闪烁)
async function refreshHealth() {
const el = document.getElementById('tab-health');
if (!el || el.classList.contains('hidden') || !el.dataset.rendered) return;
try {
const [services, xmpp, msgData] = await Promise.all([
fetchJSON('/api/services'),
fetchJSON('/api/xmpp/health'),
fetchJSON('/api/xmpp/messages').catch(() => null)
]);
const svcs = (services.services || []);
const sum = services.summary || {};
// 更新卡片数字
const okEl = document.getElementById('hSvcOk');
const totalEl = document.getElementById('hSvcTotal');
const ageEl = document.getElementById('hXmppAge');
const errEl = document.getElementById('hXmppErr');
const labelEl = document.getElementById('hXmppLabel');
if (okEl) okEl.textContent = sum.ok || 0;
if (totalEl) totalEl.textContent = sum.total || 0;
if (ageEl && xmpp) ageEl.textContent = xmpp.last_message_age_sec > 0 ? Math.round(xmpp.last_message_age_sec/60) + 'm' : '--';
if (errEl && xmpp) errEl.textContent = xmpp.error_rate_1h + '%';
// 更新服务行状态
svcs.forEach(s => {
const cell = document.getElementById('hSvc_' + s.name);
if (cell) {
const ok = s.health?.ok;
cell.textContent = (ok ? '🟢' : (ok === false ? '🔴' : '🟡')) + (ok ? ' 正常' : (ok === false ? ' 异常' : ' 未知'));
cell.className = 'p-3 ' + (ok ? 'text-[#3fb950]' : (ok === false ? 'text-[#f85149]' : 'text-[#d29922]'));
}
});
// 更新 XMPP 详情 + Bot 活动
if (xmpp) {
const d_age = document.getElementById('hXmppDetail_age');
const d_err = document.getElementById('hXmppDetail_err');
const d_eja = document.getElementById('hXmppDetail_eja');
const d_st = document.getElementById('hXmppDetail_st');
if (d_age) d_age.textContent = xmpp.last_message_age_sec > 0 ? Math.round(xmpp.last_message_age_sec) + '秒前' : '无数据';
if (d_err) d_err.textContent = xmpp.error_rate_1h + '%';
if (d_eja) d_eja.textContent = xmpp.ejabberd || '?';
if (d_st) d_st.textContent = xmpp.status;
if (ageEl && xmpp) ageEl.style.color = xmpp.last_message_age_sec > 600 ? '#f85149' : '#3fb950';
if (labelEl && xmpp) {
const labels = {ok:'XMPP 正常', degraded:'XMPP 降级', critical:'XMPP 断联'};
labelEl.textContent = labels[xmpp.status] || 'XMPP 无数据';
}
// Bot 活动
const ba = xmpp.bot_activity || {};
const bi = document.getElementById('hBot_in'), bo = document.getElementById('hBot_out');
const be = document.getElementById('hBot_err'), bl = document.getElementById('hBot_lastErr');
if (bi) bi.textContent = ba.inbound || 0;
if (bo) { bo.textContent = ba.outbound || 0; bo.className = 'font-mono ' + (ba.outbound ? 'text-[#3fb950]' : (ba.inbound ? 'text-[#f85149]' : 'text-slate-500')); }
if (be) { be.textContent = ba.errors || 0; be.className = 'font-mono ' + (ba.errors ? 'text-[#f85149]' : 'text-slate-500'); }
if (bl) {
const errAge = ba.last_error_age_sec >= 0 ? Math.round(ba.last_error_age_sec/60) + '分钟前' : '';
if (ba.last_error && ba.last_error_resolved) {
bl.textContent = '⚠️ 历史错误(' + errAge + ',已恢复): ' + ba.last_error;
bl.className = 'text-xs text-slate-500 mt-2 p-2 bg-slate-800/30 rounded';
} else if (ba.last_error) {
bl.textContent = '⚠️ ' + errAge + ': ' + ba.last_error;
bl.className = 'text-xs text-[#f85149] mt-2 p-2 bg-red-900/20 rounded';
} else {
bl.textContent = '';
}
}
// LLM Provider
const llm = xmpp.llm_provider || {};
const ls = document.getElementById('hLlmStatus'), le = document.getElementById('hLlmErr');
if (ls) { ls.textContent = llm.status || '?'; ls.className = 'font-mono ' + (llm.status === 'ok' ? 'text-[#3fb950]' : 'text-[#f85149]'); }
if (le) le.textContent = llm.error || '';
// 最近对话列表
const msgBox = document.getElementById('hXmppMsgs');
if (msgBox && msgData && msgData.messages) {
let mh = '';
msgData.messages.slice(0, 10).forEach(m => {
const isIn = m.direction === 'in';
const arrow = isIn ? '📩' : '📤';
const dirColor = isIn ? 'text-[#58a6ff]' : 'text-[#3fb950]';
const statusMark = m.status === 'ok' ? '' : ' <span class="text-[#f85149]">[' + m.status + ']</span>';
const who = isIn ? (m.from || '').split('@')[0] : '知微';
const t = (m.timestamp || '').slice(11, 16);
const preview = (m.body_preview || '').replace(/</g, '&lt;').slice(0, 80);
mh += '<div class="text-xs p-1.5 rounded bg-slate-800/40 flex gap-2"><span class="' + dirColor + ' shrink-0">' + arrow + ' ' + who + '</span><span class="text-slate-400 flex-1 break-all">' + preview + statusMark + '</span><span class="text-slate-600 shrink-0 font-mono">' + t + '</span></div>';
});
msgBox.innerHTML = mh;
}
}
// 更新时间戳
const upd = document.getElementById('hUpdated');
if (upd) upd.textContent = '更新于 ' + new Date().toLocaleTimeString();
} catch(e) { /* 静默失败,保留旧数据 */ }
}
// ── 盯盘 ──
let watchTimer = null;
// 信号徽标颜色
function sigBadgeClass(sig) {
if (!sig) return 'bg-slate-800 text-slate-400';
const buy = ['买入','可买入','可加仓'];
const sell = ['卖出','止盈'];
if (buy.some(k => sig.includes(k))) return 'bg-green-900/50 text-green-300';
if (sell.some(k => sig.includes(k))) return 'bg-red-900/50 text-red-300';
return 'bg-yellow-900/50 text-yellow-300';
}
// 格式化策略摘要(2行截断)
function fmtStrategy(s) {
const action = s.action || '';
const advice = s.position_advice || '';
const parts = [];
if (action) parts.push(action);
if (advice) parts.push(advice);
if (parts.length === 0) return '—';
return parts.join(' · ');
}
// 格式化完整策略(modal 内用)
function fmtFullStrategy(s) {
const lines = [];
if (s.action) lines.push('操作: ' + s.action);
if (s.position_advice) lines.push('仓位建议: ' + s.position_advice);
if (s.entry_low || s.entry_high) {
const el2 = s.entry_low||0, eh2 = s.entry_high||0;
const mid2 = (el2 > 0 && eh2 > el2) ? ' 中值' + ((el2+eh2)/2).toFixed(2) : '';
lines.push('买入区间: ' + (s.entry_low||'—') + '~' + (s.entry_high||'—') + mid2);
}
if (s.stop_loss || s.take_profit) lines.push('止损: ' + (s.stop_loss||'—') + ' / 止盈: ' + (s.take_profit||'—'));
if (s.rr_ratio) lines.push('RR: ' + s.rr_ratio.toFixed(2));
if (s.timing_signal) lines.push('信号: ' + s.timing_signal);
if (s.full_analysis) lines.push('\n分析: ' + s.full_analysis);
return lines.join('\n');
}
// ── 盯盘筛选/排序状态 ──
const WATCH_SIGNALS = ['买入','可买入','可加仓','卖出','止盈','关注','观望','持有','弱势持有','信号不充分'];
let watchSigFilter = new Set(WATCH_SIGNALS); // 默认全选
let watchChipsInit = false;
function watchRenderChips() {
const host = document.getElementById('watchSigChips');
if (!host) return;
host.innerHTML = WATCH_SIGNALS.map(sig => {
const on = watchSigFilter.has(sig);
const cls = on
? 'bg-amber-500/25 text-amber-300 border-amber-500/50'
: 'bg-slate-800/50 text-slate-500 border-slate-700';
return `<button onclick="watchToggleSig('${sig}')" class="px-1.5 py-0.5 rounded border ${cls} transition">${sig}</button>`;
}).join('');
}
function watchToggleSig(sig) {
if (watchSigFilter.has(sig)) watchSigFilter.delete(sig);
else watchSigFilter.add(sig);
watchRenderChips();
renderWatch();
}
function watchSigAll(all) {
watchSigFilter = all ? new Set(WATCH_SIGNALS) : new Set();
watchRenderChips();
renderWatch();
}
function watchResetFilters() {
watchSigFilter = new Set(WATCH_SIGNALS);
document.getElementById('watchSortMode').value = 'default';
document.getElementById('watchMinRR').value = '';
watchRenderChips();
renderWatch();
}
let watchRendering = false;
async function renderWatch() {
if (watchRendering) return;
watchRendering = true;
const el = document.getElementById('watchContent');
if (!el) { watchRendering = false; return; }
try {
if (watchTimer) clearInterval(watchTimer);
if (!watchChipsInit) { watchRenderChips(); watchChipsInit = true; }
const data = await fetchJSON('/api/watch');
let stocks = data.stocks || [];
// ── 筛选:信号多选 + RR下限 ──
const minRR = parseFloat(document.getElementById('watchMinRR')?.value) || 0;
stocks = stocks.filter(s => {
const sig = s.timing_signal || '信号不充分';
if (!watchSigFilter.has(sig)) return false;
if (minRR > 0 && (s.rr_ratio || 0) < minRR) return false;
return true;
});
// ── 排序:默认=服务端分组序;覆盖模式按选定键全组内排序 ──
const sortMode = document.getElementById('watchSortMode')?.value || 'default';
const sorters = {
rr_desc: (a, b) => (b.rr_ratio || 0) - (a.rr_ratio || 0),
rr_asc: (a, b) => (a.rr_ratio || 0) - (b.rr_ratio || 0),
chg_desc: (a, b) => (b.change_pct || 0) - (a.change_pct || 0),
chg_asc: (a, b) => (a.change_pct || 0) - (b.change_pct || 0),
};
if (sorters[sortMode]) stocks = [...stocks].sort(sorters[sortMode]);
document.getElementById('watchCount').textContent = stocks.length;
document.getElementById('watchRefreshTime').textContent = new Date().toLocaleTimeString();
if (stocks.length === 0) {
el.innerHTML = '<div class="p-6 text-center text-slate-500 text-sm">当前筛选条件下暂无个股(调整筛选或点重置)</div>';
return;
}
// 分组渲染(推荐操作区域独立于持仓/自选,置顶且与XMPP推荐同步)
const groups = [
{ label: '🔥 推荐操作', g: 0 },
{ label: '💼 持仓策略', g: 1 },
{ label: '⭐ 自选策略', g: 2 },
];
let html = '<div class="overflow-x-auto"><table class="w-full text-xs">';
html += '<thead><tr class="text-slate-500 border-b border-slate-800/50">' +
'<th class="text-left p-2">股票</th>' +
'<th class="text-right p-2">现价</th>' +
'<th class="text-right p-2">涨跌%</th>' +
'<th class="text-right p-2">评分</th>' +
'<th class="text-left p-2">信号</th>' +
'<th class="text-right p-2">买入区间</th>' +
'<th class="text-right p-2">止损/止盈</th>' +
'<th class="text-right p-2">RR</th>' +
'<th class="text-right p-2">仓位</th>' +
'<th class="text-left p-2">当前操作策略</th>' +
'<th class="text-center p-2">操作策略</th>' +
'</tr></thead><tbody>';
for (const grp of groups) {
const items = stocks.filter(s => s.sort_group === grp.g);
if (items.length === 0) continue;
// 分组标题行(推荐操作区域独立视觉)
const hdrCls = grp.g === 0
? 'border-y border-amber-500/50 bg-amber-900/25'
: 'border-b border-slate-700';
html += '<tr class="' + hdrCls + '">' +
'<td colspan="10" class="p-2 font-semibold ' + (grp.g === 0 ? 'text-amber-300' : 'text-slate-300') + '">' + grp.label + ' (' + items.length + ')</td>' +
'</tr>';
for (const s of items) {
const p = s.price || 0;
const cp = s.change_pct || 0;
const sig = s.timing_signal || '';
const isRec = s.sort_group === 0; // 推荐行
const sigCls = sigBadgeClass(sig);
const chgColor = cp >= 0 ? 'text-red-400' : 'text-green-400';
const el_ = s.entry_low || 0;
const eh_ = s.entry_high || 0;
const sl_ = s.stop_loss || 0;
const tp_ = s.take_profit || 0;
const rr = s.rr_ratio || 0;
const score = s.rec_score || 0;
const scoreColor = score >= 70 ? 'text-green-400' : score >= 50 ? 'text-amber-300' : 'text-slate-400';
const shares = s.shares || 0;
const posPct = s.position_pct || 0;
const buyZone = (el_ && eh_)
? el_.toFixed(2) + ' <b class="text-amber-300">' + ((el_+eh_)/2).toFixed(2) + '</b> ' + eh_.toFixed(2)
: '—';
const sltp = (sl_ || tp_) ? '损' + (sl_||'—') + '/盈' + (tp_||'—') : '—';
const posDisplay = (isRec && s.suggested_position_pct)
? '建议' + s.suggested_position_pct + '%'
: (posPct ? posPct.toFixed(1) + '%' : '—');
// 当前操作策略摘要
const strat = fmtStrategy(s);
// 推荐行高亮 + 可执行/排队徽章
const rowCls = isRec ? 'bg-amber-900/15 border-l-2 border-l-amber-400' : 'border-b border-slate-800/30 hover:bg-slate-800/20';
let recBadge = '';
if (isRec) {
if (s.rec_exec === true) {
recBadge = '<span class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400 ml-1">💰可执行</span>';
} else if (s.rec_exec === false) {
recBadge = '<span class="text-xs px-1.5 py-0.5 rounded bg-slate-500/20 text-slate-400 ml-1">⏳排队</span>';
} else {
recBadge = '<span class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 ml-1">🔥推荐</span>';
}
}
// 推荐行策略文本加粗醒目
const stratCls = isRec ? 'font-semibold text-amber-200' : 'text-slate-300';
html += '<tr class="' + rowCls + '" onclick="openStock(\'' + s.code + '\')" style="cursor:pointer">' +
'<td class="p-2"><span class="font-medium text-white">' + s.name + '</span><br><span class="text-slate-500">' + s.code + '</span>' + recBadge + '</td>' +
'<td class="p-2 text-right font-mono">' + (p ? p.toFixed(2) : '—') + '</td>' +
'<td class="p-2 text-right font-mono ' + chgColor + '">' + (cp >= 0 ? '+' : '') + (cp ? cp.toFixed(2) : '—') + '%</td>' +
'<td class="p-2 text-right font-mono font-bold ' + scoreColor + '">' + (score || '—') + '</td>' +
'<td class="p-2 text-left"><span class="text-xs px-2 py-1 rounded ' + sigCls + '">' + (sig || '—') + '</span></td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + buyZone + '</td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + sltp + '</td>' +
'<td class="p-2 text-right font-mono ' + (rr >= 1.5 ? 'text-green-400' : rr > 0 ? 'text-yellow-400' : 'text-slate-500') + '">' + (rr ? rr.toFixed(2) : '—') + ((s.rr_high>0)?'<br><span class="text-[10px] text-slate-500">'+(s.rr_low||0).toFixed(2)+'~'+s.rr_high.toFixed(2)+'</span>':'') + '</td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + posDisplay + '</td>' +
'<td class="p-2 ' + stratCls + ' max-w-[280px] break-all">' + strat + '</td>' +
'<td class="p-2 text-center"><button class="text-xs px-2 py-1 rounded bg-blue-900/50 text-blue-400 hover:bg-blue-800/50 transition" onclick="event.stopPropagation();openStrategyHistory(\'' + s.code + '\')">📋 历史</button></td>' +
'</tr>';
}
}
html += '</tbody></table></div>';
// 更新时间 + 推荐预算
html += '<div class="text-xs text-slate-600 mt-1">' + (data.updated_at ? '数据: ' + data.updated_at : '') + '</div>';
if (data.budget_pct !== undefined) {
html += '<div class="text-xs text-amber-500/70 mt-1">💰 现金 ' + (data.cash/10000).toFixed(1) + '万 / 总资产 ' + (data.total_assets/10000).toFixed(1) + '万 — 推荐操作预算 ' + data.budget_pct + '%(已用 ' + data.rec_used_pct + '%</div>';
}
el.innerHTML = html;
// 自动刷新:每60秒
watchTimer = setInterval(renderWatch, 60000);
} catch(e) {
el.innerHTML = '<div class="p-6 text-red-400 text-sm">加载失败: ' + e.message + '</div>';
watchTimer = setInterval(renderWatch, 60000);
} finally {
watchRendering = false;
}
}
// ── 操作策略历史 Modal ──
async function openStrategyHistory(code) {
const modal = document.getElementById('strategyModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
document.getElementById('strategyModalTitle').textContent = '加载中...';
document.getElementById('strategyModalMeta').textContent = '';
document.getElementById('strategyModalBody').innerHTML = '<div class="text-slate-500">正在获取策略历史...</div>';
try {
const d = await fetchJSON('/api/strategy_history/' + code + '?limit=3');
const history = d.history || [];
if (history.length === 0) {
document.getElementById('strategyModalTitle').textContent = '操作策略历史';
document.getElementById('strategyModalMeta').textContent = code + ' · 无历史记录';
document.getElementById('strategyModalBody').innerHTML = '<div class="text-slate-500 text-center py-8">暂无策略历史记录</div>';
return;
}
// 找出股票名称
const name = history[0].name || code;
document.getElementById('strategyModalTitle').textContent = name + ' · ' + code;
document.getElementById('strategyModalMeta').textContent = '最近 ' + history.length + ' 条策略记录';
let body = '';
// 最新一条默认展开,其余可折叠
for (let i = 0; i < history.length; i++) {
const h = history[i];
const isLatest = i === 0;
const collapsed = !isLatest ? ' hidden' : '';
const toggleId = 'strat-' + i;
// 策略头信息
const headInfo = [];
if (h.snapshotted_at) headInfo.push('快照: ' + h.snapshotted_at.slice(0, 16));
if (h.reassessed_at) headInfo.push('重评: ' + h.reassessed_at.slice(0, 16));
if (h.version) headInfo.push('版本: ' + h.version);
if (h.is_current) headInfo.push('⚡ 当前策略');
const action = h.action || '';
const timing = h.timing_signal || '';
const sigCls = sigBadgeClass(timing);
body += '<div class="card p-4">' +
'<div class="flex justify-between items-start mb-2">' +
'<div>' +
'<span class="font-semibold text-white">' + (action || '—') + '</span>' +
'<span class="text-xs px-2 py-0.5 rounded ml-2 ' + sigCls + '">' + (timing || '—') + '</span>' +
'<div class="text-xs text-slate-500 mt-1">' + headInfo.join(' · ') + '</div>' +
'</div>' +
(isLatest ? '' : '<button class="text-xs text-blue-400 hover:text-blue-300" onclick="toggleStrat(\'' + toggleId + '\')">展开</button>') +
'</div>';
// 策略参数
const params = [];
if (h.entry_low || h.entry_high) {
const el3 = h.entry_low||0, eh3 = h.entry_high||0;
const mid3 = (el3 > 0 && eh3 > el3) ? ' 中值' + ((el3+eh3)/2).toFixed(2) : '';
params.push('买入区间: ' + (h.entry_low||'—') + '~' + (h.entry_high||'—') + mid3);
}
if (h.stop_loss) params.push('止损: ¥' + h.stop_loss);
if (h.take_profit) params.push('止盈: ¥' + h.take_profit);
if (h.rr_ratio) params.push('RR: ' + h.rr_ratio.toFixed(2));
if (h.position_advice) params.push('仓位: ' + h.position_advice);
if (params.length > 0) {
body += '<div class="text-xs text-slate-300 mb-2">' + params.join(' · ') + '</div>';
}
// 完整分析(折叠)
const fa = h.full_analysis || '';
if (fa) {
body += '<details class="text-xs">' +
'<summary class="text-slate-400 cursor-pointer hover:text-blue-400">📋 完整分析</summary>' +
'<pre class="mt-1 text-slate-300 whitespace-pre-wrap bg-slate-900/50 rounded p-2 max-h-48 overflow-y-auto font-sans leading-relaxed">' + fa.replace(/</g, '&lt;') + '</pre>' +
'</details>';
}
body += '</div>';
}
document.getElementById('strategyModalBody').innerHTML = body;
} catch(e) {
document.getElementById('strategyModalTitle').textContent = '操作策略历史';
document.getElementById('strategyModalMeta').textContent = code;
document.getElementById('strategyModalBody').innerHTML = '<div class="text-red-400">加载失败: ' + e.message + '</div>';
}
}
function closeStrategyModal() {
document.getElementById('strategyModal').classList.add('hidden');
document.getElementById('strategyModal').classList.remove('flex');
}
function toggleStrat(id) {
const el = document.getElementById(id);
if (el) {
el.classList.toggle('hidden');
}
}
// ── 开发原则 Tab(仿 AgentsMeeting: G规范/K测试/F健康/H需求)──
let _princLoaded = {};
function renderPrinciples() {
// 绑定子tab按钮
document.querySelectorAll('.princ-btn').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('.princ-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.princ-pane').forEach(p => p.classList.add('hidden'));
btn.classList.add('active');
const id = btn.dataset.princ;
document.getElementById('princ-' + id).classList.remove('hidden');
_loadPrinc(id);
};
});
// 默认加载当前激活的子tab
const active = document.querySelector('.princ-btn.active');
_loadPrinc(active ? active.dataset.princ : 'spec');
}
function _loadPrinc(id) {
if (id === 'spec') return _renderPrincSpec();
if (id === 'tests') return _renderPrincTests();
if (id === 'health') return _renderPrincHealth();
if (id === 'prd') return _renderPrincPrd();
}
async function _renderPrincSpec() {
const el = document.getElementById('princ-spec');
el.innerHTML = '<div class="text-slate-500 py-4">加载中...</div>';
try {
const d = await fetchJSON('/api/spec');
if (!d.ok) { el.innerHTML = '<div class="text-red-400 py-4">' + (d.error || '加载失败') + '</div>'; return; }
let h = '<div class="card p-5">' + mdRender(d.content) + '</div>';
try {
const dh = await fetchJSON('/api/spec/history');
if (dh.ok && dh.log && dh.log.length) {
h += '<div class="card p-4 mt-3"><div class="text-sm font-semibold text-slate-300 cursor-pointer" onclick="var e=document.getElementById(\'specHist\');e.classList.toggle(\'hidden\')">历史版本 <span class="text-slate-500 text-xs">(' + dh.count + ' commits)</span></div><div id="specHist" class="hidden mt-2 text-xs text-slate-500 font-mono space-y-1">';
dh.log.forEach(l => { h += '<div>' + l.replace(/</g, '&lt;') + '</div>'; });
h += '</div></div>';
}
} catch(e) {}
el.innerHTML = h;
} catch(e) { el.innerHTML = '<div class="text-red-400 py-4">加载失败: ' + e.message + '</div>'; }
}
async function _renderPrincTests() {
const el = document.getElementById('princ-tests');
el.innerHTML = '<div class="text-slate-500 py-4">加载中...</div>';
try {
const d = await fetchJSON('/api/tests');
if (!d.ok) { el.innerHTML = '<div class="text-red-400 py-4">' + (d.error || '加载失败') + '</div>'; return; }
const s = d.summary || {};
let h = '<div class="grid grid-cols-3 gap-3 mb-4">';
h += '<div class="card p-3 text-center"><div class="text-2xl font-bold text-white font-mono">' + (s.total||0) + '</div><div class="text-xs text-slate-500">Total</div></div>';
h += '<div class="card p-3 text-center"><div class="text-2xl font-bold text-[#3fb950] font-mono">' + (s.passed||0) + '</div><div class="text-xs text-slate-500">Passed</div></div>';
h += '<div class="card p-3 text-center"><div class="text-2xl font-bold text-[#f85149] font-mono">' + (s.failed||0) + '</div><div class="text-xs text-slate-500">Failed</div></div>';
h += '</div>';
const fails = (d.tests||[]).filter(t => !t.ok);
if (fails.length) {
h += '<div class="text-sm font-semibold text-[#f85149] mb-2">Failed (' + fails.length + ')</div>';
fails.forEach(t => {
h += '<div class="card p-3 mb-2" style="border-color:#f85149"><span class="text-xs px-1.5 py-0.5 rounded bg-[#f85149] text-white font-bold">FAIL</span> <strong class="text-sm">' + t.name.replace(/</g,'&lt;') + '</strong><div class="text-xs text-slate-500 mt-1">' + (t.detail||'').replace(/</g,'&lt;') + '</div></div>';
});
}
const passes = (d.tests||[]).filter(t => t.ok);
h += '<div class="text-sm font-semibold text-[#3fb950] mb-2 mt-3">Passed (' + passes.length + ')</div>';
passes.forEach(t => {
h += '<div class="card p-2 mb-1"><span class="text-xs px-1.5 py-0.5 rounded bg-[#3fb950] text-white font-bold">PASS</span> <span class="text-sm">' + t.name.replace(/</g,'&lt;') + '</span> <span class="text-xs text-slate-500">' + (t.detail||'').replace(/</g,'&lt;') + '</span></div>';
});
h += '<div class="text-xs text-slate-500 mt-3">数据源: agents_health_check (cron */5min) · 最后运行: ' + (d.time||'?') + '</div>';
el.innerHTML = h;
} catch(e) { el.innerHTML = '<div class="text-red-400 py-4">加载失败: ' + e.message + '</div>'; }
}
function _renderPrincHealth() {
const el = document.getElementById('princ-health');
if (_princLoaded.health) return;
el.innerHTML = '<div class="card p-4"><div class="text-sm text-slate-400 mb-2">完整健康监控与 🏥 健康 Tab 同源。快捷入口:<a href="javascript:switchTab(\'health\')" class="text-[#58a6ff]">🏥 健康 Tab</a></div>' +
'<iframe src="/mofin_health.html" style="width:100%;height:80vh;border:none;background:#0d1117;border-radius:8px;"></iframe></div>';
_princLoaded.health = true;
}
async function _renderPrincPrd() {
const el = document.getElementById('princ-prd');
el.innerHTML = '<div class="text-slate-500 py-4">加载中...</div>';
try {
const d = await fetchJSON('/api/prd');
if (!d.ok) { el.innerHTML = '<div class="card p-6 text-center text-slate-500">' + (d.error || 'prd.md 尚未建立') + '</div>'; return; }
el.innerHTML = '<div class="card p-5">' + mdRender(d.content) + '</div>';
} catch(e) { el.innerHTML = '<div class="text-red-400 py-4">加载失败: ' + e.message + '</div>'; }
}
// markdown 渲染(移植自 AgentsMeeting dashboard
function mdRender(md) {
if (!md) return '';
let h = '';
const blocks = md.split('\n\n');
for (const raw of blocks) {
const block = raw.trim();
if (!block) continue;
if (block.startsWith('```')) {
const code = block.replace(/^```[\s\S]*?\n/, '').replace(/```$/, '').trim();
h += '<pre class="font-mono text-xs" style="background:#0d1117;padding:12px;border-radius:6px;overflow-x:auto;border:1px solid #30363d">' + code.replace(/</g,'&lt;') + '</pre>';
continue;
}
if (block.startsWith('#### ')) { h += '<h4 class="text-[#58a6ff] font-semibold mt-4 mb-1 text-sm">' + inMd(block.slice(5).replace(/</g,'&lt;')) + '</h4>'; continue; }
if (block.startsWith('### ')) { h += '<h3 class="text-[#58a6ff] font-semibold mt-4 mb-1">' + inMd(block.slice(4).replace(/</g,'&lt;')) + '</h3>'; continue; }
if (block.startsWith('## ')) { h += '<h2 class="text-[#58a6ff] font-semibold mt-5 mb-2 text-lg border-b border-slate-800 pb-1">' + inMd(block.slice(3).replace(/</g,'&lt;')) + '</h2>'; continue; }
if (block.startsWith('# ')) { h += '<h1 class="text-[#58a6ff] font-bold mt-5 mb-2 text-xl">' + inMd(block.slice(2).replace(/</g,'&lt;')) + '</h1>'; continue; }
if (block.startsWith('> ')) { h += '<blockquote class="border-l-2 border-[#58a6ff] pl-3 py-1 my-2 text-slate-500 text-sm">' + inMd(block.slice(2).replace(/</g,'&lt;')) + '</blockquote>'; continue; }
if (block.startsWith('---') || block.startsWith('***')) { h += '<hr class="border-slate-800 my-3">'; continue; }
if (block.startsWith('- ') || block.startsWith('* ')) {
const items = block.split('\n');
h += '<ul class="list-disc pl-5 my-1 text-sm space-y-0.5">';
items.forEach(li => { const t = li.replace(/^[-*]\s*/, '').trim(); if (t) h += '<li>' + inMd(t.replace(/</g,'&lt;')) + '</li>'; });
h += '</ul>';
continue;
}
if (/^\d+\.\s/.test(block)) {
const items = block.split('\n');
h += '<ol class="list-decimal pl-5 my-1 text-sm space-y-0.5">';
items.forEach(li => { const t = li.replace(/^\d+\.\s*/, '').trim(); if (t) h += '<li>' + inMd(t.replace(/</g,'&lt;')) + '</li>'; });
h += '</ol>';
continue;
}
if (block.indexOf('|') >= 0 && block.indexOf('\n|') >= 0) {
const rows = block.split('\n');
let inTable = false;
rows.forEach(row => {
if (row.indexOf('---') >= 0) return;
const cells = row.split('|').filter(c => c.trim());
if (cells.length < 2) return;
if (!inTable) {
h += '<table class="my-2 text-xs w-full"><tr>' + cells.map(c => '<th class="px-2 py-1 border border-slate-700 text-slate-500 bg-slate-900">' + inMd(c.trim().replace(/</g,'&lt;')) + '</th>').join('') + '</tr>';
inTable = true;
} else {
h += '<tr>' + cells.map(c => '<td class="px-2 py-1 border border-slate-800">' + inMd(c.trim().replace(/</g,'&lt;')) + '</td>').join('') + '</tr>';
}
});
if (inTable) h += '</table>';
continue;
}
h += '<p class="text-sm my-1.5 leading-relaxed text-slate-300">' + inMd(block.replace(/</g,'&lt;')) + '</p>';
}
return h;
}
function inMd(t) {
return t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code class="bg-slate-900 px-1 rounded text-xs font-mono">$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-[#58a6ff]" target="_blank">$1</a>');
}
function renderResearch() {
const el = document.getElementById('tab-research');
if (!el) return;
el.innerHTML = '<div class="flex gap-2 items-center mb-4"><h2 class="text-lg font-bold">📋 策略研究 — 全流程回测</h2>' +
'<select id="btPeriod" class="bg-slate-800 text-sm rounded-lg px-2 py-1 border border-slate-700" onchange="runBacktest()">' +
'<option value="1m">近 1 个月</option>' +
'<option value="6m" selected>近 6 个月</option>' +
'<option value="1y">近 1 年</option>' +
'<option value="2y">近 2 年</option>' +
'</select>' +
'<button onclick="runBacktest()" class="px-3 py-1 bg-blue-600 hover:bg-blue-500 rounded-lg text-sm">▶ 运行</button></div>' +
'<div id="btResults" class="text-slate-400 text-sm">点击「运行」开始回测验证</div>';
window.btEl = el;
}
async function runBacktest() {
const period = document.getElementById('btPeriod')?.value || '6m';
const resultsEl = document.getElementById('btResults');
if (!resultsEl) return;
resultsEl.innerHTML = '<div class="text-blue-400 animate-pulse">⏳ 回测运行中(约 15-30 秒)...</div>';
try {
const resp = await fetch('/api/research/backtest?period=' + period);
const data = await resp.json();
if (data.error) { resultsEl.innerHTML = '<div class="text-red-400">错误: ' + data.error + '</div>'; return; }
displayBacktestResults(data);
} catch(e) {
resultsEl.innerHTML = '<div class="text-red-400">请求失败: ' + e.message + '</div>';
}
}
function displayBacktestResults(data) {
const el = document.getElementById('btResults');
if (!el) return;
const s = data.summary || {};
const winRate = s.win_rate || 0;
const winRateClass = winRate >= 50 ? 'text-green-400' : winRate >= 40 ? 'text-yellow-400' : 'text-red-400';
const sharpeClass = (s.sharpe_ratio || 0) >= 1 ? 'text-green-400' : (s.sharpe_ratio || 0) >= 0 ? 'text-yellow-400' : 'text-red-400';
let html = '<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">' +
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">测试周期</div><div class="text-sm font-mono">' + (data.period || '') + '</div></div>' +
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">初始资金</div><div class="text-sm font-mono">¥' + (s.capital_end ? (data.capital || 1000000).toLocaleString() : '100万') + '</div></div>' +
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">最终资金</div><div class="text-sm font-mono">¥' + (s.capital_end ? s.capital_end.toLocaleString() : '-') + '</div></div>' +
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">总交易</div><div class="text-sm font-mono">' + (s.total_trades || 0) + ' 笔</div></div>' +
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">胜率</div><div class="text-lg font-bold ' + winRateClass + '">' + s.win_rate + '%</div></div>' +
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">平均收益</div><div class="text-lg font-bold ' + ((s.avg_profit_pct || 0) >= 0 ? 'text-green-400' : 'text-red-400') + '">' + (s.avg_profit_pct || 0) + '%</div></div>' +
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">夏普比率</div><div class="text-lg font-bold ' + sharpeClass + '">' + (s.sharpe_ratio || 0).toFixed(2) + '</div></div>' +
'<div class="bg-slate-800/50 rounded-lg p-3"><div class="text-xs text-slate-500">最大回撤</div><div class="text-lg font-bold text-red-400">' + (s.max_drawdown_pct || 0).toFixed(1) + '%</div></div>' +
'</div>' +
'<div class="grid grid-cols-4 gap-2 mb-4 text-xs">' +
'<div class="bg-green-900/20 rounded p-2"><span class="text-green-400">✅ 盈利: ' + (s.wins || 0) + '</span> <span class="text-slate-500">均+' + (s.avg_win_pct || 0).toFixed(1) + '%</span></div>' +
'<div class="bg-red-900/20 rounded p-2"><span class="text-red-400">❌ 亏损: ' + (s.losses || 0) + '</span> <span class="text-slate-500">均' + (s.avg_loss_pct || 0).toFixed(1) + '%</span></div>' +
'<div class="bg-slate-800/30 rounded p-2"><span class="text-yellow-400">📊 盈亏比: ' + (s.profit_factor || 0).toFixed(2) + '</span></div>' +
'<div class="bg-slate-800/30 rounded p-2"><span class="text-slate-300">📈 筛选: ' + (data.total_stocks_screened || 0) + ' 只</span></div>' +
'</div>';
// Trade list
const trades = data.trades || [];
html += '<div class="mb-2 text-sm font-bold text-slate-300">交易明细(前 50 笔)</div>' +
'<div class="overflow-x-auto"><table class="w-full text-xs"><thead><tr class="text-slate-500 border-b border-slate-700">' +
'<th class="text-left px-2 py-1">代码</th><th class="text-left px-2 py-1">名称</th><th class="text-left px-2 py-1">买入日</th>' +
'<th class="text-right px-2 py-1">买入价</th><th class="text-right px-2 py-1">卖出价</th><th class="text-right px-2 py-1">收益</th>' +
'<th class="text-center px-2 py-1">结果</th><th class="text-right px-2 py-1">持仓</th><th class="text-right px-2 py-1">评分</th></tr></thead><tbody>';
for (const t of trades) {
const pnlClass = (t.profit_pct || 0) >= 0 ? 'text-green-400' : 'text-red-400';
const reasonMap = { 'target': '🎯 止盈', 'stop': '🛑 止损', 'keep': '⏳ 持仓中' };
html += '<tr class="border-b border-slate-800/50 hover:bg-slate-800/30">' +
'<td class="px-2 py-1 font-mono">' + (t.code || '') + '</td>' +
'<td class="px-2 py-1">' + (t.name || '') + '</td>' +
'<td class="px-2 py-1 font-mono">' + (t.entry_date || '') + '</td>' +
'<td class="text-right px-2 py-1 font-mono">' + (t.entry_price || 0).toFixed(2) + '</td>' +
'<td class="text-right px-2 py-1 font-mono">' + (t.exit_price || 0).toFixed(2) + '</td>' +
'<td class="text-right px-2 py-1 font-mono ' + pnlClass + '">' + (t.profit_pct || 0).toFixed(1) + '%</td>' +
'<td class="text-center px-2 py-1">' + (reasonMap[t.exit_reason] || t.exit_reason || '') + '</td>' +
'<td class="text-right px-2 py-1">' + (t.hold_days || 0) + 'd</td>' +
'<td class="text-right px-2 py-1">' + (t.score || 0) + '</td></tr>';
}
html += '</tbody></table></div>';
el.innerHTML = html;
}
</script>
</body>
</html>