Files

1186 lines
55 KiB
HTML
Raw Permalink Blame History

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