Files
MoFin/static/index.html
T
hmo 9914ff9457 feat(盯盘): 推荐操作区域置顶独立 + tag与XMPP动作级信号自动同步
- mofin_db: write_holding_strategy 内置tag同步语义——动作级信号
  (买入/可买入/可加仓/卖出/止盈)→current_recommend; 信号降级→
  清除current_recommend; active_manual人工标记永不被自动流覆盖/清除
- 新增 sync_recommend_tag() 供裸SQL调用方
- batch_reassess.save_result / per_stock stage-2 调用同步
- 盯盘Tab: '重点推荐'更名'推荐操作', 区域独立琥珀色视觉, 置顶
同步语义: XMPP买入信号(ACTION级告警)的个股=推荐操作区域个股,
信号消失(重评降级)时区域同步消失
2026-07-21 23:31:57 +08:00

1727 lines
90 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; }
</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>
<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-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 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="modalChart" class="h-48 mb-4 bg-slate-900 rounded-xl p-2"></div>
<div>
<h3 class="text-sm font-semibold text-slate-300 mb-2">📜 操作建议历史</h3>
<div id="modalHistory" class="space-y-2"></div>
</div>
</div>
</div>
<!-- 提示词内容弹窗(独立,避免与stockModal冲突) -->
<div id="promptModal" class="fixed inset-0 modal-overlay hidden items-center justify-center z-50" onclick="if(event.target===this)closePromptModal()">
<div class="bg-[#1a1a24] border border-slate-700 rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto mx-4 p-6" onclick="event.stopPropagation()">
<div class="flex justify-between items-start mb-4">
<div>
<h2 id="promptModalTitle" class="text-lg font-bold text-white"></h2>
</div>
<button onclick="closePromptModal()" class="text-slate-500 hover:text-white text-xl">&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) {
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();
}
// ── Data Fetching ──
async function fetchJSON(url) {
try {
const r = await fetch(url);
return await r.json();
} catch(e) { return {}; }
}
async function refreshAll() {
document.getElementById('tab-overview').classList.add('loading');
const [overview, portfolio, watchlist, reports] = await Promise.all([
fetchJSON('/api/overview'),
fetchJSON('/api/portfolio'),
fetchJSON('/api/watchlist'),
fetchJSON('/api/reports'),
]);
portfolioData = portfolio;
watchlistData = watchlist;
reportsData = Array.isArray(reports) ? reports : [];
document.getElementById('updateTime').textContent = '更新 ' + new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit'});
document.getElementById('tab-overview').classList.remove('loading');
renderTab(document.querySelector('.tab-btn.active')?.dataset?.tab || 'overview');
}
// ── Overview Tab ──
function renderOverview() {
const el = document.getElementById('tab-overview');
fetchJSON('/api/overview').then(d => {
const h = d.top_movers || [];
const alertHtml = h.length ? h.map(s => `
<div class="flex items-center justify-between p-2 bg-slate-800/50 rounded-lg">
<span class="text-sm">${s.name||s.code}</span>
<span class="text-sm font-mono ${(s.change_pct||0)>=0?'badge-up':'badge-down'}">
${(s.change_pct||0)>=0?'+':''}${(s.change_pct||0).toFixed(2)}%
</span>
</div>
`).join('') : '<div class="text-xs text-slate-500">暂无大幅异动</div>';
el.innerHTML = `
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
${statCard('总资产', formatMoney(d.total_assets), '💰')}
${statCard('股票市值', formatMoney(d.stock_value), '📈')}
${statCard('仓位', (d.position_pct||0).toFixed(1)+'%', '📊')}
${statCard('总盈亏', formatMoney(d.total_pnl, true), (d.total_pnl||0)>=0?'🟢':'🔴')}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="card p-4">
<h3 class="text-sm font-semibold mb-3">⚡ 大幅异动 <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">📋 九维分析</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 buyZone = (s.entry_low||0) + '~' + (s.entry_high||0);
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 + '\')">📋 查看完整九维分析</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):'—'}</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 ──
function renderEvaluation() {
const el = document.getElementById('tab-evaluation');
Promise.all([
fetchJSON('/api/evaluation'),
fetchJSON('/api/stats/accuracy'),
]).then(([evals, stats]) => {
const p1 = stats.phase1 || {};
const p2 = stats.phase2 || {};
el.innerHTML = `
<div class="flex items-center gap-2 mb-4">
<span class="text-sm font-semibold">策略双维度评估 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('evaluation','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('evaluation','ai')">§</span></span></span>
<span class="text-xs bg-blue-900/50 text-blue-300 px-2 py-0.5 rounded-full">${evals.length} 只已评估</span>
<button class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 ml-auto" onclick="triggerEval()">🔄 重新评估</button>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<div class="card p-3 text-center">
<div class="text-lg font-bold text-green-400">${p1.correct||0}</div>
<div class="text-xs text-slate-500">阶段一 达标</div>
</div>
<div class="card p-3 text-center">
<div class="text-lg font-bold text-red-400">${p1.wrong||0}</div>
<div class="text-xs text-slate-500">阶段一 止损</div>
</div>
<div class="card p-3 text-center">
<div class="text-lg font-bold text-yellow-400">${p1.pending||0}</div>
<div class="text-xs text-slate-500">待验证</div>
</div>
<div class="card p-3 text-center">
<div class="text-lg font-bold text-white">${p1.accuracy_pct||0}%</div>
<div class="text-xs text-slate-500">准确率</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead><tr class="text-slate-500 border-b border-slate-800/50">
<th class="text-left p-3">股票</th><th class="text-left p-3">策略</th>
<th class="text-left p-3">理论盈亏</th><th class="text-left p-3">实际盈亏</th>
<th class="text-left p-3">状态</th>
</tr></thead>
<tbody>${evals.map(x => {
const adv = (x.advice_evaluation && x.advice_evaluation[0]) || {};
const t = x.theoretical || {};
const a = x.actual || {};
const status = t.status || 'safe';
const icon = status === 'take_profit_hit' ? '🟢' : status === 'stop_loss_hit' ? '🔴' : status === 'in_entry_zone' ? '📥' : '💤';
return `<tr class="stock-row border-b border-slate-800/30">
<td class="p-3"><div class="font-medium text-white">${x.name}</div><div class="text-slate-500">${x.code}</div></td>
<td class="p-3 text-slate-300">${x.current || (x.advice_evaluation && x.advice_evaluation[0] && x.advice_evaluation[0].current_advice || '') || '—'}</td>
<td class="p-3"><span class="${(t.theoretical_pnl_pct||0)>=0?'badge-up':'badge-down'}">${(t.theoretical_pnl_pct||0).toFixed(1)}%</span></td>
<td class="p-3"><span class="${(a.actual_pnl_pct||0)>=0?'badge-up':'badge-down'}">${(a.actual_pnl_pct||0).toFixed(1)}%</span></td>
<td class="p-3">${icon} ${status.replace(/_/g,' ')}</td>
</tr>`;
}).join('')}</tbody>
</table>
</div>
</div>
<div class="text-xs text-slate-600 mt-2">更新时间: ${stats.updated_at?.slice(0,16)||'—'}</div>
`;
// Load feedback
fetchJSON('/api/feedback').then(fb => {
const trend = fb.accuracy_trend || {};
const completed = fb.phase1_completed_count || 0;
const reassess = fb.reassess_needed_count || 0;
if (!completed && !reassess) return;
const fbEl = document.createElement('div');
fbEl.className = 'card p-4 mt-4';
fbEl.innerHTML = `
<div class="flex items-center gap-2 mb-3">
<span class="text-sm font-semibold">🔄 反馈闭环</span>
<span class="text-xs text-slate-500">趋势: ${trend.trend||'stable'}</span>
<span class="text-xs ${completed>0?'text-green-400':'text-slate-500'}">完成 ${completed}</span>
<span class="text-xs ${reassess>0?'text-yellow-400':'text-slate-500'}">重评 ${reassess}</span>
</div>
${(fb.feedback||[]).filter(f => f.adjustments?.length).slice(0,10).map(f => f.adjustments.map(a => `
<div class="flex items-start gap-2 p-2 bg-slate-800/30 rounded-lg mb-1 text-xs">
<span class="${a.type==='phase1_success'?'text-green-400':a.type==='phase1_failure'?'text-red-400':'text-yellow-400'}">${a.type==='phase1_success'?'✅':a.type==='phase1_failure'?'❌':'🔄'}</span>
<span class="text-slate-300">${a.message}</span>
</div>
`).join('')).join('')}
`;
el.appendChild(fbEl);
});
});
}
function triggerEval() {
const btn = document.querySelector('#tab-evaluation button');
btn.textContent = '⏳ 评估中...';
btn.disabled = true;
fetch('/api/evaluation/trigger', {method:'POST'}).then(r => r.json()).then(d => {
btn.textContent = '🔄 重新评估';
btn.disabled = false;
renderEvaluation();
});
}
// ── Stock Detail Modal ──
async function openStock(code) {
const el = document.getElementById('stockModal');
el.classList.remove('hidden');
el.classList.add('flex');
document.getElementById('modalTitle').textContent = '加载中...';
const [stockData] = await Promise.all([
fetchJSON(`/api/stock/${code}`),
]);
// Also find in portfolio
const inPortfolio = (portfolioData.holdings||[]).find(s => s.code === code);
const name = inPortfolio?.name || stockData.name || code;
document.getElementById('modalTitle').textContent = name;
document.getElementById('modalMeta').textContent = `${code} · ${inPortfolio?.sector||stockData.sector||''}`;
document.getElementById('modalInfo').innerHTML = inPortfolio ? `
<div><div class="text-xs text-slate-500">成本</div><div class="text-sm font-mono">${(inPortfolio.cost||0).toFixed(2)}</div></div>
<div><div class="text-xs text-slate-500">现价</div><div class="text-sm font-mono">${(inPortfolio.price||0).toFixed(2)}</div></div>
<div><div class="text-xs text-slate-500">仓位</div><div class="text-sm font-mono">${(inPortfolio.position_pct||0).toFixed(2)}%</div></div>
<div><div class="text-xs text-slate-500">盈亏</div><div class="text-sm font-mono ${((inPortfolio.price||0)-(inPortfolio.cost||0))>=0?'badge-up':'badge-down'}">
${formatMoney(((inPortfolio.price||0)-(inPortfolio.cost||0))*(inPortfolio.shares||0), true)}
</div></div>
` : `<div class="col-span-full text-xs text-slate-500">暂无持仓数据</div>`;
// Render recommendation history
const history = stockData.history || [];
document.getElementById('modalHistory').innerHTML = history.length ? history.map(h => `
<div class="flex items-start gap-2 p-2 bg-slate-800/30 rounded-lg">
<span class="text-xs text-slate-500 w-14 flex-shrink-0">${h.time?.slice(5,16)||''}</span>
<span class="text-xs font-mono w-14 flex-shrink-0">¥${h.price||'—'}</span>
<span class="text-xs flex-1">${h.recommendation||''}${h.reason?' — '+h.reason:''}</span>
</div>
`).join('') : '<div class="text-xs text-slate-500">暂无操作建议记录</div>';
// Mini chart (mock data if no real data)
const chartCanvas = document.getElementById('modalChart');
chartCanvas.innerHTML = '<canvas id="stockChart"></canvas>';
const ctx = document.getElementById('stockChart').getContext('2d');
if (stockData.price_history && stockData.price_history.length > 1) {
new Chart(ctx, {
type: 'line',
data: {
labels: stockData.price_history.map(p => p.date),
datasets: [{
label: '价格',
data: stockData.price_history.map(p => p.price),
borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.1)',
fill: true, tension: 0.3, pointRadius: 2,
}]
},
options: { responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: { display: false }, y: { ticks: { font: {size:10} } } }
}
});
} else {
chartCanvas.innerHTML = '<div class="flex items-center justify-center h-full text-xs text-slate-500">暂无价格历史数据</div>';
}
}
function closeStock() {
document.getElementById('stockModal').classList.add('hidden');
}
// ── Prompts Tab ──
function renderPrompts() {
const el = document.getElementById('tab-prompts');
el.innerHTML = '<div class="text-center text-slate-500 py-8">加载中...</div>';
Promise.all([
fetch('/api/prompts').then(r => r.json()),
fetch('/api/prompts/effectiveness').then(r => r.json()),
]).then(([data, effectiveness]) => {
const prompts = data.prompts || [];
const categories = data.categories || {};
let html = '<div class="space-y-4">';
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) lines.push('买入区间: ' + (s.entry_low||'—') + '~' + (s.entry_high||'—'));
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');
}
async function renderWatch() {
const el = document.getElementById('watchContent');
if (!el) return;
try {
if (watchTimer) clearInterval(watchTimer);
const data = await fetchJSON('/api/watch');
const stocks = data.stocks || [];
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-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 shares = s.shares || 0;
const posPct = s.position_pct || 0;
const buyZone = (el_ && eh_) ? el_.toFixed(1) + '~' + eh_.toFixed(1) : '—';
const sltp = (sl_ || tp_) ? '损' + (sl_||'—') + '/盈' + (tp_||'—') : '—';
const posDisplay = 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';
const recBadge = isRec ? '<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-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) : '—') + '</td>' +
'<td class="p-2 text-right font-mono text-slate-300">' + posDisplay + '</td>' +
'<td class="p-2 ' + stratCls + ' max-w-[200px] break-all line-clamp-2">' + 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>';
el.innerHTML = html;
// 自动刷新:每30秒
watchTimer = setInterval(renderWatch, 30000);
} catch(e) {
el.innerHTML = '<div class="p-6 text-red-400 text-sm">加载失败: ' + e.message + '</div>';
watchTimer = setInterval(renderWatch, 60000);
}
}
// ── 操作策略历史 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) params.push('买入区间: ' + (h.entry_low||'—') + '~' + (h.entry_high||'—'));
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>');
}
</script>
</body>
</html>