index.html 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>价格与价差监控 Binance-OpenOcean</title>
  7. <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  8. <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
  9. <style>
  10. body { font-family: Arial, sans-serif; margin: 20px; background-color: #f4f4f4; color: #333; }
  11. .container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); margin-bottom: 20px; }
  12. h1, h2 { text-align: center; color: #333; }
  13. table { width: 100%; border-collapse: collapse; margin-top: 20px; margin-bottom: 20px; }
  14. th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
  15. th { background-color: #e9e9e9; }
  16. .price-up { color: green; }
  17. .price-down { color: red; }
  18. .error-message { color: #c00; font-style: italic; font-size: 0.9em; }
  19. .status-cell { /* For status messages, not just errors */ }
  20. .timestamp { font-size: 0.9em; color: #666; text-align: right; margin-top: 15px; }
  21. .chart-container {
  22. position: relative;
  23. height: 45vh;
  24. width: 90vw;
  25. margin: auto;
  26. margin-bottom: 10px;
  27. }
  28. .controls-container {
  29. text-align: center;
  30. margin-bottom: 20px;
  31. margin-top: 5px;
  32. }
  33. .control-button {
  34. background-color: #007bff;
  35. color: white;
  36. border: none;
  37. padding: 8px 15px;
  38. text-align: center;
  39. text-decoration: none;
  40. display: inline-block;
  41. font-size: 14px;
  42. border-radius: 5px;
  43. cursor: pointer;
  44. margin: 0 5px;
  45. }
  46. .control-button:hover {
  47. background-color: #0056b3;
  48. }
  49. .pause-button-active {
  50. background-color: #ffc107;
  51. color: #333;
  52. }
  53. .pause-button-active:hover {
  54. background-color: #e0a800;
  55. }
  56. </style>
  57. </head>
  58. <body>
  59. <!-- HTML 结构与之前相同,此处省略 -->
  60. <div class="container">
  61. <h1>USDT 价格监控</h1>
  62. <div class="controls-container">
  63. <button id="pause-resume-button" class="control-button">暂停刷新</button>
  64. </div>
  65. <table>
  66. <thead>
  67. <tr>
  68. <th>平台</th>
  69. <th>价格</th>
  70. <th>状态/错误</th>
  71. </tr>
  72. </thead>
  73. <tbody>
  74. <tr>
  75. <td>OpenOcean (BSC)</td>
  76. <td id="oo-price">加载中...</td>
  77. <td id="oo-status" class="status-cell"></td>
  78. </tr>
  79. <tr>
  80. <td>Gate.io (Spot)</td>
  81. <td id="gate-price">加载中...</td>
  82. <td id="gate-status" class="status-cell"></td>
  83. </tr>
  84. <tr>
  85. <td><b>价差百分比</b></td>
  86. <td id="diff-percentage" colspan="2">计算中...</td>
  87. </tr>
  88. </tbody>
  89. </table>
  90. <div class="timestamp">最后更新: <span id="last-updated">N/A</span></div>
  91. </div>
  92. <div class="container">
  93. <h2>价格历史曲线</h2>
  94. <div class="chart-container">
  95. <canvas id="priceHistoryChart"></canvas>
  96. </div>
  97. <div class="controls-container">
  98. <button id="reset-price-zoom-button" class="control-button">重置缩放</button>
  99. </div>
  100. </div>
  101. <div class="container">
  102. <h2>价差百分比历史曲线</h2>
  103. <div class="chart-container">
  104. <canvas id="diffHistoryChart"></canvas>
  105. </div>
  106. <div class="controls-container">
  107. <button id="reset-diff-zoom-button" class="control-button">重置缩放</button>
  108. </div>
  109. </div>
  110. <script>
  111. let priceChartInstance = null;
  112. let diffChartInstance = null;
  113. let dataUpdateIntervalID = null;
  114. let isPaused = false;
  115. const REFRESH_INTERVAL_MS = 5000;
  116. const pauseResumeButton = document.getElementById('pause-resume-button');
  117. let isSyncingZoomPan = false; // 标志位,防止同步死循环
  118. function formatPrice(priceStr) {
  119. if (priceStr === null || priceStr === undefined || priceStr === "N/A") return "N/A";
  120. const price = parseFloat(priceStr);
  121. return isNaN(price) ? "N/A" : price.toFixed(6);
  122. }
  123. // 函数:从源图表同步X轴到目标图表
  124. function syncXAxes(sourceChart, targetChart) {
  125. if (isSyncingZoomPan || !sourceChart || !targetChart) return; // 如果正在同步或图表未定义,则跳过
  126. isSyncingZoomPan = true; // 设置标志位
  127. const sourceXScale = sourceChart.scales.x;
  128. const targetXScale = targetChart.scales.x;
  129. if (sourceXScale && targetXScale) {
  130. targetXScale.options.min = sourceXScale.min; // 同步X轴的最小值
  131. targetXScale.options.max = sourceXScale.max; // 同步X轴的最大值
  132. targetChart.update('none'); // 更新目标图表,'none' 表示不执行动画
  133. }
  134. // 短暂延迟后重置标志位,允许下一次同步
  135. // 如果不加延迟,快速连续操作可能会有问题
  136. requestAnimationFrame(() => {
  137. isSyncingZoomPan = false;
  138. });
  139. }
  140. function initializeChart(ctx, chartIdentifier, chartType, datasetsConfig, initialLabels, titleText) {
  141. const chartInstance = new Chart(ctx, {
  142. type: 'line',
  143. data: {
  144. labels: initialLabels,
  145. datasets: datasetsConfig
  146. },
  147. options: {
  148. responsive: true,
  149. maintainAspectRatio: false,
  150. animation: {
  151. duration: 150
  152. },
  153. scales: {
  154. x: {
  155. title: { display: true, text: '时间' },
  156. // 我们将通过回调函数同步 min/max,这里不设置
  157. },
  158. y: {
  159. title: { display: true, text: chartType === 'price' ? '价格 (USDT)' : '价差 (%)' },
  160. beginAtZero: chartType === 'diff' ? false : undefined
  161. }
  162. },
  163. plugins: {
  164. legend: { position: 'top' },
  165. title: { display: true, text: titleText },
  166. zoom: {
  167. pan: {
  168. enabled: true,
  169. mode: 'x', // 只在X轴上平移 (Y轴通常不需要同步)
  170. threshold: 5,
  171. onPanComplete({chart}) { // 平移完成回调
  172. if (chartIdentifier === 'price' && diffChartInstance) {
  173. syncXAxes(chart, diffChartInstance);
  174. } else if (chartIdentifier === 'diff' && priceChartInstance) {
  175. syncXAxes(chart, priceChartInstance);
  176. }
  177. }
  178. },
  179. zoom: {
  180. wheel: { enabled: true, speed: 0.1 }, // 调整滚轮速度
  181. pinch: { enabled: true },
  182. drag: { enabled: true, backgroundColor: 'rgba(0,123,255,0.25)'},
  183. mode: 'x', // 只在X轴上缩放
  184. onZoomComplete({chart}) { // 缩放完成回调
  185. if (chartIdentifier === 'price' && diffChartInstance) {
  186. syncXAxes(chart, diffChartInstance);
  187. } else if (chartIdentifier === 'diff' && priceChartInstance) {
  188. syncXAxes(chart, priceChartInstance);
  189. }
  190. }
  191. }
  192. }
  193. },
  194. elements: {
  195. point:{ radius: 2 }
  196. }
  197. }
  198. });
  199. return chartInstance;
  200. }
  201. function updateChartData(chartInstance, newLabels, newDatasetsData) {
  202. if (!chartInstance) return; // 确保图表实例存在
  203. const currentXMin = chartInstance.scales.x.min;
  204. const currentXMax = chartInstance.scales.x.max;
  205. const isZoomed = (currentXMin !== undefined && currentXMax !== undefined &&
  206. (currentXMin !== chartInstance.data.labels[0] || currentXMax !== chartInstance.data.labels[chartInstance.data.labels.length - 1]));
  207. chartInstance.data.labels = newLabels;
  208. newDatasetsData.forEach((datasetData, index) => {
  209. chartInstance.data.datasets[index].data = datasetData;
  210. });
  211. // 如果图表之前是缩放状态,并且新的标签范围与旧的缩放范围不完全匹配,
  212. // 尝试保持缩放,否则新的数据可能会导致缩放重置。
  213. // 但这里简单处理:如果暂停,不更新 x 轴的 min/max,让用户控制。
  214. // 如果正在刷新,则让图表根据新数据自动调整。
  215. // 若要更精细控制 (如保持缩放比例滚动),会更复杂。
  216. if (!isPaused) {
  217. // 当数据刷新时,如果用户是手动缩放的,我们不应该重置它
  218. // Chart.js 会在数据更新时自动调整范围,除非 min/max 固定
  219. // 为了允许新数据扩展X轴,我们不在这里设置 min/max
  220. // 除非我们想实现固定窗口滚动
  221. } else {
  222. // 如果暂停了,并且图表被用户缩放了,保持这个缩放
  223. if(isZoomed) {
  224. chartInstance.options.scales.x.min = currentXMin;
  225. chartInstance.options.scales.x.max = currentXMax;
  226. } else {
  227. // 如果没缩放,允许图表自动调整(尽管暂停时数据不会变)
  228. chartInstance.options.scales.x.min = undefined;
  229. chartInstance.options.scales.x.max = undefined;
  230. }
  231. }
  232. chartInstance.update('quiet');
  233. }
  234. function updateDisplayAndCharts() {
  235. fetch('/data')
  236. .then(response => response.json())
  237. .then(data => {
  238. const current = data.current;
  239. // ... (表格数据更新部分与之前相同,此处省略以保持简洁) ...
  240. document.getElementById('oo-price').textContent = formatPrice(current.oo_price);
  241. document.getElementById('gate-price').textContent = formatPrice(current.gate_price);
  242. const diffEl = document.getElementById('diff-percentage');
  243. if (current.difference_percentage && current.difference_percentage !== "N/A") {
  244. diffEl.textContent = current.difference_percentage;
  245. const diffValue = parseFloat(current.difference_percentage.replace('%', ''));
  246. if (!isNaN(diffValue)) {
  247. if (diffValue > 0) diffEl.className = 'price-up';
  248. else if (diffValue < 0) diffEl.className = 'price-down';
  249. else diffEl.className = '';
  250. } else {
  251. diffEl.className = '';
  252. }
  253. } else {
  254. diffEl.textContent = current.difference_percentage || "N/A";
  255. diffEl.className = '';
  256. }
  257. const ooStatusEl = document.getElementById('oo-status');
  258. ooStatusEl.textContent = current.oo_error ? `错误: ${current.oo_error}` : '正常';
  259. ooStatusEl.className = current.oo_error ? 'status-cell error-message' : 'status-cell ';
  260. const gateStatusEl = document.getElementById('gate-status');
  261. gateStatusEl.textContent = current.gate_error ? `错误: ${current.gate_error}` : '正常';
  262. gateStatusEl.className = current.gate_error ? 'status-cell error-message' : 'status-cell ';
  263. document.getElementById('last-updated').textContent = current.last_updated || "N/A";
  264. const history = data.history;
  265. // --- 价格历史图表 ---
  266. const priceCtx = document.getElementById('priceHistoryChart').getContext('2d');
  267. const priceDatasets = [
  268. { label: 'OpenOcean', data: history.prices.oo, borderColor: 'rgb(75, 192, 192)', tension: 0.1, fill: false },
  269. { label: 'Gate.io', data: history.prices.gate, borderColor: 'rgb(255, 99, 132)', tension: 0.1, fill: false }
  270. ];
  271. if (!priceChartInstance) {
  272. priceChartInstance = initializeChart(priceCtx, 'price', 'price', priceDatasets, history.prices.labels, '价格历史');
  273. } else {
  274. updateChartData(priceChartInstance, history.prices.labels, [history.prices.oo, history.prices.gate]);
  275. }
  276. // --- 价差历史图表 ---
  277. const diffCtx = document.getElementById('diffHistoryChart').getContext('2d');
  278. const diffDatasets = [{ label: '价差百分比 (OO vs Gate)', data: history.difference.values, borderColor: 'rgb(54, 162, 235)', tension: 0.1, fill: false }];
  279. if (!diffChartInstance) {
  280. diffChartInstance = initializeChart(diffCtx, 'diff', 'diff', diffDatasets, history.difference.labels, '价差百分比历史');
  281. } else {
  282. updateChartData(diffChartInstance, history.difference.labels, [history.difference.values]);
  283. }
  284. // 初始加载或取消暂停时، 手动同步一次 (如果 X 轴不同步)
  285. // 避免初始加载时两个图表的 x 轴范围不一样
  286. if (priceChartInstance && diffChartInstance &&
  287. (priceChartInstance.scales.x.min !== diffChartInstance.scales.x.min ||
  288. priceChartInstance.scales.x.max !== diffChartInstance.scales.x.max) &&
  289. (!isPaused || !dataUpdateIntervalID ) // 只有在首次加载或从暂停恢复时才这样做
  290. ) {
  291. // console.log("Initial or resume sync needed");
  292. // syncXAxes(priceChartInstance, diffChartInstance); // 让价格图表作为主导
  293. }
  294. })
  295. .catch(error => {
  296. console.error('Error fetching data:', error);
  297. // ... (错误处理与之前相同) ...
  298. });
  299. }
  300. function togglePauseResume() {
  301. isPaused = !isPaused;
  302. if (isPaused) {
  303. clearInterval(dataUpdateIntervalID);
  304. dataUpdateIntervalID = null;
  305. pauseResumeButton.textContent = '继续刷新';
  306. pauseResumeButton.classList.add('pause-button-active');
  307. console.log("Data refresh PAUSED");
  308. } else {
  309. pauseResumeButton.textContent = '暂停刷新';
  310. pauseResumeButton.classList.remove('pause-button-active');
  311. updateDisplayAndCharts(); // Refresh immediately
  312. dataUpdateIntervalID = setInterval(updateDisplayAndCharts, REFRESH_INTERVAL_MS);
  313. console.log("Data refresh RESUMED");
  314. }
  315. }
  316. function resetAllZooms() {
  317. if (priceChartInstance) {
  318. priceChartInstance.resetZoom();
  319. // 重置后,手动清除 options 里的 min/max,确保下次数据更新能自动调整
  320. priceChartInstance.options.scales.x.min = undefined;
  321. priceChartInstance.options.scales.x.max = undefined;
  322. priceChartInstance.update('none'); // 立即应用
  323. }
  324. if (diffChartInstance) {
  325. diffChartInstance.resetZoom();
  326. diffChartInstance.options.scales.x.min = undefined;
  327. diffChartInstance.options.scales.x.max = undefined;
  328. diffChartInstance.update('none'); // 立即应用
  329. }
  330. // 重置后,如果两个图表X轴范围不同,需要再次同步
  331. // 简单起见,下次 updateDisplayAndCharts 时会自动尝试同步(如果需要)
  332. // 或者我们可以在 updateDisplayAndCharts 中,如果 isZoomed 为 false,则不设置 min/max
  333. if (priceChartInstance && diffChartInstance) {
  334. // 在这里,我们希望两个图表都显示完整的数据范围
  335. // 所以清空min/max后,让下次数据更新(如果是暂停状态,手动触发一次)来重建
  336. if(isPaused){ // 如果是暂停状态,主动更新一下以确保X轴重置并数据重绘
  337. updateDisplayAndCharts();
  338. }
  339. }
  340. }
  341. // --- Event Listeners ---
  342. pauseResumeButton.addEventListener('click', togglePauseResume);
  343. document.getElementById('reset-price-zoom-button').addEventListener('click', () => {
  344. resetAllZooms(); // 现在一个按钮重置所有,并尝试同步
  345. });
  346. document.getElementById('reset-diff-zoom-button').addEventListener('click', () => {
  347. resetAllZooms(); // 现在一个按钮重置所有,并尝试同步
  348. });
  349. // Initial data load and start interval
  350. updateDisplayAndCharts();
  351. if (!isPaused) {
  352. dataUpdateIntervalID = setInterval(updateDisplayAndCharts, REFRESH_INTERVAL_MS);
  353. }
  354. </script>
  355. </body>
  356. </html>