aa0f740381
完整数据采集+分析管道: - market_watch.py:90行业板块采集(同花顺/东方财富) - 市场精选推荐 cron:全市场分析+候选池+星级推荐 - price_monitor.py:持仓/自选高频价格监控 - refresh_mtf_cache.py:多周期K线缓存 - 策略评估/知识萃取管道 文档:docs/ 含完整需求+架构设计 注意:尚未配置 git remote,笑笑接手后自行配置
247 lines
10 KiB
HTML
247 lines
10 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>
|
|
<style>
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
body { font-family: -apple-system, 'PingFang SC', sans-serif; background: #0f0f1a; color: #e0e0e0; min-height: 100vh; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px; }
|
|
.container { max-width: 800px; width: 100%; }
|
|
h1 { font-size: 22px; margin-bottom: 8px; color: #00d4ff; }
|
|
.sub { color: #888; font-size: 13px; margin-bottom: 30px; }
|
|
.upload-box { border: 2px dashed #333; border-radius: 12px; padding: 40px; text-align: center; cursor: pointer; transition: all .3s; background: #1a1a2e; }
|
|
.upload-box:hover { border-color: #00d4ff; background: #1e1e35; }
|
|
.upload-box.dragover { border-color: #00d4ff; background: #1a1a3e; }
|
|
.upload-box .icon { font-size: 48px; margin-bottom: 12px; }
|
|
.upload-box .hint { color: #888; font-size: 13px; margin-top: 8px; }
|
|
input[type=file] { display: none; }
|
|
#preview { display: none; max-width: 100%; max-height: 400px; margin: 15px auto; border-radius: 8px; }
|
|
#loading { display: none; text-align: center; padding: 20px; }
|
|
#loading .spinner { width: 40px; height: 40px; border: 3px solid #333; border-top-color: #00d4ff; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 12px; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
#result { display: none; margin-top: 24px; }
|
|
.result-box { background: #1a1a2e; border-radius: 12px; padding: 20px; }
|
|
.result-box h3 { color: #00d4ff; font-size: 16px; margin-bottom: 15px; }
|
|
.result-box .type-badge { display: inline-block; padding: 2px 10px; border-radius: 4px; font-size: 12px; margin-bottom: 15px; }
|
|
.badge-portfolio { background: #1a3a3a; color: #00ff88; }
|
|
.badge-watchlist { background: #3a2a1a; color: #ffaa00; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #222; }
|
|
th { color: #888; font-weight: 500; white-space: nowrap; }
|
|
tr:hover td { background: #222; }
|
|
.actions { margin-top: 20px; display: flex; gap: 10px; }
|
|
.btn { padding: 10px 24px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; transition: all .2s; }
|
|
.btn-primary { background: #00d4ff; color: #000; }
|
|
.btn-primary:hover { background: #00b8e0; }
|
|
.btn-secondary { background: #333; color: #e0e0e0; }
|
|
.btn-secondary:hover { background: #444; }
|
|
.btn-danger { background: #d63031; color: #fff; }
|
|
.btn-danger:hover { background: #b22020; }
|
|
#error { display: none; background: #2a1a1a; border: 1px solid #d63031; border-radius: 8px; padding: 15px; margin-top: 15px; color: #ff6b6b; font-size: 14px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1><a href="/" style="text-decoration:none;color:#00d4ff">📸 持仓截图解析</a></h1>
|
|
<p class="sub"><a href="/" style="color:#666;text-decoration:none">← 返回仪表盘</a> · 上传持仓截图或自选截图,知微自动识别并更新数据</p>
|
|
|
|
<div class="upload-box" id="dropZone" onclick="document.getElementById('fileInput').click()">
|
|
<div class="icon">📤</div>
|
|
<div>点击上传、拖拽图片、或 Ctrl+V 粘贴</div>
|
|
<div class="hint">支持 PNG / JPG / JPEG,建议清晰截图</div>
|
|
</div>
|
|
<input type="file" id="fileInput" accept="image/png,image/jpeg,image/jpg">
|
|
|
|
<img id="preview">
|
|
|
|
<div id="loading">
|
|
<div class="spinner"></div>
|
|
<div>🔍 知微正在分析图片...</div>
|
|
</div>
|
|
|
|
<div id="error"></div>
|
|
|
|
<div id="result">
|
|
<div class="result-box">
|
|
<span class="type-badge" id="typeBadge">持仓</span>
|
|
<h3 id="resultTitle"></h3>
|
|
<div id="summaryBox"></div>
|
|
<div id="resultTable"></div>
|
|
<div class="actions">
|
|
<button class="btn btn-primary" onclick="confirmUpdate()">✅ 确认更新</button>
|
|
<button class="btn btn-secondary" onclick="resetPage()">🔄 重新上传</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="success" style="display:none">
|
|
<div class="result-box" style="text-align:center;padding:30px">
|
|
<div style="font-size:48px;margin-bottom:12px">✅</div>
|
|
<h3 style="color:#00ff88">数据已更新</h3>
|
|
<p id="successMsg" style="color:#888;margin:8px 0"></p>
|
|
<button class="btn btn-primary" onclick="resetPage()" style="margin-top:15px">📸 上传下一张</button>
|
|
<a href="/" class="btn btn-secondary" style="display:inline-block;margin-top:10px;text-decoration:none">📊 返回仪表盘</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const dropZone = document.getElementById('dropZone');
|
|
const fileInput = document.getElementById('fileInput');
|
|
const preview = document.getElementById('preview');
|
|
const loading = document.getElementById('loading');
|
|
const result = document.getElementById('result');
|
|
const success = document.getElementById('success');
|
|
const error = document.getElementById('error');
|
|
let lastResult = null;
|
|
|
|
// Drag & drop
|
|
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
|
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('dragover'); handleFile(e.dataTransfer.files[0]); });
|
|
|
|
// Paste support (截图后直接Ctrl+V)
|
|
document.addEventListener('paste', e => {
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
for (const item of items) {
|
|
if (item.type.startsWith('image/')) {
|
|
e.preventDefault();
|
|
const blob = item.getAsFile();
|
|
if (blob) handleFile(blob);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
fileInput.addEventListener('change', e => handleFile(e.target.files[0]));
|
|
|
|
function handleFile(file) {
|
|
if (!file) return;
|
|
if (!file.type.startsWith('image/')) { showError('请上传图片文件'); return; }
|
|
if (file.size > 10 * 1024 * 1024) { showError('图片太大,请小于10MB'); return; }
|
|
|
|
// Show preview
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
preview.src = e.target.result;
|
|
preview.style.display = 'block';
|
|
dropZone.style.display = 'none';
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
// Upload
|
|
loading.style.display = 'block';
|
|
error.style.display = 'none';
|
|
result.style.display = 'none';
|
|
|
|
const form = new FormData();
|
|
form.append('image', file);
|
|
|
|
fetch('/api/upload/analyze', { method: 'POST', body: form })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
loading.style.display = 'none';
|
|
if (data.error) { showError(data.error); return; }
|
|
lastResult = data;
|
|
showResult(data);
|
|
})
|
|
.catch(err => {
|
|
loading.style.display = 'none';
|
|
showError('上传失败: ' + err.message);
|
|
});
|
|
}
|
|
|
|
// 格式化数字
|
|
function formatNum(n) {
|
|
if (!n) return '-';
|
|
const num = parseFloat(n);
|
|
if (isNaN(num)) return n;
|
|
return num.toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
|
}
|
|
|
|
function showResult(data) {
|
|
result.style.display = 'block';
|
|
const badge = document.getElementById('typeBadge');
|
|
const title = document.getElementById('resultTitle');
|
|
const table = document.getElementById('resultTable');
|
|
const summaryBox = document.getElementById('summaryBox');
|
|
|
|
const isPortfolio = data.type === 'portfolio';
|
|
badge.className = 'type-badge ' + (isPortfolio ? 'badge-portfolio' : 'badge-watchlist');
|
|
badge.textContent = isPortfolio ? '📊 持仓截图' : '⭐ 自选截图';
|
|
title.textContent = `解析出 ${data.stocks.length} 只股票`;
|
|
|
|
// 显示汇总数据
|
|
const s = data.summary || {};
|
|
let sumHtml = '';
|
|
if (isPortfolio && (s.total_assets || s.stock_value || s.cash || s.day_pnl)) {
|
|
sumHtml = '<div style="display:flex;flex-wrap:wrap;gap:12px;margin-bottom:15px;padding:12px;background:#151525;border-radius:8px">';
|
|
if (s.total_assets) sumHtml += `<div style="flex:1;min-width:120px"><div style="color:#888;font-size:12px">总资产</div><div style="font-size:18px;font-weight:600;color:#00d4ff">${formatNum(s.total_assets)}</div></div>`;
|
|
if (s.stock_value) sumHtml += `<div style="flex:1;min-width:120px"><div style="color:#888;font-size:12px">股票市值</div><div style="font-size:18px;font-weight:600;color:#e0e0e0">${formatNum(s.stock_value)}</div></div>`;
|
|
if (s.cash) sumHtml += `<div style="flex:1;min-width:120px"><div style="color:#888;font-size:12px">可用资金</div><div style="font-size:18px;font-weight:600;color:#e0e0e0">${formatNum(s.cash)}</div></div>`;
|
|
if (s.day_pnl) sumHtml += `<div style="flex:1;min-width:120px"><div style="color:#888;font-size:12px">当日盈亏</div><div style="font-size:18px;font-weight:600;color:${(s.day_pnl||'').includes('-')?'#ff6b6b':'#00ff88'}">${formatNum(s.day_pnl)}</div></div>`;
|
|
sumHtml += '</div>';
|
|
} else if (isPortfolio) {
|
|
sumHtml = '<div style="padding:12px;margin-bottom:15px;background:#151525;border-radius:8px;color:#888;font-size:13px">⚠️ 未识别到总资产/现金等汇总数据,确认更新后将保留原有数据</div>';
|
|
}
|
|
summaryBox.innerHTML = sumHtml;
|
|
|
|
let html = '<table><thead><tr><th>代码</th><th>名称</th><th>数量</th><th>现价</th><th>成本</th><th>盈亏</th><th>仓位%</th></tr></thead><tbody>';
|
|
data.stocks.forEach(s => {
|
|
html += `<tr>
|
|
<td>${s.code || '-'}</td>
|
|
<td>${s.name || '-'}</td>
|
|
<td>${s.shares || '-'}</td>
|
|
<td>${s.price || '-'}</td>
|
|
<td>${s.cost || '-'}</td>
|
|
<td style="color:${(s.pnl||'').includes('+')?'#00ff88':'#ff6b6b'}">${s.pnl || '-'}</td>
|
|
<td>${s.position_pct || '-'}</td>
|
|
</tr>`;
|
|
});
|
|
html += '</tbody></table>';
|
|
table.innerHTML = html;
|
|
}
|
|
|
|
function confirmUpdate() {
|
|
if (!lastResult) return;
|
|
loading.style.display = 'block';
|
|
fetch('/api/upload/confirm', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(lastResult)
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
loading.style.display = 'none';
|
|
if (data.error) { showError(data.error); return; }
|
|
result.style.display = 'none';
|
|
document.getElementById('successMsg').textContent = data.message;
|
|
success.style.display = 'block';
|
|
})
|
|
.catch(err => {
|
|
loading.style.display = 'none';
|
|
showError('更新失败: ' + err.message);
|
|
});
|
|
}
|
|
|
|
function showError(msg) {
|
|
error.textContent = msg;
|
|
error.style.display = 'block';
|
|
}
|
|
|
|
function resetPage() {
|
|
preview.style.display = 'none';
|
|
dropZone.style.display = 'block';
|
|
result.style.display = 'none';
|
|
success.style.display = 'none';
|
|
error.style.display = 'none';
|
|
loading.style.display = 'none';
|
|
lastResult = null;
|
|
fileInput.value = '';
|
|
preview.src = '';
|
|
}
|
|
</script>
|
|
</body>
|
|
</html> |