- mofin_db: write_holding_strategy 内置tag同步语义——动作级信号 (买入/可买入/可加仓/卖出/止盈)→current_recommend; 信号降级→ 清除current_recommend; active_manual人工标记永不被自动流覆盖/清除 - 新增 sync_recommend_tag() 供裸SQL调用方 - batch_reassess.save_result / per_stock stage-2 调用同步 - 盯盘Tab: '重点推荐'更名'推荐操作', 区域独立琥珀色视觉, 置顶 同步语义: XMPP买入信号(ACTION级告警)的个股=推荐操作区域个股, 信号消失(重评降级)时区域同步消失
1727 lines
90 KiB
HTML
1727 lines
90 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>MoFin · 莫荷情报</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||
* { font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif; }
|
||
body { background: #0f0f13; color: #e2e8f0; }
|
||
.tab-btn { transition: all 0.15s; }
|
||
.tab-btn.active { background: #1e293b; color: #60a5fa; border-color: #60a5fa; }
|
||
.princ-btn { transition: all 0.15s; color: #8b949e; }
|
||
.princ-btn.active { background: #1e293b; color: #60a5fa; }
|
||
.card { background: #1a1a24; border: 1px solid #2a2a3a; border-radius: 12px; }
|
||
.badge-up { color: #22c55e; }
|
||
.badge-down { color: #ef4444; }
|
||
.pnl-badge { font-weight: 600; }
|
||
.stock-row:hover { background: #22222e; cursor: pointer; }
|
||
.loading { opacity: 0.5; pointer-events: none; }
|
||
.modal-overlay { background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); }
|
||
.spec-btn {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
width: 18px; height: 18px; border-radius: 50%;
|
||
font-size: 11px; font-weight: 700; line-height: 1;
|
||
cursor: pointer; transition: background 0.15s; user-select: none;
|
||
}
|
||
.spec-btn.help { color: #58a6ff; background: rgba(88,166,255,0.1); }
|
||
.spec-btn.help:hover { background: rgba(88,166,255,0.25); }
|
||
.spec-btn.ai { color: #d29922; background: rgba(210,153,34,0.1); }
|
||
.spec-btn.ai:hover { background: rgba(210,153,34,0.25); }
|
||
.spec-btns { display: inline-flex; gap: 4px; }
|
||
::-webkit-scrollbar { width: 6px; }
|
||
::-webkit-scrollbar-track { background: #0f0f13; }
|
||
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
|
||
</style>
|
||
</head>
|
||
<body class="min-h-screen">
|
||
|
||
<div class="max-w-7xl mx-auto px-4 py-4">
|
||
<!-- Header -->
|
||
<div class="flex items-center justify-between mb-6">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-2xl">📊</span>
|
||
<h1 class="text-xl font-bold text-white tracking-tight">MoFin</h1>
|
||
<span class="text-xs text-slate-500 bg-slate-800 px-2 py-0.5 rounded-full">知微</span>
|
||
</div>
|
||
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||
<span id="updateTime">—</span>
|
||
<button onclick="refreshAll()" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded-lg transition">↻</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tabs -->
|
||
<div class="flex gap-1 mb-6 bg-slate-900/50 rounded-xl p-1 border border-slate-800/50 overflow-x-auto" id="tabBar">
|
||
<button class="tab-btn active px-4 py-2 text-sm rounded-lg" data-tab="overview">📈 概览</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="holdings">💼 持仓</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="watchlist">⭐ 自选</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg blink-tab" data-tab="watch" style="color:#f97316;border-color:#f97316">👁️ 盯盘</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="prompts">📝 提示词</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="evaluation">📊 评估</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="market">🌐 市场</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="signals">🔍 信号</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="health">🏥 健康</button>
|
||
<button class="tab-btn px-4 py-2 text-sm rounded-lg" data-tab="principles">📐 开发原则</button>
|
||
<a href="/upload" class="tab-btn px-4 py-2 text-sm rounded-lg" style="text-decoration:none;color:#fbbf24;border-color:#fbbf24">📸 上传</a>
|
||
</div>
|
||
|
||
<!-- Tab Content -->
|
||
<div id="tab-overview" class="tab-content"></div>
|
||
<div id="tab-holdings" class="tab-content hidden"></div>
|
||
<div id="tab-watchlist" class="tab-content hidden"></div>
|
||
<div id="tab-evaluation" class="tab-content hidden"></div>
|
||
<div id="tab-prompts" class="tab-content hidden"></div>
|
||
<div id="tab-market" class="tab-content hidden"></div>
|
||
<div id="tab-signals" class="tab-content hidden"></div>
|
||
<div id="tab-health" class="tab-content hidden"></div>
|
||
<div id="tab-principles" class="tab-content hidden">
|
||
<div class="flex gap-1 mb-4 bg-slate-900/50 rounded-xl p-1 border border-slate-800/50" id="princBar">
|
||
<button class="princ-btn active px-4 py-1.5 text-sm rounded-lg" data-princ="spec">G 规范</button>
|
||
<button class="princ-btn px-4 py-1.5 text-sm rounded-lg" data-princ="tests">K 测试</button>
|
||
<button class="princ-btn px-4 py-1.5 text-sm rounded-lg" data-princ="health">F 健康</button>
|
||
<button class="princ-btn px-4 py-1.5 text-sm rounded-lg" data-princ="prd">H 需求</button>
|
||
</div>
|
||
<div id="princ-spec" class="princ-pane"></div>
|
||
<div id="princ-tests" class="princ-pane hidden"></div>
|
||
<div id="princ-health" class="princ-pane hidden"></div>
|
||
<div id="princ-prd" class="princ-pane hidden"></div>
|
||
</div>
|
||
<div id="tab-watch" class="tab-content hidden">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-lg font-semibold text-slate-200">👁️ 盯盘 · 全部策略</h2>
|
||
<span class="text-xs text-slate-500"><span id="watchRefreshTime">—</span> <span id="watchCount" class="text-yellow-400 font-mono">0</span> 只</span>
|
||
</div>
|
||
<div id="watchContent" class="card overflow-hidden"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Strategy History Modal -->
|
||
<div id="strategyModal" class="fixed inset-0 modal-overlay hidden items-center justify-center z-50" onclick="if(event.target===this)closeStrategyModal()">
|
||
<div class="bg-[#1a1a24] border border-slate-700 rounded-2xl w-full max-w-2xl max-h-[85vh] overflow-y-auto mx-4 p-6" onclick="event.stopPropagation()">
|
||
<div class="flex justify-between items-start mb-4">
|
||
<div>
|
||
<h2 id="strategyModalTitle" class="text-lg font-bold text-white">—</h2>
|
||
<p id="strategyModalMeta" class="text-xs text-slate-500 mt-1">—</p>
|
||
</div>
|
||
<button onclick="closeStrategyModal()" class="text-slate-500 hover:text-white text-xl">×</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="modalChart" class="h-48 mb-4 bg-slate-900 rounded-xl p-2"></div>
|
||
<div>
|
||
<h3 class="text-sm font-semibold text-slate-300 mb-2">📜 操作建议历史</h3>
|
||
<div id="modalHistory" class="space-y-2"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 提示词内容弹窗(独立,避免与stockModal冲突) -->
|
||
<div id="promptModal" class="fixed inset-0 modal-overlay hidden items-center justify-center z-50" onclick="if(event.target===this)closePromptModal()">
|
||
<div class="bg-[#1a1a24] border border-slate-700 rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto mx-4 p-6" onclick="event.stopPropagation()">
|
||
<div class="flex justify-between items-start mb-4">
|
||
<div>
|
||
<h2 id="promptModalTitle" class="text-lg font-bold text-white">—</h2>
|
||
</div>
|
||
<button onclick="closePromptModal()" class="text-slate-500 hover:text-white text-xl">×</button>
|
||
</div>
|
||
<div id="promptModalBody" class="text-sm text-slate-300 leading-relaxed"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ── State ──
|
||
let portfolioData = { holdings: [] };
|
||
let watchlistData = { stocks: [] };
|
||
let reportsData = [];
|
||
|
||
// ── Tab Switching ──
|
||
// 事件委托:点击 tabBar 内的按钮自动切换
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const tabBar = document.getElementById('tabBar');
|
||
if (tabBar) {
|
||
tabBar.addEventListener('click', function(e) {
|
||
const btn = e.target.closest('.tab-btn');
|
||
if (!btn) return;
|
||
if (btn.tagName === 'A') return; // 上传链接保持原行为
|
||
const name = btn.dataset.tab;
|
||
if (!name) return;
|
||
// 切走 health 时清理定时器
|
||
if (name !== 'health' && healthRefreshInterval) {
|
||
clearInterval(healthRefreshInterval);
|
||
healthRefreshInterval = null;
|
||
const he = document.getElementById('tab-health');
|
||
if (he) delete he.dataset.rendered;
|
||
}
|
||
if (name === 'health' && !healthRefreshInterval) {
|
||
healthRefreshInterval = setInterval(refreshHealth, 10000);
|
||
}
|
||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
|
||
document.getElementById('tab-' + name).classList.remove('hidden');
|
||
btn.classList.add('active');
|
||
renderTab(name);
|
||
});
|
||
}
|
||
refreshAll();
|
||
setInterval(refreshAll, 120000);
|
||
});
|
||
|
||
function switchTab(name) {
|
||
// 切走 health 时清理定时器
|
||
if (name !== 'health' && healthRefreshInterval) {
|
||
clearInterval(healthRefreshInterval);
|
||
healthRefreshInterval = null;
|
||
const he = document.getElementById('tab-health');
|
||
if (he) delete he.dataset.rendered;
|
||
}
|
||
// 进入 health 时确保定时器运行
|
||
if (name === 'health' && !healthRefreshInterval) {
|
||
healthRefreshInterval = setInterval(refreshHealth, 10000);
|
||
}
|
||
const btn = document.querySelector(`[data-tab="${name}"]`);
|
||
if (btn) {
|
||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
|
||
document.getElementById('tab-' + name).classList.remove('hidden');
|
||
btn.classList.add('active');
|
||
renderTab(name);
|
||
}
|
||
}
|
||
|
||
function renderTab(name) {
|
||
if (name === 'overview') renderOverview();
|
||
else if (name === 'holdings') renderHoldings();
|
||
else if (name === 'watchlist') renderWatchlist();
|
||
else if (name === 'evaluation') renderEvaluation();
|
||
else if (name === 'prompts') renderPrompts();
|
||
else if (name === 'market') renderMarket();
|
||
else if (name === 'signals') renderSignals();
|
||
else if (name === 'health') renderHealth();
|
||
else if (name === 'principles') renderPrinciples();
|
||
else if (name === 'watch') renderWatch();
|
||
}
|
||
|
||
// ── Data Fetching ──
|
||
async function fetchJSON(url) {
|
||
try {
|
||
const r = await fetch(url);
|
||
return await r.json();
|
||
} catch(e) { return {}; }
|
||
}
|
||
|
||
async function refreshAll() {
|
||
document.getElementById('tab-overview').classList.add('loading');
|
||
const [overview, portfolio, watchlist, reports] = await Promise.all([
|
||
fetchJSON('/api/overview'),
|
||
fetchJSON('/api/portfolio'),
|
||
fetchJSON('/api/watchlist'),
|
||
fetchJSON('/api/reports'),
|
||
]);
|
||
portfolioData = portfolio;
|
||
watchlistData = watchlist;
|
||
reportsData = Array.isArray(reports) ? reports : [];
|
||
document.getElementById('updateTime').textContent = '更新 ' + new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit'});
|
||
document.getElementById('tab-overview').classList.remove('loading');
|
||
renderTab(document.querySelector('.tab-btn.active')?.dataset?.tab || 'overview');
|
||
}
|
||
|
||
// ── Overview Tab ──
|
||
function renderOverview() {
|
||
const el = document.getElementById('tab-overview');
|
||
fetchJSON('/api/overview').then(d => {
|
||
const h = d.top_movers || [];
|
||
const alertHtml = h.length ? h.map(s => `
|
||
<div class="flex items-center justify-between p-2 bg-slate-800/50 rounded-lg">
|
||
<span class="text-sm">${s.name||s.code}</span>
|
||
<span class="text-sm font-mono ${(s.change_pct||0)>=0?'badge-up':'badge-down'}">
|
||
${(s.change_pct||0)>=0?'+':''}${(s.change_pct||0).toFixed(2)}%
|
||
</span>
|
||
</div>
|
||
`).join('') : '<div class="text-xs text-slate-500">暂无大幅异动</div>';
|
||
|
||
el.innerHTML = `
|
||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||
${statCard('总资产', formatMoney(d.total_assets), '💰')}
|
||
${statCard('股票市值', formatMoney(d.stock_value), '📈')}
|
||
${statCard('仓位', (d.position_pct||0).toFixed(1)+'%', '📊')}
|
||
${statCard('总盈亏', formatMoney(d.total_pnl, true), (d.total_pnl||0)>=0?'🟢':'🔴')}
|
||
</div>
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
<div class="card p-4">
|
||
<h3 class="text-sm font-semibold mb-3">⚡ 大幅异动 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('portfolio','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('portfolio','ai')">§</span></span></h3>
|
||
<div class="space-y-2">${alertHtml}</div>
|
||
</div>
|
||
<div class="card p-4">
|
||
<h3 class="text-sm font-semibold mb-3">📋 最新动态</h3>
|
||
<div id="latestReports" class="text-xs space-y-2">加载中...</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
// Load latest 3 reports
|
||
if (reportsData.length) {
|
||
document.getElementById('latestReports').innerHTML = reportsData.slice(0,3).map(r =>
|
||
`<div class="flex justify-between p-2 bg-slate-800/30 rounded-lg">
|
||
<span>${r.title||r.id}</span>
|
||
<span class="text-slate-500">${r.created_at?.slice(5,16)||''}</span>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
});
|
||
}
|
||
|
||
function statCard(label, value, icon) {
|
||
return `<div class="card p-4">
|
||
<div class="text-xs text-slate-500 mb-1">${icon} ${label}</div>
|
||
<div class="text-xl font-bold font-mono text-white">${value}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function formatMoney(v, signed=false) {
|
||
if (!v && v!==0) return '—';
|
||
const n = Number(v).toFixed(2);
|
||
if (signed) return n>=0 ? '+¥'+n : '-¥'+Math.abs(n).toFixed(2);
|
||
return '¥'+n.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||
}
|
||
|
||
// ── Holdings Tab ──
|
||
function renderHoldings() {
|
||
const el = document.getElementById('tab-holdings');
|
||
const holdings = portfolioData.holdings || [];
|
||
el.innerHTML = `
|
||
<div class="card overflow-hidden">
|
||
<div class="p-3 border-b border-slate-800/50 flex justify-between items-center">
|
||
<span class="text-sm font-semibold">持仓 · ${holdings.length} 只</span>
|
||
<span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('portfolio','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('portfolio','ai')">§</span></span>
|
||
<span class="text-xs text-slate-500">点击查看详情</span>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-xs">
|
||
<thead><tr class="text-slate-500 border-b border-slate-800/50">
|
||
<th class="text-left p-3">股票</th><th class="text-right p-3">仓位%</th>
|
||
<th class="text-right p-3">成本</th><th class="text-right p-3">现价</th>
|
||
<th class="text-right p-3">涨跌%</th><th class="text-right p-3">盈亏</th>
|
||
<th class="text-left p-3">操作建议</th>
|
||
</tr></thead>
|
||
<tbody>${holdings.map(s => {
|
||
const pnl = ((s.price||0) - (s.cost||0)) * (s.shares||0);
|
||
return `<tr class="stock-row border-b border-slate-800/30" onclick="openStock('${s.code}')">
|
||
<td class="p-3"><div class="font-medium text-white">${s.name}</div><div class="text-slate-500">${s.code}</div></td>
|
||
<td class="p-3 text-right font-mono">${(s.position_pct||0).toFixed(2)}</td>
|
||
<td class="p-3 text-right font-mono">${(s.cost||0).toFixed(2)}</td>
|
||
<td class="p-3 text-right font-mono">${(s.price||0).toFixed(2)}</td>
|
||
<td class="p-3 text-right font-mono ${(s.change_pct||0)>=0?'badge-up':'badge-down'}">
|
||
${(s.change_pct||0)>=0?'+':''}${(s.change_pct||0).toFixed(2)}
|
||
</td>
|
||
<td class="p-3 text-right font-mono ${pnl>=0?'badge-up':'badge-down'}">
|
||
${pnl>=0?'+':''}${pnl.toFixed(0)}
|
||
</td>
|
||
<td class="p-3">${renderRec(s)}</td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderRec(s) {
|
||
const a = s.analysis || {};
|
||
const parts = [];
|
||
if (a.stop_loss) parts.push(`<span class="text-red-400">止损${a.stop_loss}</span>`);
|
||
if (a.take_profit) parts.push(`<span class="text-green-400">止盈${a.take_profit}</span>`);
|
||
if (a.suggestion) parts.push(`<span class="text-blue-400">${a.suggestion}</span>`);
|
||
if (a.buy_zone_low && a.buy_zone_high) parts.push(`<span class="text-yellow-400">补仓${a.buy_zone_low}~${a.buy_zone_high}</span>`);
|
||
if (a.reason) parts.push(`<span class="text-slate-400 text-[11px]">${a.reason}</span>`);
|
||
return parts.length ? parts.join(' · ') : `<span class="text-slate-500">待分析</span>`;
|
||
}
|
||
|
||
// ── Watchlist Tab ──
|
||
function showFullAnalysis(code) {
|
||
const text = window._fullAnalysisMap[code] || '暂无完整分析';
|
||
const modal = document.createElement('div');
|
||
modal.id = 'analysisModal';
|
||
modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center';
|
||
modal.innerHTML = `<div style="background:#1e293b;border:1px solid #334155;border-radius:12px;max-width:700px;width:90%;max-height:80vh;overflow-y:auto;padding:24px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||
<span style="font-size:16px;font-weight:bold;color:#e2e8f0">📋 九维分析</span>
|
||
<span onclick="this.closest('#analysisModal').remove()" style="cursor:pointer;font-size:24px;color:#94a3b8">×</span>
|
||
</div>
|
||
<pre style="font-size:12px;color:#cbd5e1;line-height:1.6;white-space:pre-wrap;font-family:inherit;margin:0">${text}</pre>
|
||
</div>`;
|
||
modal.addEventListener('click', function(e) { if (e.target === this) this.remove(); });
|
||
document.body.appendChild(modal);
|
||
}
|
||
|
||
function renderWatchlist() {
|
||
const el = document.getElementById('tab-watchlist');
|
||
const stocks = watchlistData.stocks || [];
|
||
// 构建 code→full_analysis 映射
|
||
window._fullAnalysisMap = {};
|
||
stocks.forEach(s => { window._fullAnalysisMap[s.code] = s.full_analysis || ''; });
|
||
el.innerHTML = `
|
||
<div class="card overflow-hidden">
|
||
<div class="p-3 border-b border-slate-800/50 flex justify-between items-center">
|
||
<span class="text-sm font-semibold">自选 · ${stocks.length} 只</span>
|
||
<span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('watchlist','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('watchlist','ai')">§</span></span>
|
||
<span class="text-xs text-slate-500">按推荐价值排序</span>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-xs">
|
||
<thead><tr class="text-slate-500 border-b border-slate-800/50">
|
||
<th class="text-left p-3">股票</th><th class="text-right p-3">现价</th>
|
||
<th class="text-right p-3">涨跌%</th><th class="text-right p-3">买入区间</th>
|
||
<th class="text-right p-3">RR</th><th class="text-right p-3">信号</th><th class="text-right p-3">仓位</th>
|
||
</tr></thead>
|
||
<tbody>${stocks.map(s => {
|
||
const buyZone = (s.entry_low||0) + '~' + (s.entry_high||0);
|
||
const rr = s.rr_ratio || 0;
|
||
const sl = s.stop_loss || 0;
|
||
const tp = s.take_profit || 0;
|
||
const signal = s.timing_signal || '';
|
||
const action = (s.action||'');
|
||
const tech = s.tech_snapshot || '';
|
||
const sector = s.sector_context || '';
|
||
const price = s.price || 0;
|
||
const chg = s.change_pct || 0;
|
||
const priceDisplay = price ? price.toFixed(2) : '—';
|
||
// 构建完整策略显示
|
||
let detailLines = [];
|
||
if (action) detailLines.push(action);
|
||
if (tech) detailLines.push('📊 ' + tech);
|
||
if (sector) detailLines.push('🏭 ' + sector);
|
||
const detailText = detailLines.join(' | ');
|
||
// 完整分析(点击展开)
|
||
const fullAnalysis = s.full_analysis || '';
|
||
const detailDisplay = fullAnalysis ?
|
||
detailText + '<br><span class="text-blue-400 cursor-pointer" onclick="showFullAnalysis(\'' + s.code + '\')">📋 查看完整九维分析</span>' :
|
||
detailText;
|
||
return `
|
||
<tr class="border-b border-slate-800/30 hover:bg-slate-800/20 cursor-pointer" onclick="openStock('${s.code}')">
|
||
<td class="p-3"><div class="font-medium text-white">${s.name}</div><div class="text-slate-500 text-[10px]">${s.code}</div></td>
|
||
<td class="p-3 text-right font-mono text-sm">${priceDisplay}</td>
|
||
<td class="p-3 text-right font-mono text-sm ${chg>=0?'text-green-400':'text-red-400'}">${chg>=0?'+':''}${chg.toFixed(2)}</td>
|
||
<td class="p-3 text-right font-mono text-xs text-slate-300">${buyZone}<br><span class="text-slate-500">损${sl} 盈${tp}</span></td>
|
||
<td class="p-3 text-right font-mono text-sm ${rr>=1.5?'text-green-400':rr>0?'text-yellow-400':'text-slate-500'}">${rr>0?rr.toFixed(2):'—'}</td>
|
||
<td class="p-3 text-right"><span class="text-xs px-2 py-1 rounded ${signal.includes('买入')?'bg-green-900/50 text-green-300':signal.includes('关注')?'bg-yellow-900/50 text-yellow-300':'bg-slate-800 text-slate-400'}">${signal||'—'}</span></td>
|
||
<td class="p-3 text-right text-xs text-slate-400">${s.position_advice || '—'}</td>
|
||
</tr>
|
||
<tr class="border-b border-slate-800/20 hover:bg-slate-800/10 cursor-pointer" onclick="openStock('${s.code}')">
|
||
<td colspan="7" class="p-2 pl-3 text-xs text-slate-400 leading-relaxed">${detailDisplay || '—'}</td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ── Market Tab ──
|
||
let marketRefreshInterval = null;
|
||
|
||
function refreshMarketData() {
|
||
const el = document.getElementById('tab-market');
|
||
if (!el || el.classList.contains('hidden')) return;
|
||
Promise.all([
|
||
fetchJSON('/api/market'),
|
||
fetchJSON('/api/signals'),
|
||
fetchJSON('/api/candidates'),
|
||
]).then(([mkt, signals, candidates]) => {
|
||
const sectors = mkt.sectors || [];
|
||
const sigs = signals || [];
|
||
const cands = candidates || [];
|
||
const trend = sigs.filter(s => s.source && s.source !== 'xiaoguo').concat(sigs.filter(s => s.source === 'market_scanner'));
|
||
el.innerHTML = `
|
||
<div class="flex items-center justify-between mb-3">
|
||
<span class="text-sm font-semibold">🌐 市场全景 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('market','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('market','ai')">§</span><span class="spec-btn help" onclick="showModuleHelp('signals','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('signals','ai')">§</span></span></span>
|
||
</div>
|
||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-4">
|
||
<div class="card p-4">
|
||
<h3 class="text-sm font-semibold mb-2">🏭 行业热点</h3>
|
||
${sectors.length ? sectors.slice(0,8).map(s => `
|
||
<div class="flex justify-between items-center p-1.5 ${s.change>=0?'':'bg-slate-800/20'} rounded mb-0.5">
|
||
<span class="text-xs">${s.name}</span>
|
||
<span class="text-xs font-mono ${(s.change||0)>=0?'badge-up':'badge-down'}">${(s.change||0)>=0?'+':''}${(s.change||0).toFixed(1)}%</span>
|
||
</div>
|
||
`).join('') : '<div class="text-xs text-slate-500">暂无</div>'}
|
||
</div>
|
||
<div class="card p-4">
|
||
<h3 class="text-sm font-semibold mb-2">📡 趋势信号 (${trend.length})</h3>
|
||
${trend.length ? trend.slice(0,6).map(s => `
|
||
<div class="p-1.5 bg-slate-800/30 rounded mb-0.5 text-xs">
|
||
<span class="font-semibold ${s.overall_sentiment==='利好'?'text-green-400':s.overall_sentiment==='利空'?'text-red-400':'text-slate-400'}">${s.overall_sentiment||'中性'}</span>
|
||
${s.sector} ${(s.summary||'').slice(0,60)}
|
||
</div>
|
||
`).join('') : '<div class="text-xs text-slate-500">暂无</div>'}
|
||
</div>
|
||
<div class="card p-4">
|
||
<h3 class="text-sm font-semibold mb-2">📊 信号汇总 (${sigs.length})</h3>
|
||
${sigs.length ? sigs.slice(0,6).map(s => `
|
||
<div class="p-1.5 bg-slate-800/30 rounded mb-0.5 text-xs">
|
||
<span class="font-semibold ${s.overall_sentiment==='利好'?'text-green-400':s.overall_sentiment==='利空'?'text-red-400':'text-slate-400'}">${s.overall_sentiment||'中性'}</span>
|
||
${s.sector} ${(s.summary||'').slice(0,60)}
|
||
</div>
|
||
`).join('') : '<div class="text-xs text-slate-500">暂无</div>'}
|
||
</div>
|
||
</div>
|
||
<div class="card p-4 mb-4">
|
||
<h3 class="text-sm font-semibold mb-2">🎯 主力建仓候选 (${cands.length})</h3>
|
||
${cands.length ? `
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-xs">
|
||
<thead><tr class="text-slate-400">
|
||
<th class="text-left p-1">代码</th><th class="text-left p-1">名称</th>
|
||
<th class="text-center p-1">S2</th><th class="text-center p-1">S3</th>
|
||
<th class="text-center p-1">S4</th><th class="text-center p-1">S5</th>
|
||
<th class="text-center p-1">终评</th><th class="text-left p-1">理由</th>
|
||
<th class="text-left p-1">状态</th>
|
||
</tr></thead>
|
||
<tbody>${cands.slice(0,15).map(c => {
|
||
const s2 = c.pass_s2 ? '✅' : c.score_2nd ? '❌' : '⏳';
|
||
const s3 = c.pass_s3 ? '✅' : c.score_3rd ? '❌' : '⏳';
|
||
const s4 = c.pass_s4 ? '✅' : c.score_4th ? '❌' : '⏳';
|
||
const s5 = c.pass_s5 ? '✅' : c.score_5th ? '❌' : '⏳';
|
||
const status = c.promoted ? '⭐已入自选' : '📋候选';
|
||
const log = c.log ? JSON.parse(c.log) : [];
|
||
const logSummary = log.slice(-2).map(function(l){ return 'S' + l.stage + ':' + (l.passed ? '✅' : '❌'); }).join(' ');
|
||
return '<tr class="border-b border-slate-800/50 hover:bg-slate-800/20">' +
|
||
'<td class="p-1 font-mono">' + c.code + '</td>' +
|
||
'<td class="p-1">' + c.name + '</td>' +
|
||
'<td class="p-1 text-center">' + s2 + '</td>' +
|
||
'<td class="p-1 text-center">' + s3 + '</td>' +
|
||
'<td class="p-1 text-center">' + s4 + '</td>' +
|
||
'<td class="p-1 text-center">' + s5 + '</td>' +
|
||
'<td class="p-1 text-center font-bold">' + (c.score_final||'-') + '</td>' +
|
||
'<td class="p-1 text-slate-400 max-w-[200px] truncate">' + (c.reason||'').slice(0,50) + '</td>' +
|
||
'<td class="p-1"><span class="' + (c.promoted ? 'text-yellow-400' : 'text-slate-400') + '">' + status + '</span>' +
|
||
'<span class="text-slate-600 block text-[10px]">' + logSummary + '</span></td>' +
|
||
'</tr>';
|
||
}).join('')}</tbody>
|
||
</table>
|
||
</div>
|
||
` : '<div class="text-xs text-slate-500">暂无候选(开盘后自动扫描)</div>'}
|
||
</div>
|
||
<h3 class="text-sm font-semibold mb-2">📋 全部板块排行(${sectors.length})</h3>
|
||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1 max-h-48 overflow-y-auto">
|
||
${sectors.map(s => `
|
||
<div class="flex justify-between p-1 bg-slate-800/20 rounded text-xs">
|
||
<span>${s.name}</span>
|
||
<span class="font-mono ${(s.change||0)>=0?'badge-up':'badge-down'}">${(s.change||0)>=0?'+':''}${(s.change||0).toFixed(1)}%</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
// 市场页自动刷新(30秒)
|
||
function renderMarket() {
|
||
if (marketRefreshInterval) clearInterval(marketRefreshInterval);
|
||
refreshMarketData();
|
||
marketRefreshInterval = setInterval(refreshMarketData, 30000);
|
||
}
|
||
|
||
// ── Evaluation Tab ──
|
||
function renderEvaluation() {
|
||
const el = document.getElementById('tab-evaluation');
|
||
Promise.all([
|
||
fetchJSON('/api/evaluation'),
|
||
fetchJSON('/api/stats/accuracy'),
|
||
]).then(([evals, stats]) => {
|
||
const p1 = stats.phase1 || {};
|
||
const p2 = stats.phase2 || {};
|
||
el.innerHTML = `
|
||
<div class="flex items-center gap-2 mb-4">
|
||
<span class="text-sm font-semibold">策略双维度评估 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('evaluation','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('evaluation','ai')">§</span></span></span>
|
||
<span class="text-xs bg-blue-900/50 text-blue-300 px-2 py-0.5 rounded-full">${evals.length} 只已评估</span>
|
||
<button class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 ml-auto" onclick="triggerEval()">🔄 重新评估</button>
|
||
</div>
|
||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||
<div class="card p-3 text-center">
|
||
<div class="text-lg font-bold text-green-400">${p1.correct||0}</div>
|
||
<div class="text-xs text-slate-500">阶段一 达标</div>
|
||
</div>
|
||
<div class="card p-3 text-center">
|
||
<div class="text-lg font-bold text-red-400">${p1.wrong||0}</div>
|
||
<div class="text-xs text-slate-500">阶段一 止损</div>
|
||
</div>
|
||
<div class="card p-3 text-center">
|
||
<div class="text-lg font-bold text-yellow-400">${p1.pending||0}</div>
|
||
<div class="text-xs text-slate-500">待验证</div>
|
||
</div>
|
||
<div class="card p-3 text-center">
|
||
<div class="text-lg font-bold text-white">${p1.accuracy_pct||0}%</div>
|
||
<div class="text-xs text-slate-500">准确率</div>
|
||
</div>
|
||
</div>
|
||
<div class="card overflow-hidden">
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-xs">
|
||
<thead><tr class="text-slate-500 border-b border-slate-800/50">
|
||
<th class="text-left p-3">股票</th><th class="text-left p-3">策略</th>
|
||
<th class="text-left p-3">理论盈亏</th><th class="text-left p-3">实际盈亏</th>
|
||
<th class="text-left p-3">状态</th>
|
||
</tr></thead>
|
||
<tbody>${evals.map(x => {
|
||
const adv = (x.advice_evaluation && x.advice_evaluation[0]) || {};
|
||
const t = x.theoretical || {};
|
||
const a = x.actual || {};
|
||
const status = t.status || 'safe';
|
||
const icon = status === 'take_profit_hit' ? '🟢' : status === 'stop_loss_hit' ? '🔴' : status === 'in_entry_zone' ? '📥' : '💤';
|
||
return `<tr class="stock-row border-b border-slate-800/30">
|
||
<td class="p-3"><div class="font-medium text-white">${x.name}</div><div class="text-slate-500">${x.code}</div></td>
|
||
<td class="p-3 text-slate-300">${x.current || (x.advice_evaluation && x.advice_evaluation[0] && x.advice_evaluation[0].current_advice || '') || '—'}</td>
|
||
<td class="p-3"><span class="${(t.theoretical_pnl_pct||0)>=0?'badge-up':'badge-down'}">${(t.theoretical_pnl_pct||0).toFixed(1)}%</span></td>
|
||
<td class="p-3"><span class="${(a.actual_pnl_pct||0)>=0?'badge-up':'badge-down'}">${(a.actual_pnl_pct||0).toFixed(1)}%</span></td>
|
||
<td class="p-3">${icon} ${status.replace(/_/g,' ')}</td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="text-xs text-slate-600 mt-2">更新时间: ${stats.updated_at?.slice(0,16)||'—'}</div>
|
||
`;
|
||
// Load feedback
|
||
fetchJSON('/api/feedback').then(fb => {
|
||
const trend = fb.accuracy_trend || {};
|
||
const completed = fb.phase1_completed_count || 0;
|
||
const reassess = fb.reassess_needed_count || 0;
|
||
if (!completed && !reassess) return;
|
||
const fbEl = document.createElement('div');
|
||
fbEl.className = 'card p-4 mt-4';
|
||
fbEl.innerHTML = `
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-sm font-semibold">🔄 反馈闭环</span>
|
||
<span class="text-xs text-slate-500">趋势: ${trend.trend||'stable'}</span>
|
||
<span class="text-xs ${completed>0?'text-green-400':'text-slate-500'}">完成 ${completed}</span>
|
||
<span class="text-xs ${reassess>0?'text-yellow-400':'text-slate-500'}">重评 ${reassess}</span>
|
||
</div>
|
||
${(fb.feedback||[]).filter(f => f.adjustments?.length).slice(0,10).map(f => f.adjustments.map(a => `
|
||
<div class="flex items-start gap-2 p-2 bg-slate-800/30 rounded-lg mb-1 text-xs">
|
||
<span class="${a.type==='phase1_success'?'text-green-400':a.type==='phase1_failure'?'text-red-400':'text-yellow-400'}">${a.type==='phase1_success'?'✅':a.type==='phase1_failure'?'❌':'🔄'}</span>
|
||
<span class="text-slate-300">${a.message}</span>
|
||
</div>
|
||
`).join('')).join('')}
|
||
`;
|
||
el.appendChild(fbEl);
|
||
});
|
||
});
|
||
}
|
||
function triggerEval() {
|
||
const btn = document.querySelector('#tab-evaluation button');
|
||
btn.textContent = '⏳ 评估中...';
|
||
btn.disabled = true;
|
||
fetch('/api/evaluation/trigger', {method:'POST'}).then(r => r.json()).then(d => {
|
||
btn.textContent = '🔄 重新评估';
|
||
btn.disabled = false;
|
||
renderEvaluation();
|
||
});
|
||
}
|
||
|
||
// ── Stock Detail Modal ──
|
||
async function openStock(code) {
|
||
const el = document.getElementById('stockModal');
|
||
el.classList.remove('hidden');
|
||
el.classList.add('flex');
|
||
document.getElementById('modalTitle').textContent = '加载中...';
|
||
|
||
const [stockData] = await Promise.all([
|
||
fetchJSON(`/api/stock/${code}`),
|
||
]);
|
||
|
||
// Also find in portfolio
|
||
const inPortfolio = (portfolioData.holdings||[]).find(s => s.code === code);
|
||
const name = inPortfolio?.name || stockData.name || code;
|
||
document.getElementById('modalTitle').textContent = name;
|
||
document.getElementById('modalMeta').textContent = `${code} · ${inPortfolio?.sector||stockData.sector||''}`;
|
||
|
||
document.getElementById('modalInfo').innerHTML = inPortfolio ? `
|
||
<div><div class="text-xs text-slate-500">成本</div><div class="text-sm font-mono">${(inPortfolio.cost||0).toFixed(2)}</div></div>
|
||
<div><div class="text-xs text-slate-500">现价</div><div class="text-sm font-mono">${(inPortfolio.price||0).toFixed(2)}</div></div>
|
||
<div><div class="text-xs text-slate-500">仓位</div><div class="text-sm font-mono">${(inPortfolio.position_pct||0).toFixed(2)}%</div></div>
|
||
<div><div class="text-xs text-slate-500">盈亏</div><div class="text-sm font-mono ${((inPortfolio.price||0)-(inPortfolio.cost||0))>=0?'badge-up':'badge-down'}">
|
||
${formatMoney(((inPortfolio.price||0)-(inPortfolio.cost||0))*(inPortfolio.shares||0), true)}
|
||
</div></div>
|
||
` : `<div class="col-span-full text-xs text-slate-500">暂无持仓数据</div>`;
|
||
|
||
// Render recommendation history
|
||
const history = stockData.history || [];
|
||
document.getElementById('modalHistory').innerHTML = history.length ? history.map(h => `
|
||
<div class="flex items-start gap-2 p-2 bg-slate-800/30 rounded-lg">
|
||
<span class="text-xs text-slate-500 w-14 flex-shrink-0">${h.time?.slice(5,16)||''}</span>
|
||
<span class="text-xs font-mono w-14 flex-shrink-0">¥${h.price||'—'}</span>
|
||
<span class="text-xs flex-1">${h.recommendation||''}${h.reason?' — '+h.reason:''}</span>
|
||
</div>
|
||
`).join('') : '<div class="text-xs text-slate-500">暂无操作建议记录</div>';
|
||
|
||
// Mini chart (mock data if no real data)
|
||
const chartCanvas = document.getElementById('modalChart');
|
||
chartCanvas.innerHTML = '<canvas id="stockChart"></canvas>';
|
||
const ctx = document.getElementById('stockChart').getContext('2d');
|
||
if (stockData.price_history && stockData.price_history.length > 1) {
|
||
new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: stockData.price_history.map(p => p.date),
|
||
datasets: [{
|
||
label: '价格',
|
||
data: stockData.price_history.map(p => p.price),
|
||
borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.1)',
|
||
fill: true, tension: 0.3, pointRadius: 2,
|
||
}]
|
||
},
|
||
options: { responsive: true, maintainAspectRatio: false,
|
||
plugins: { legend: { display: false } },
|
||
scales: { x: { display: false }, y: { ticks: { font: {size:10} } } }
|
||
}
|
||
});
|
||
} else {
|
||
chartCanvas.innerHTML = '<div class="flex items-center justify-center h-full text-xs text-slate-500">暂无价格历史数据</div>';
|
||
}
|
||
}
|
||
|
||
function closeStock() {
|
||
document.getElementById('stockModal').classList.add('hidden');
|
||
}
|
||
|
||
// ── Prompts Tab ──
|
||
function renderPrompts() {
|
||
const el = document.getElementById('tab-prompts');
|
||
el.innerHTML = '<div class="text-center text-slate-500 py-8">加载中...</div>';
|
||
|
||
Promise.all([
|
||
fetch('/api/prompts').then(r => r.json()),
|
||
fetch('/api/prompts/effectiveness').then(r => r.json()),
|
||
]).then(([data, effectiveness]) => {
|
||
const prompts = data.prompts || [];
|
||
const categories = data.categories || {};
|
||
|
||
let html = '<div class="space-y-4">';
|
||
html += '<div class="flex items-center justify-between mb-2"><span class="text-sm font-semibold">📝 提示词管理 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp(\'prompts\',\'human\')">?</span><span class="spec-btn ai" onclick="showModuleHelp(\'prompts\',\'ai\')">§</span></span></span></div>';
|
||
|
||
// 分类筛选按钮
|
||
const catSet = {};
|
||
prompts.forEach(p => { catSet[p.category] = true; });
|
||
html += '<div class="flex flex-wrap gap-2 mb-4">';
|
||
html += '<button class="px-3 py-1 text-xs rounded-full bg-slate-700 text-white" onclick="filterPromptTable(\'\')">全部</button>';
|
||
Object.keys(catSet).sort().forEach(cat => {
|
||
html += `<button class="px-3 py-1 text-xs rounded-full bg-slate-800 text-slate-300 hover:bg-slate-700" onclick="filterPromptTable('${cat}')">${categories[cat]||cat}</button>`;
|
||
});
|
||
html += '<button class="px-3 py-1 text-xs rounded-full bg-amber-900/50 text-amber-400 ml-auto" onclick="loadPromptReport()">📊 版本有效性报告</button>';
|
||
html += '</div>';
|
||
|
||
// 提示词列表
|
||
if (prompts.length === 0) {
|
||
html += '<div class="card p-6 text-center">' +
|
||
'<div class="text-slate-400 mb-2">⚠️ 提示词注册表未初始化</div>' +
|
||
'<div class="text-xs text-slate-500">请在服务器运行 prompt_manager/init_registry.py 初始化注册表。</div>' +
|
||
'<div class="text-xs text-slate-600 mt-2">路径: /home/hmo/MoFin/prompt_manager/init_registry.py</div>' +
|
||
'</div>';
|
||
} else {
|
||
html += '<div class="card overflow-hidden"><table class="w-full text-sm">';
|
||
html += '<thead><tr class="text-left text-slate-400 border-b border-slate-700/50"><th class="p-3 font-medium">提示词</th><th class="p-3 font-medium">分类</th><th class="p-3 font-medium">当前版本</th><th class="p-3 font-medium">版本数</th><th class="p-3 font-medium">关联策略</th><th class="p-3 font-medium">成功率</th><th class="p-3 font-medium">操作</th></tr></thead><tbody>';
|
||
|
||
prompts.forEach(p => {
|
||
const verCount = (p.versions||[]).length;
|
||
const curVer = p.current_version || '-';
|
||
const eff = effectiveness[`${p.id}@${curVer}`] || {};
|
||
const sr = eff.success_rate !== undefined ? eff.success_rate + '%' : '-';
|
||
const total = eff.total || 0;
|
||
|
||
html += `<tr class="border-b border-slate-800/50 hover:bg-slate-800/30 prompt-row" data-category="${p.category}">
|
||
<td class="p-3"><strong>${p.name}</strong><br><small class="text-slate-500">${p.id}</small></td>
|
||
<td class="p-3"><span class="px-2 py-0.5 text-xs rounded-full bg-slate-700 text-slate-300">${categories[p.category]||p.category}</span></td>
|
||
<td class="p-3"><span class="px-2 py-0.5 text-xs rounded-full bg-blue-900/50 text-blue-400">${curVer}</span></td>
|
||
<td class="p-3">${verCount}</td>
|
||
<td class="p-3">${total}</td>
|
||
<td class="p-3">${sr}</td>
|
||
<td class="p-3">
|
||
<button class="px-2 py-1 text-xs bg-blue-900/50 text-blue-400 hover:bg-blue-800/50 rounded" onclick="showPromptVersions('${p.id}')">📋 版本</button>
|
||
</td>
|
||
</tr>`;
|
||
});
|
||
|
||
html += '</tbody></table></div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
el.innerHTML = html;
|
||
|
||
// 保存数据供版本弹窗使用
|
||
window._promptData = data;
|
||
}).catch(err => {
|
||
el.innerHTML = '<div class="card p-6 text-center">' +
|
||
'<div class="text-amber-400 mb-2">⚠️ 提示词注册表加载失败</div>' +
|
||
'<div class="text-xs text-slate-400">错误: ' + (err.message || 'unknown') + '</div>' +
|
||
'<div class="text-xs text-slate-500 mt-2">请确认服务器已运行 prompt_manager/init_registry.py 初始化注册表。</div>' +
|
||
'</div>';
|
||
});
|
||
}
|
||
|
||
function filterPromptTable(category) {
|
||
document.querySelectorAll('.prompt-row').forEach(row => {
|
||
row.style.display = (!category || row.dataset.category === category) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function showPromptVersions(promptId) {
|
||
fetch(`/api/prompts/${promptId}`)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const prompt = data.prompt || {};
|
||
const history = data.version_history || [];
|
||
const versions = prompt.versions || [];
|
||
|
||
let html = `<p class="text-xs text-slate-400 mb-3">${prompt.description || ''}</p>`;
|
||
html += '<div class="overflow-x-auto"><table class="w-full text-sm">';
|
||
html += '<thead><tr class="text-left text-slate-400 border-b border-slate-700">';
|
||
html += '<th class="p-2">版本</th><th class="p-2">标签</th><th class="p-2">状态</th><th class="p-2">时间</th><th class="p-2">变更说明</th></tr></thead><tbody>';
|
||
|
||
// 按版本号排序
|
||
const sorted = [...history].sort((a, b) => a.version.localeCompare(b.version, undefined, {numeric: true}));
|
||
|
||
sorted.forEach(h => {
|
||
const statusText = h.is_current ? '当前' : h.status;
|
||
const statusClass = h.is_current ? 'text-green-400' : h.status==='deprecated' ? 'text-amber-400' : 'text-slate-500';
|
||
|
||
// 找到该版本的完整内容
|
||
const verData = versions.find(v => v.version === h.version);
|
||
const contentPreview = verData?.content ? verData.content.slice(0, 80) + '...' : '';
|
||
|
||
html += `<tr class="border-b border-slate-800/50 hover:bg-slate-700/50 cursor-pointer" onclick="showVersionDetail('${promptId}', '${h.version}')">
|
||
<td class="p-2 font-mono">${h.version}</td>
|
||
<td class="p-2">${h.label}</td>
|
||
<td class="p-2 ${statusClass}">${statusText}</td>
|
||
<td class="p-2 text-xs text-slate-500">${h.created_at}</td>
|
||
<td class="p-2 text-xs text-slate-400">${h.changelog}</td>
|
||
</tr>`;
|
||
});
|
||
|
||
html += '</tbody></table></div>';
|
||
html += '<p class="text-xs text-slate-500 mt-2">💡 点击任意行查看该版本的完整内容</p>';
|
||
|
||
openPromptModal(prompt.name + ' · 版本历史', html);
|
||
|
||
// 缓存数据,供 showVersionDetail 使用
|
||
window._promptVersionsCache = { promptId, versions };
|
||
});
|
||
}
|
||
|
||
function showVersionDetail(promptId, version) {
|
||
const cache = window._promptVersionsCache;
|
||
const versions = cache?.promptId === promptId ? cache.versions : [];
|
||
|
||
// 直接从API拿(保证最新)
|
||
fetch(`/api/prompts/${promptId}`)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const prompt = data.prompt || {};
|
||
const v = (prompt.versions || []).find(x => x.version === version);
|
||
if (!v) return;
|
||
|
||
const isCurrent = v.version === prompt.current_version;
|
||
const statusLabel = isCurrent ? '当前版本' : v.status;
|
||
const statusColor = isCurrent ? 'bg-blue-900/50 text-blue-400' : 'bg-slate-700 text-slate-400';
|
||
|
||
let html = `<div class="mb-3 flex items-center gap-2 flex-wrap">
|
||
<span class="font-mono font-bold text-lg">${v.version}</span>
|
||
<span class="text-sm text-slate-400">${v.label}</span>
|
||
<span class="px-2 py-0.5 text-xs rounded-full ${statusColor}">${statusLabel}</span>
|
||
<span class="text-xs text-slate-500 ml-auto">${v.created_at}</span>
|
||
</div>`;
|
||
|
||
if (v.changelog) {
|
||
html += `<div class="mb-3 text-xs text-slate-400 bg-slate-800/50 px-3 py-2 rounded-lg">🔄 ${v.changelog}</div>`;
|
||
}
|
||
|
||
html += `<pre class="text-xs bg-slate-900 p-4 rounded-lg overflow-x-auto whitespace-pre-wrap leading-relaxed">${v.content || '(无内容)'}</pre>`;
|
||
|
||
// 底部导航
|
||
const allVersions = prompt.versions || [];
|
||
const sorted = [...allVersions].sort((a, b) => a.version.localeCompare(b.version, undefined, {numeric: true}));
|
||
const idx = sorted.findIndex(x => x.version === version);
|
||
html += '<div class="flex justify-between mt-4 pt-3 border-t border-slate-700/50">';
|
||
if (idx > 0) {
|
||
html += `<button class="text-xs text-blue-400 hover:text-blue-300" onclick="showVersionDetail('${promptId}', '${sorted[idx-1].version}')">← ${sorted[idx-1].version}</button>`;
|
||
} else {
|
||
html += '<span></span>';
|
||
}
|
||
html += `<button class="text-xs text-slate-400 hover:text-slate-300" onclick="showPromptVersions('${promptId}')">↑ 返回版本列表</button>`;
|
||
if (idx < sorted.length - 1) {
|
||
html += `<button class="text-xs text-blue-400 hover:text-blue-300" onclick="showVersionDetail('${promptId}', '${sorted[idx+1].version}')">${sorted[idx+1].version} →</button>`;
|
||
} else {
|
||
html += '<span></span>';
|
||
}
|
||
html += '</div>';
|
||
|
||
openPromptModal(prompt.name + ' · ' + v.version + ' ' + v.label, html);
|
||
});
|
||
}
|
||
|
||
function loadPromptReport() {
|
||
fetch('/api/prompts/report')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
openPromptModal('📊 提示词版本有效性报告',
|
||
`<pre class="text-xs text-slate-300 whitespace-pre-wrap">${data.report||'暂无数据'}</pre>`);
|
||
});
|
||
}
|
||
|
||
function openPromptModal(title, bodyHtml) {
|
||
document.getElementById('promptModalTitle').textContent = title;
|
||
document.getElementById('promptModalBody').innerHTML = bodyHtml;
|
||
document.getElementById('promptModal').classList.remove('hidden');
|
||
document.getElementById('promptModal').classList.add('flex');
|
||
}
|
||
|
||
function closePromptModal() {
|
||
document.getElementById('promptModal').classList.add('hidden');
|
||
document.getElementById('promptModal').classList.remove('flex');
|
||
}
|
||
|
||
// ── 信号页 ──
|
||
async function renderSignals() {
|
||
const el = document.getElementById('tab-signals');
|
||
el.innerHTML = '<div class="text-slate-400 text-sm">加载中…</div>';
|
||
try {
|
||
const [signals, candidates] = await Promise.all([
|
||
fetchJSON('/api/signals'),
|
||
fetchJSON('/api/candidates')
|
||
]);
|
||
let html = `
|
||
<div class="flex items-center justify-between mb-3">
|
||
<span class="text-sm font-semibold">🔍 信号 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('signals','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('signals','ai')">§</span></span></span>
|
||
</div>
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||
<div class="card p-4">
|
||
<div class="text-xs text-slate-500 mb-1">今日信号</div>
|
||
<div class="flex gap-4 text-sm">
|
||
<span class="text-blue-400">📡 趋势信号: ${signals.length || 0} 条</span>
|
||
<span class="text-green-400">🎯 候选股: ${candidates.length || 0} 只</span>
|
||
</div>
|
||
</div>
|
||
<div class="card p-4">
|
||
<div class="text-xs text-slate-500 mb-1">全市场扫描</div>
|
||
<div class="flex gap-4 text-sm">
|
||
<span class="text-slate-300">📋 候选池: ${candidates.length || 0} 只</span>
|
||
<span class="text-yellow-400">🏆 已入自选: ${candidates.filter(c => c.promoted).length || 0} 只</span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
if (!signals || signals.length === 0) {
|
||
html += '<div class="card p-6 text-center text-slate-500 text-sm">暂无信号</div>';
|
||
el.innerHTML = html;
|
||
return;
|
||
}
|
||
|
||
html += '<div class="space-y-3">';
|
||
for (const s of signals) {
|
||
const sev = s.severity || '-';
|
||
const sevColor = sev === 'high' ? 'text-red-400' : sev === 'medium' ? 'text-yellow-400' : 'text-green-400';
|
||
const sent = s.overall_sentiment || '-';
|
||
const sentColor = sent === '利好' ? 'text-green-400' : sent === '利空' ? 'text-red-400' : 'text-slate-400';
|
||
const source = s.source || 'trend';
|
||
const sourceIcon = source === 'market_scanner' ? '🎯' : source === 'macro' ? '🌐' : '📡';
|
||
html += `
|
||
<div class="card p-4">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<span class="${sevColor} text-xs font-semibold">${sev.toUpperCase()}</span>
|
||
<span class="${sentColor} text-sm font-semibold">${sent}</span>
|
||
<span class="text-slate-500 text-xs">${sourceIcon} ${source}</span>
|
||
<span class="text-slate-600 text-xs ml-auto">${s.created_at ? s.created_at.slice(5,16) : ''}</span>
|
||
</div>
|
||
<div class="text-sm text-slate-300">${s.sector || '-'}</div>
|
||
<div class="text-xs text-slate-500 mt-1">${(s.summary || '').slice(0,120)}</div>
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
el.innerHTML = html;
|
||
} catch(e) {
|
||
el.innerHTML = `<div class="text-red-400 text-sm">加载失败: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
// ── Spec System ──
|
||
let _specCache = {};
|
||
|
||
function closeSpecModal() {
|
||
const el = document.getElementById('specModal');
|
||
if (el) { el.classList.add('hidden'); el.classList.remove('flex'); }
|
||
}
|
||
|
||
function showModuleHelp(module, type) {
|
||
const cacheKey = module + ':' + type;
|
||
const cached = _specCache[cacheKey];
|
||
const modal = document.getElementById('specModal');
|
||
modal.classList.remove('hidden');
|
||
modal.classList.add('flex');
|
||
|
||
// Show loading
|
||
document.getElementById('specModalTitle').textContent = '加载中...';
|
||
document.getElementById('specModalSubtitle').textContent = '';
|
||
document.getElementById('specModalBody').innerHTML = '<div class="text-slate-500">正在获取规范...</div>';
|
||
|
||
const url = `/api/module-spec/${module}`;
|
||
const doRender = function(spec) {
|
||
if (spec.error) {
|
||
document.getElementById('specModalBody').innerHTML = `<div class="text-red-400">获取失败: ${spec.error}</div>`;
|
||
return;
|
||
}
|
||
_specCache[cacheKey] = spec;
|
||
_renderSpecModal(spec, type);
|
||
};
|
||
|
||
if (cached) {
|
||
doRender(cached);
|
||
} else {
|
||
fetchJSON(url).then(spec => {
|
||
if (spec && typeof spec === 'object') {
|
||
_specCache[cacheKey] = spec;
|
||
doRender(spec);
|
||
} else {
|
||
document.getElementById('specModalBody').innerHTML = '<div class="text-slate-500">无规范数据</div>';
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function _renderSpecModal(spec, type) {
|
||
const titleEl = document.getElementById('specModalTitle');
|
||
const subEl = document.getElementById('specModalSubtitle');
|
||
const bodyEl = document.getElementById('specModalBody');
|
||
|
||
let html = '';
|
||
|
||
if (type === 'human') {
|
||
const h = spec.human_help || {};
|
||
titleEl.textContent = h.title || spec.module || module;
|
||
subEl.textContent = 'Human Help · 使用指南';
|
||
|
||
if (h.description && h.description.length) {
|
||
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#58a6ff] mb-1">说明</div><p class="text-[#c9d1d9]">' + h.description.join('<br>') + '</p></div>';
|
||
}
|
||
if (h.usage && h.usage.length) {
|
||
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#3fb950] mb-1">用法</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
|
||
h.usage.forEach(u => { html += '<li>' + u + '</li>'; });
|
||
html += '</ul></div>';
|
||
}
|
||
if (h.troubleshooting && h.troubleshooting.length) {
|
||
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#d29922] mb-1">故障排除</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
|
||
h.troubleshooting.forEach(t => { html += '<li>' + t + '</li>'; });
|
||
html += '</ul></div>';
|
||
}
|
||
} else {
|
||
const a = spec.ai_spec || {};
|
||
titleEl.textContent = spec.module || module;
|
||
subEl.textContent = 'AI Spec · 模块规范';
|
||
|
||
if (a.apis && a.apis.length) {
|
||
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#58a6ff] mb-1">APIs</div>';
|
||
a.apis.forEach(api => {
|
||
const method = api.method || 'GET';
|
||
const color = method === 'GET' ? 'text-[#3fb950]' : method === 'POST' ? 'text-[#d29922]' : method === 'DELETE' ? 'text-[#f85149]' : 'text-[#58a6ff]';
|
||
html += '<div class="bg-[#0d1117] rounded p-2 mb-1 text-xs"><span class="font-mono font-bold ' + color + '">' + method + '</span> <span class="font-mono text-[#c9d1d9]">' + (api.path || '') + '</span>';
|
||
if (api.description) html += '<div class="text-[#8b949e] mt-0.5">' + api.description + '</div>';
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
if (a.dependencies && a.dependencies.length) {
|
||
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#3fb950] mb-1">依赖</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
|
||
a.dependencies.forEach(d => { html += '<li>' + d + '</li>'; });
|
||
html += '</ul></div>';
|
||
}
|
||
if (a.constraints && a.constraints.length) {
|
||
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#d29922] mb-1">约束</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
|
||
a.constraints.forEach(c => { html += '<li>' + c + '</li>'; });
|
||
html += '</ul></div>';
|
||
}
|
||
if (a.must_not && a.must_not.length) {
|
||
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#f85149] mb-1">禁止</div><ul class="list-disc list-inside text-[#c9d1d9] space-y-1">';
|
||
a.must_not.forEach(m => { html += '<li>' + m + '</li>'; });
|
||
html += '</ul></div>';
|
||
}
|
||
if (a.related_files && a.related_files.length) {
|
||
html += '<div class="mb-4"><div class="text-xs font-semibold text-[#58a6ff] mb-1">相关文件</div><ul class="list-disc list-inside text-xs font-mono text-[#8b949e] space-y-1">';
|
||
a.related_files.forEach(f => { html += '<li>' + f + '</li>'; });
|
||
html += '</ul></div>';
|
||
}
|
||
}
|
||
|
||
if (!html) {
|
||
html = '<div class="text-slate-500">该模块暂无规范数据</div>';
|
||
}
|
||
|
||
bodyEl.innerHTML = html;
|
||
}
|
||
|
||
// ── Health Tab (native) ──
|
||
let healthRefreshInterval = null;
|
||
|
||
async function renderHealth() {
|
||
const el = document.getElementById('tab-health');
|
||
el.innerHTML = '<div class="text-slate-500 py-8">加载中...</div>';
|
||
try {
|
||
const [services, monitor] = await Promise.all([
|
||
fetchJSON('/api/services'),
|
||
fetchJSON('/api/monitor')
|
||
]);
|
||
|
||
// XMPP 通道健康(独立 fetch,不阻塞主流程)
|
||
let xmpp = null;
|
||
try { xmpp = await fetchJSON('/api/xmpp/health'); } catch(e) {}
|
||
// 最近 XMPP 对话日志(bot 挂钩 log_xmpp 后的真实聊天记录)
|
||
let xmppMsgs = [];
|
||
try { const md = await fetchJSON('/api/xmpp/messages'); xmppMsgs = md.messages || []; } catch(e) {}
|
||
|
||
const svcs = services.services || [];
|
||
const summary = services.summary || { ok: 0, total: 0 };
|
||
const tasks = monitor.tasks || [];
|
||
|
||
// Summary cards — 包含 XMPP 状态
|
||
let html = '<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">';
|
||
html += '<div class="card p-3 text-center"><div class="text-lg font-bold text-[#3fb950] font-mono" id="hSvcOk">' + (summary.ok || 0) + '</div><div class="text-xs text-slate-500">正常服务</div></div>';
|
||
html += '<div class="card p-3 text-center"><div class="text-lg font-bold text-white font-mono" id="hSvcTotal">' + (summary.total || 0) + '</div><div class="text-xs text-slate-500">总服务数</div></div>';
|
||
const xmppStatus = xmpp ? xmpp.status : 'unknown';
|
||
const xmppColor = xmppStatus === 'ok' ? '#3fb950' : xmppStatus === 'degraded' ? '#d29922' : '#f85149';
|
||
const xmppLabel = xmppStatus === 'ok' ? 'XMPP 正常' : xmppStatus === 'degraded' ? 'XMPP 降级' : xmppStatus === 'critical' ? 'XMPP 断联' : 'XMPP 无数据';
|
||
html += '<div class="card p-3 text-center"><div class="text-lg font-bold font-mono" id="hXmppAge" style="color:' + xmppColor + '">' + (xmpp ? (xmpp.last_message_age_sec > 0 ? Math.round(xmpp.last_message_age_sec/60) + 'm' : '--') : '--') + '</div><div class="text-xs text-slate-500" id="hXmppLabel">' + xmppLabel + '</div></div>';
|
||
html += '<div class="card p-3 text-center"><div class="text-lg font-bold text-[#d29922] font-mono" id="hXmppErr">' + (xmpp ? xmpp.error_rate_1h + '%' : '--') + '</div><div class="text-xs text-slate-500">XMPP 错误率(1h)</div></div>';
|
||
html += '</div>';
|
||
|
||
// Services by layer
|
||
const layers = {};
|
||
svcs.forEach(s => {
|
||
const layer = s.layer || 'other';
|
||
if (!layers[layer]) layers[layer] = [];
|
||
layers[layer].push(s);
|
||
});
|
||
|
||
html += '<div class="card overflow-hidden mb-4"><table class="w-full text-xs"><thead><tr class="text-slate-500 border-b border-slate-800/50"><th class="text-left p-3">层级</th><th class="text-left p-3">服务</th><th class="text-left p-3">端口</th><th class="text-left p-3">状态</th></tr></thead><tbody>';
|
||
Object.keys(layers).sort().forEach(layer => {
|
||
layers[layer].forEach((s, i) => {
|
||
const ok = s.health?.ok;
|
||
const statusIcon = ok ? '🟢' : (ok === false ? '🔴' : '🟡');
|
||
const statusColor = ok ? 'text-[#3fb950]' : (ok === false ? 'text-[#f85149]' : 'text-[#d29922]');
|
||
const label = s.label || s.name || s.type || '';
|
||
const port = s.port || '-';
|
||
if (i === 0) {
|
||
html += '<tr class="border-b border-slate-800/30"><td class="p-3 font-semibold text-slate-300" rowspan="' + layers[layer].length + '">' + layer + '</td><td class="p-3">' + s.name + ' <span class="text-slate-500">' + label + '</span></td><td class="p-3 font-mono">' + port + '</td><td class="p-3 ' + statusColor + '" id="hSvc_' + s.name + '">' + statusIcon + (ok ? ' 正常' : (ok === false ? ' 异常' : ' 未知')) + '</td></tr>';
|
||
} else {
|
||
html += '<tr class="border-b border-slate-800/30"><td class="p-3">' + s.name + ' <span class="text-slate-500">' + label + '</span></td><td class="p-3 font-mono">' + port + '</td><td class="p-3 ' + statusColor + '" id="hSvc_' + s.name + '">' + statusIcon + (ok ? ' 正常' : (ok === false ? ' 异常' : ' 未知')) + '</td></tr>';
|
||
}
|
||
});
|
||
});
|
||
html += '</tbody></table></div>';
|
||
|
||
// Task status
|
||
if (tasks.length) {
|
||
html += '<div class="card overflow-hidden"><table class="w-full text-xs"><thead><tr class="text-slate-500 border-b border-slate-800/50"><th class="text-left p-3">任务</th><th class="text-left p-3">状态</th><th class="text-left p-3">详情</th></tr></thead><tbody>';
|
||
tasks.forEach(t => {
|
||
const st = t.status || '';
|
||
const sc = st === 'ok' || st === 'running' ? 'text-[#3fb950]' : st === 'error' ? 'text-[#f85149]' : st === 'pending' ? 'text-[#d29922]' : 'text-slate-500';
|
||
html += '<tr class="border-b border-slate-800/30"><td class="p-3">' + (t.name || '') + '</td><td class="p-3 ' + sc + '">' + st + '</td><td class="p-3 text-slate-500">' + (t.detail || '') + '</td></tr>';
|
||
});
|
||
html += '</tbody></table></div>';
|
||
}
|
||
|
||
html += '<div class="text-xs text-slate-600 mt-2" id="hUpdated">更新于 ' + (monitor.generated_at || new Date().toLocaleTimeString()) + '</div>';
|
||
|
||
// XMPP 通道详情
|
||
if (xmpp) {
|
||
html += '<div class="card p-4 mt-4"><h3 class="text-sm font-semibold text-[#58a6ff] mb-2">📡 XMPP 通道</h3>';
|
||
html += '<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">';
|
||
html += '<div><span class="text-slate-500">最后消息</span><br><span class="font-mono" id="hXmppDetail_age">' + (xmpp.last_message_age_sec > 0 ? Math.round(xmpp.last_message_age_sec) + '秒前' : '无数据') + '</span></div>';
|
||
html += '<div><span class="text-slate-500">1h错误率</span><br><span class="font-mono" id="hXmppDetail_err">' + xmpp.error_rate_1h + '%</span></div>';
|
||
html += '<div><span class="text-slate-500">ejabberd</span><br><span class="font-mono" id="hXmppDetail_eja">' + (xmpp.ejabberd || '?') + '</span></div>';
|
||
html += '<div><span class="text-slate-500">状态</span><br><span class="font-mono" id="hXmppDetail_st">' + xmpp.status + '</span></div>';
|
||
html += '</div>';
|
||
|
||
// Bot 活动
|
||
const ba = xmpp.bot_activity || {};
|
||
html += '<div class="grid grid-cols-3 gap-2 text-xs mt-3 pt-3 border-t border-slate-800/50">';
|
||
html += '<div><span class="text-slate-500">📩 入站</span><br><span class="font-mono text-[#58a6ff]" id="hBot_in">' + (ba.inbound || 0) + '</span></div>';
|
||
html += '<div><span class="text-slate-500">📤 出站</span><br><span class="font-mono ' + (ba.outbound ? 'text-[#3fb950]' : (ba.inbound ? 'text-[#f85149]' : 'text-slate-500')) + '" id="hBot_out">' + (ba.outbound || 0) + '</span></div>';
|
||
html += '<div><span class="text-slate-500">⚠️ 错误</span><br><span class="font-mono ' + (ba.errors ? 'text-[#f85149]' : 'text-slate-500') + '" id="hBot_err">' + (ba.errors || 0) + '</span></div>';
|
||
html += '</div>';
|
||
if (ba.last_error) {
|
||
const errAge = ba.last_error_age_sec >= 0 ? Math.round(ba.last_error_age_sec/60) + '分钟前' : '';
|
||
if (ba.last_error_resolved) {
|
||
html += '<div class="text-xs text-slate-500 mt-2 p-2 bg-slate-800/30 rounded" id="hBot_lastErr">⚠️ 历史错误(' + errAge + ',已恢复): ' + ba.last_error + '</div>';
|
||
} else {
|
||
html += '<div class="text-xs text-[#f85149] mt-2 p-2 bg-red-900/20 rounded" id="hBot_lastErr">⚠️ ' + errAge + ': ' + ba.last_error + '</div>';
|
||
}
|
||
}
|
||
|
||
// Hermes Gateway + LLM Provider
|
||
const gw = xmpp.gateways || {};
|
||
const llm = xmpp.llm_provider || {};
|
||
html += '<div class="grid grid-cols-2 gap-2 text-xs mt-3 pt-3 border-t border-slate-800/50">';
|
||
html += '<div><span class="text-slate-500">🔌 Hermes Gateway</span><br>';
|
||
Object.entries(gw).forEach(([k,v]) => {
|
||
html += '<span class="font-mono ' + (v.alive ? 'text-[#3fb950]' : 'text-[#f85149]') + '">' + k + ':' + v.port + (v.alive ? ' ✅' : ' ❌') + '</span> ';
|
||
});
|
||
html += '</div>';
|
||
html += '<div><span class="text-slate-500">🧠 LLM Provider</span><br><span class="font-mono ' + (llm.status === 'ok' ? 'text-[#3fb950]' : 'text-[#f85149]') + '" id="hLlmStatus">' + (llm.status || '?') + '</span>';
|
||
if (llm.error) html += '<br><span class="text-[#f85149]" id="hLlmErr">' + llm.error + '</span>';
|
||
html += '</div></div>';
|
||
|
||
// ── 最近 XMPP 对话日志 ──
|
||
html += '<div class="mt-3 pt-3 border-t border-slate-800/50">';
|
||
html += '<div class="text-xs text-slate-500 mb-2">💬 最近对话(' + xmppMsgs.length + ' 条)</div>';
|
||
if (xmppMsgs.length === 0) {
|
||
html += '<div class="text-xs text-slate-600 p-2">暂无记录 — bot 重启后对话开始采集</div>';
|
||
} else {
|
||
html += '<div class="space-y-1 max-h-64 overflow-y-auto" id="hXmppMsgs">';
|
||
xmppMsgs.slice(0, 10).forEach(m => {
|
||
const isIn = m.direction === 'in';
|
||
const arrow = isIn ? '📩' : '📤';
|
||
const dirColor = isIn ? 'text-[#58a6ff]' : 'text-[#3fb950]';
|
||
const statusMark = m.status === 'ok' ? '' : ' <span class="text-[#f85149]">[' + m.status + ']</span>';
|
||
const who = isIn ? (m.from || '').split('@')[0] : '知微';
|
||
const t = (m.timestamp || '').slice(11, 16);
|
||
const preview = (m.body_preview || '').replace(/</g, '<').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) lines.push('买入区间: ' + (s.entry_low||'—') + '~' + (s.entry_high||'—'));
|
||
if (s.stop_loss || s.take_profit) lines.push('止损: ' + (s.stop_loss||'—') + ' / 止盈: ' + (s.take_profit||'—'));
|
||
if (s.rr_ratio) lines.push('RR: ' + s.rr_ratio.toFixed(2));
|
||
if (s.timing_signal) lines.push('信号: ' + s.timing_signal);
|
||
if (s.full_analysis) lines.push('\n分析: ' + s.full_analysis);
|
||
return lines.join('\n');
|
||
}
|
||
|
||
async function renderWatch() {
|
||
const el = document.getElementById('watchContent');
|
||
if (!el) return;
|
||
try {
|
||
if (watchTimer) clearInterval(watchTimer);
|
||
const data = await fetchJSON('/api/watch');
|
||
const stocks = data.stocks || [];
|
||
document.getElementById('watchCount').textContent = stocks.length;
|
||
document.getElementById('watchRefreshTime').textContent = new Date().toLocaleTimeString();
|
||
|
||
if (stocks.length === 0) {
|
||
el.innerHTML = '<div class="p-6 text-center text-slate-500 text-sm">暂无策略数据</div>';
|
||
return;
|
||
}
|
||
|
||
// 分组渲染(推荐操作区域独立于持仓/自选,置顶且与XMPP推荐同步)
|
||
const groups = [
|
||
{ label: '🔥 推荐操作', g: 0 },
|
||
{ label: '💼 持仓策略', g: 1 },
|
||
{ label: '⭐ 自选策略', g: 2 },
|
||
];
|
||
|
||
let html = '<div class="overflow-x-auto"><table class="w-full text-xs">';
|
||
html += '<thead><tr class="text-slate-500 border-b border-slate-800/50">' +
|
||
'<th class="text-left p-2">股票</th>' +
|
||
'<th class="text-right p-2">现价</th>' +
|
||
'<th class="text-right p-2">涨跌%</th>' +
|
||
'<th class="text-left p-2">信号</th>' +
|
||
'<th class="text-right p-2">买入区间</th>' +
|
||
'<th class="text-right p-2">止损/止盈</th>' +
|
||
'<th class="text-right p-2">RR</th>' +
|
||
'<th class="text-right p-2">仓位</th>' +
|
||
'<th class="text-left p-2">当前操作策略</th>' +
|
||
'<th class="text-center p-2">操作策略</th>' +
|
||
'</tr></thead><tbody>';
|
||
|
||
for (const grp of groups) {
|
||
const items = stocks.filter(s => s.sort_group === grp.g);
|
||
if (items.length === 0) continue;
|
||
|
||
// 分组标题行(推荐操作区域独立视觉)
|
||
const hdrCls = grp.g === 0
|
||
? 'border-y border-amber-500/50 bg-amber-900/25'
|
||
: 'border-b border-slate-700';
|
||
html += '<tr class="' + hdrCls + '">' +
|
||
'<td colspan="10" class="p-2 font-semibold ' + (grp.g === 0 ? 'text-amber-300' : 'text-slate-300') + '">' + grp.label + ' (' + items.length + ')</td>' +
|
||
'</tr>';
|
||
|
||
for (const s of items) {
|
||
const p = s.price || 0;
|
||
const cp = s.change_pct || 0;
|
||
const sig = s.timing_signal || '';
|
||
const isRec = s.sort_group === 0; // 推荐行
|
||
|
||
const sigCls = sigBadgeClass(sig);
|
||
const chgColor = cp >= 0 ? 'text-red-400' : 'text-green-400';
|
||
const el_ = s.entry_low || 0;
|
||
const eh_ = s.entry_high || 0;
|
||
const sl_ = s.stop_loss || 0;
|
||
const tp_ = s.take_profit || 0;
|
||
const rr = s.rr_ratio || 0;
|
||
const shares = s.shares || 0;
|
||
const posPct = s.position_pct || 0;
|
||
|
||
const buyZone = (el_ && eh_) ? el_.toFixed(1) + '~' + eh_.toFixed(1) : '—';
|
||
const sltp = (sl_ || tp_) ? '损' + (sl_||'—') + '/盈' + (tp_||'—') : '—';
|
||
const posDisplay = posPct ? posPct.toFixed(1) + '%' : '—';
|
||
|
||
// 当前操作策略摘要
|
||
const strat = fmtStrategy(s);
|
||
|
||
// 推荐行高亮
|
||
const rowCls = isRec ? 'bg-amber-900/15 border-l-2 border-l-amber-400' : 'border-b border-slate-800/30 hover:bg-slate-800/20';
|
||
const recBadge = isRec ? '<span class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 ml-1">🔥推荐</span>' : '';
|
||
|
||
// 推荐行策略文本加粗醒目
|
||
const stratCls = isRec ? 'font-semibold text-amber-200' : 'text-slate-300';
|
||
|
||
html += '<tr class="' + rowCls + '" onclick="openStock(\'' + s.code + '\')" style="cursor:pointer">' +
|
||
'<td class="p-2"><span class="font-medium text-white">' + s.name + '</span><br><span class="text-slate-500">' + s.code + '</span>' + recBadge + '</td>' +
|
||
'<td class="p-2 text-right font-mono">' + (p ? p.toFixed(2) : '—') + '</td>' +
|
||
'<td class="p-2 text-right font-mono ' + chgColor + '">' + (cp >= 0 ? '+' : '') + (cp ? cp.toFixed(2) : '—') + '%</td>' +
|
||
'<td class="p-2 text-left"><span class="text-xs px-2 py-1 rounded ' + sigCls + '">' + (sig || '—') + '</span></td>' +
|
||
'<td class="p-2 text-right font-mono text-slate-300">' + buyZone + '</td>' +
|
||
'<td class="p-2 text-right font-mono text-slate-300">' + sltp + '</td>' +
|
||
'<td class="p-2 text-right font-mono ' + (rr >= 1.5 ? 'text-green-400' : rr > 0 ? 'text-yellow-400' : 'text-slate-500') + '">' + (rr ? rr.toFixed(2) : '—') + '</td>' +
|
||
'<td class="p-2 text-right font-mono text-slate-300">' + posDisplay + '</td>' +
|
||
'<td class="p-2 ' + stratCls + ' max-w-[200px] break-all line-clamp-2">' + strat + '</td>' +
|
||
'<td class="p-2 text-center"><button class="text-xs px-2 py-1 rounded bg-blue-900/50 text-blue-400 hover:bg-blue-800/50 transition" onclick="event.stopPropagation();openStrategyHistory(\'' + s.code + '\')">📋 历史</button></td>' +
|
||
'</tr>';
|
||
}
|
||
}
|
||
|
||
html += '</tbody></table></div>';
|
||
// 更新时间
|
||
html += '<div class="text-xs text-slate-600 mt-1">' + (data.updated_at ? '数据: ' + data.updated_at : '') + '</div>';
|
||
|
||
el.innerHTML = html;
|
||
// 自动刷新:每30秒
|
||
watchTimer = setInterval(renderWatch, 30000);
|
||
} catch(e) {
|
||
el.innerHTML = '<div class="p-6 text-red-400 text-sm">加载失败: ' + e.message + '</div>';
|
||
watchTimer = setInterval(renderWatch, 60000);
|
||
}
|
||
}
|
||
|
||
// ── 操作策略历史 Modal ──
|
||
async function openStrategyHistory(code) {
|
||
const modal = document.getElementById('strategyModal');
|
||
modal.classList.remove('hidden');
|
||
modal.classList.add('flex');
|
||
document.getElementById('strategyModalTitle').textContent = '加载中...';
|
||
document.getElementById('strategyModalMeta').textContent = '';
|
||
document.getElementById('strategyModalBody').innerHTML = '<div class="text-slate-500">正在获取策略历史...</div>';
|
||
|
||
try {
|
||
const d = await fetchJSON('/api/strategy_history/' + code + '?limit=3');
|
||
const history = d.history || [];
|
||
if (history.length === 0) {
|
||
document.getElementById('strategyModalTitle').textContent = '操作策略历史';
|
||
document.getElementById('strategyModalMeta').textContent = code + ' · 无历史记录';
|
||
document.getElementById('strategyModalBody').innerHTML = '<div class="text-slate-500 text-center py-8">暂无策略历史记录</div>';
|
||
return;
|
||
}
|
||
// 找出股票名称
|
||
const name = history[0].name || code;
|
||
document.getElementById('strategyModalTitle').textContent = name + ' · ' + code;
|
||
document.getElementById('strategyModalMeta').textContent = '最近 ' + history.length + ' 条策略记录';
|
||
|
||
let body = '';
|
||
// 最新一条默认展开,其余可折叠
|
||
for (let i = 0; i < history.length; i++) {
|
||
const h = history[i];
|
||
const isLatest = i === 0;
|
||
const collapsed = !isLatest ? ' hidden' : '';
|
||
const toggleId = 'strat-' + i;
|
||
|
||
// 策略头信息
|
||
const headInfo = [];
|
||
if (h.snapshotted_at) headInfo.push('快照: ' + h.snapshotted_at.slice(0, 16));
|
||
if (h.reassessed_at) headInfo.push('重评: ' + h.reassessed_at.slice(0, 16));
|
||
if (h.version) headInfo.push('版本: ' + h.version);
|
||
if (h.is_current) headInfo.push('⚡ 当前策略');
|
||
|
||
const action = h.action || '';
|
||
const timing = h.timing_signal || '';
|
||
const sigCls = sigBadgeClass(timing);
|
||
|
||
body += '<div class="card p-4">' +
|
||
'<div class="flex justify-between items-start mb-2">' +
|
||
'<div>' +
|
||
'<span class="font-semibold text-white">' + (action || '—') + '</span>' +
|
||
'<span class="text-xs px-2 py-0.5 rounded ml-2 ' + sigCls + '">' + (timing || '—') + '</span>' +
|
||
'<div class="text-xs text-slate-500 mt-1">' + headInfo.join(' · ') + '</div>' +
|
||
'</div>' +
|
||
(isLatest ? '' : '<button class="text-xs text-blue-400 hover:text-blue-300" onclick="toggleStrat(\'' + toggleId + '\')">展开</button>') +
|
||
'</div>';
|
||
|
||
// 策略参数
|
||
const params = [];
|
||
if (h.entry_low || h.entry_high) params.push('买入区间: ' + (h.entry_low||'—') + '~' + (h.entry_high||'—'));
|
||
if (h.stop_loss) params.push('止损: ¥' + h.stop_loss);
|
||
if (h.take_profit) params.push('止盈: ¥' + h.take_profit);
|
||
if (h.rr_ratio) params.push('RR: ' + h.rr_ratio.toFixed(2));
|
||
if (h.position_advice) params.push('仓位: ' + h.position_advice);
|
||
|
||
if (params.length > 0) {
|
||
body += '<div class="text-xs text-slate-300 mb-2">' + params.join(' · ') + '</div>';
|
||
}
|
||
|
||
// 完整分析(折叠)
|
||
const fa = h.full_analysis || '';
|
||
if (fa) {
|
||
body += '<details class="text-xs">' +
|
||
'<summary class="text-slate-400 cursor-pointer hover:text-blue-400">📋 完整分析</summary>' +
|
||
'<pre class="mt-1 text-slate-300 whitespace-pre-wrap bg-slate-900/50 rounded p-2 max-h-48 overflow-y-auto font-sans leading-relaxed">' + fa.replace(/</g, '<') + '</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>');
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |