3138 lines
181 KiB
HTML
3138 lines
181 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>MoFin · 知微情报</title>
|
||
<script src="/tailwind.js"></script>
|
||
<script src="/chart.min.js"></script>
|
||
<style>
|
||
/* System font stack — no Google Fonts dependency */
|
||
* { 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; }
|
||
.spec-btn.colinfo { color: #58a6ff; background: rgba(88,166,255,0.1); }
|
||
.spec-btn.colinfo:hover { background: rgba(88,166,255,0.25); }
|
||
.spec-th-help { margin-left: 3px; display: inline-flex; gap: 3px; vertical-align: middle; }
|
||
.spec-th-col { display: flex; flex-direction: column; align-items: center; line-height: 1.1; }
|
||
.spec-th-label { white-space: nowrap; }
|
||
.spec-th-btns { display: inline-flex; gap: 3px; margin-top: 1px; }
|
||
.spec-th-btns .spec-btn { width: 15px; height: 15px; font-size: 10px; }
|
||
::-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; }
|
||
|
||
#alertFloat {
|
||
position: fixed; top: 12px; right: 12px; z-index: 9999;
|
||
max-width: 380px; min-width: 260px;
|
||
background: rgba(26,26,36,0.95); border: 1px solid #7f1d1d;
|
||
border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.6);
|
||
font-size: 12px; color: #e2e8f0; overflow: hidden;
|
||
transition: all 0.25s ease;
|
||
}
|
||
#alertFloat.collapsed { min-width: 0; background: transparent; border-color: transparent; box-shadow: none; }
|
||
#alertFloat .alert-head {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 8px 12px; cursor: pointer; user-select: none;
|
||
background: rgba(127,29,29,0.35); border-bottom: 1px solid rgba(127,29,29,0.4);
|
||
}
|
||
#alertFloat.collapsed .alert-head { background: #7f1d1d; border-bottom: none; border-radius: 12px; }
|
||
#alertFloat .alert-title { font-weight: 600; font-size: 12px; display: flex; align-items: center; gap: 6px; }
|
||
#alertFloat .alert-count {
|
||
background: #ef4444; color: white; border-radius: 999px;
|
||
padding: 1px 8px; font-size: 11px; font-weight: 700;
|
||
}
|
||
#alertFloat .alert-toggle { color: #fca5a5; font-size: 14px; }
|
||
#alertFloat .alert-body { max-height: 320px; overflow-y: auto; padding: 6px 0; }
|
||
#alertFloat .alert-item {
|
||
padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,0.06);
|
||
line-height: 1.5;
|
||
}
|
||
#alertFloat .alert-item:last-child { border-bottom: none; }
|
||
#alertFloat .alert-item .a-src { color: #fbbf24; font-size: 11px; font-weight: 600; }
|
||
#alertFloat .alert-item .a-title { color: #fecaca; font-weight: 600; }
|
||
#alertFloat .alert-item .a-detail { color: #94a3b8; font-size: 11px; }
|
||
#alertFloat .alert-item.critical { border-left: 3px solid #ef4444; }
|
||
#alertFloat .alert-item.error { border-left: 3px solid #f97316; }
|
||
#alertFloat .alert-item.warning { border-left: 3px solid #eab308; }
|
||
@keyframes alert-pop { 0% { opacity: 0; transform: translateY(-8px); } 100% { opacity: 1; transform: translateY(0); } }
|
||
#alertFloat.new-alert { animation: alert-pop 0.3s ease; }
|
||
|
||
</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="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>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="docs">📚 文档</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-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-docs" 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>
|
||
|
||
<!-- 浮动异常区(右上角,任何板块可见,2026-08-12 老莫指示) -->
|
||
<div id="alertFloat" class="collapsed" style="display:none" onclick="toggleAlertFloat(event)">
|
||
<div class="alert-head">
|
||
<span class="alert-title">⚠️ 异常 <span id="alertCount" class="alert-count" style="display:none">0</span></span>
|
||
<span class="alert-toggle">▾</span>
|
||
</div>
|
||
<div id="alertBody" class="alert-body" style="display:none"></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">×</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">×</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">×</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">×</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();
|
||
else if (name === 'docs') renderDocs();
|
||
}
|
||
|
||
// ── 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 : [];
|
||
renderAlerts(overview && overview.alerts ? overview.alerts : []);
|
||
// 右上角时间显示:来源时间说明
|
||
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>';
|
||
|
||
// ── 温区自适应横幅(2026-08-13 概览页置顶)──
|
||
let regimeHtml = '';
|
||
try {
|
||
const rw = d.regime_weights || null;
|
||
if (rw && rw.state) {
|
||
const regIcon = {trend_up:'📈', choppy:'〰️', trend_down:'📉'}[rw.state] || '🌐';
|
||
const regName = {trend_up:'趋势市', choppy:'震荡市', trend_down:'下跌市'}[rw.state] || rw.state;
|
||
const regColor = rw.state==='trend_up' ? 'text-emerald-400' : rw.state==='choppy' ? 'text-amber-400' : 'text-red-400';
|
||
const bandIcon = {panic:'🔴', fear:'🟠', neutral:'🟡', greed:'🟢', euphoria:'🔵'}[rw.temp_band] || '⚪';
|
||
const bandName = {panic:'恐慌', fear:'偏冷', neutral:'中性', greed:'偏热', euphoria:'亢奋'}[rw.temp_band] || rw.temp_band;
|
||
const activeList = (rw.active || []).slice(0, 6);
|
||
regimeHtml = '<div class="bg-indigo-900/25 border border-indigo-500/40 rounded-xl p-3 mb-4">' +
|
||
'<div class="flex items-center justify-between flex-wrap gap-2">' +
|
||
'<div class="flex items-center gap-2"><span class="text-lg">🌡️</span>' +
|
||
'<span class="font-bold ' + regColor + '">' + regIcon + ' ' + regName + '</span>' +
|
||
'<span class="text-xs text-slate-400">(平滑' + (rw.state_date||'') + ')</span></div>' +
|
||
'<div class="flex items-center gap-3 text-xs">' +
|
||
'<span>温度: <b>' + bandIcon + ' ' + bandName + '</b>' + (rw.temp_rsi ? ' <span class="text-slate-500">RSI=' + rw.temp_rsi + '</span>' : '') + '</span>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
(activeList.length ? '<div class="flex flex-wrap gap-1.5 mt-2">' + activeList.map(n => {
|
||
const w = rw.weights && rw.weights[n];
|
||
const wr = w && w.best_win_rate != null ? ' ' + w.best_win_rate + '%' : '';
|
||
return '<span class="text-[11px] bg-emerald-500/15 text-emerald-300 border border-emerald-500/30 rounded px-2 py-0.5"><span class="font-mono font-bold">' + n + '</span>' + wr + '</span>';
|
||
}).join('') + '</div>' : '') +
|
||
'<div class="text-[11px] text-slate-500 mt-1.5">策略按当前温区自动激活(strategy_weights.json),选股/买卖/重评已温区感知</div>' +
|
||
'</div>';
|
||
// ── 港股温区横幅(2026-08-14 港股接入,与A股分别呈现)──
|
||
try {
|
||
const hk = rw.markets && rw.markets.hk;
|
||
if (hk && hk.state) {
|
||
const hkRegIcon = {trend_up:'📈', choppy:'〰️', trend_down:'📉'}[hk.state] || '🌐';
|
||
const hkRegName = {trend_up:'趋势市', choppy:'震荡市', trend_down:'下跌市'}[hk.state] || hk.state;
|
||
const hkRegColor = hk.state==='trend_up' ? 'text-emerald-400' : hk.state==='choppy' ? 'text-amber-400' : 'text-red-400';
|
||
const hkActive = (hk.active || []).slice(0, 6);
|
||
regimeHtml += '<div class="bg-sky-900/25 border border-sky-500/40 rounded-xl p-3 mb-4">' +
|
||
'<div class="flex items-center justify-between flex-wrap gap-2">' +
|
||
'<div class="flex items-center gap-2"><span class="text-lg">🇭🇰</span>' +
|
||
'<span class="font-bold ' + hkRegColor + '">' + hkRegIcon + ' ' + hkRegName + '</span>' +
|
||
'<span class="text-xs text-slate-400">(港股·平滑' + (hk.state_date||'') + ')</span></div>' +
|
||
'<div class="flex items-center gap-3 text-xs">' +
|
||
'<span>温度: <b>' + (hk.temp_band||'?') + '</b></span>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
(hkActive.length ? '<div class="flex flex-wrap gap-1.5 mt-2">' + hkActive.map(n => {
|
||
return '<span class="text-[11px] bg-sky-500/15 text-sky-300 border border-sky-500/30 rounded px-2 py-0.5"><span class="font-mono font-bold">' + n + '</span></span>';
|
||
}).join('') + '</div>' : '<div class="text-[11px] text-slate-500 mt-2">当前温区无港股激活策略</div>') +
|
||
'<div class="text-[11px] text-slate-500 mt-1.5">港股策略按港股温区独立调度(hk_strategies),与A股分别呈现</div>' +
|
||
'</div>';
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
} catch(e) {}
|
||
|
||
el.innerHTML = regimeHtml + `
|
||
<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">×</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 showColumnHelp(colKey) {
|
||
// 显示策略表某列的含义说明(数据源:specs/strategy.json human_help.columns[colKey])
|
||
const cacheKey = 'strategy:human';
|
||
const cached = _specCache[cacheKey];
|
||
const modal = document.getElementById('specModal');
|
||
modal.classList.remove('hidden');
|
||
modal.classList.add('flex');
|
||
document.getElementById('specModalTitle').textContent = '加载中...';
|
||
document.getElementById('specModalSubtitle').textContent = '';
|
||
document.getElementById('specModalBody').innerHTML = '<div class="text-slate-500">正在获取列说明...</div>';
|
||
const render = function(spec) {
|
||
const cols = (spec && spec.human_help && spec.human_help.columns) || {};
|
||
const col = cols[colKey];
|
||
if (!col) {
|
||
document.getElementById('specModalBody').innerHTML = `<div class="text-red-400">未找到列说明: ${colKey}</div>`;
|
||
return;
|
||
}
|
||
document.getElementById('specModalTitle').textContent = col.label || colKey;
|
||
document.getElementById('specModalSubtitle').textContent = '列说明 · 这列是干嘛的';
|
||
let html = '<div class="mb-4"><div class="text-xs font-semibold text-[#58a6ff] mb-1">这列是什么</div><p class="text-[#c9d1d9]">' + (col.desc || '—') + '</p></div>';
|
||
if (col.see) html += '<div class="mb-2"><div class="text-xs font-semibold text-[#3fb950] mb-1">怎么读 / 注意</div><p class="text-[#8b949e]">' + col.see + '</p></div>';
|
||
html += '<div class="text-[10px] text-slate-600 mt-4">来源:specs/strategy.json → human_help.columns.' + colKey + '(SSOT)</div>';
|
||
document.getElementById('specModalBody').innerHTML = html;
|
||
};
|
||
if (cached) { render(cached); }
|
||
else {
|
||
fetchJSON('/api/module-spec/strategy').then(spec => {
|
||
if (spec && typeof spec === 'object') { _specCache[cacheKey] = spec; render(spec); }
|
||
else document.getElementById('specModalBody').innerHTML = '<div class="text-slate-500">无规范数据</div>';
|
||
});
|
||
}
|
||
}
|
||
|
||
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, '<').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, '<').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, '<') + '</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, '<') + '</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,'<') + '</strong><div class="text-xs text-slate-500 mt-1">' + (t.detail||'').replace(/</g,'<') + '</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,'<') + '</span> <span class="text-xs text-slate-500">' + (t.detail||'').replace(/</g,'<') + '</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,'<') + '</pre>';
|
||
continue;
|
||
}
|
||
if (block.startsWith('#### ')) { h += '<h4 class="text-[#58a6ff] font-semibold mt-4 mb-1 text-sm">' + inMd(block.slice(5).replace(/</g,'<')) + '</h4>'; continue; }
|
||
if (block.startsWith('### ')) { h += '<h3 class="text-[#58a6ff] font-semibold mt-4 mb-1">' + inMd(block.slice(4).replace(/</g,'<')) + '</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,'<')) + '</h2>'; continue; }
|
||
if (block.startsWith('# ')) { h += '<h1 class="text-[#58a6ff] font-bold mt-5 mb-2 text-xl">' + inMd(block.slice(2).replace(/</g,'<')) + '</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,'<')) + '</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,'<')) + '</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,'<')) + '</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,'<')) + '</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,'<')) + '</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,'<')) + '</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>');
|
||
}
|
||
|
||
// ── 📚 文档 Tab(2026-08-11 重构为左右布局:左侧栏目录 + 右侧内容)──
|
||
let _docsState = { currentPath: null, tree: null };
|
||
|
||
async function renderDocs() {
|
||
const el = document.getElementById('tab-docs');
|
||
if (!el) return;
|
||
el.innerHTML =
|
||
'<div class="flex gap-4" style="min-height:600px">' +
|
||
// 左侧栏:目录树
|
||
'<div class="w-64 flex-shrink-0 border-r border-slate-800 pr-3 overflow-y-auto" style="max-height:75vh">' +
|
||
'<div class="text-xs font-semibold text-slate-400 mb-2 uppercase tracking-wide sticky top-0 bg-slate-950 py-1">📚 文档目录</div>' +
|
||
'<div id="docs-tree" class="space-y-1 text-sm"></div>' +
|
||
'</div>' +
|
||
// 右侧:内容区
|
||
'<div class="flex-1 min-w-0 overflow-y-auto" style="max-height:75vh">' +
|
||
'<div id="docs-content" class="text-sm"></div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
// 首次加载:渲染目录树
|
||
await _renderDocsTree();
|
||
// 默认显示版本变更(运营者最关心的)
|
||
if (!_docsState.currentPath) {
|
||
await _docsRead('VERSIONS.md');
|
||
}
|
||
}
|
||
|
||
async function _renderDocsTree() {
|
||
const tree = document.getElementById('docs-tree');
|
||
const data = await fetchJSON('/api/docs/index');
|
||
if (!data.ok) {
|
||
tree.innerHTML = '<div class="text-red-400 text-xs">' + (data.error || '加载失败') + '</div>';
|
||
return;
|
||
}
|
||
_docsState.tree = data.index;
|
||
const byCat = {};
|
||
data.index.forEach(d => {
|
||
byCat[d.category] = byCat[d.category] || [];
|
||
byCat[d.category].push(d);
|
||
});
|
||
let html = '';
|
||
for (const [cat, docs] of Object.entries(byCat)) {
|
||
html += '<div class="mb-3">' +
|
||
'<div class="text-[11px] font-semibold text-slate-500 mb-1 uppercase tracking-wide">' + cat + '</div>' +
|
||
'<div class="space-y-0.5">' +
|
||
docs.map(d =>
|
||
'<div class="cursor-pointer hover:bg-slate-800/60 px-2 py-1 rounded text-slate-300 truncate" title="' + d.path + '" onclick="_docsRead(\'' + d.path + '\')">' +
|
||
(d.core ? '⭐ ' : '') + d.title + '</div>'
|
||
).join('') +
|
||
'</div></div>';
|
||
}
|
||
tree.innerHTML = html;
|
||
// 高亮当前选中项
|
||
_docsHighlight();
|
||
}
|
||
|
||
function _docsHighlight() {
|
||
document.querySelectorAll('#docs-tree [title]').forEach(el => el.classList.remove('bg-slate-800', 'text-blue-400'));
|
||
if (_docsState.currentPath) {
|
||
document.querySelectorAll('#docs-tree [title="' + _docsState.currentPath + '"]').forEach(el => {
|
||
el.classList.add('bg-slate-800', 'text-blue-400');
|
||
});
|
||
}
|
||
}
|
||
|
||
async function _docsRead(path) {
|
||
_docsState.currentPath = path;
|
||
_docsHighlight();
|
||
const content = document.getElementById('docs-content');
|
||
if (!content) return;
|
||
content.innerHTML = '<div class="text-slate-500 text-xs">加载中...</div>';
|
||
const data = await fetchJSON('/api/docs/read?path=' + encodeURIComponent(path));
|
||
if (!data.ok) {
|
||
content.innerHTML = '<div class="text-red-400">' + (data.error || '加载失败') + '</div>';
|
||
return;
|
||
}
|
||
// 版本变更用特殊排版(日期+标题+正文分段)
|
||
if (path === 'VERSIONS.md') {
|
||
content.innerHTML = _renderVersionsHTML(data.content);
|
||
} else {
|
||
content.innerHTML =
|
||
'<div class="mb-2 text-xs text-slate-500 border-b border-slate-800 pb-2">📄 ' + path + '</div>' +
|
||
'<div class="bg-slate-900/50 border border-slate-800 rounded-xl p-5">' + mdRender(data.content) + '</div>';
|
||
}
|
||
}
|
||
|
||
function _renderVersionsHTML(md) {
|
||
// 解析 VERSIONS.md:按 ## 分段,每段一个版本卡片
|
||
const parts = md.split(/^##\s+/m).filter(p => p.trim() && !p.startsWith('阅读指南') && !p.startsWith('维护说明'));
|
||
const cards = parts.map(p => {
|
||
const lines = p.split('\n');
|
||
const title = lines[0].trim();
|
||
const body = lines.slice(1).join('\n').trim();
|
||
return '<div class="mb-4 border border-slate-800 rounded-xl p-4 bg-slate-900/50">' +
|
||
'<div class="font-semibold text-slate-100 mb-2 text-base">' + title + '</div>' +
|
||
'<div class="text-slate-300 leading-relaxed text-[13px]">' + mdRender(body) + '</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
return '<div class="mb-2 text-xs text-slate-500 border-b border-slate-800 pb-2">📜 版本变更记录(给运营者的详细说明)</div>' + cards;
|
||
}
|
||
|
||
function renderResearch() {
|
||
const el = document.getElementById('tab-research');
|
||
if (!el) return;
|
||
// 2026-08-18 局部刷新(老莫):已渲染过骨架则不再整页重建(保留滚动/展开),只刷新数据
|
||
if (window._researchRendered) {
|
||
loadStrategyList();
|
||
loadEvolutionBox();
|
||
return;
|
||
}
|
||
window._researchRendered = true;
|
||
el.innerHTML =
|
||
'<div class="flex gap-2 items-center mb-3">' +
|
||
'<span class="text-sm font-semibold text-slate-300">📋 策略研究</span>' +
|
||
'<span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp(\'strategy\',\'human\')">?</span><span class="spec-btn ai" onclick="showModuleHelp(\'strategy\',\'ai\')">§</span></span>' +
|
||
'<span class="text-xs text-slate-500">按市场×温区分组 · 每表格=该市该温区所有可用策略 · 🟢=当前市激活 · 温度=当前温区</span>' +
|
||
'</div>' +
|
||
'<div class="flex gap-2 items-center mb-3">' +
|
||
'<button onclick="switchRsub(\'strategy\')" id="rsubtab-strategy" class="px-3 py-1 rounded text-sm bg-emerald-600/40 border border-emerald-600/60">📋 策略</button>' +
|
||
'<button onclick="switchRsub(\'evo\')" id="rsubtab-evo" class="px-3 py-1 rounded text-sm bg-slate-800 border border-slate-700">📊 策略评估</button>' +
|
||
'<span class="text-xs text-slate-500">策略效果评估(到期策略自动评估)</span>' +
|
||
'</div>' +
|
||
'<div id="rsub-strategy">' +
|
||
'<div id="evo-box" class="mb-3 bg-slate-800/60 border border-slate-700 rounded-lg p-3"><div class="text-xs text-slate-500">📊 评估模块加载中...</div></div>' +
|
||
'<div class="flex gap-2 items-center mb-3 flex-wrap">' +
|
||
'<h2 class="text-lg font-bold">📋 策略研究 — 版本迭代对比</h2>' +
|
||
'<select id="btPeriod" onchange="saveBtPeriod()" class="bg-slate-800 text-sm rounded-lg px-2 py-1 border border-slate-700">' +
|
||
'<option value="1m">近 1 个月</option><option value="6m">近 6 个月</option>' +
|
||
'<option value="1y">近 1 年</option><option value="2y">近 2 年</option><option value="5y">近 5 年</option><option value="10y">近 10 年</option></select>' +
|
||
'<select id="btMarket" class="bg-slate-800 text-sm rounded-lg px-2 py-1 border border-slate-700" onchange="loadStrategyList()">' +
|
||
'<option value="all">全部市场</option><option value="a">A股</option><option value="hk">港股</option></select>' +
|
||
'<span class="text-xs text-slate-500">每版策略基于上一版归因分析迭代 · 点击行展开明细</span></div>' +
|
||
'<div id="load-hint" class="hidden text-amber-300 text-sm font-semibold py-3 px-3 mb-1 rounded border border-amber-500/40 bg-amber-500/10 animate-pulse"></div>' +
|
||
'<div id="strategyList"><div class="text-slate-500 text-sm">加载中...</div></div>' +
|
||
'<div id="strategyDetail" class="mt-4"></div>' +
|
||
'</div>' +
|
||
'<div id="rsub-evo" class="hidden">' +
|
||
'<div class="text-xs text-slate-500 mb-2">📊 策略评估 — AB路线每日研究成果(LLM主导研究,产出策略默认不激活待审查)</div>' +
|
||
'<div id="evo-log-list"><div class="text-slate-500 text-sm">加载中...</div></div>' +
|
||
'</div>';
|
||
// 首次渲染即加载策略列表(无子Tab切换)。
|
||
// 2026-08-15 修复:不能在此直接 loadStrategyList()——此时 btPeriod.value 还是初始默认 '1m',
|
||
// 与下方恢复 savedPt='2y' 后的调用并发竞态,1m 响应后到会覆盖 2y 渲染(周期错乱+综合分被小样本置信打崩)。
|
||
loadEvolutionBox();
|
||
// 2026-08-18 子Tab切换 + 进化日志
|
||
window.switchRsub = function(name) {
|
||
const isStr = name === 'strategy';
|
||
document.getElementById('rsub-strategy').classList.toggle('hidden', !isStr);
|
||
document.getElementById('rsub-evo').classList.toggle('hidden', isStr);
|
||
const bs = document.getElementById('rsubtab-strategy'), be = document.getElementById('rsubtab-evo');
|
||
if (bs) bs.className = 'px-3 py-1 rounded text-sm ' + (isStr ? 'bg-emerald-600/40 border border-emerald-600/60' : 'bg-slate-800 border border-slate-700');
|
||
if (be) be.className = 'px-3 py-1 rounded text-sm ' + (isStr ? 'bg-slate-800 border border-slate-700' : 'bg-emerald-600/40 border border-emerald-600/60');
|
||
if (!isStr) loadEvoLog();
|
||
};
|
||
window.loadEvoLog = function() {
|
||
const el = document.getElementById('evo-log-list');
|
||
if (!el) return;
|
||
fetch('/api/research/log').then(r => r.json()).then(data => {
|
||
const rows = data.log || [];
|
||
if (!rows.length) { el.innerHTML = '<div class="text-slate-500 text-sm">暂无研究记录(每日AB研究任务运行后出现)</div>'; return; }
|
||
let html = '<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-left px-2 py-1">尝试</th>' +
|
||
'<th class="text-left px-2 py-1">结果</th><th class="text-left px-2 py-1">产出策略</th></tr></thead><tbody>';
|
||
rows.forEach(r => {
|
||
html += '<tr class="border-b border-slate-800/40"><td class="px-2 py-1">' + (r.log_date||'') + '</td>' +
|
||
'<td class="px-2 py-1">' + (r.market||'') + '/' + (r.weak_regime||'') + '</td>' +
|
||
'<td class="px-2 py-1 whitespace-normal">' + (r.finding||'') + '</td>' +
|
||
'<td class="px-2 py-1">' + (r.experiment||'') + '</td>' +
|
||
'<td class="px-2 py-1">' + (r.result||'') + '</td>' +
|
||
'<td class="px-2 py-1">' + (r.produced_strategy||'') + (r.produced_verified ? ' <span class="text-green-400">✅</span>' : '') + '</td></tr>';
|
||
});
|
||
el.innerHTML = html + '</tbody></table>';
|
||
}).catch(e => { el.innerHTML = '<div class="text-red-400 text-sm">加载失败: ' + e + '</div>'; });
|
||
};
|
||
// 恢复用户上次选择的周期(防自动刷新重置,2026-07-30老爸抓包)
|
||
const savedPt = window._btPeriodVal || '2y';
|
||
const ptSel = document.getElementById('btPeriod');
|
||
if (ptSel) ptSel.value = savedPt;
|
||
loadStrategyList();
|
||
}
|
||
|
||
function saveBtPeriod() {
|
||
const sel = document.getElementById('btPeriod');
|
||
if (sel) window._btPeriodVal = sel.value;
|
||
loadStrategyList();
|
||
}
|
||
|
||
async function loadStrategyList() {
|
||
window._stratScrollY = window.scrollY || 0; // 2026-08-18 触发渲染前保存滚动位置
|
||
const listEl = document.getElementById('strategyList');
|
||
// 2026-08-18 加载中提示(独立提示条,不破坏 strategyList 旧内容/滚动)
|
||
const hintEl = document.getElementById('load-hint');
|
||
if (hintEl) {
|
||
hintEl.textContent = '⏳ 加载中...';
|
||
hintEl.classList.remove('hidden');
|
||
window._hintShownAt = Date.now(); // 记录显示时刻,保证最小可见时长
|
||
}
|
||
try {
|
||
const ptMap = {'1m':'1m','6m':'6m','1y':'1y','2y':'2y','5y':'5y','10y':'10y'};
|
||
const pt = ptMap[window._btPeriodVal || document.getElementById('btPeriod')?.value || '2y'] || '2y';
|
||
const [resp, ovResp] = await Promise.all([
|
||
fetch('/api/research/strategies?period_tag=' + pt),
|
||
fetch('/api/overview').catch(() => null)
|
||
]);
|
||
const data = await resp.json();
|
||
if (data.error) { listEl.innerHTML = '<div class="text-red-400">' + data.error + '</div>'; return; }
|
||
// 2026-08-15 温区信息并入策略表(横幅取消):A股/港股温区状态+温度,供6表格当前市高亮与温度展示
|
||
const ov = ovResp ? await ovResp.json().catch(() => null) : null;
|
||
const rw = (ov && ov.regime_weights) || null;
|
||
window._regimeA = rw && rw.state ? {state: rw.state, temp_band: rw.temp_band, temp_rsi: rw.temp_rsi} : null;
|
||
const hkR = rw && rw.markets && rw.markets.hk;
|
||
window._regimeHK = hkR && hkR.state ? {state: hkR.state, temp_band: hkR.temp_band} : null;
|
||
window._regimeActive = data.regime_active || [];
|
||
window._regimeState = (rw && rw.state) || data.regime_state || 'unknown';
|
||
const mkt = document.getElementById('btMarket')?.value || 'all';
|
||
let strats = data.strategies || [];
|
||
// 市场语义:'all'=未分市场的A股回测(港股回填前跑的),'hk'=港股宇宙,'a'=显式A股
|
||
if (mkt === 'hk') strats = strats.filter(s => (s.market || 'all') === 'hk');
|
||
else if (mkt === 'a') strats = strats.filter(s => (s.market || 'all') !== 'hk');
|
||
// 2026-08-16 大盘基准(从任一策略 qualification 提取,供温区表头显示)
|
||
for (const s of strats) {
|
||
if (s.qualification) {
|
||
const anyQ = Object.values(s.qualification)[0];
|
||
if (anyQ && anyQ.bench_1y != null) {
|
||
window._bench1y = anyQ.bench_1y;
|
||
window._bench2y = anyQ.bench_2y;
|
||
window._bench10y = anyQ.bench_10y;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
renderStrategyTable(strats);
|
||
// 2026-08-18 最小显示400ms:即使fetch快也让用户看到加载提示
|
||
if (hintEl) {
|
||
const waited = Date.now() - (window._hintShownAt || 0);
|
||
if (waited < 400) {
|
||
setTimeout(() => hintEl.classList.add('hidden'), 400 - waited);
|
||
} else {
|
||
hintEl.classList.add('hidden');
|
||
}
|
||
}
|
||
} catch(e) {
|
||
listEl.innerHTML = '<div class="text-red-400">加载失败: ' + e.message + '</div>';
|
||
if (hintEl) { hintEl.textContent = '⚠️ 加载失败'; hintEl.classList.remove('hidden'); }
|
||
}
|
||
}
|
||
|
||
const ASC_DEFAULT = new Set(['max_dd']); // 回撤类:小=好,默认升序
|
||
async function loadEvolutionBox() {
|
||
const box = document.getElementById('evo-box');
|
||
if (!box) return;
|
||
try {
|
||
const r = await fetch('/api/research/effectiveness/summary');
|
||
const d = await r.json();
|
||
let h = '<div class="flex items-center justify-between mb-2"><span class="text-base font-semibold text-emerald-400">🧬 策略评估</span><button onclick="loadEvolutionBox()" class="text-sm text-blue-400 hover:text-blue-300">刷新</button></div>';
|
||
|
||
// ── 基线对比 ──
|
||
if (d.baseline) {
|
||
h += '<div class="grid grid-cols-3 gap-2 mb-3"><span class="text-sm text-slate-500 col-span-3">5年回测基线:</span>';
|
||
for (const [v, b] of Object.entries(d.baseline)) {
|
||
const clr = v === 'v_next4' ? 'text-emerald-400' : 'text-slate-300';
|
||
h += '<div class="bg-slate-900/50 rounded px-2 py-1"><span class="'+clr+' font-mono font-bold text-sm">'+v+'</span><br><span class="text-slate-500 text-xs">胜率'+b.win_rate+'% 收益'+b.total_return+'% 回撤'+b.max_dd+'%</span></div>';
|
||
}
|
||
h += '</div>';
|
||
}
|
||
|
||
// ── 健康度 ──
|
||
if (d.health && d.health.length > 0) {
|
||
const hs = d.health[0]; const sc = hs.health_score;
|
||
const color = sc >= 70 ? 'text-emerald-400' : sc >= 50 ? 'text-amber-400' : 'text-red-400';
|
||
h += '<div class="mb-2 text-sm"><span class="'+color+' font-bold">健康度: '+sc+'分</span><span class="text-slate-500"> (实盘'+hs.live_trades+'笔 vs 回测'+hs.backtest_wr+'%胜率)</span>';
|
||
if (hs.live_trades === 0) h += '<span class="text-slate-600 text-xs"> [暂无实盘数据, 分数为中性]</span>';
|
||
h += '</div>';
|
||
}
|
||
|
||
// ── 进化历史 ──
|
||
if (d && d.length > 0) {
|
||
h += '<div class="text-sm text-slate-400 mb-1">🔄 迭代历史:</div>';
|
||
for (const e of d.slice(0,5)) {
|
||
h += '<div class="text-sm text-slate-300 mb-0.5">'+e.parent_version+' → '+e.child_version+' <span class="text-slate-500">'+e.change_description+'</span>'+(e.promoted?' <span class="text-emerald-400 text-xs">✅采纳</span>':'')+'</div>';
|
||
}
|
||
} else {
|
||
h += '<div class="text-sm text-slate-600 mb-1">🔄 迭代历史: 暂无 (auto_iterator 待触发)</div>';
|
||
}
|
||
|
||
// ── 教训 ──
|
||
if (d.lessons && d.lessons.length > 0) {
|
||
h += '<div class="text-sm text-slate-400 mt-2 mb-1">📝 回测教训 (基于v_next4 5y交易分析):</div>';
|
||
for (const l of d.lessons.slice(0,5)) {
|
||
h += '<div class="text-sm text-slate-300 mb-0.5">'+(l.lesson_type==='win_pattern'?'✅盈利规律':'❌亏损规律')+' '+l.lesson_text+' <span class="text-slate-600 text-xs">'+Math.round(l.confidence*100)+'%置信</span></div>';
|
||
}
|
||
}
|
||
|
||
// ── 2026-08-16 假设归纳(方向一:数据驱动的优化假设)──
|
||
if (d.hypotheses && d.hypotheses.length > 0) {
|
||
h += '<div class="text-sm text-slate-400 mt-2 mb-1">🧪 数据归纳优化假设(方向一,基于最新交易数据):</div>';
|
||
for (const hp of d.hypotheses.slice(0,6)) {
|
||
const ex = hp.excess_pp != null ? ' <span class="text-amber-400 text-xs">Δ'+hp.excess_pp+'pp</span>' : '';
|
||
h += '<div class="text-sm text-slate-300 mb-1 bg-slate-900/40 rounded px-2 py-1">' +
|
||
'<span class="font-mono font-bold text-blue-300">'+hp.strategy+'</span> ' + hp.hypothesis + ex +
|
||
'<div class="text-[10px] text-slate-500">证据: ' + (hp.evidence || '') + '</div></div>';
|
||
}
|
||
} else {
|
||
h += '<div class="text-sm text-slate-600 mt-2 mb-1">🧪 假设归纳: 暂无显著假设(交易样本不足或特征均匀)</div>';
|
||
}
|
||
|
||
// ── 2026-08-16 B组候选 + AB对照(方向二:由果及因挖掘 vs A组)──
|
||
h += '<div class="text-sm text-slate-400 mt-2 mb-1">🔬 B组挖掘 + AB对照(方向二):</div>';
|
||
// A组 = 当前激活策略(baseline),B组 = 候选
|
||
const aNames = d.baseline ? Object.keys(d.baseline) : [];
|
||
if (d.b_group && d.b_group.length > 0) {
|
||
for (const b of d.b_group.slice(0,6)) {
|
||
h += '<div class="text-sm text-slate-300 mb-1 bg-slate-900/40 rounded px-2 py-1">' +
|
||
'<div><span class="text-emerald-400 font-bold">[B组 '+b.regime.replace('trend_','')+']</span> ' + b.hypothesis +
|
||
' <span class="text-slate-500 text-xs">' + (b.trades_est||'?') + '样本 好果率' + (b.good_rate || b.ok_rate || b.big_rate || '?') + '%</span></div>' +
|
||
'<div class="text-[10px] text-slate-500">对照A组(' + b.regime.replace('trend_','') + '): ' + (aNames.join(', ') || '—') + '</div>' +
|
||
(b.status === 'verified' ? '<button onclick="mergeBGroup()" class="text-[10px] px-1.5 py-0.5 bg-emerald-500/20 text-emerald-300 rounded hover:bg-emerald-500/30 mt-1">⬇️融合到A组</button>' : '') +
|
||
'</div>';
|
||
}
|
||
} else {
|
||
h += '<div class="text-sm text-slate-600 mb-1">暂无B组候选(挖掘中;通过模拟验证的候选可融合到A组)</div>';
|
||
}
|
||
|
||
// ── 2026-08-16 策略资格概览(温区适应评估)──
|
||
if (d.qual_overview && d.qual_overview.length > 0) {
|
||
h += '<div class="text-sm text-slate-400 mt-2 mb-1">📊 策略温区资格(长期/近期/当下):</div><div class="grid grid-cols-2 gap-1.5">';
|
||
for (const qo of d.qual_overview) {
|
||
const q = qo.qualification || {};
|
||
const rgTxt = Object.entries(q).map(([rg, v]) =>
|
||
'<span class="text-slate-500">'+rg.replace('trend_','')+'</span>' +
|
||
(v.long_ok?'✅':'❌')+(v.mid_ok?'✅':'❌')+(v.short_ok?'✅':'❌')).join(' ');
|
||
h += '<div class="bg-slate-900/40 rounded px-2 py-1 text-xs">' +
|
||
'<span class="font-mono font-bold text-blue-300">'+qo.strategy+'</span> ' +
|
||
'<span class="text-slate-500">'+qo.market+'</span><div class="text-[10px]">'+rgTxt+'</div></div>';
|
||
}
|
||
h += '</div>';
|
||
}
|
||
|
||
box.innerHTML = h;
|
||
} catch (e) { box.innerHTML = '<div class="text-sm text-red-400">加载失败: '+e.message+'</div>'; }
|
||
}
|
||
// 研究Tab活跃时加载进化模块
|
||
new MutationObserver(()=>{if(!document.getElementById('tab-research')?.classList?.contains('hidden'))loadEvolutionBox();}).observe(document.body,{attributes:true,subtree:true});
|
||
|
||
|
||
function sortBy(key) {
|
||
if (window._sortKey === key) {
|
||
window._sortDir = window._sortDir === 'desc' ? 'asc' : 'desc';
|
||
} else {
|
||
window._sortKey = key;
|
||
window._sortDir = 'desc';
|
||
}
|
||
renderStrategyTable(window._strats || []);
|
||
}
|
||
|
||
function sortStrategies(strategies, key, dir) {
|
||
const get = (s) => {
|
||
const st = s.summary_stats || {}; const pf = st.portfolio || {};
|
||
switch(key) {
|
||
case 'version': return s.version || '';
|
||
case 'name': return s.name || '';
|
||
case 'sizing_slots': return st.sizing_slots || 10;
|
||
case 'composite': return s._composite != null ? s._composite : -999;
|
||
case 'universality': return (st.universality || {}).score || 0;
|
||
case 'total_return': return pf.total_return_pct != null ? pf.total_return_pct : -999;
|
||
case 'capital_final': return pf.capital_final || 0;
|
||
case 'cagr': return pf.cagr_pct != null ? pf.cagr_pct : -999;
|
||
case 'trades': return st.total_trades || 0;
|
||
case 'hold': return st.avg_hold_days || 0;
|
||
case 'win_rate': return st.win_rate != null ? st.win_rate : -999;
|
||
case 'avg_profit': return st.avg_profit_pct != null ? st.avg_profit_pct : -999;
|
||
case 'sharpe': return st.sharpe_ratio != null ? st.sharpe_ratio : -999;
|
||
case 'profit_factor': return st.profit_factor != null ? st.profit_factor : -999;
|
||
case 'max_dd': return pf.portfolio_max_dd_pct != null ? pf.portfolio_max_dd_pct : 999;
|
||
default: return 0;
|
||
}
|
||
};
|
||
const arr = [...strategies];
|
||
arr.sort((a, b) => {
|
||
const va = get(a), vb = get(b);
|
||
let cmp = (typeof va === 'string') ? va.localeCompare(vb) : va - vb;
|
||
return dir === 'desc' ? -cmp : cmp;
|
||
});
|
||
return arr;
|
||
}
|
||
|
||
// ── 2026-08-16 手动可用性(全局,供 onclick 调用):激活只能是可用的策略 ──
|
||
async function toggleAvail(version, checked) {
|
||
try {
|
||
await fetch('/api/research/availability', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({version: version, available: checked, note: '手动切换'})
|
||
});
|
||
loadStrategyList(); // 刷新(state 可能变化)
|
||
} catch(e) { console.error('toggleAvail failed', e); }
|
||
}
|
||
// 2026-08-16 AB融合:B组verified候选 → A组实施(需老莫确认)
|
||
async function mergeBGroup() {
|
||
if (!confirm('确认将B组候选融合到A组实施?融合后可手动启用/停用。')) return;
|
||
try {
|
||
const r = await fetch('/api/evolution/merge_b_group', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'});
|
||
const d = await r.json();
|
||
alert('融合结果: ' + JSON.stringify(d.merged || d.error || d));
|
||
loadEvolutionBox();
|
||
loadStrategyList();
|
||
} catch(e) { alert('融合失败: ' + e.message); }
|
||
}
|
||
async function batchAvail(rg, available) {
|
||
const list = (window._strats || [])
|
||
.filter(s => s.qualification && s.qualification[rg] && (s.market || 'all') !== 'hk')
|
||
.map(s => s.version);
|
||
if (!list.length) return;
|
||
try {
|
||
await fetch('/api/research/availability?batch=1', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({versions: list, available: available, note: '批量' + (available ? '可用' : '不可用')})
|
||
});
|
||
loadStrategyList();
|
||
} catch(e) { console.error('batchAvail failed', e); }
|
||
}
|
||
|
||
function renderStrategyTable(strategies) {
|
||
const el = document.getElementById('strategyList');
|
||
// 2026-08-18 滚动兜底:重建前保存整页滚动位置
|
||
if (window._stratScrollY == null) window._stratScrollY = window.scrollY || 0;
|
||
if (!strategies.length) { el.innerHTML = '<div class="text-slate-500">暂无策略版本</div>'; return; }
|
||
// 先算综合分(排序要用),再排序
|
||
for (const s of strategies) {
|
||
const st = s.summary_stats || {}; const pf = st.portfolio || {};
|
||
if (st.total_trades == null) { s._composite = null; continue; }
|
||
// 综合分用 MC 全参与口径(消除集中仓挑装运气,2026-07-29 老爸指正)
|
||
const pff = st.portfolio_full || pf; // portfolio_full 优先(MC),无则回退
|
||
const ret = Math.min(pff.total_return_pct || 0, 100) / 100 * 30;
|
||
const wr = (st.win_rate || 0) / 100 * 20;
|
||
const sh = Math.min(Math.max(st.sharpe_ratio || 0, 0), 20) / 20 * 20;
|
||
const pfc = Math.min(st.profit_factor || 0, 5) / 5 * 15;
|
||
const dd = (1 - Math.min(pff.portfolio_max_dd_pct || 0, 50) / 50) * 15;
|
||
// 置信系数:样本量(<40笔递减) × 普适月份(<6月递减)——小样本/单窗口策略的可信度打折
|
||
const n = st.total_trades || 0;
|
||
const months = (st.universality || {}).months || 0;
|
||
const conf = Math.min(1, n / 40) * (months > 0 ? Math.min(1, months / 6) : 1);
|
||
// 2026-08-17 老莫:效率惩罚(信号/成交比过高=大量信号未成交)
|
||
const _posN = (st.portfolio || {}).positions_taken || 0;
|
||
const _sigN = n;
|
||
const _ratioN = _posN > 0 ? _sigN / _posN : 99;
|
||
const _effN = _ratioN <= 2 ? 1.0 : _ratioN <= 5 ? 0.9 : _ratioN <= 10 ? 0.75 : 0.5;
|
||
s._confidence = conf;
|
||
s._composite = Math.round((ret + wr + sh + pfc + dd) * conf * _effN);
|
||
}
|
||
// 保存数据用于列排序
|
||
window._strats = strategies;
|
||
if (window._sortKey) {
|
||
strategies = sortStrategies(strategies, window._sortKey, window._sortDir);
|
||
}
|
||
// 默认排序(未点列排序时):非港股在前,港股分身沉底;点列排序后以用户选择为准
|
||
if (!window._sortKey) {
|
||
strategies.sort((a, b) => {
|
||
const am = (a.market || 'all') === 'hk' ? 1 : 0;
|
||
const bm = (b.market || 'all') === 'hk' ? 1 : 0;
|
||
if (am !== bm) return am - bm;
|
||
return (a.version || '').localeCompare(b.version || '');
|
||
});
|
||
}
|
||
// 找每列最优值用于高亮
|
||
const best = { win_rate: -999, sharpe_ratio: -999, profit_factor: -999, avg_profit_pct: -999,
|
||
cagr_pct: -999, portfolio_max_dd_pct: 999 };
|
||
for (const s of strategies) {
|
||
const st = s.summary_stats || {};
|
||
const pf = st.portfolio || {};
|
||
if (st.win_rate != null) best.win_rate = Math.max(best.win_rate, st.win_rate);
|
||
if (st.sharpe_ratio != null) best.sharpe_ratio = Math.max(best.sharpe_ratio, st.sharpe_ratio);
|
||
if (st.profit_factor != null) best.profit_factor = Math.max(best.profit_factor, st.profit_factor);
|
||
if (st.avg_profit_pct != null) best.avg_profit_pct = Math.max(best.avg_profit_pct, st.avg_profit_pct);
|
||
{ const _c = (st.portfolio_full || pf).cagr_pct; if (_c != null) best.cagr_pct = Math.max(best.cagr_pct, _c); }
|
||
if (pf.portfolio_max_dd_pct != null) best.portfolio_max_dd_pct = Math.min(best.portfolio_max_dd_pct, pf.portfolio_max_dd_pct);
|
||
}
|
||
const hl = (val, bestVal, invert) => {
|
||
if (val == null) return '';
|
||
const isBest = invert ? (val === bestVal && bestVal !== 999) : (val === bestVal && bestVal !== -999);
|
||
return isBest ? 'text-green-400 font-bold' : '';
|
||
};
|
||
// 条件格式色条:计算每列 min/max,单元格背景按相对大小画色条
|
||
const CURRENT_STRATEGY = 'v_next4';
|
||
const CURRENT_FAMILY = new Set(['v_weak', 's2_panic', 'v_next4']);
|
||
// 证伪标记数据驱动(2026-08-15):从 API s.deprecated 读,替代前端硬编码
|
||
const DEPRECATED = {};
|
||
const ranges = {};
|
||
const cols = ['composite','universality_score','full_return_pct','capital_final','cagr_pct','total_trades','avg_hold_days','win_rate','avg_profit_pct','sharpe_ratio','profit_factor','portfolio_max_dd_pct'];
|
||
for (const c of cols) {
|
||
let mn = Infinity, mx = -Infinity;
|
||
for (const s of strategies) {
|
||
const st = s.summary_stats || {}; const pf = st.portfolio || {};
|
||
const v = c === 'composite' ? s._composite : (c === 'universality_score' ? (st.universality || {}).score : (c === 'full_return_pct' ? (st.portfolio_full || {}).total_return_pct : ((c in pf) ? pf[c] : st[c])));
|
||
if (v != null) { mn = Math.min(mn, v); mx = Math.max(mx, v); }
|
||
}
|
||
ranges[c] = { mn: mn === Infinity ? 0 : mn, mx: mx === -Infinity ? 1 : mx };
|
||
}
|
||
const bar = (c, v, invert) => {
|
||
if (v == null) return '';
|
||
const { mn, mx } = ranges[c];
|
||
const span = (mx - mn) || 1;
|
||
let pct = (v - mn) / span;
|
||
if (invert) pct = 1 - pct;
|
||
pct = Math.max(0.03, Math.min(1, pct));
|
||
const color = invert ? 'rgba(96,165,250,0.28)' : 'rgba(59,130,246,0.30)';
|
||
return `background:linear-gradient(to right, ${color} ${pct*100}%, transparent ${pct*100}%);`;
|
||
};
|
||
const cell = (c, v, fmt, extraCls, invert) => {
|
||
const cls = 'text-right px-2 py-1.5 font-mono ' + (extraCls || '');
|
||
const sty = bar(c, v, invert);
|
||
return `<td class="${cls}" style="${sty}">${v != null ? fmt(v) : '—'}</td>`;
|
||
};
|
||
const th = (key, label, cls, title, colKey) => {
|
||
const arrow = window._sortKey === key ? (window._sortDir === 'desc' ? '▼' : '▲') : '';
|
||
// 2026-08-17 老莫:?§按钮只保留第一个表格表头(_colBtnsOn 控制)
|
||
const showBtns = colKey && window._colBtnsOn !== false;
|
||
const helpBtns = showBtns ? `<span class="spec-btn colinfo" title="看这列是干嘛的" onclick="event.stopPropagation();showColumnHelp('${colKey}')">ⓘ</span><span class="spec-btn ai" title="看策略模块spec(接口/约束)" onclick="event.stopPropagation();showModuleHelp('strategy','ai')">§</span>` : '';
|
||
// 2026-08-17 老莫:ⓘ§按钮放第二行(spec-th-row),列名一行保持列宽,避免横向滚动条
|
||
const thInner = showBtns
|
||
? `<div class="spec-th-col"><div class="spec-th-label">${label}${arrow ? ' ' + arrow : ''}</div><div class="spec-th-btns">${helpBtns}</div></div>`
|
||
: `${label}${arrow ? ' ' + arrow : ''}`;
|
||
return `<th class="${cls || 'text-right'} px-2 py-1 cursor-pointer hover:text-slate-300 select-none align-top" onclick="sortBy('${key}')" title="${title || '点击排序'}">${thInner}</th>`;
|
||
};
|
||
// 2026-08-13 温区自适应:按"适应温区"展开——每策略每温区一行
|
||
// 当前 = 当前温区激活(后端 s.current 已改为 regime_weights.active 判断);历史 = 其余,默认折叠
|
||
const REGIME_META = {trend_up:{icon:'📈',name:'趋势市'}, choppy:{icon:'〰️',name:'震荡市'}, trend_down:{icon:'📉',name:'下跌市'}};
|
||
const renderGroup = (groupStrats, groupTitle, groupIcon, collapsed) => {
|
||
let ghtml = '<div class="overflow-x-auto"><table class="w-full text-xs whitespace-nowrap"><thead><tr class="text-slate-500 border-b border-slate-700">' +
|
||
'<th class="text-left px-2 py-1.5">适应温区</th>' +
|
||
th('version', '版本', 'text-left', '策略版本标识', 'version') + th('name', '名称', 'text-left', '策略人话名称', 'name') +
|
||
th('sizing_slots', '仓位', 'text-center') +
|
||
'<th class="text-center px-2 py-1.5">说明</th>' +
|
||
th('composite', '综合', 'text-right', '多维度综合评分', 'composite') +
|
||
th('universality', '普适', 'text-right', '跨独立年份有效性(非特例)', 'universality') +
|
||
th('full_return', '全参与', 'text-right', '全参与模拟总收益', 'full_return') +
|
||
th('capital_final', '最终资产', 'text-right', '组合模拟期末资产', 'capital_final') +
|
||
th('cagr', '年化', 'text-right', '年化收益率', 'cagr') +
|
||
th('trades', '执行数', 'text-right', '实际参与组合模拟的交易笔数;信号总数=为鼠标悬停查看', 'trades') +
|
||
th('hold', '均持仓', 'text-right', '平均持仓天数', 'hold') +
|
||
th('win_rate', '胜率', 'text-right', '盈利笔数占比', 'win_rate') +
|
||
th('avg_profit', '平均收益', 'text-right', '每笔平均收益率', 'avg_profit') + th('sharpe', '夏普', 'text-right', '每单位波动超额收益', 'sharpe') +
|
||
th('profit_factor', '盈亏比', 'text-right', '总盈利/总亏损', 'profit_factor') + th('max_dd', '资产回撤', 'text-right', '组合模拟最大回撤', 'max_dd') +
|
||
'<th class="text-center px-2 py-1.5">操作</th></tr></thead><tbody>';
|
||
for (const s of groupStrats) {
|
||
const st = s.summary_stats || {};
|
||
const pf = st.portfolio || {};
|
||
const hasResult = st.total_trades != null;
|
||
const smallSample = hasResult && st.total_trades < 40;
|
||
const isCurrent = s.current === true;
|
||
const deprecatedReason = s.deprecated; // 数据驱动(2026-08-15,从API读)
|
||
// 温区展开:有 regime_winrates 则每温区一行;无则单行("整体")
|
||
const rws = s.regime_winrates || {};
|
||
const regimes = Object.keys(rws).length ? Object.keys(rws).sort() : ['overall'];
|
||
for (const rg of regimes) {
|
||
const rw = rws[rg];
|
||
const isOverall = rg === 'overall';
|
||
const rgMeta = REGIME_META[rg] || {icon:'🌐', name: rg === 'overall' ? '整体' : rg};
|
||
const rgActive = !isOverall && isCurrent;
|
||
// ── 温区行数据:交易统计用该温区数据;组合级指标温区不适用(温区分散不成连续组合)──
|
||
const wrShow = rw && rw.win_rate != null ? rw.win_rate : (isOverall ? st.win_rate : null);
|
||
const pnlShow = rw && rw.avg_pnl != null ? rw.avg_pnl : (isOverall ? st.avg_profit_pct : null);
|
||
const tradesShow = rw && rw.trades != null ? rw.trades : (isOverall ? st.total_trades : null);
|
||
const holdShow = rw && rw.avg_hold_days != null ? rw.avg_hold_days : (isOverall ? st.avg_hold_days : null);
|
||
const sharpeShow = rw && rw.sharpe_ratio != null ? rw.sharpe_ratio : (isOverall ? st.sharpe_ratio : null);
|
||
const pfShow = rw && rw.profit_factor != null ? rw.profit_factor : (isOverall ? st.profit_factor : null);
|
||
// 组合级列:整体行用温区加权综合(s.regime_weighted,2026-08-15 按温区时长占比加权各温区表现);温区行用温区级组合(rw.portfolio)+ 温区级 composite + 温区级 universality
|
||
const rwPf = rw && rw.portfolio ? rw.portfolio : null;
|
||
const rwUniv = rw && rw.universality ? rw.universality : null;
|
||
// 温区加权整体综合(regime_weighted.composite);温区级 composite(用温区 portfolio 算)
|
||
let rgComposite = null;
|
||
if (rwPf) {
|
||
const rgRet = Math.min(rwPf.total_return_pct || 0, 100) / 100 * 30;
|
||
const rgWr = (rw && rw.win_rate || 0) / 100 * 20;
|
||
const rgSh = Math.min(Math.max((rwPf.sharpe_ratio || 0), 0), 20) / 20 * 20;
|
||
const rgPfc = Math.min((rwPf.profit_factor || 0), 5) / 5 * 15;
|
||
const rgDd = (1 - Math.min(rwPf.portfolio_max_dd_pct || 0, 50) / 50) * 15;
|
||
rgComposite = Math.round((rgRet + rgWr + rgSh + rgPfc + rgDd) * (s._confidence || 1));
|
||
}
|
||
const compositeShow = isOverall ? (s.regime_weighted && s.regime_weighted.composite != null ? s.regime_weighted.composite : s._composite) : rgComposite;
|
||
// 温区加权普适(用温区时长占比加权各温区月份分散度);温区级普适(rw.universality)
|
||
const universalityShow = isOverall ? (st.universality || {}).score : (rwUniv ? rwUniv.score : null);
|
||
const retShow = isOverall ? (s.regime_weighted && s.regime_weighted.total_return_pct != null ? s.regime_weighted.total_return_pct : (st.portfolio_full||{}).total_return_pct) : (rwPf ? rwPf.total_return_pct : null);
|
||
const capShow = isOverall ? (st.portfolio_full||pf).capital_final : (rwPf ? rwPf.capital_final : null);
|
||
const cagrShow = isOverall ? (s.regime_weighted && s.regime_weighted.cagr_pct != null ? s.regime_weighted.cagr_pct : (st.portfolio_full||pf).cagr_pct) : (rwPf ? rwPf.cagr_pct : null);
|
||
const ddShow = isOverall ? (s.regime_weighted && s.regime_weighted.portfolio_max_dd_pct != null ? s.regime_weighted.portfolio_max_dd_pct : (st.portfolio_full||pf).portfolio_max_dd_pct) : (rwPf ? rwPf.portfolio_max_dd_pct : null);
|
||
const rowCls = 'border-b border-slate-800/50 hover:bg-slate-800/30 cursor-pointer' + (rgActive ? ' bg-emerald-900/20 border-l-2 border-l-emerald-400' : '');
|
||
ghtml += '<tr class="' + rowCls + '" onclick="showStrategyDetail(\'' + s.version + '\')">' +
|
||
'<td class="px-2 py-1.5 whitespace-nowrap">' +
|
||
(isOverall ? '<span class="text-slate-500">整体</span>' :
|
||
'<span title="' + rgMeta.name + '">' + rgMeta.icon + ' ' + rgMeta.name + '</span>') +
|
||
(rw && rw.win_rate != null ? ' <span class="text-[10px] text-slate-500">胜率' + rw.win_rate + '%</span>' : '') +
|
||
(rw && rw.avg_pnl != null ? ' <span class="text-[10px] ' + (rw.avg_pnl >= 0 ? 'text-green-500/70' : 'text-red-500/70') + '">' + (rw.avg_pnl >= 0 ? '+' : '') + rw.avg_pnl + '%</span>' : '') +
|
||
(rw && rw.trades != null ? ' <span class="text-[10px] text-slate-600">' + rw.trades + '笔</span>' : '') +
|
||
'</td>' +
|
||
'<td class="px-2 py-1.5 font-mono font-bold ' + (deprecatedReason ? 'text-red-400/70 line-through' : (rgActive ? 'text-emerald-400' : 'text-blue-400')) + '">' + s.version + (rgActive ? ' <span class="text-[10px] bg-emerald-500/20 text-emerald-300 px-1 rounded">激活</span>' : '') + (s.state === 'available' && !rgActive ? ' <span class="text-[10px] bg-blue-500/20 text-blue-300 px-1 rounded" title="候选池内,当前温区未激活">可用</span>' : '') + (deprecatedReason ? ' <span class="text-[10px] bg-red-500/20 text-red-300 px-1 rounded" title="' + deprecatedReason.replace(/"/g, '"') + '">已证伪</span>' : '') + ((s.market && s.market !== 'all') ? ' <span class="text-[10px] bg-cyan-500/20 text-cyan-300 px-1 rounded">' + (s.market === 'hk' ? '港' : 'A') + '</span>' : '') + '</td>' +
|
||
'<td class="px-2 py-1.5 whitespace-normal break-words" title="' + (s.hypothesis || s.summary || '').replace(/"/g, '"') + '">' + (s.name || '') + (smallSample ? ' <span class="text-amber-500" title="样本<40笔">⚠️</span>' : '') + '</td>' +
|
||
'<td class="text-center px-2 py-1.5 font-mono text-cyan-300">' + (st.sizing_slots ? st.sizing_slots + '仓' : '10仓') + (st.conviction_model ? '<button onclick="event.stopPropagation();showConviction(\'' + s.version + '\')" class="text-amber-400 hover:text-amber-300" title="查看信念分级仓位规则">⚡</button>' : '') + '</td>' +
|
||
'<td class="text-center px-2 py-1.5">' + ((s.description && s.description.title) ? '<button onclick="event.stopPropagation();showDesc(\'' + s.version + '\')" class="text-blue-400 hover:text-blue-300" title="查看策略说明">📖</button>' : '<span class="text-slate-700">—</span>') + '</td>' +
|
||
cell('composite', compositeShow, v => v + (s._confidence != null && s._confidence < 1 ? '<span class="text-slate-500 text-[10px]">×' + s._confidence.toFixed(2) + '</span>' : ''), 'font-bold text-amber-300') +
|
||
cell('universality_score', universalityShow, v => v + '<span class="text-slate-500" title="普适=跨独立年份有效性: 有效年占比×覆盖因子。100分=每年都有效(非特例)">' + (isOverall ? ('/' + ((st.universality || {}).valid_years || 0) + '年有效') : (rwUniv ? ('/' + (rwUniv.valid_years || 0) + '年有效') : '?')) + '</span>') +
|
||
cell('full_return_pct', retShow, v => v + '%', 'font-bold text-amber-300') +
|
||
cell('capital_final', capShow, v => '¥' + (v/10000).toFixed(0) + '万') +
|
||
cell('cagr_pct', cagrShow, v => v + '%', hl(cagrShow, best.cagr_pct)) +
|
||
(st.portfolio && st.portfolio.positions_taken != null
|
||
? '<td class="text-right px-2 py-1.5" title="信号数 ' + (tradesShow != null ? tradesShow : '?') + ' → 实际成交 ' + st.portfolio.positions_taken + ' 笔(10等分仓位组合模拟)">' + st.portfolio.positions_taken + (tradesShow != null && tradesShow !== st.portfolio.positions_taken ? ' <span class="text-[10px] text-slate-500">/' + (tradesShow > 999 ? Math.round(tradesShow/1000) + 'k' : tradesShow) + '</span>' : '') + '</td>'
|
||
: cell('total_trades', tradesShow, v => (v > 999 ? Math.round(v/1000) + 'k' : v), '', false)) +
|
||
cell('avg_hold_days', holdShow, v => v + 'd') +
|
||
cell('win_rate', wrShow, v => v + '%', (isOverall ? hl(wrShow, best.win_rate) : (wrShow >= 50 ? 'text-green-400' : 'text-red-400'))) +
|
||
cell('avg_profit_pct', pnlShow, v => v + '%', (pnlShow || 0) >= 0 ? 'text-green-400' : 'text-red-400') +
|
||
cell('sharpe_ratio', sharpeShow, v => v, hl(sharpeShow, best.sharpe_ratio)) +
|
||
cell('profit_factor', pfShow, v => v, hl(pfShow, best.profit_factor)) +
|
||
cell('portfolio_max_dd_pct', ddShow, v => v + '%', hl(ddShow, best.portfolio_max_dd_pct, true), true) +
|
||
'<td class="text-center px-2 py-1.5" onclick="event.stopPropagation()">' +
|
||
'<button onclick="runStrategyBacktest(\'' + s.version + '\')" class="px-2 py-0.5 bg-blue-600/60 hover:bg-blue-500 rounded text-xs">▶回测</button>' +
|
||
'</td></tr>';
|
||
}
|
||
}
|
||
ghtml += '</tbody></table></div>';
|
||
return ghtml;
|
||
};
|
||
// ── 2026-08-15 按温区分组:当前温区激活区块改为温区优先(当前温区排最前高亮),
|
||
// 每个温区下列出所有在该温区有数据的策略(按该温区胜率降序)──
|
||
const REGIME_ORDER = ['trend_up', 'choppy', 'trend_down'];
|
||
|
||
// ── 2026-08-17 策略评分分级(老莫):基于资格叉数 + 综合分排名 ──
|
||
// 不合格=≥2个叉 / 待观察=1个叉 / 合格/良好/优秀=3勾+综合分区间
|
||
const GRADE_STD = {
|
||
poor: {label: '不合格', cls: 'bg-red-500/20 text-red-300', need: '叉≥2'},
|
||
watch: {label: '待观察', cls: 'bg-amber-500/20 text-amber-300', need: '叉=1'},
|
||
ok: {label: '合格', cls: 'bg-slate-500/20 text-slate-300', need: '3勾+综合60-74'},
|
||
good: {label: '良好', cls: 'bg-emerald-500/25 text-emerald-200', need: '3勾+综合75-84'},
|
||
great: {label: '优秀', cls: 'bg-emerald-400/30 text-emerald-100', need: '3勾+综合≥85'},
|
||
};
|
||
const gradeOf = (qg, comp) => {
|
||
if (!qg) return null;
|
||
const ok3 = qg.long_ok && qg.mid_ok && qg.short_ok;
|
||
const xCount = [qg.long_ok, qg.mid_ok, qg.short_ok].filter(x => !x).length;
|
||
if (!ok3) {
|
||
if (xCount >= 2) return 'poor';
|
||
if (xCount === 1) return 'watch';
|
||
return null;
|
||
}
|
||
if (comp >= 85) return 'great';
|
||
if (comp >= 75) return 'good';
|
||
return 'ok';
|
||
};
|
||
// 2026-08-18 按市场×温区判定激活(兼容 hk: 前缀)
|
||
window._isActRegime = function(s, rg) {
|
||
const cr = s.current_regimes || [];
|
||
const isHk = (s.market === 'hk') || cr.some(x => x.indexOf('hk:') === 0);
|
||
return isHk ? cr.includes('hk:' + rg) : cr.includes(rg);
|
||
};
|
||
const renderRegimeRow = (s, rg, bkKey) => {
|
||
// 2026-08-15 行级激活按策略自身(后端已按所属市场温区算好 s.current),不再按表格 isCur
|
||
const rw = (s.regime_winrates || {})[rg] || {};
|
||
const rwPf = rw.portfolio || null;
|
||
const wr = rw.win_rate != null ? rw.win_rate : null;
|
||
const pnl = rw.avg_pnl != null ? rw.avg_pnl : null;
|
||
const tr = rw.trades != null ? rw.trades : null;
|
||
const smallSample = tr != null && tr < 40;
|
||
let comp = null;
|
||
if (rwPf) {
|
||
const ret = Math.min(rwPf.total_return_pct || 0, 100) / 100 * 30;
|
||
const w = (wr || 0) / 100 * 20;
|
||
const sh = Math.min(Math.max((rwPf.sharpe_ratio || 0), 0), 20) / 20 * 20;
|
||
const pfc = Math.min((rwPf.profit_factor || 0), 5) / 5 * 15;
|
||
const dd = (1 - Math.min(rwPf.portfolio_max_dd_pct || 0, 50) / 50) * 15;
|
||
// 2026-08-15:温区行置信用温区自己样本量(regime_winrates 来自预计算表 strategy_regime_perf_by_period,
|
||
// 按所选周期计算)——用整体 conf 会把温区综合分错误打折(如 1m 视角整体只有 1 笔 → 全部归零)
|
||
const rn = tr || 0;
|
||
const rm = rw.universality ? (rw.universality.months || 0) : 0;
|
||
const rconf = Math.min(1, rn / 40) * (rm > 0 ? Math.min(1, rm / 6) : 1);
|
||
// 2026-08-17 老莫:效率惩罚——信号/成交比过高(大量信号未成交=质量差)综合分打折
|
||
const _sig = tr != null ? tr : 0, _pos = (rw && rw.positions_taken) || 0;
|
||
const _ratio = _pos > 0 ? _sig / _pos : 99;
|
||
const _eff = _ratio <= 2 ? 1.0 : _ratio <= 5 ? 0.9 : _ratio <= 10 ? 0.75 : 0.5;
|
||
comp = Math.round((ret + w + sh + pfc + dd) * rconf * _eff);
|
||
}
|
||
const cagr = rwPf ? rwPf.cagr_pct : null;
|
||
const ddV = rwPf ? rwPf.portfolio_max_dd_pct : null;
|
||
const univ = rw.universality ? rw.universality.score : null;
|
||
const univM = rw.universality ? (rw.universality.months + '/' + (rw.universality.regime_total_months || '?')) : null;
|
||
const univYears = rw.universality ? ((rw.universality.valid_years || 0) + '/' + (rw.universality.years || 0) + '年') : null;
|
||
// ── 2026-08-16 行状态按【该表温区 rg】独立判定(老莫反复强调)──
|
||
const qg = (s.qualification || {})[rg];
|
||
const rLong = qg && qg.long_ok, rMid = qg && qg.mid_ok, rShort = qg && qg.short_ok;
|
||
const r3 = rLong && rMid && rShort; // 三项全过
|
||
const rNearOk = rMid || rShort; // 近期有亮点(含长期不过但近期过:❌❌✅/❌✅✅)
|
||
const rManual = s.manual_available !== false;
|
||
// ── 2026-08-17 状态与评分分离(老莫):
|
||
// 状态 = 已激活(current=true,实盘跑)/未激活(current=false,不跑)
|
||
// 评分 = 不合格(≥2叉)/待观察(1叉)/合格/良好/优秀(3勾+综合分区间)
|
||
const rGrade = gradeOf(qg, comp);
|
||
let rStateCls, rStateBadge, rRowBg;
|
||
const _rgActive = window._isActRegime(s, rg); // 该策略在当前表温区激活
|
||
if (!rManual) { rStateCls = 'text-slate-500'; rStateBadge = '<span class="text-[10px] bg-slate-500/20 text-slate-400 px-1 rounded">手动停用</span>'; rRowBg = 'opacity-50'; }
|
||
else if (_rgActive) {
|
||
rStateCls = 'text-emerald-400';
|
||
rStateBadge = '<span class="text-[10px] bg-emerald-500/40 text-emerald-100 px-1 rounded font-bold">已激活</span>';
|
||
rRowBg = 'bg-emerald-900/15';
|
||
} else {
|
||
rStateCls = 'text-blue-400';
|
||
rStateBadge = '<span class="text-[10px] bg-slate-500/20 text-slate-300 px-1 rounded">未激活</span>';
|
||
rRowBg = '';
|
||
}
|
||
const rGradeBadge = rGrade && rGrade !== 'ok' && rGrade !== 'good' && rGrade !== 'great' ? '' : '';
|
||
// 2026-08-18 按温区判定激活(而非全局 s.current)——该策略在当前表温区 rg 是否激活
|
||
const isActive = window._isActRegime(s, rg);
|
||
// 2026-08-18 展开持久化:读 localStorage 决定备选行是否 hidden(非总是折叠)
|
||
const _bkAttr = bkKey ? ' data-bk="' + bkKey + '"' + (window._bkOpen(bkKey) ? '' : ' class="hidden"') : '';
|
||
return '<tr' + (_bkAttr || '') + ' class="border-b border-slate-800/50 hover:bg-slate-800/30 cursor-pointer ' + rRowBg + '" onclick="showStrategyDetail(\'' + s.version + '\')">' +
|
||
'<td class="px-2 py-1.5 font-mono font-bold ' + rStateCls + '">' + s.version + ' ' + rStateBadge + ((s.market && s.market !== 'all') ? ' <span class="text-[10px] bg-cyan-500/20 text-cyan-300 px-1 rounded">' + (s.market === 'hk' ? '港' : 'A') + '</span>' : '') + '</td>' +
|
||
'<td class="px-2 py-1.5 whitespace-normal break-words" title="' + (s.hypothesis || s.summary || '').replace(/"/g, '"') + '">' + (s.name || '') + (smallSample ? ' <span class="text-amber-500" title="样本<40笔">⚠️</span>' : '') + ' ' +
|
||
(rGrade ? '<span class="text-[10px] px-1 py-0.5 rounded ' + GRADE_STD[rGrade].cls + '" title="评分: ' + GRADE_STD[rGrade].need + '">' + GRADE_STD[rGrade].label + '</span>' : '') + '</td>' +
|
||
// 2026-08-16 资格列:长期/近期/当下 三项达标徽章
|
||
'<td class="text-center px-1 py-1.5 whitespace-nowrap" onclick="event.stopPropagation()">' + (s.qualification && s.qualification[rg] ? (
|
||
'<span title="长期10y年化' + (s.qualification[rg].cagr_10y != null ? s.qualification[rg].cagr_10y + '%' : '?') + ' vs 大盘' + (s.qualification[rg].bench_10y != null ? s.qualification[rg].bench_10y + '%' : '?') + '">' + (s.qualification[rg].long_ok ? '✅' : '❌') + '</span>' +
|
||
'<span title="近期2y年化' + (s.qualification[rg].cagr_2y != null ? s.qualification[rg].cagr_2y + '%' : '?') + ' vs 大盘' + (s.qualification[rg].bench_2y != null ? s.qualification[rg].bench_2y + '%' : '?') + '">' + (s.qualification[rg].mid_ok ? '✅' : '❌') + '</span>' +
|
||
'<span title="当下1y年化' + (s.qualification[rg].cagr_1y != null ? s.qualification[rg].cagr_1y + '%' : '?') + ' vs 大盘' + (s.qualification[rg].bench_1y != null ? s.qualification[rg].bench_1y + '%' : '?') + '">' + (s.qualification[rg].short_ok ? '✅' : '❌') + '</span>'
|
||
) : '<span class="text-slate-600">—</span>') +
|
||
(s.qual_warning ? '<div class="text-[9px] ' + (s.qual_warning.indexOf('回升') >= 0 ? 'text-green-400' : 'text-amber-400') + '">' + s.qual_warning + '</div>' : '') +
|
||
'</td>' +
|
||
// 2026-08-16 可用性开关(手动把关)
|
||
'<td class="text-center px-1 py-1.5" onclick="event.stopPropagation()">' +
|
||
'<input type="checkbox" ' + (s.manual_available !== false ? 'checked' : '') +
|
||
' onclick="toggleAvail(\'' + s.version + '\', this.checked)" title="手动可用性(激活只能是可用的策略)" class="accent-emerald-500 cursor-pointer">' +
|
||
'</td>' +
|
||
cell('win_rate', wr, v => v + '%', wr != null && wr >= 50 ? 'text-green-400' : 'text-red-400') +
|
||
cell('avg_profit_pct', pnl, v => v + '%', (pnl || 0) >= 0 ? 'text-green-400' : 'text-red-400') +
|
||
(rw && rw.positions_taken != null
|
||
? '<td class="text-right px-2 py-1.5" title="信号数 ' + (tr != null ? tr : '?') + '(组合模拟实际成交 ' + rw.positions_taken + ' 笔)">' + rw.positions_taken + (tr != null && tr !== rw.positions_taken ? ' <span class="text-[10px] text-slate-500">/' + tr + '</span>' : '') + '</td>'
|
||
: cell('total_trades', tr, v => v, '', false)) +
|
||
cell('avg_hold_days', rw.avg_hold_days != null ? rw.avg_hold_days : null, v => v + 'd') +
|
||
cell('cagr_pct', cagr, v => v + '%', 'font-bold text-amber-300') +
|
||
cell('composite', comp, v => v, 'font-bold text-amber-300') +
|
||
cell('sharpe_ratio', rw.sharpe_ratio != null ? rw.sharpe_ratio : null, v => v) +
|
||
cell('profit_factor', rw.profit_factor != null ? rw.profit_factor : null, v => v) +
|
||
cell('universality_score', univ, v => v + ' <span class="text-slate-500" title="普适=跨独立年份有效性: 有效年占比×覆盖因子。100分=每年都有效(非特例)">' + (univYears || '') + '</span>') +
|
||
cell('portfolio_max_dd_pct', ddV, v => v + '%', '') +
|
||
'<td class="text-center px-2 py-1.5" onclick="event.stopPropagation()"><button onclick="runStrategyBacktest(\'' + s.version + '\')" class="px-2 py-0.5 bg-blue-600/60 hover:bg-blue-500 rounded text-xs">▶回测</button></td></tr>';
|
||
};
|
||
const renderRegimeTable = (strats, rg, isCur, marketLabel, tempHtml) => {
|
||
// 2026-08-16 温区表显示该温区下【资格达标】的策略(qualification[rg] 存在 = 有10y数据+长期合格)
|
||
// 策略可适应多个温区:在它达标的每个温区表都显示一行(不再局限单个 best_regime)
|
||
const metas = strats
|
||
.filter(s => s.qualification && s.qualification[rg] && (s.regime_winrates || {})[rg] && (s.regime_winrates || {})[rg].trades != null);
|
||
const regimeGet = (s, key) => {
|
||
const rw = (s.regime_winrates || {})[rg] || {};
|
||
const pf = rw.portfolio || {};
|
||
switch (key) {
|
||
case 'version': return s.version || '';
|
||
case 'name': return s.name || '';
|
||
case 'win_rate': return rw.win_rate != null ? rw.win_rate : -999;
|
||
case 'avg_profit': return rw.avg_pnl != null ? rw.avg_pnl : -999;
|
||
case 'trades': return rw.trades || 0;
|
||
case 'hold': return rw.avg_hold_days != null ? rw.avg_hold_days : 0;
|
||
case 'cagr': return pf.cagr_pct != null ? pf.cagr_pct : -999;
|
||
case 'sharpe': return rw.sharpe_ratio != null ? rw.sharpe_ratio : -999;
|
||
case 'profit_factor': return rw.profit_factor != null ? rw.profit_factor : -999;
|
||
case 'composite': {
|
||
// 复用温区行综合分公式(与 renderRegimeRow 一致)
|
||
const ret = Math.min(pf.total_return_pct || 0, 100) / 100 * 30;
|
||
const w = (rw.win_rate || 0) / 100 * 20;
|
||
const sh = Math.min(Math.max((pf.sharpe_ratio || 0), 0), 20) / 20 * 20;
|
||
const pfc = Math.min((pf.profit_factor || 0), 5) / 5 * 15;
|
||
const dd = (1 - Math.min(pf.portfolio_max_dd_pct || 0, 50) / 50) * 15;
|
||
const rn = rw.trades || 0;
|
||
const rm = rw.universality ? (rw.universality.months || 0) : 0;
|
||
const rconf = Math.min(1, rn / 40) * (rm > 0 ? Math.min(1, rm / 6) : 1);
|
||
// 2026-08-17 效率惩罚(与 renderRegimeRow 一致)——否则排序分≠显示分,排名错乱
|
||
const _sig = rn, _pos = rw.positions_taken || 0;
|
||
const _ratio = _pos > 0 ? _sig / _pos : 99;
|
||
const _eff = _ratio <= 2 ? 1.0 : _ratio <= 5 ? 0.9 : _ratio <= 10 ? 0.75 : 0.5;
|
||
return Math.round((ret + w + sh + pfc + dd) * rconf * _eff);
|
||
}
|
||
case 'universality': return (rw.universality || {}).score || 0;
|
||
case 'max_dd': return pf.portfolio_max_dd_pct != null ? pf.portfolio_max_dd_pct : 999;
|
||
default: return 0;
|
||
}
|
||
};
|
||
if (window._sortKey) {
|
||
const key = window._sortKey, dir = window._sortDir;
|
||
metas.sort((a, b) => {
|
||
const va = regimeGet(a, key), vb = regimeGet(b, key);
|
||
let cmp = (typeof va === 'string') ? va.localeCompare(vb) : va - vb;
|
||
return dir === 'desc' ? -cmp : cmp;
|
||
});
|
||
} else {
|
||
metas.sort((x, y) => ((y.regime_winrates || {})[rg].win_rate || -1) - ((x.regime_winrates || {})[rg].win_rate || -1));
|
||
}
|
||
if (!metas.length) return '';
|
||
const meta = REGIME_META[rg] || {icon: '🌐', name: rg};
|
||
return '<div class="mb-3 rounded-lg border ' + (isCur ? 'border-emerald-700/60 bg-emerald-900/10' : 'border-slate-700') + '">' +
|
||
'<div class="flex items-center gap-2 px-3 py-1.5 text-sm ' + (isCur ? 'text-emerald-300' : 'text-slate-400') + '">' +
|
||
marketLabel + ' · ' + meta.icon + ' ' + meta.name + (isCur ? ' <span class="text-[10px] bg-emerald-500/20 text-emerald-300 px-1 rounded">当前温区</span>' : '') + (tempHtml || '') +
|
||
'<span class="text-xs text-slate-500">(' + metas.length + '个策略)</span>' +
|
||
'<span class="text-[10px] text-slate-600">' + (isCur ? '当前温区匹配即激活' : '策略适应温区(best_regime)') + '</span>' +
|
||
'<span class="text-[10px] text-slate-500 ml-auto">大盘基准: 1y' + (window._bench1y != null ? window._bench1y + '%' : '?') +
|
||
' 2y' + (window._bench2y != null ? window._bench2y + '%' : '?') +
|
||
' 10y' + (window._bench10y != null ? window._bench10y + '%' : '?') + '</span>' +
|
||
'<button onclick="batchAvail(\'' + rg + '\', true)" class="text-[10px] px-1.5 py-0.5 bg-emerald-500/20 text-emerald-300 rounded hover:bg-emerald-500/30">✅全可用</button>' +
|
||
'<button onclick="batchAvail(\'' + rg + '\', false)" class="text-[10px] px-1.5 py-0.5 bg-red-500/20 text-red-300 rounded hover:bg-red-500/30">🚫全不可用</button>' +
|
||
'</div>' +
|
||
'<div class="overflow-x-auto"><table class="w-full text-xs whitespace-nowrap"><thead><tr class="text-slate-500 border-b border-slate-700">' +
|
||
th('version', '版本', 'text-left', '策略版本标识', 'version') + th('name', '名称', 'text-left', '策略人话名称', 'name') +
|
||
'<th class="text-center px-2 py-1 align-top" title="长期10y/近期2y/当下1y 适应温区年化 vs 大盘"><div class="spec-th-col"><div class="spec-th-label">资格</div>' + (window._colBtnsOn !== false ? '<div class="spec-th-btns"><span class="spec-btn colinfo" title="看这列是干嘛的" onclick="event.stopPropagation();showColumnHelp(\'qualification\')">ⓘ</span><span class="spec-btn ai" title="看策略模块spec" onclick="event.stopPropagation();showModuleHelp(\'strategy\',\'ai\')">§</span></div>' : '') + '</div></th>' +
|
||
'<th class="text-center px-2 py-1 align-top"><div class="spec-th-col"><div class="spec-th-label">可用</div>' + (window._colBtnsOn !== false ? '<div class="spec-th-btns"><span class="spec-btn colinfo" title="看这列是干嘛的" onclick="event.stopPropagation();showColumnHelp(\'manual_available\')">ⓘ</span><span class="spec-btn ai" title="看策略模块spec" onclick="event.stopPropagation();showModuleHelp(\'strategy\',\'ai\')">§</span></div>' : '') + '</div></th>' +
|
||
th('win_rate', '胜率', 'text-right', '盈利笔数占比', 'win_rate') + th('avg_profit', '平均收益', 'text-right', '每笔平均收益率', 'avg_profit') + th('trades', '成交/信号', 'text-right', '实际入场笔数 / 触发信号数(组合模拟10等分仓位下实际成交)', 'trades') +
|
||
th('hold', '均持仓', 'text-right', '平均持仓天数', 'hold') + th('cagr', '年化', 'text-right', '年化收益率', 'cagr') + th('composite', '综合', 'text-right', '多维度综合评分', 'composite') +
|
||
th('sharpe', '夏普', 'text-right', '每单位波动超额收益', 'sharpe') + th('profit_factor', '盈亏比', 'text-right', '总盈利/总亏损', 'profit_factor') +
|
||
th('universality', '普适', 'text-right', '跨独立年份有效性(非特例)', 'universality') + th('max_dd', '回撤', 'text-right', '组合模拟最大回撤', 'max_dd') +
|
||
'<th class="text-center px-2 py-1.5">操作</th></tr></thead>' +
|
||
// ── 2026-08-17 分区(老莫):上区=已激活,下区=备选(资格达标未激活),默认折叠 ──
|
||
(() => {
|
||
const _isAct = (s) => window._isActRegime(s, rg);
|
||
const active = metas.filter(_isAct);
|
||
const backup = metas.filter(s => !_isAct(s));
|
||
let html = '<tbody class="align-top">';
|
||
if (active.length) {
|
||
html += '<tr class="bg-emerald-900/20"><td colspan="15" class="px-2 py-1 text-[10px] text-emerald-300 font-bold">✅ 已激活 (' + active.length + ')</td></tr>';
|
||
html += active.map(s => renderRegimeRow(s, rg)).join('');
|
||
}
|
||
if (backup.length) {
|
||
html += '<tr class="border-t border-slate-700/50"><td colspan="15" class="px-2 py-1 text-[10px] text-slate-400">' +
|
||
'<button onclick="toggleBackup(\'' + marketLabel + rg + '\')" class="hover:text-slate-200">' +
|
||
'<span id="bk-arrow-' + marketLabel + rg + '">' + (window._bkOpen(marketLabel + rg) ? '\u25bc' : '\u25b6') + '</span> 备选 (资格达标未激活, ' + backup.length + ') <span class="text-slate-600">点击展开</span></button></td></tr>';
|
||
// 2026-08-17 修复:备选行直接进同一 tbody(renderRegimeRow 传 bkKey, 行加 data-bk+hidden),列宽与表头对齐
|
||
html += backup.map(s => renderRegimeRow(s, rg, marketLabel + rg)).join('');
|
||
}
|
||
html += '</tbody></table></div></div>';
|
||
return html;
|
||
})();
|
||
};
|
||
// ── 2026-08-17 备选区展开/折叠(老莫)──
|
||
window.toggleBackup = function(key) {
|
||
const rows = document.querySelectorAll('[data-bk="' + key + '"]');
|
||
const arrow = document.getElementById('bk-arrow-' + key);
|
||
if (!rows.length) { if (arrow) arrow.textContent = '\u25b6'; return; }
|
||
const hidden = rows[0].classList.contains('hidden');
|
||
rows.forEach(r => r.classList.toggle('hidden'));
|
||
if (arrow) arrow.textContent = hidden ? '\u25bc' : '\u25b6';
|
||
// 2026-08-18 展开状态持久化(刷新保留)
|
||
try { localStorage.setItem('bk_open_' + key, hidden ? '1' : '0'); } catch(e) {}
|
||
};
|
||
window._bkOpen = function(key) { try { return localStorage.getItem('bk_open_' + key) === '1'; } catch(e) { return false; } };
|
||
// ── 2026-08-15 按市场×温区展示:6表格 = A股当前市 → 港股当前市 → A股其他2市 → 港股其他2市,
|
||
// 每表格列出该市场在该温区有数据的所有可用策略(含未激活,按该温区胜率降序),激活策略高亮;温度并入当前市标题尾部
|
||
const usableStrats = strategies.filter(s => s.current === true || s.state === 'available');
|
||
const unavailableStrats = strategies.filter(s => s.current !== true && s.state !== 'available');
|
||
const aStrats = usableStrats.filter(s => (s.market || 'all') !== 'hk');
|
||
const hkStrats = usableStrats.filter(s => (s.market || 'all') === 'hk');
|
||
const regA = window._regimeA || null;
|
||
const regHK = window._regimeHK || null;
|
||
const aCur = regA && regA.state;
|
||
const hkCur = regHK && regHK.state;
|
||
const tempHtml = (reg, hasRsi) => {
|
||
if (!reg || !reg.temp_band) return '';
|
||
const bandIcon = {panic:'🔴', fear:'🟠', neutral:'🟡', greed:'🟢', euphoria:'🔵'}[reg.temp_band] || '⚪';
|
||
const bandName = {panic:'恐慌', fear:'偏冷', neutral:'中性', greed:'偏热', euphoria:'亢奋'}[reg.temp_band] || reg.temp_band;
|
||
return ' · 温度: <b>' + bandIcon + ' ' + bandName + '</b>' + (hasRsi && reg.temp_rsi ? ' <span class="text-slate-500 text-xs">RSI=' + reg.temp_rsi + '</span>' : '');
|
||
};
|
||
let html = '';
|
||
// 2026-08-17 老莫:?§按钮只保留第一个表格(A股当前市)表头
|
||
window._colBtnsOn = true;
|
||
// 头两个:A股当前市 + 港股当前市(标题尾部带温度)
|
||
if (aCur) html += renderRegimeTable(aStrats, aCur, true, '🇨🇳 A股', tempHtml(regA, true));
|
||
window._colBtnsOn = false;
|
||
if (hkCur) html += renderRegimeTable(hkStrats, hkCur, true, '🇭🇰 港股', tempHtml(regHK, false));
|
||
// 后四个:A股其他2市 + 港股其他2市
|
||
for (const rg of REGIME_ORDER) if (rg !== aCur) html += renderRegimeTable(aStrats, rg, false, '🇨🇳 A股', '');
|
||
for (const rg of REGIME_ORDER) if (rg !== hkCur) html += renderRegimeTable(hkStrats, rg, false, '🇭🇰 港股', '');
|
||
if (unavailableStrats.length) {
|
||
html += '<div class="mb-4 opacity-70">' +
|
||
'<button class="w-full flex items-center justify-between px-3 py-2 bg-slate-800/50 hover:bg-slate-800 rounded-lg border border-slate-700 text-sm" onclick="this.nextElementSibling.classList.toggle(\'hidden\'); this.querySelector(\'span\').textContent = this.nextElementSibling.classList.contains(\'hidden\') ? \'▶\' : \'▼\'">' +
|
||
'<span class="text-slate-400">📚 不可用(已证伪 / 历史版本)</span><span class="text-slate-500 text-xs">(' + unavailableStrats.length + '个)</span><span class="text-slate-500">▶</span></button>' +
|
||
'<div class="hidden mt-2">' + renderGroup(unavailableStrats, '不可用', '📚', true) + '</div>' +
|
||
'</div>';
|
||
}
|
||
if (!usableStrats.length && !unavailableStrats.length) {
|
||
html = '<div class="text-slate-500">暂无策略版本</div>';
|
||
}
|
||
html += '<div class="text-xs text-slate-600 mt-2">总收益/最终资产 = 100万本金·最多10等分仓位·24个月组合模拟 · ⚠️样本<40笔 · 绿色=该列最优 · 悬停名称查看改进假设</div>';
|
||
el.innerHTML = html;
|
||
// 2026-08-18 恢复整页滚动位置(解决重建后滚动归零,老莫反馈)
|
||
if (window._stratScrollY != null) { window.scrollTo(0, window._stratScrollY); window._stratScrollY = null; }
|
||
}
|
||
|
||
async function runStrategyBacktest(version) {
|
||
const period = document.getElementById('btPeriod')?.value || '6m';
|
||
const detail = document.getElementById('strategyDetail');
|
||
detail.innerHTML = '<div class="text-blue-400 animate-pulse text-sm">⏳ 正在回测 ' + version + '(约15-30秒)...</div>';
|
||
try {
|
||
const mkt = document.getElementById('btMarket')?.value || 'all';
|
||
const resp = await fetch('/api/research/backtest?strategy=' + version + '&period=' + period + '&market=' + mkt);
|
||
const data = await resp.json();
|
||
if (data.error) { detail.innerHTML = '<div class="text-red-400">' + data.error + '</div>'; return; }
|
||
await loadStrategyList();
|
||
showStrategyResult(data);
|
||
} catch(e) {
|
||
detail.innerHTML = '<div class="text-red-400">失败: ' + e.message + '</div>';
|
||
}
|
||
}
|
||
|
||
function showDesc(version) {
|
||
// 2026-08-18 补回(commit 9f1185fc 误删导致📖按钮失效)
|
||
const s = (window._strats || []).find(x => x.version === version);
|
||
const d = s && s.description;
|
||
if (!d || !d.title) { alert('暂无说明'); return; }
|
||
// 用模态框展示说明(替代旧 alert,含进化说明)
|
||
const evoRows = (window._evolutionHistory || []).filter(e => e.child_version === version || e.parent_version === version);
|
||
let evoHtml = '';
|
||
if (evoRows.length) {
|
||
evoHtml = '\n\n【进化来源】\n' + evoRows.map(e =>
|
||
(e.parent_version || '?') + ' → ' + (e.child_version || version) +
|
||
(e.change_description ? ':' + e.change_description : '')).join('\n');
|
||
}
|
||
const text = ['【' + d.title + '】', '【算法】' + (d.algorithm || ''), '【思路】' + (d.rationale || ''), '【证据】' + (d.evidence || '')].join('\n\n') + evoHtml;
|
||
const el = document.getElementById('desc-modal');
|
||
if (el) {
|
||
el.querySelector('.modal-body').innerHTML = text.replace(/\n/g, '<br>');
|
||
el.classList.remove('hidden');
|
||
} else {
|
||
alert(text);
|
||
}
|
||
}
|
||
|
||
async function showStrategyDetail(version) {
|
||
const detail = document.getElementById('strategyDetail');
|
||
detail.innerHTML = '<div class="text-slate-500 text-sm">加载 ' + version + ' 明细...</div>';
|
||
try {
|
||
const [anaResp, tradesResp] = await Promise.all([
|
||
fetch('/api/research/analysis?strategy=' + version),
|
||
fetch('/api/research/trades?strategy=' + version)
|
||
]);
|
||
const ana = await anaResp.json();
|
||
const tradesData = await tradesResp.json();
|
||
showStrategyResult({ strategy: version, trades: tradesData.trades || [], insights: (ana.insights || []), analysis: ana });
|
||
} catch(e) {
|
||
detail.innerHTML = '<div class="text-red-400">失败: ' + e.message + '</div>';
|
||
}
|
||
}
|
||
|
||
function showStrategyResult(data) {
|
||
const el = document.getElementById('strategyDetail');
|
||
const s = data.summary || {};
|
||
let html = '<div class="border border-slate-700 rounded-xl p-4 bg-slate-900/40">' +
|
||
'<div class="flex items-center gap-3 mb-3"><h3 class="font-bold text-blue-300">' + (data.strategy || '') + ' ' + (data.strategy_name || '') + '</h3>' +
|
||
'<span class="text-xs text-slate-500">' + (data.period || '') + '</span></div>';
|
||
if (s.total_trades != null) {
|
||
html += '<div class="grid grid-cols-3 md:grid-cols-6 gap-2 mb-3 text-center">' +
|
||
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">交易</div><div class="font-mono">' + s.total_trades + '</div></div>' +
|
||
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">胜率</div><div class="font-mono ' + (s.win_rate >= 50 ? 'text-green-400' : 'text-yellow-400') + '">' + s.win_rate + '%</div></div>' +
|
||
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">平均收益</div><div class="font-mono ' + (s.avg_profit_pct >= 0 ? 'text-green-400' : 'text-red-400') + '">' + s.avg_profit_pct + '%</div></div>' +
|
||
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">夏普</div><div class="font-mono">' + s.sharpe_ratio + '</div></div>' +
|
||
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">盈亏比</div><div class="font-mono">' + (s.profit_factor || '—') + '</div></div>' +
|
||
'<div class="bg-slate-800/50 rounded p-2"><div class="text-xs text-slate-500">回撤</div><div class="font-mono text-red-400">' + s.max_drawdown_pct + '%</div></div></div>';
|
||
}
|
||
// 归因洞察
|
||
if (data.insights && data.insights.length) {
|
||
html += '<div class="mb-3"><div class="text-sm font-bold text-slate-300 mb-1">🔬 因子归因洞察(下一版迭代依据)</div>' +
|
||
'<div class="space-y-1">' + data.insights.map(i => '<div class="text-xs text-slate-400 bg-slate-800/30 rounded px-2 py-1">' + i + '</div>').join('') + '</div></div>';
|
||
}
|
||
// 交易明细
|
||
const trades = (data.trades || []).slice(0, 50);
|
||
if (trades.length) {
|
||
html += '<div class="text-sm font-bold text-slate-300 mb-1">交易明细(前50笔)</div>' +
|
||
'<div class="overflow-x-auto max-h-96 overflow-y-auto"><table class="w-full text-xs"><thead class="sticky top-0 bg-slate-900"><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>';
|
||
const reasonMap = { target: '🎯止盈', stop: '🛑止损', keep: '⏳到期', staged_end: '🪜分批', trail: '📈跟踪' };
|
||
for (const t of trades) {
|
||
const pc = (t.profit_pct || 0) >= 0 ? 'text-green-400' : 'text-red-400';
|
||
html += '<tr class="border-b border-slate-800/40">' +
|
||
'<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 ' + pc + '">' + (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>';
|
||
}
|
||
html += '</div>';
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
|
||
|
||
// ── 浮动异常区逻辑(2026-08-12)──
|
||
let lastAlertKeys = '';
|
||
let alertAutoHideTimer = null;
|
||
|
||
function toggleAlertFloat(ev) {
|
||
if (ev) ev.stopPropagation();
|
||
const el = document.getElementById('alertFloat');
|
||
const body = document.getElementById('alertBody');
|
||
const isCollapsed = el.classList.contains('collapsed');
|
||
el.classList.toggle('collapsed');
|
||
body.style.display = isCollapsed ? 'block' : 'none';
|
||
el.querySelector('.alert-toggle').textContent = isCollapsed ? '▴' : '▾';
|
||
if (alertAutoHideTimer) { clearTimeout(alertAutoHideTimer); alertAutoHideTimer = null; }
|
||
}
|
||
|
||
function renderAlerts(alerts) {
|
||
const el = document.getElementById('alertFloat');
|
||
const body = document.getElementById('alertBody');
|
||
const countEl = document.getElementById('alertCount');
|
||
const list = Array.isArray(alerts) ? alerts : [];
|
||
// 只显示 error/critical/warning(info 不打扰)
|
||
const shown = list.filter(a => a.level !== 'info');
|
||
const keys = shown.map(a => a.ts + '_' + a.source + '_' + a.code).join('|');
|
||
|
||
if (!shown.length) {
|
||
// 无异常 → 整个浮窗隐藏(2026-08-13 老莫反馈:无异常不应显示红底感叹号)
|
||
el.style.display = 'none';
|
||
el.classList.add('collapsed');
|
||
body.style.display = 'none';
|
||
countEl.style.display = 'none';
|
||
countEl.textContent = '0';
|
||
lastAlertKeys = '';
|
||
return;
|
||
}
|
||
|
||
// 有异常 → 显示浮窗
|
||
el.style.display = '';
|
||
|
||
// 新异常 → 自动展开几秒后折叠
|
||
const isNew = keys !== lastAlertKeys;
|
||
lastAlertKeys = keys;
|
||
|
||
countEl.style.display = 'inline-block';
|
||
countEl.textContent = shown.length;
|
||
|
||
const levelIcon = { critical: '🔴', error: '🟠', warning: '🟡' };
|
||
body.innerHTML = shown.map(a =>
|
||
`<div class="alert-item ${a.level || 'error'}">
|
||
<div><span class="a-src">${levelIcon[a.level]||'🟠'} ${a.source||''}</span>
|
||
${a.code ? `<span class="font-mono text-slate-500">${a.code}</span>` : ''}</div>
|
||
<div class="a-title">${a.title || ''}</div>
|
||
${a.detail ? `<div class="a-detail">${a.detail}</div>` : ''}
|
||
<div class="text-slate-600 text-[10px]">${a.ts_str || ''}</div>
|
||
</div>`
|
||
).join('');
|
||
|
||
// 折叠态且新消息 → 展开 5 秒自动收起
|
||
if (isNew && el.classList.contains('collapsed')) {
|
||
el.classList.remove('collapsed');
|
||
body.style.display = 'block';
|
||
el.querySelector('.alert-toggle').textContent = '▴';
|
||
if (alertAutoHideTimer) clearTimeout(alertAutoHideTimer);
|
||
alertAutoHideTimer = setTimeout(() => {
|
||
el.classList.add('collapsed');
|
||
body.style.display = 'none';
|
||
el.querySelector('.alert-toggle').textContent = '▾';
|
||
alertAutoHideTimer = null;
|
||
}, 5000);
|
||
}
|
||
}
|
||
|
||
// refreshAll 内联渲染:把 alerts 交给 renderAlerts
|
||
|
||
</script>
|
||
</body>
|
||
</html> |