GoldStock / index.html
fishercui's picture
Upload 3 files
e29c6b8 verified
Raw
History Blame Contribute Delete
28.3 kB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>多股黄金分割智能看盘面板</title>
<!-- 苹果 iOS PWA 专属 Meta 标签 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="黄金分割">
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="h-screen w-screen overflow-hidden bg-gray-100 flex flex-col p-2 md:p-4 text-sm md:text-base">
<!-- Header -->
<div class="flex items-center justify-between mb-2 md:mb-4 flex-shrink-0">
<div class="flex items-center gap-2 md:gap-4">
<!-- 手机端汉堡菜单按钮 -->
<button onclick="toggleSidebar()" class="md:hidden text-gray-700 hover:text-blue-600 focus:outline-none p-1">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<h1 class="text-xl md:text-2xl font-bold text-gray-800">📈 A股黄金分割 (云端版)</h1>
<div class="hidden md:flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm border border-gray-200">
<span id="statusDot" class="w-3 h-3 rounded-full bg-red-500 animate-pulse"></span>
<span id="statusText" class="text-xs text-red-600 font-bold">等待后台启动...</span>
</div>
</div>
<div id="globalLoading" class="hidden text-blue-600 font-bold text-sm md:text-base">数据同步中...</div>
</div>
<!-- Main Layout Area -->
<div class="flex flex-1 gap-2 md:gap-4 min-h-0 relative">
<!-- Mobile Overlay -->
<div id="sidebarOverlay" class="fixed inset-0 bg-black opacity-50 z-20 hidden md:hidden" onclick="toggleSidebar()"></div>
<!-- Left Sidebar: Watchlist (Responsive Drawer) -->
<div id="sidebar" class="fixed inset-y-0 left-0 transform -translate-x-full md:relative md:translate-x-0 transition duration-200 ease-in-out z-30 w-64 md:w-64 bg-white border border-gray-200 rounded-none md:rounded shadow-2xl md:shadow p-4 flex flex-col flex-shrink-0 h-full md:h-auto">
<div class="flex justify-between items-center border-b pb-2 mb-4">
<h2 class="text-lg font-bold text-gray-700">自选股列表</h2>
<button onclick="toggleSidebar()" class="md:hidden text-gray-500 font-bold text-2xl leading-none">&times;</button>
</div>
<div class="flex gap-2 mb-4 flex-shrink-0 relative">
<div class="flex-1 min-w-0 relative">
<input type="text" id="newSymbolInput" placeholder="输入代码/拼音" class="w-full px-2 py-1 bg-gray-50 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" autocomplete="off">
<ul id="searchResults" class="absolute z-50 w-full bg-white border border-gray-200 shadow-lg max-h-60 overflow-y-auto hidden mt-1 rounded"></ul>
</div>
<button onclick="addStockFromInput()" class="flex-shrink-0 bg-blue-600 hover:bg-blue-700 text-white font-bold px-3 py-1 rounded">+</button>
</div>
<ul id="stockList" class="flex-1 overflow-y-auto space-y-2 mb-4">
<!-- Stock list injected here -->
</ul>
<div class="flex flex-col gap-2 flex-shrink-0 border-t pt-4">
<button onclick="saveState()" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded transition duration-150">
💾 保存所有设置
</button>
<button onclick="refreshData()" class="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded transition duration-150">
🔄 刷新所有数据
</button>
</div>
</div>
<!-- Right Area: Chart and Analysis stacked vertically -->
<div class="flex-1 flex flex-col min-w-0 gap-2 md:gap-4 w-full">
<!-- Top: Chart Area -->
<div class="flex-1 bg-white border border-gray-200 rounded shadow flex flex-col min-h-0 relative">
<!-- Controls Toolbar -->
<div class="flex gap-2 md:gap-4 p-2 md:p-4 border-b bg-gray-50 flex-shrink-0 items-end flex-wrap overflow-x-auto">
<div>
<label class="block text-xs font-medium text-gray-600">前期高点</label>
<input type="number" id="highInput" class="w-20 md:w-24 mt-1 px-1 py-1 bg-white border border-gray-300 rounded shadow-sm text-xs md:text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600">前期低点</label>
<input type="number" id="lowInput" class="w-20 md:w-24 mt-1 px-1 py-1 bg-white border border-gray-300 rounded shadow-sm text-xs md:text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600">趋势方向</label>
<select id="trendInput" class="w-24 md:w-40 mt-1 px-1 py-1 bg-white border border-gray-300 rounded shadow-sm text-xs md:text-sm">
<option value="up">上涨回撤</option>
<option value="down">下跌反弹</option>
</select>
</div>
<div class="flex-1 flex justify-end">
<button onclick="calculateManual()" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-1 px-2 md:px-4 rounded shadow text-xs md:text-sm">
计算
</button>
</div>
</div>
<!-- ECharts Container -->
<div class="flex-1 relative min-h-[250px]">
<div id="mainChart" class="absolute inset-0"></div>
</div>
</div>
<!-- Bottom: Analysis Panel -->
<div id="analysisPanelContainer" class="h-32 md:h-40 bg-blue-50 border border-blue-200 rounded shadow p-2 md:p-4 flex-shrink-0 flex flex-col">
<h2 class="text-sm md:text-base font-bold text-blue-800 border-b border-blue-200 pb-1 mb-1 md:mb-2 flex-shrink-0">🤖 盘面动态轨迹分析</h2>
<div id="analysisPanel" class="flex-1 overflow-y-auto text-gray-700 leading-relaxed text-xs md:text-sm pr-2">
请先选择股票,并在主图中拖动右上角框选工具选取分析区间。
</div>
</div>
</div>
</div>
<script>
// Sidebar Toggle Logic for Mobile
function toggleSidebar(forceShow) {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebarOverlay');
if (forceShow === true || sidebar.classList.contains('-translate-x-full')) {
sidebar.classList.remove('-translate-x-full');
overlay.classList.remove('hidden');
} else {
sidebar.classList.add('-translate-x-full');
overlay.classList.add('hidden');
}
}
let myChart = echarts.init(document.getElementById('mainChart'));
let appState = {
current: null,
stocks: {}
};
window.addEventListener('resize', () => {
myChart.resize();
});
// ================= MATHEMATICS =================
function calculateFibonacci(high, low, trend) {
const diff = high - low;
const ratios = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
let levels = {};
ratios.forEach(ratio => {
if (trend === 'up') levels[ratio] = high - (diff * ratio);
else levels[ratio] = low + (diff * ratio);
});
return levels;
}
function generateAnalysis(stockData, brushEndIdx, levels, trend) {
if (stockData.length === 0) return "";
const latestPrice = stockData[stockData.length - 1].close;
const postWaveData = stockData.slice(brushEndIdx + 1);
let analysis = "";
if (trend === 'up') {
let min_low = latestPrice;
if (postWaveData.length > 0) min_low = Math.min(...postWaveData.map(d => parseFloat(d.low)));
const bounce = ((latestPrice - min_low) / min_low) * 100;
let depthStr = "";
if (min_low > levels['0.382']) depthStr = "最大回撤极浅(未破0.382),强势洗盘。";
else if (min_low > levels['0.618']) depthStr = `最大回撤触及 ${min_low < levels['0.5'] ? '0.618 附近' : '0.5 半山腰'}。`;
else depthStr = "深度回调,跌破0.618防线,形态遭破坏。";
let currentStr = bounce > 3 ? `现已企稳,较底部回升 <span class="text-red-600 font-bold">${bounce.toFixed(2)}%</span>。` : `最新价距底部无明显反弹,弱势筑底。`;
analysis = depthStr + " " + currentStr;
} else {
let max_high = latestPrice;
if (postWaveData.length > 0) max_high = Math.max(...postWaveData.map(d => parseFloat(d.high)));
const drop = ((max_high - latestPrice) / max_high) * 100;
let heightStr = "";
if (max_high < levels['0.382']) heightStr = "反弹极微弱(未触及0.382),空头压制明显。";
else if (max_high < levels['0.618']) heightStr = `中级反抽,最高触及 ${max_high > levels['0.5'] ? '0.618 强阻' : '0.5 阻力'}。`;
else heightStr = "反弹强势,曾上攻突破0.618。";
let currentStr = drop > 3 ? `现遇阻回落,较最高点下跌 <span class="text-green-600 font-bold">${drop.toFixed(2)}%</span>。` : `最新价维持高位,有向上突破迹象。`;
analysis = heightStr + " " + currentStr;
}
return analysis;
}
// ================= CHART RENDERING =================
function drawChart(symbol, isNewLoad = false) {
const stockState = appState.stocks[symbol];
if (!stockState || stockState.data.length === 0) return;
const high = parseFloat(document.getElementById('highInput').value) || stockState.high;
const low = parseFloat(document.getElementById('lowInput').value) || stockState.low;
const trend = document.getElementById('trendInput').value || stockState.trend;
const dates = stockState.data.map(item => item.date);
const values = stockState.data.map(item => [item.open, item.close, item.low, item.high]);
let markLineData = [];
if (high && low) {
const fibLevels = calculateFibonacci(high, low, trend);
markLineData = Object.keys(fibLevels).map(ratio => {
let color = ratio === '0.618' || ratio === '0.382' ? '#e74c3c' : '#34495e';
return {
yAxis: fibLevels[ratio],
label: { formatter: `{b}:{c}`, position: 'end', color: color, fontWeight: 'bold' },
lineStyle: { type: 'dashed', color: color, width: 2 },
name: `Fib ${ratio}`
};
});
const analysisHtml = generateAnalysis(stockState.data, stockState.lastBrushEndIndex, fibLevels, trend);
stockState.analysisHtml = analysisHtml;
document.getElementById('analysisPanel').innerHTML = analysisHtml;
}
let currentStart = stockState.zoomStart || 0;
let currentEnd = stockState.zoomEnd || 100;
const totalDays = stockState.data.length;
if (isNewLoad && totalDays > 0) {
currentStart = Math.max(0, ((totalDays - 125) / totalDays) * 100);
} else if (!isNewLoad) {
const currentOption = myChart.getOption();
if (currentOption && currentOption.dataZoom && currentOption.dataZoom.length > 0) {
currentStart = currentOption.dataZoom[0].start;
currentEnd = currentOption.dataZoom[0].end;
}
}
stockState.zoomStart = currentStart;
stockState.zoomEnd = currentEnd;
const stockName = stockState.name ? ` ${stockState.name}` : '';
const option = {
title: { text: `[${symbol}${stockName}]`, left: 0, textStyle: { fontSize: window.innerWidth < 768 ? 14 : 18 } },
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
toolbox: { feature: { brush: { type: ['lineX', 'clear'] } } },
brush: { xAxisIndex: 'all', brushLink: 'all', outOfBrush: { colorAlpha: 0.3 } },
grid: { left: window.innerWidth < 768 ? '10%' : '3%', right: window.innerWidth < 768 ? '15%' : '8%', bottom: '15%', top: '15%' },
xAxis: { type: 'category', data: dates, scale: true, boundaryGap: false },
yAxis: { scale: true, splitArea: { show: true } },
dataZoom: [
{ type: 'inside', start: currentStart, end: currentEnd },
{ show: true, type: 'slider', top: '90%', start: currentStart, end: currentEnd }
],
series: [{
name: '日K', type: 'candlestick', data: values,
itemStyle: { color: '#ef232a', color0: '#14b143', borderColor: '#ef232a', borderColor0: '#14b143' },
markLine: { symbol: ['none', 'none'], data: markLineData }
}]
};
myChart.setOption(option);
if (stockState.brushRange) {
setTimeout(() => {
myChart.dispatchAction({
type: 'brush',
areas: [{ brushType: 'lineX', coordRange: stockState.brushRange, xAxisIndex: 0 }]
});
}, 100);
}
}
// ================= EVENT LISTENERS =================
myChart.on('brushEnd', function(params) {
if (!appState.current || params.areas.length === 0) return;
const stockState = appState.stocks[appState.current];
const range = params.areas[0].coordRange;
stockState.brushRange = range;
const startIndex = Math.max(0, Math.floor(range[0]));
const endIndex = Math.min(stockState.data.length - 1, Math.ceil(range[1]));
stockState.lastBrushEndIndex = endIndex;
const slicedData = stockState.data.slice(startIndex, endIndex + 1);
if (slicedData.length > 0) {
let maxHigh = -Infinity, minLow = Infinity;
let maxIdx = -1, minIdx = -1;
slicedData.forEach((d, i) => {
const h = parseFloat(d.high), l = parseFloat(d.low);
if (h > maxHigh) { maxHigh = h; maxIdx = i; }
if (l < minLow) { minLow = l; minIdx = i; }
});
stockState.high = maxHigh;
stockState.low = minLow;
stockState.trend = (minIdx < maxIdx) ? 'up' : 'down';
updateUIFromState(appState.current);
}
});
myChart.on('datazoom', function () {
if(!appState.current) return;
const option = myChart.getOption();
appState.stocks[appState.current].zoomStart = option.dataZoom[0].start;
appState.stocks[appState.current].zoomEnd = option.dataZoom[0].end;
});
function calculateManual() {
if (!appState.current) return;
const stockState = appState.stocks[appState.current];
stockState.high = parseFloat(document.getElementById('highInput').value);
stockState.low = parseFloat(document.getElementById('lowInput').value);
stockState.trend = document.getElementById('trendInput').value;
drawChart(appState.current, false);
}
// ================= MULTI-STOCK STATE LOGIC =================
function updateUIFromState(symbol) {
const stockState = appState.stocks[symbol];
if(!stockState) return;
if(stockState.high) document.getElementById('highInput').value = stockState.high.toFixed(2);
if(stockState.low) document.getElementById('lowInput').value = stockState.low.toFixed(2);
if(stockState.trend) document.getElementById('trendInput').value = stockState.trend;
document.getElementById('analysisPanel').innerHTML = stockState.analysisHtml || "请在主图中拖动框选工具选取分析区间。";
}
function renderStockList() {
const ul = document.getElementById('stockList');
ul.innerHTML = '';
Object.keys(appState.stocks).forEach(sym => {
const st = appState.stocks[sym];
const displayName = st.name ? `${sym} ${st.name}` : sym;
const li = document.createElement('li');
li.className = `flex justify-between items-center p-2 rounded cursor-pointer border text-sm ${sym === appState.current ? 'bg-blue-100 border-blue-400 font-bold' : 'bg-gray-50 border-gray-200 hover:bg-gray-100'}`;
const textSpan = document.createElement('span');
textSpan.className = 'flex-1 truncate';
textSpan.innerText = displayName;
textSpan.onclick = () => switchStock(sym);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-2 text-red-400 hover:text-red-600 font-bold text-lg leading-none p-1';
removeBtn.innerText = '×';
removeBtn.onclick = (e) => { e.stopPropagation(); removeStock(sym); };
li.appendChild(textSpan);
li.appendChild(removeBtn);
ul.appendChild(li);
});
}
function removeStock(symbol) {
delete appState.stocks[symbol];
if (appState.current === symbol) {
const remaining = Object.keys(appState.stocks);
if (remaining.length > 0) switchStock(remaining[0]);
else {
appState.current = null;
myChart.clear();
document.getElementById('analysisPanel').innerHTML = "";
document.getElementById('highInput').value = '';
document.getElementById('lowInput').value = '';
renderStockList();
}
} else {
renderStockList();
}
}
function switchStock(symbol) {
appState.current = symbol;
renderStockList();
updateUIFromState(symbol);
myChart.clear();
drawChart(symbol, false);
setTimeout(() => myChart.resize(), 10);
// Mobile: automatically close sidebar after choosing a stock
if (window.innerWidth < 768) {
toggleSidebar(false);
}
}
async function fetchStockDataAPI(symbol) {
try {
// 使用相对路径!这样就可以通过云端访问了。
const response = await fetch(`/api/stock?symbol=${symbol}`);
if (!response.ok) throw new Error("网络错误");
return await response.json();
} catch (error) {
alert(`无法拉取 ${symbol} 数据`);
return null;
}
}
async function addStockFromInput() {
const symbol = document.getElementById('newSymbolInput').value.trim();
if (!symbol) return;
if (appState.stocks[symbol]) {
switchStock(symbol);
return;
}
document.getElementById('globalLoading').classList.remove('hidden');
const data = await fetchStockDataAPI(symbol);
document.getElementById('globalLoading').classList.add('hidden');
if (data && data.data && data.data.length > 0) {
appState.stocks[symbol] = {
name: data.name || "", data: data.data,
high: null, low: null, trend: 'up', zoomStart: null, zoomEnd: null,
brushRange: null, lastBrushEndIndex: data.data.length - 1, analysisHtml: ""
};
let mx = -Infinity, mn = Infinity;
data.data.forEach(d => { if(d.high > mx) mx=d.high; if(d.low < mn) mn=d.low; });
appState.stocks[symbol].high = mx; appState.stocks[symbol].low = mn;
document.getElementById('newSymbolInput').value = '';
appState.current = symbol;
renderStockList();
updateUIFromState(symbol);
drawChart(symbol, true);
if (window.innerWidth < 768) toggleSidebar(false);
}
}
function saveState() {
let storableState = { current: appState.current, stocks: {} };
for (let sym in appState.stocks) {
const st = appState.stocks[sym];
storableState.stocks[sym] = {
name: st.name, high: st.high, low: st.low, trend: st.trend,
zoomStart: st.zoomStart, zoomEnd: st.zoomEnd,
brushRange: st.brushRange, lastBrushEndIndex: st.lastBrushEndIndex
};
}
localStorage.setItem('stockGoldRuleV2', JSON.stringify(storableState));
alert('设置已成功保存到浏览器本地缓存!');
}
async function loadState() {
const saved = localStorage.getItem('stockGoldRuleV2');
if (!saved) return;
const parsed = JSON.parse(saved);
document.getElementById('globalLoading').classList.remove('hidden');
for (let sym in parsed.stocks) {
const st = parsed.stocks[sym];
const res = await fetchStockDataAPI(sym);
if (res && res.data) {
appState.stocks[sym] = {
name: res.name || st.name || "", data: res.data,
high: st.high, low: st.low, trend: st.trend,
zoomStart: st.zoomStart, zoomEnd: st.zoomEnd,
brushRange: st.brushRange, lastBrushEndIndex: st.lastBrushEndIndex, analysisHtml: ""
};
}
}
document.getElementById('globalLoading').classList.add('hidden');
if (parsed.current && appState.stocks[parsed.current]) switchStock(parsed.current);
else renderStockList();
}
async function refreshData() {
document.getElementById('globalLoading').classList.remove('hidden');
for (let sym in appState.stocks) {
const res = await fetchStockDataAPI(sym);
if (res && res.data) {
appState.stocks[sym].data = res.data;
appState.stocks[sym].name = res.name || appState.stocks[sym].name || "";
}
}
document.getElementById('globalLoading').classList.add('hidden');
if (appState.current) switchStock(appState.current);
alert('全量数据已刷新至最新交易日!');
}
let isServerReady = false;
async function checkServerStatus() {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
const res = await fetch("/api/health", { signal: controller.signal });
clearTimeout(timeoutId);
if (res.ok) {
if (!isServerReady) {
isServerReady = true;
document.getElementById('statusDot').className = "w-3 h-3 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.8)]";
document.getElementById('statusText').className = "text-xs md:text-sm text-green-600 font-bold";
document.getElementById('statusText').innerText = "服务已连接";
loadState();
}
} else throw new Error("Not OK");
} catch (e) {
if (isServerReady || !isServerReady) {
isServerReady = false;
document.getElementById('statusDot').className = "w-3 h-3 rounded-full bg-red-500 animate-pulse";
document.getElementById('statusText').className = "text-xs md:text-sm text-red-600 font-bold";
document.getElementById('statusText').innerText = "服务连接中...";
}
}
}
// SEARCH AUTOCOMPLETE
let searchTimeout = null;
const searchInput = document.getElementById('newSymbolInput');
const searchResults = document.getElementById('searchResults');
searchInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
if (!query) { searchResults.classList.add('hidden'); return; }
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(async () => {
try {
const res = await fetch("/api/search?q=" + encodeURIComponent(query));
if (!res.ok) return;
const data = await res.json();
if (data && data.length > 0) {
searchResults.innerHTML = '';
data.forEach(item => {
const li = document.createElement('li');
li.className = 'px-3 py-2 cursor-pointer hover:bg-blue-50 border-b border-gray-100 text-sm text-gray-700';
li.innerHTML = `<strong>${item.code}</strong> ${item.name} <span class="text-xs text-gray-400">(${item.pinyin})</span>`;
li.onclick = () => {
searchInput.value = item.code;
searchResults.classList.add('hidden');
addStockFromInput();
};
searchResults.appendChild(li);
});
searchResults.classList.remove('hidden');
} else {
searchResults.innerHTML = '<li class="px-3 py-2 text-sm text-gray-500">无结果</li>';
searchResults.classList.remove('hidden');
}
} catch (error) {}
}, 300);
});
document.addEventListener('click', (e) => {
if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
searchResults.classList.add('hidden');
}
});
window.onload = () => {
checkServerStatus();
setInterval(checkServerStatus, 2000);
};
</script>
</body>
</html>