You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2054 lines
86 KiB

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数据缓冲平台</title>
<style>
:root {
--primary: #4f46e5;
--primary-light: #818cf8;
--primary-dark: #3730a3;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-400: #9ca3af;
--gray-500: #6b7280;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
--sidebar-width: 240px;
--header-height: 64px;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: var(--gray-50);
color: var(--gray-800);
line-height: 1.5;
}
/* Sidebar */
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: var(--sidebar-width);
background: var(--gray-900);
color: white;
z-index: 100;
display: flex;
flex-direction: column;
transition: transform 0.3s;
}
.sidebar-brand {
height: var(--header-height);
display: flex;
align-items: center;
padding: 0 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.sidebar-brand svg { width: 28px; height: 28px; margin-right: 12px; color: var(--primary-light); }
.sidebar-brand h1 { font-size: 16px; font-weight: 600; letter-spacing: -0.5px; }
.sidebar-nav { flex: 1; padding: 16px 12px; overflow-y: auto; }
.nav-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: var(--radius);
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
color: var(--gray-400);
text-decoration: none;
}
.nav-item:hover { background: rgba(255,255,255,0.08); color: white; }
.nav-item.active { background: var(--primary); color: white; }
.nav-item svg { width: 20px; height: 20px; margin-right: 12px; flex-shrink: 0; }
.nav-item span { font-size: 14px; font-weight: 500; }
.nav-divider { height: 1px; background: rgba(255,255,255,0.1); margin: 12px 0; }
/* Main Content */
.main {
margin-left: var(--sidebar-width);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
height: var(--header-height);
background: white;
border-bottom: 1px solid var(--gray-200);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
position: sticky;
top: 0;
z-index: 50;
}
.header h2 { font-size: 20px; font-weight: 600; color: var(--gray-900); }
.header-actions { display: flex; align-items: center; gap: 12px; }
.content { flex: 1; padding: 32px; overflow-y: auto; min-height: calc(100vh - var(--header-height)); }
/* Page Sections */
.page { display: none; }
.page.active { display: block; }
/* Cards */
.card {
background: white;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px;
margin-bottom: 24px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--gray-100);
}
.card-title { font-size: 16px; font-weight: 600; color: var(--gray-900); }
.card-subtitle { font-size: 13px; color: var(--gray-500); margin-top: 2px; }
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.stat-card {
background: white;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
display: flex;
align-items: flex-start;
gap: 16px;
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon.blue { background: #eff6ff; color: var(--info); }
.stat-icon.green { background: #ecfdf5; color: var(--success); }
.stat-icon.yellow { background: #fffbeb; color: var(--warning); }
.stat-icon.red { background: #fef2f2; color: var(--danger); }
.stat-icon svg { width: 24px; height: 24px; }
.stat-content { flex: 1; }
.stat-value { font-size: 28px; font-weight: 700; color: var(--gray-900); line-height: 1.2; }
.stat-label { font-size: 13px; color: var(--gray-500); margin-top: 4px; }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
text-decoration: none;
}
.btn svg { width: 16px; height: 16px; }
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-success { background: var(--success); color: white; }
.btn-success:hover { background: #059669; }
.btn-warning { background: var(--warning); color: white; }
.btn-warning:hover { background: #d97706; }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover { background: #dc2626; }
.btn-outline {
background: white;
color: var(--gray-700);
border: 1px solid var(--gray-300);
}
.btn-outline:hover { background: var(--gray-50); border-color: var(--gray-400); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-sm { padding: 6px 12px; font-size: 13px; }
/* Form */
.form-group { margin-bottom: 20px; }
.form-label { display: block; margin-bottom: 6px; font-size: 14px; font-weight: 500; color: var(--gray-700); }
.form-input, .form-select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--gray-300);
border-radius: var(--radius);
font-size: 14px;
transition: border-color 0.2s;
background: white;
}
.form-input:focus, .form-select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
align-items: end;
}
/* Upload Area */
.upload-zone {
border: 2px dashed var(--gray-300);
border-radius: var(--radius);
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: var(--gray-50);
}
.upload-zone:hover, .upload-zone.dragover {
border-color: var(--primary);
background: #f0f0ff;
}
.upload-zone svg { width: 48px; height: 48px; color: var(--gray-400); margin-bottom: 12px; }
.upload-zone p { color: var(--gray-600); }
.upload-zone .hint { color: var(--gray-400); font-size: 13px; margin-top: 8px; }
.upload-zone input[type="file"] { display: none; }
/* Table */
.table-container { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--gray-200);
font-size: 14px;
}
th {
background: var(--gray-50);
font-weight: 600;
color: var(--gray-600);
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
tr:hover { background: var(--gray-50); }
/* Badge */
.badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.badge-success { background: #d1fae5; color: #065f46; }
.badge-warning { background: #fef3c7; color: #92400e; }
.badge-danger { background: #fee2e2; color: #991b1b; }
.badge-info { background: #dbeafe; color: #1e40af; }
.badge-gray { background: var(--gray-100); color: var(--gray-600); }
/* Symbol Grid */
.symbol-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
margin-top: 16px;
max-height: 500px;
overflow-y: auto;
padding-right: 8px;
}
.symbol-card {
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: var(--radius);
padding: 14px;
text-align: center;
transition: all 0.2s;
position: relative;
}
.symbol-card:hover { border-color: var(--primary-light); box-shadow: var(--shadow-md); }
.symbol-name { font-weight: 600; color: var(--gray-800); font-size: 14px; }
.symbol-code { font-family: 'SF Mono', 'Cascadia Code', monospace; font-size: 13px; color: var(--primary); margin-top: 4px; }
.symbol-actions {
display: flex;
gap: 6px;
margin-top: 10px;
justify-content: center;
}
.symbol-actions .btn-sm {
padding: 4px 8px;
font-size: 12px;
}
.add-symbol-form {
background: var(--gray-50);
border: 2px dashed var(--gray-300);
border-radius: var(--radius);
padding: 20px;
margin-top: 16px;
}
.add-symbol-form .form-row {
grid-template-columns: 1fr 1fr auto;
gap: 12px;
align-items: end;
}
.highlight {
background: #fef08a;
padding: 0 2px;
border-radius: 2px;
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: white;
border-radius: var(--radius);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
max-width: 600px;
width: 90%;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--gray-200);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: var(--gray-900);
}
.modal-close {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--gray-500);
}
.modal-close:hover {
color: var(--gray-700);
}
.modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--gray-200);
display: flex;
gap: 12px;
justify-content: flex-end;
}
.symbol-select-list {
max-height: 400px;
overflow-y: auto;
}
.symbol-select-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: var(--radius);
cursor: pointer;
transition: background 0.2s;
}
.symbol-select-item:hover {
background: var(--gray-50);
}
.symbol-select-item input[type="checkbox"] {
margin-right: 12px;
width: 18px;
height: 18px;
cursor: pointer;
}
.symbol-select-info {
flex: 1;
}
.symbol-select-name {
font-weight: 500;
color: var(--gray-800);
}
.symbol-select-code {
font-family: 'SF Mono', 'Cascadia Code', monospace;
font-size: 13px;
color: var(--primary);
}
/* K-line Chart */
.chart-container {
background: white;
border-radius: var(--radius);
padding: 20px;
margin-top: 20px;
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--gray-200);
}
.chart-title {
font-size: 16px;
font-weight: 600;
color: var(--gray-900);
}
.period-tabs {
display: flex;
gap: 8px;
}
.period-tab {
padding: 6px 14px;
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
font-weight: 500;
background: var(--gray-100);
color: var(--gray-600);
border: 1px solid transparent;
transition: all 0.2s;
}
.period-tab:hover {
background: var(--gray-200);
}
.period-tab.active {
background: var(--primary);
color: white;
}
#klineChart {
width: 100%;
height: 500px;
}
/* Log */
.log-panel {
background: var(--gray-900);
color: #e5e7eb;
border-radius: var(--radius);
padding: 16px;
font-family: 'SF Mono', 'Cascadia Code', 'Courier New', monospace;
font-size: 13px;
max-height: 400px;
overflow-y: auto;
line-height: 1.8;
}
.log-panel .log-line { margin: 2px 0; }
.log-panel .log-time { color: var(--gray-500); }
.log-panel .log-success { color: var(--success); }
.log-panel .log-error { color: var(--danger); }
.log-panel .log-info { color: var(--info); }
/* Progress */
.progress-bar {
width: 100%;
height: 6px;
background: var(--gray-200);
border-radius: 9999px;
overflow: hidden;
margin: 16px 0;
}
.progress-fill {
height: 100%;
background: var(--primary);
border-radius: 9999px;
transition: width 0.3s;
}
/* Toast */
.toast-container {
position: fixed;
top: 80px;
right: 32px;
z-index: 200;
display: flex;
flex-direction: column;
gap: 12px;
}
.toast {
background: white;
border-radius: var(--radius);
box-shadow: var(--shadow-md);
padding: 16px 20px;
display: flex;
align-items: center;
gap: 12px;
min-width: 320px;
animation: slideIn 0.3s;
border-left: 4px solid;
}
.toast.success { border-color: var(--success); }
.toast.error { border-color: var(--danger); }
.toast.info { border-color: var(--info); }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* Empty State */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--gray-400);
}
.empty-state svg { width: 64px; height: 64px; margin-bottom: 16px; }
.empty-state p { font-size: 15px; }
/* Tabs */
.tabs {
display: flex;
border-bottom: 2px solid var(--gray-200);
margin-bottom: 24px;
}
.tab {
padding: 12px 20px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: var(--gray-500);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}
.tab:hover { color: var(--gray-700); }
.tab.active { color: var(--primary); border-color: var(--primary); }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Spinner */
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Responsive */
@media (max-width: 768px) {
.sidebar { transform: translateX(-100%); }
.sidebar.open { transform: translateX(0); }
.main { margin-left: 0; }
.content { padding: 20px; }
.form-row { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>
</svg>
<h1>数据缓冲平台</h1>
</div>
<nav class="sidebar-nav">
<a class="nav-item active" data-page="dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
<span>仪表盘</span>
</a>
<a class="nav-item" data-page="config">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<span>品种配置</span>
</a>
<div class="nav-divider"></div>
<a class="nav-item" data-page="fetch">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
<span>数据获取</span>
</a>
<a class="nav-item" data-page="tasks">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span>定时任务</span>
</a>
<div class="nav-divider"></div>
<a class="nav-item" data-page="cache">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
<span>缓存查询</span>
</a>
<a class="nav-item" data-page="logs">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<span>运行日志</span>
</a>
</nav>
</aside>
<!-- Main -->
<div class="main">
<header class="header">
<h2 id="pageTitle">仪表盘</h2>
<div class="header-actions">
<button class="btn btn-outline btn-sm" onclick="location.reload()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
刷新
</button>
<a href="/docs" target="_blank" class="btn btn-outline btn-sm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
API 文档
</a>
</div>
</header>
<div class="content">
<!-- Dashboard -->
<div class="page active" id="page-dashboard">
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="stat-icon blue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
</div>
<div class="stat-content">
<div class="stat-value" id="statSymbols">-</div>
<div class="stat-label">配置品种数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon green">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<div class="stat-content">
<div class="stat-value" id="statCached">-</div>
<div class="stat-label">已缓存品种</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon yellow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<div class="stat-content">
<div class="stat-value" id="statTasks">-</div>
<div class="stat-label">运行中任务</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon red">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
</div>
<div class="stat-content">
<div class="stat-value" id="statFresh">-</div>
<div class="stat-label">缓存新鲜度</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div>
<div class="card-title">快捷操作</div>
</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="btn btn-primary" onclick="navigateTo('config')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
上传配置
</button>
<button class="btn btn-success" onclick="navigateTo('fetch')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
批量获取
</button>
<button class="btn btn-warning" onclick="navigateTo('tasks')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
定时任务
</button>
<button class="btn btn-outline" onclick="navigateTo('cache')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
查询缓存
</button>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">最近采集记录</div>
</div>
<div id="recentFetchLog">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<p>暂无采集记录</p>
</div>
</div>
</div>
</div>
<!-- Config -->
<div class="page" id="page-config">
<div class="card">
<div class="card-header">
<div>
<div class="card-title">上传品种配置</div>
<div class="card-subtitle">支持拖拽或点击上传 JSON 配置文件</div>
</div>
</div>
<div class="upload-zone" id="uploadZone">
<input type="file" id="fileInput" accept=".json">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<p>点击或拖拽文件到此处</p>
<p class="hint">JSON 格式: {"futures": {"沪银": "AG2606"}, "stock": {...}}</p>
</div>
</div>
<div class="card">
<div class="card-header">
<div>
<div class="card-title">当前配置品种</div>
<div class="card-subtitle" id="configCount">加载中...</div>
</div>
<div style="display: flex; gap: 12px; align-items: center;">
<div style="position: relative;">
<input class="form-input" id="symbolSearch" placeholder="搜索品种..." style="width: 200px; padding-left: 36px;" oninput="filterSymbols(this.value)">
<svg style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--gray-400);" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</div>
<button class="btn btn-outline btn-sm" onclick="loadConfig()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
刷新
</button>
</div>
</div>
<div id="configDisplay">
<div class="tabs" id="configTabs">
<div class="tab active" data-tab="futures">期货品种</div>
<div class="tab" data-tab="stock">股票品种</div>
</div>
<div class="tab-content active" id="tab-futures"></div>
<div class="tab-content" id="tab-stock"></div>
</div>
<div class="add-symbol-form">
<div class="card-title" style="margin-bottom: 16px; font-size: 15px;">手动添加品种</div>
<div class="form-row">
<div class="form-group" style="margin-bottom: 0;">
<label class="form-label">品种名称</label>
<input class="form-input" id="newSymbolName" placeholder="如: 沪银">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label class="form-label">品种代码</label>
<input class="form-input" id="newSymbolCode" placeholder="如: AG2606">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label class="form-label">类型</label>
<select class="form-select" id="newSymbolType">
<option value="futures">期货</option>
<option value="stock">股票</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 0;">
<button class="btn btn-primary" onclick="addSymbol()" style="height: 42px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
添加
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Fetch -->
<div class="page" id="page-fetch">
<div class="card">
<div class="card-header">
<div>
<div class="card-title">批量获取行情数据</div>
<div class="card-subtitle">智能缓存:已存在且有效的数据不会重复请求</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">数据类型</label>
<select class="form-select" id="fetchDataType">
<option value="futures">期货</option>
<option value="stock">股票</option>
</select>
</div>
<div class="form-group">
<label class="form-label">周期(逗号分隔)</label>
<input class="form-input" id="fetchPeriods" value="5min,15min,30min,60min,daily">
</div>
<div class="form-group">
<button class="btn btn-success" id="btnBatchFetch" onclick="batchFetchAll()" style="height: 42px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
一键批量获取
</button>
</div>
</div>
<div id="fetchProgress" class="hidden">
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width: 0%"></div></div>
<p id="progressText" style="text-align: center; color: var(--gray-500); font-size: 14px;"></p>
</div>
<div id="fetchResult" class="hidden" style="margin-top: 20px;"></div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">单个品种查询</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">品种代码</label>
<input class="form-input" id="querySymbol" placeholder="如: AG2606">
</div>
<div class="form-group">
<label class="form-label">周期</label>
<select class="form-select" id="queryPeriod">
<option value="">全部周期</option>
<option value="5min">5分钟</option>
<option value="15min">15分钟</option>
<option value="30min">30分钟</option>
<option value="60min">60分钟</option>
<option value="daily">日线</option>
</select>
</div>
<div class="form-group">
<button class="btn btn-primary" onclick="queryCache()" style="height: 42px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
查询缓存
</button>
</div>
</div>
<div id="queryResult" class="hidden" style="margin-top: 20px;"></div>
</div>
</div>
<!-- Tasks -->
<div class="page" id="page-tasks">
<div class="card">
<div class="card-header">
<div>
<div class="card-title">批量创建定时任务</div>
<div class="card-subtitle">为配置中的所有品种自动创建定时采集任务</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">数据类型</label>
<select class="form-select" id="taskDataType">
<option value="futures">期货</option>
<option value="stock">股票</option>
</select>
</div>
<div class="form-group">
<label class="form-label">采集周期</label>
<input class="form-input" id="taskPeriods" value="5min,15min,60min">
</div>
<div class="form-group">
<label class="form-label">轮询间隔(秒)</label>
<input class="form-input" type="number" id="taskInterval" value="300" min="30" max="86400">
</div>
<div class="form-group">
<button class="btn btn-warning" id="btnBatchTasks" onclick="batchCreateTasks()" style="height: 42px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
批量创建任务
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">任务列表</div>
<button class="btn btn-outline btn-sm" onclick="loadTasks()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
刷新
</button>
</div>
<div class="table-container" id="taskTable">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<p>暂无定时任务</p>
</div>
</div>
</div>
</div>
<!-- Cache -->
<div class="page" id="page-cache">
<div class="card">
<div class="card-header">
<div class="card-title">缓存状态查询</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">品种代码</label>
<input class="form-input" id="cacheSymbol" placeholder="如: AG2606">
</div>
<div class="form-group">
<button class="btn btn-primary" onclick="checkCacheStatus()" style="height: 42px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
查看状态
</button>
</div>
</div>
<div id="cacheStatusResult" class="hidden" style="margin-top: 20px;"></div>
</div>
</div>
<!-- Logs -->
<div class="page" id="page-logs">
<div class="card">
<div class="card-header">
<div class="card-title">运行日志</div>
<button class="btn btn-outline btn-sm" onclick="clearLogs()">清空</button>
</div>
<div class="log-panel" id="logPanel">
<div class="log-line"><span class="log-time">[系统]</span> <span class="log-info">平台已就绪,等待操作...</span></div>
</div>
</div>
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<!-- Batch Fetch Modal -->
<div class="modal-overlay" id="batchFetchModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">选择要获取的合约</div>
<button class="modal-close" onclick="closeBatchFetchModal()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 24px; height: 24px;">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div style="margin-bottom: 12px;">
<div style="position: relative;">
<input class="form-input" id="symbolSearchModal" placeholder="搜索合约..." style="width: 100%; padding-left: 36px;" oninput="filterModalSymbols(this.value)">
<svg style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--gray-400);" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</div>
</div>
<div style="margin-bottom: 16px; display: flex; gap: 12px; align-items: center;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="selectAllSymbols" onchange="toggleSelectAll()" style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
<span style="font-weight: 500;">全选</span>
</label>
<span id="selectedCount" style="color: var(--gray-500); font-size: 14px;">已选择 0 个合约</span>
</div>
<div class="symbol-select-list" id="symbolSelectList"></div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeBatchFetchModal()">取消</button>
<button class="btn btn-success" id="btnConfirmBatchFetch" onclick="confirmBatchFetch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
开始获取
</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<script>
const API = '/api/v1';
let currentConfig = {};
let logs = [];
let currentSearchTerm = '';
let selectedSymbolsForFetch = [];
let modalSymbolsData = [];
// Navigation
function navigateTo(page) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById(`page-${page}`).classList.add('active');
document.querySelector(`.nav-item[data-page="${page}"]`).classList.add('active');
const titles = {
dashboard: '仪表盘',
config: '品种配置',
fetch: '数据获取',
tasks: '定时任务',
cache: '缓存查询',
logs: '运行日志'
};
document.getElementById('pageTitle').textContent = titles[page] || page;
}
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => navigateTo(item.dataset.page));
});
// Config tabs
document.querySelectorAll('#configTabs .tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('#configTabs .tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('#configDisplay .tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
});
});
// Toast
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<span style="font-size: 18px;">${type === 'success' ? '✅' : type === 'error' ? '❌' : ''}</span>
<span style="flex: 1; font-size: 14px;">${message}</span>
`;
container.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
// Log
function addLog(msg, level = 'info') {
const time = new Date().toLocaleTimeString();
logs.push({ time, msg, level });
const panel = document.getElementById('logPanel');
const line = document.createElement('div');
line.className = 'log-line';
line.innerHTML = `<span class="log-time">[${time}]</span> <span class="log-${level}">${msg}</span>`;
panel.appendChild(line);
panel.scrollTop = panel.scrollHeight;
}
function clearLogs() {
document.getElementById('logPanel').innerHTML = '';
logs = [];
addLog('日志已清空', 'info');
}
// Upload
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); });
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
uploadZone.addEventListener('drop', e => {
e.preventDefault();
uploadZone.classList.remove('dragover');
if (e.dataTransfer.files.length) handleUpload(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', () => { if (fileInput.files.length) handleUpload(fileInput.files[0]); });
async function handleUpload(file) {
addLog(`上传文件: ${file.name}`);
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(`${API}/config/upload`, { method: 'POST', body: formData });
const data = await res.json();
if (res.ok) {
addLog(`上传成功: 期货 ${data.futures_symbols} 个, 股票 ${data.stock_symbols}`, 'success');
showToast('配置文件上传成功', 'success');
loadConfig();
} else {
addLog(`上传失败: ${data.detail}`, 'error');
showToast(data.detail, 'error');
}
} catch (e) {
addLog(`上传异常: ${e.message}`, 'error');
}
}
// Load Config
async function loadConfig() {
try {
const res = await fetch(`${API}/config`);
currentConfig = await res.json();
renderConfig();
updateDashboard();
} catch (e) {
addLog(`加载配置失败: ${e.message}`, 'error');
}
}
function renderConfig() {
const futures = currentConfig.futures || {};
const stock = currentConfig.stock || {};
const futuresCount = Object.keys(futures).length;
const stockCount = Object.keys(stock).length;
document.getElementById('configCount').textContent = `期货 ${futuresCount} 个, 股票 ${stockCount}`;
const renderGrid = (items, type) => {
let filteredItems = items;
if (currentSearchTerm) {
const searchTerm = currentSearchTerm.toLowerCase();
filteredItems = {};
for (const [name, code] of Object.entries(items)) {
if (name.toLowerCase().includes(searchTerm) || code.toLowerCase().includes(searchTerm)) {
filteredItems[name] = code;
}
}
}
if (!Object.keys(filteredItems).length) {
return currentSearchTerm
? `<div class="empty-state"><p>未找到匹配 "${currentSearchTerm}" 的品种</p></div>`
: '<div class="empty-state"><p>暂无品种</p></div>';
}
const highlightText = (text) => {
if (!currentSearchTerm) return text;
const regex = new RegExp(`(${currentSearchTerm})`, 'gi');
return text.replace(regex, '<span class="highlight">$1</span>');
};
return `<div class="symbol-grid">${Object.entries(filteredItems).map(([name, code]) =>
`<div class="symbol-card">
<div class="symbol-name">${highlightText(name)}</div>
<div class="symbol-code">${highlightText(code)}</div>
<div class="symbol-actions">
<button class="btn btn-primary btn-sm" onclick="editSymbol('${type}', '${code}', '${name}')">修改</button>
<button class="btn btn-danger btn-sm" onclick="deleteSymbol('${type}', '${code}')">删除</button>
</div>
</div>`
).join('')}</div>`;
};
document.getElementById('tab-futures').innerHTML = renderGrid(futures, 'futures');
document.getElementById('tab-stock').innerHTML = renderGrid(stock, 'stock');
}
function filterSymbols(searchTerm) {
currentSearchTerm = searchTerm.trim();
renderConfig();
}
async function addSymbol() {
const name = document.getElementById('newSymbolName').value.trim();
const code = document.getElementById('newSymbolCode').value.trim();
const type = document.getElementById('newSymbolType').value;
if (!name || !code) {
return showToast('请填写品种名称和代码', 'error');
}
const symbols = type === 'futures' ? (currentConfig.futures || {}) : (currentConfig.stock || {});
if (symbols[name]) {
return showToast('品种名称已存在', 'error');
}
symbols[name] = code;
const fullConfig = {
futures: currentConfig.futures || {},
stock: currentConfig.stock || {}
};
fullConfig[type] = symbols;
try {
const res = await fetch(`${API}/config/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fullConfig),
});
if (res.ok) {
showToast('品种添加成功', 'success');
document.getElementById('newSymbolName').value = '';
document.getElementById('newSymbolCode').value = '';
loadConfig();
} else {
const data = await res.json();
showToast(data.detail || '添加失败', 'error');
}
} catch (e) {
showToast(`添加失败: ${e.message}`, 'error');
}
}
async function editSymbol(type, oldCode, oldName) {
const newName = prompt('请输入新的品种名称:', oldName);
if (!newName || newName === oldName) return;
const newCode = prompt('请输入新的品种代码:', oldCode);
if (!newCode) return;
const symbols = type === 'futures' ? (currentConfig.futures || {}) : (currentConfig.stock || {});
delete symbols[oldName];
symbols[newName] = newCode;
const fullConfig = {
futures: currentConfig.futures || {},
stock: currentConfig.stock || {}
};
fullConfig[type] = symbols;
try {
const res = await fetch(`${API}/config/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fullConfig),
});
if (res.ok) {
showToast('品种修改成功', 'success');
loadConfig();
} else {
const data = await res.json();
showToast(data.detail || '修改失败', 'error');
}
} catch (e) {
showToast(`修改失败: ${e.message}`, 'error');
}
}
async function deleteSymbol(type, code) {
if (!confirm('确定删除此品种?')) return;
const symbols = type === 'futures' ? (currentConfig.futures || {}) : (currentConfig.stock || {});
const name = Object.keys(symbols).find(k => symbols[k] === code);
if (name) {
delete symbols[name];
}
const fullConfig = {
futures: currentConfig.futures || {},
stock: currentConfig.stock || {}
};
fullConfig[type] = symbols;
try {
const res = await fetch(`${API}/config/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fullConfig),
});
if (res.ok) {
showToast('品种删除成功', 'success');
loadConfig();
} else {
const data = await res.json();
showToast(data.detail || '删除失败', 'error');
}
} catch (e) {
showToast(`删除失败: ${e.message}`, 'error');
}
}
// Dashboard
async function updateDashboard() {
const futures = currentConfig.futures || {};
document.getElementById('statSymbols').textContent = Object.keys(futures).length || '-';
try {
const tasksRes = await fetch(`${API}/tasks`);
const tasksData = await tasksRes.json();
const running = tasksData.tasks.filter(t => t.running).length;
document.getElementById('statTasks').textContent = running || '0';
// Count cached symbols
let cachedCount = 0;
for (const code of Object.values(futures)) {
const res = await fetch(`${API}/data/cache-status/${code}`);
const status = await res.json();
if (status.cached_periods && status.cached_periods.length) cachedCount++;
}
document.getElementById('statCached').textContent = cachedCount;
document.getElementById('statFresh').textContent = cachedCount > 0 ? `${Math.round(cachedCount / Object.keys(futures).length * 100)}%` : '-';
} catch (e) {}
}
// Batch Fetch
async function batchFetchAll() {
const dataType = document.getElementById('fetchDataType').value;
if (!currentConfig[dataType] || Object.keys(currentConfig[dataType]).length === 0) {
showToast(`没有可用的${dataType === 'futures' ? '期货' : '股票'}品种配置`, 'error');
return;
}
openBatchFetchModal(dataType);
}
function openBatchFetchModal(dataType) {
const symbols = currentConfig[dataType] || {};
modalSymbolsData = Object.entries(symbols).map(([name, code]) => ({ name, code }));
renderModalSymbols();
document.getElementById('selectAllSymbols').checked = true;
updateSelectedCount();
document.getElementById('batchFetchModal').classList.add('active');
}
function renderModalSymbols(filterText = '') {
const listContainer = document.getElementById('symbolSelectList');
const filterLower = filterText.toLowerCase();
const filteredSymbols = filterText
? modalSymbolsData.filter(s =>
s.name.toLowerCase().includes(filterLower) ||
s.code.toLowerCase().includes(filterLower)
)
: modalSymbolsData;
let html = '';
filteredSymbols.forEach(({ name, code }) => {
html += `
<label class="symbol-select-item">
<input type="checkbox" value="${code}" data-name="${name}" checked onchange="updateSelectedCount()">
<div class="symbol-select-info">
<div class="symbol-select-name">${highlightTextInModal(name, filterText)}</div>
<div class="symbol-select-code">${highlightTextInModal(code, filterText)}</div>
</div>
</label>
`;
});
if (filteredSymbols.length === 0) {
html = `<div class="empty-state" style="padding: 40px 20px;"><p>未找到匹配 "${filterText}" 的合约</p></div>`;
}
listContainer.innerHTML = html;
updateSelectedCount();
}
function highlightTextInModal(text, searchTerm) {
if (!searchTerm) return text;
const regex = new RegExp(`(${searchTerm})`, 'gi');
return text.replace(regex, '<span class="highlight">$1</span>');
}
function filterModalSymbols(searchTerm) {
renderModalSymbols(searchTerm.trim());
}
function closeBatchFetchModal() {
document.getElementById('batchFetchModal').classList.remove('active');
}
function toggleSelectAll() {
const selectAll = document.getElementById('selectAllSymbols').checked;
const checkboxes = document.querySelectorAll('#symbolSelectList input[type="checkbox"]');
checkboxes.forEach(cb => cb.checked = selectAll);
updateSelectedCount();
}
function updateSelectedCount() {
const checkboxes = document.querySelectorAll('#symbolSelectList input[type="checkbox"]:checked');
const count = checkboxes.length;
const total = document.querySelectorAll('#symbolSelectList input[type="checkbox"]').length;
document.getElementById('selectedCount').textContent = `已选择 ${count}/${total} 个合约`;
const selectAll = document.getElementById('selectAllSymbols');
if (count === total) {
selectAll.checked = true;
} else if (count === 0) {
selectAll.checked = false;
} else {
selectAll.checked = false;
}
}
async function confirmBatchFetch() {
const checkboxes = document.querySelectorAll('#symbolSelectList input[type="checkbox"]:checked');
if (checkboxes.length === 0) {
showToast('请至少选择一个合约', 'error');
return;
}
closeBatchFetchModal();
const dataType = document.getElementById('fetchDataType').value;
const periods = document.getElementById('fetchPeriods').value;
const btn = document.getElementById('btnBatchFetch');
const selectedSymbols = Array.from(checkboxes).map(cb => ({
code: cb.value,
name: cb.dataset.name
}));
btn.disabled = true;
btn.innerHTML = '<div class="spinner"></div> 正在获取...';
const progressDiv = document.getElementById('fetchProgress');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
const resultDiv = document.getElementById('fetchResult');
progressDiv.classList.remove('hidden');
progressFill.style.width = '0%';
progressText.textContent = `准备获取 ${selectedSymbols.length} 个合约...`;
resultDiv.classList.add('hidden');
addLog(`开始批量获取 [${dataType}] 数据,周期: ${periods},合约数: ${selectedSymbols.length}`);
const totalSymbols = selectedSymbols.length;
let processedCount = 0;
let successCount = 0;
let failedCount = 0;
let cachedCount = 0;
const results = [];
for (const symbol of selectedSymbols) {
try {
progressText.textContent = `正在获取: ${symbol.name} (${symbol.code}) [${processedCount + 1}/${totalSymbols}]`;
const res = await fetch(`${API}/config/batch-fetch-all`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data_type: dataType,
periods: periods,
selected_symbols: symbol.code
}),
});
const data = await res.json();
addLog(`API返回 [${symbol.code}]: 成功=${data.success?.length || 0}, 失败=${data.failed?.length || 0}, 缓存=${data.cached?.length || 0}`, 'info');
processedCount++;
const progress = (processedCount / totalSymbols) * 100;
progressFill.style.width = `${progress}%`;
if (data.success && data.success.length > 0) {
successCount++;
const detail = data.details && data.details[symbol.code];
const isCached = detail && detail.source === 'cache';
if (isCached) {
cachedCount++;
}
results.push({
name: symbol.name,
code: symbol.code,
status: isCached ? 'cached' : 'success',
statusText: isCached ? '缓存命中' : '已更新',
source: detail ? detail.source : '-',
price: detail && detail.current_price ? detail.current_price.toFixed(2) : '-',
candleCount: detail && detail.timeframes && detail.timeframes.length > 0 ?
detail.timeframes.reduce((sum, tf) => sum + tf.candle_count, 0) : 0
});
} else if (data.failed && data.failed.length > 0) {
failedCount++;
results.push({
name: symbol.name,
code: symbol.code,
status: 'failed',
statusText: '失败',
source: '-',
price: '-',
error: data.failed[0].error || '未知错误'
});
}
} catch (e) {
processedCount++;
failedCount++;
const progress = (processedCount / totalSymbols) * 100;
progressFill.style.width = `${progress}%`;
results.push({
name: symbol.name,
code: symbol.code,
status: 'failed',
statusText: '异常',
source: '-',
price: '-',
error: e.message
});
}
}
progressFill.style.width = '100%';
progressText.textContent = `完成! 成功: ${successCount}, 失败: ${failedCount}, 缓存: ${cachedCount}`;
addLog(`批量获取完成: 成功 ${successCount}, 失败 ${failedCount}, 缓存 ${cachedCount}`,
failedCount === 0 ? 'success' : 'error');
showToast(`获取完成: ${successCount} 成功, ${failedCount} 失败, ${cachedCount} 缓存`, failedCount === 0 ? 'success' : 'error');
let html = `
<div style="margin-bottom: 16px; padding: 12px; background: var(--gray-50); border-radius: var(--radius);">
<div style="display: flex; gap: 24px; font-size: 14px;">
<span>总计: <strong style="color: var(--gray-900);">${totalSymbols}</strong></span>
<span>成功: <strong style="color: var(--success);">${successCount}</strong></span>
<span>失败: <strong style="color: var(--danger);">${failedCount}</strong></span>
<span>缓存: <strong style="color: var(--info);">${cachedCount}</strong></span>
</div>
</div>
<div class="table-container">
<table>
<thead><tr><th>品种</th><th>代码</th><th>状态</th><th>K线数</th><th>来源</th><th>最新价</th>${failedCount > 0 ? '<th>错误信息</th>' : ''}</tr></thead>
<tbody>
`;
for (const item of results) {
const statusBadge = item.status === 'cached'
? `<span class="badge badge-info">${item.statusText}</span>`
: item.status === 'success'
? `<span class="badge badge-success">${item.statusText}</span>`
: `<span class="badge badge-danger">${item.statusText}</span>`;
html += `<tr>
<td>${item.name}</td>
<td><code style="color: var(--primary);">${item.code}</code></td>
<td>${statusBadge}</td>
<td>${item.candleCount || '-'}</td>
<td><span class="badge badge-gray">${item.source}</span></td>
<td style="font-weight: 600;">${item.price}</td>
${failedCount > 0 ? `<td style="color: var(--danger); font-size: 13px;">${item.error || '-'}</td>` : ''}
</tr>`;
}
html += '</tbody></table></div>';
resultDiv.innerHTML = html;
resultDiv.classList.remove('hidden');
updateDashboard();
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg> 一键批量获取';
}, 2000);
}
// Query Cache
let currentQueryData = null;
let klineChart = null;
async function queryCache() {
const symbol = document.getElementById('querySymbol').value.trim();
const period = document.getElementById('queryPeriod').value;
if (!symbol) return showToast('请输入品种代码', 'error');
addLog(`查询缓存: ${symbol} ${period || '全部'}`);
try {
const url = period ? `${API}/data/latest/${symbol}/${period}` : `${API}/data/latest/${symbol}`;
const res = await fetch(url);
const data = await res.json();
if (!res.ok) {
showToast(data.detail || '未找到缓存数据', 'error');
return;
}
addLog(`查询成功: ${symbol}, 缓存 ${data.timeframes ? data.timeframes.length : 0} 个周期`, 'success');
currentQueryData = data;
if (!data.timeframes || data.timeframes.length === 0) {
document.getElementById('queryResult').innerHTML = '<div class="empty-state"><p>暂无K线数据</p></div>';
document.getElementById('queryResult').classList.remove('hidden');
return;
}
renderKlineChart(data.timeframes[0], symbol);
} catch (e) {
showToast(`查询失败: ${e.message}`, 'error');
}
}
function renderKlineChart(timeframe, symbol) {
const resultContainer = document.getElementById('queryResult');
let html = `
<div class="chart-container">
<div class="chart-header">
<div class="chart-title">${symbol} K线图</div>
${timeframe.period === '__all__' || currentQueryData.timeframes.length > 1 ? `
<div class="period-tabs" id="periodTabs">
${currentQueryData.timeframes.map(tf => `
<div class="period-tab ${tf.period === timeframe.period ? 'active' : ''}"
onclick="switchPeriod('${tf.period}')">
${getPeriodLabel(tf.period)}
</div>
`).join('')}
</div>
` : ''}
</div>
<div id="klineChart"></div>
</div>
`;
resultContainer.innerHTML = html;
resultContainer.classList.remove('hidden');
setTimeout(() => initECharts(timeframe, symbol), 100);
}
function initECharts(timeframe, symbol) {
const chartDom = document.getElementById('klineChart');
if (!chartDom) return;
if (klineChart) {
klineChart.dispose();
}
klineChart = echarts.init(chartDom);
const candles = timeframe.candles || [];
if (candles.length === 0) {
chartDom.innerHTML = '<div class="empty-state"><p>该周期暂无数据</p></div>';
return;
}
const dates = candles.map(c => c.datetime);
const data = candles.map(c => [c.open, c.close, c.low, c.high]);
const volumes = candles.map((c, i) => ({
value: c.volume || 0,
index: i,
close: c.close,
open: c.open
}));
const upColor = '#ef4444';
const downColor = '#10b981';
const option = {
animation: true,
legend: {
bottom: 10,
left: 'center',
data: ['K线', 'MA5', 'MA10', 'MA20', 'MA60']
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
crossStyle: {
color: '#999'
},
link: { xAxisIndex: 'all' }
},
formatter: function(params) {
if (!params || params.length === 0) return '';
const date = params[0].axisValue;
let result = `<div style="font-weight: 600; margin-bottom: 8px;">${date}</div>`;
params.forEach(param => {
if (param.seriesName === 'K线' && param.data) {
const [open, close, low, high] = param.data;
const change = ((close - open) / open * 100).toFixed(2);
result += `
<div>开: ${open} | 收: ${close}</div>
<div>低: ${low} | 高: ${high}</div>
<div>涨跌: ${change}%</div>
`;
} else if (param.seriesName.startsWith('MA')) {
result += `<div>${param.seriesName}: ${param.data}</div>`;
} else if (param.seriesName === '成交量') {
result += `<div>成交量: ${param.data}</div>`;
}
});
return result;
}
},
grid: [
{ left: 60, right: 40, height: '60%' },
{ left: 60, right: 40, top: '75%', height: '15%' }
],
xAxis: [
{
type: 'category',
data: dates,
gridIndex: 0,
axisLine: { lineStyle: { color: '#8392A5' } },
axisLabel: { show: false }
},
{
type: 'category',
data: dates,
gridIndex: 1,
axisLine: { lineStyle: { color: '#8392A5' } },
axisLabel: {
show: true,
formatter: function(value) {
return value.substring(5);
}
}
}
],
yAxis: [
{
scale: true,
gridIndex: 0,
splitArea: { show: true },
axisLine: { lineStyle: { color: '#8392A5' } },
splitLine: { lineStyle: { color: '#E8EEF4' } }
},
{
scale: true,
gridIndex: 1,
splitNumber: 2,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false }
}
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1],
start: Math.max(0, 100 - 100 * 100 / candles.length),
end: 100
},
{
show: true,
xAxisIndex: [0, 1],
type: 'slider',
bottom: 10,
start: Math.max(0, 100 - 100 * 100 / candles.length),
end: 100,
height: 20
}
],
series: [
{
name: 'K线',
type: 'candlestick',
data: data,
itemStyle: {
color: upColor,
color0: downColor,
borderColor: upColor,
borderColor0: downColor
}
},
{
name: 'MA5',
type: 'line',
data: calculateMA(candles, 5),
smooth: true,
lineStyle: { width: 1 }
},
{
name: 'MA10',
type: 'line',
data: calculateMA(candles, 10),
smooth: true,
lineStyle: { width: 1 }
},
{
name: 'MA20',
type: 'line',
data: calculateMA(candles, 20),
smooth: true,
lineStyle: { width: 1 }
},
{
name: 'MA60',
type: 'line',
data: calculateMA(candles, 60),
smooth: true,
lineStyle: { width: 1 }
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes.map(v => {
return {
value: v.value,
itemStyle: {
color: v.close >= v.open ? upColor : downColor
}
};
})
}
]
};
klineChart.setOption(option);
window.addEventListener('resize', () => {
if (klineChart) klineChart.resize();
});
}
function calculateMA(candles, days) {
const result = [];
for (let i = 0; i < candles.length; i++) {
if (i < days - 1) {
result.push('-');
} else {
let sum = 0;
for (let j = 0; j < days; j++) {
sum += candles[i - j].close;
}
result.push((sum / days).toFixed(2));
}
}
return result;
}
function getPeriodLabel(period) {
const map = {
'5min': '5分钟',
'15min': '15分钟',
'30min': '30分钟',
'60min': '60分钟',
'daily': '日线',
'__all__': '全部'
};
return map[period] || period;
}
function switchPeriod(period) {
if (!currentQueryData || !currentQueryData.timeframes) return;
const timeframe = currentQueryData.timeframes.find(tf => tf.period === period);
if (!timeframe) return;
const symbol = document.getElementById('querySymbol').value.trim();
renderKlineChart(timeframe, symbol);
}
// Batch Tasks
async function batchCreateTasks() {
const dataType = document.getElementById('taskDataType').value;
const periods = document.getElementById('taskPeriods').value;
const interval = document.getElementById('taskInterval').value;
const btn = document.getElementById('btnBatchTasks');
btn.disabled = true;
btn.innerHTML = '<div class="spinner"></div> 创建中...';
addLog(`批量创建定时任务: ${dataType}, 间隔 ${interval}s`);
try {
const res = await fetch(`${API}/config/batch-tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data_type: dataType, periods, interval_seconds: parseInt(interval) }),
});
const data = await res.json();
addLog(`任务创建完成: 成功 ${data.created.length}, 失败 ${data.failed.length}`,
data.failed.length === 0 ? 'success' : 'error');
showToast(`成功创建 ${data.created.length} 个定时任务`, 'success');
loadTasks();
} catch (e) {
addLog(`创建任务异常: ${e.message}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> 批量创建任务';
}
}
// Load Tasks
async function loadTasks() {
try {
const res = await fetch(`${API}/tasks`);
const data = await res.json();
if (!data.tasks.length) {
document.getElementById('taskTable').innerHTML = '<div class="empty-state"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><p>暂无定时任务</p></div>';
return;
}
let html = `<table>
<thead><tr><th>ID</th><th>品种</th><th>周期</th><th>间隔</th><th>状态</th><th>最后执行</th><th>操作</th></tr></thead>
<tbody>`;
for (const t of data.tasks) {
const statusBadge = t.running
? '<span class="badge badge-success">运行中</span>'
: t.enabled
? '<span class="badge badge-warning">已停止</span>'
: '<span class="badge badge-gray">已禁用</span>';
html += `<tr>
<td>${t.id}</td>
<td><code style="color: var(--primary);">${t.symbol}</code></td>
<td>${t.periods.join(', ')}</td>
<td>${t.interval_seconds}s</td>
<td>${statusBadge}</td>
<td>${t.last_run ? new Date(t.last_run).toLocaleString() : '-'}</td>
<td>
${t.running
? `<button class="btn btn-warning btn-sm" onclick="stopTask(${t.id})">停止</button>`
: `<button class="btn btn-success btn-sm" onclick="startTask(${t.id})">启动</button>`
}
<button class="btn btn-danger btn-sm" onclick="deleteTask(${t.id})">删除</button>
</td>
</tr>`;
}
html += '</tbody></table>';
document.getElementById('taskTable').innerHTML = html;
} catch (e) {
addLog(`加载任务失败: ${e.message}`, 'error');
}
}
async function stopTask(id) {
await fetch(`${API}/tasks/${id}/stop`, { method: 'POST' });
showToast('任务已停止', 'success');
loadTasks();
}
async function startTask(id) {
await fetch(`${API}/tasks/${id}/start`, { method: 'POST' });
showToast('任务已启动', 'success');
loadTasks();
}
async function deleteTask(id) {
if (!confirm('确定删除此任务?')) return;
await fetch(`${API}/tasks/${id}`, { method: 'DELETE' });
showToast('任务已删除', 'success');
loadTasks();
}
// Cache Status
async function checkCacheStatus() {
const symbol = document.getElementById('cacheSymbol').value.trim();
if (!symbol) return showToast('请输入品种代码', 'error');
addLog(`查询缓存状态: ${symbol}`);
try {
const res = await fetch(`${API}/data/cache-status/${symbol}`);
const data = await res.json();
if (data.status === 'no_data') {
showToast(`${symbol} 无缓存数据`, 'info');
document.getElementById('cacheStatusResult').innerHTML = `<div class="empty-state"><p>品种 ${symbol} 暂无缓存数据</p></div>`;
document.getElementById('cacheStatusResult').classList.remove('hidden');
return;
}
let html = `<div class="table-container"><table>
<thead><tr><th>周期</th><th>K线数</th><th>获取时间</th><th>数据年龄</th><th>新鲜度</th></tr></thead><tbody>`;
for (const p of data.cached_periods) {
const fresh = p.is_fresh;
html += `<tr>
<td><span class="badge badge-info">${p.period}</span></td>
<td>${p.candle_count}</td>
<td>${new Date(p.fetched_at).toLocaleString()}</td>
<td>${p.age_seconds}秒</td>
<td><span class="badge ${fresh ? 'badge-success' : 'badge-warning'}">${fresh ? '新鲜' : '过期'}</span></td>
</tr>`;
}
html += '</tbody></table></div>';
document.getElementById('cacheStatusResult').innerHTML = html;
document.getElementById('cacheStatusResult').classList.remove('hidden');
addLog(`${symbol} 缓存状态: ${data.cached_periods.length} 个周期`, 'success');
} catch (e) {
showToast(`查询失败: ${e.message}`, 'error');
}
}
// Init
loadConfig();
loadTasks();
</script>
</body>
</html>