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.

2702 lines
119 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 style="display: flex; gap: 12px;">
<button class="btn btn-outline" onclick="showHistoryTasks()">
<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>
历史任务
</button>
<button class="btn btn-warning" onclick="openCreateTaskModal()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
批量创建任务
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">任务列表</div>
<div style="display: flex; gap: 12px;">
<button class="btn btn-danger btn-sm" onclick="batchDeleteTasks()" id="btnBatchDeleteTasks" style="display: none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
批量删除
</button>
<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>
<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>
<!-- History Tasks -->
<div class="page" id="page-history-tasks" style="display: none;">
<div class="card">
<div class="card-header">
<div>
<div class="card-title">历史任务</div>
<div class="card-subtitle">查看已完成的任务记录</div>
</div>
<button class="btn btn-outline" onclick="showActiveTasks()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
返回任务列表
</button>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">已完成任务列表</div>
<button class="btn btn-outline btn-sm" onclick="loadHistoryTasks()">
<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="historyTaskTable">
<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>
<!-- Create Task Modal -->
<div class="modal-overlay" id="createTaskModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">批量创建定时任务</div>
<button class="modal-close" onclick="closeCreateTaskModal()">
<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 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 style="margin-bottom: 16px;">
<div style="position: relative; margin-bottom: 12px;">
<input class="form-input" id="taskSymbolSearch" placeholder="搜索合约..." style="width: 100%; padding-left: 36px;" oninput="filterTaskSymbols(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>
<label style="display: flex; align-items: center; cursor: pointer; margin-bottom: 12px;">
<input type="checkbox" id="taskSelectAll" onchange="toggleTaskSelectAll()" style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
<span style="font-weight: 500;">全选</span>
</label>
<span id="taskSelectedCount" style="color: var(--gray-500); font-size: 14px;">已选择 0 个合约</span>
<div class="symbol-select-list" id="taskSymbolList" style="max-height: 200px; margin-top: 12px;"></div>
</div>
<div class="form-group">
<label class="form-label">采集周期</label>
<input class="form-input" id="taskPeriods" value="5min,15min,60min" placeholder="如: 5min,15min,60min">
</div>
<div class="form-group">
<label class="form-label">任务类型</label>
<select class="form-select" id="taskType" onchange="onTaskTypeChange()">
<option value="daily">每天定时执行</option>
<option value="once">仅执行一次</option>
<option value="interval" selected>指定周期循环</option>
</select>
</div>
<div id="taskIntervalGroup" class="form-group">
<label class="form-label">轮询间隔(秒)</label>
<input class="form-input" type="number" id="taskInterval" value="300" min="30" max="86400">
</div>
<div id="taskTimeGroup" class="form-group" style="display: none;">
<label class="form-label">执行时间</label>
<input class="form-input" type="time" id="taskTime" value="09:00">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeCreateTaskModal()">取消</button>
<button class="btn btn-warning" onclick="confirmCreateTasks()">
<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>
创建任务
</button>
</div>
</div>
</div>
<!-- Task Detail Modal -->
<div class="modal-overlay" id="taskDetailModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">任务详情</div>
<button class="modal-close" onclick="closeTaskDetailModal()">
<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" id="taskDetailContent"></div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeTaskDetailModal()">关闭</button>
<button class="btn btn-primary" id="btnEditTask" onclick="">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
修改
</button>
</div>
</div>
</div>
<!-- Edit Task Modal -->
<div class="modal-overlay" id="editTaskModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">修改任务</div>
<button class="modal-close" onclick="closeEditTaskModal()">
<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">
<input type="hidden" id="editTaskId">
<div class="form-group">
<label class="form-label">品种代码</label>
<input class="form-input" id="editTaskSymbol" readonly style="background: var(--gray-100);">
</div>
<div class="form-group">
<label class="form-label">数据类型</label>
<select class="form-select" id="editTaskDataType">
<option value="futures">期货</option>
<option value="stock">股票</option>
</select>
</div>
<div class="form-group">
<label class="form-label">采集周期</label>
<input class="form-input" id="editTaskPeriods" placeholder="如: 5min,15min,60min">
</div>
<div class="form-group">
<label class="form-label">任务类型</label>
<select class="form-select" id="editTaskType" onchange="onEditTaskTypeChange()">
<option value="daily">每天定时执行</option>
<option value="once">仅执行一次</option>
<option value="interval">指定周期循环</option>
</select>
</div>
<div id="editTaskIntervalGroup" class="form-group">
<label class="form-label">轮询间隔(秒)</label>
<input class="form-input" type="number" id="editTaskInterval" value="300" min="30" max="86400">
</div>
<div id="editTaskTimeGroup" class="form-group" style="display: none;">
<label class="form-label">执行时间</label>
<input class="form-input" type="time" id="editTaskTime" value="09:00">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeEditTaskModal()">取消</button>
<button class="btn btn-primary" onclick="saveEditTask()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></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 = [];
let taskSymbolsData = [];
let selectedTaskSymbols = [];
let currentTaskId = null;
let allTasks = [];
// 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();
}
// Task Management
function openCreateTaskModal() {
const dataType = document.getElementById('taskDataType').value;
taskSymbolsData = Object.entries(currentConfig[dataType] || {}).map(([name, code]) => ({ name, code }));
renderTaskSymbols();
document.getElementById('taskSelectAll').checked = true;
updateTaskSelectedCount();
document.getElementById('createTaskModal').classList.add('active');
}
function closeCreateTaskModal() {
document.getElementById('createTaskModal').classList.remove('active');
}
function renderTaskSymbols(filterText = '') {
const listContainer = document.getElementById('taskSymbolList');
const filterLower = filterText.toLowerCase();
const filteredSymbols = filterText
? taskSymbolsData.filter(s =>
s.name.toLowerCase().includes(filterLower) ||
s.code.toLowerCase().includes(filterLower)
)
: taskSymbolsData;
let html = '';
filteredSymbols.forEach(({ name, code }) => {
html += `
<label class="symbol-select-item">
<input type="checkbox" value="${code}" data-name="${name}" checked onchange="updateTaskSelectedCount()">
<div class="symbol-select-info">
<div class="symbol-select-name">${name}</div>
<div class="symbol-select-code">${code}</div>
</div>
</label>
`;
});
if (filteredSymbols.length === 0) {
html = `<div class="empty-state" style="padding: 20px;"><p>未找到匹配的合约</p></div>`;
}
listContainer.innerHTML = html;
updateTaskSelectedCount();
}
function filterTaskSymbols(searchTerm) {
renderTaskSymbols(searchTerm.trim());
}
function toggleTaskSelectAll() {
const selectAll = document.getElementById('taskSelectAll').checked;
const checkboxes = document.querySelectorAll('#taskSymbolList input[type="checkbox"]');
checkboxes.forEach(cb => cb.checked = selectAll);
updateTaskSelectedCount();
}
function updateTaskSelectedCount() {
const checkboxes = document.querySelectorAll('#taskSymbolList input[type="checkbox"]:checked');
const count = checkboxes.length;
const total = document.querySelectorAll('#taskSymbolList input[type="checkbox"]').length;
document.getElementById('taskSelectedCount').textContent = `已选择 ${count}/${total} 个合约`;
const selectAll = document.getElementById('taskSelectAll');
if (count === total) {
selectAll.checked = true;
} else if (count === 0) {
selectAll.checked = false;
} else {
selectAll.checked = false;
}
}
function onTaskTypeChange() {
const taskType = document.getElementById('taskType').value;
document.getElementById('taskIntervalGroup').style.display = taskType === 'interval' ? 'block' : 'none';
document.getElementById('taskTimeGroup').style.display = (taskType === 'daily' || taskType === 'once') ? 'block' : 'none';
}
async function confirmCreateTasks() {
const checkboxes = document.querySelectorAll('#taskSymbolList input[type="checkbox"]:checked');
if (checkboxes.length === 0) {
showToast('请至少选择一个合约', 'error');
return;
}
const dataType = document.getElementById('taskDataType').value;
const periods = document.getElementById('taskPeriods').value;
const taskType = document.getElementById('taskType').value;
const interval = document.getElementById('taskInterval').value;
const time = document.getElementById('taskTime').value;
let intervalSeconds = parseInt(interval);
if (taskType === 'daily') {
intervalSeconds = 86400; // 24小时
}
const selectedSymbols = Array.from(checkboxes).map(cb => cb.value);
closeCreateTaskModal();
addLog(`批量创建定时任务: ${dataType}, ${selectedSymbols.length}个合约, 类型: ${taskType}`);
try {
let createdCount = 0;
for (const symbol of selectedSymbols) {
const body = {
symbol: symbol,
data_type: dataType,
periods: periods,
interval_seconds: intervalSeconds,
task_type: taskType
};
if (taskType === 'daily' || taskType === 'once') {
body.run_time = time;
}
const res = await fetch(`${API}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (data.success) {
createdCount++;
}
}
showToast(`成功创建 ${createdCount} 个定时任务`, 'success');
loadTasks();
} catch (e) {
showToast(`创建任务失败: ${e.message}`, 'error');
}
}
async function loadTasks() {
try {
const res = await fetch(`${API}/tasks`);
const data = await res.json();
allTasks = data.tasks || [];
if (!allTasks.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>';
document.getElementById('btnBatchDeleteTasks').style.display = 'none';
return;
}
let html = `
<div style="margin-bottom: 12px; display: flex; align-items: center; gap: 12px;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="selectAllTasks" onchange="toggleSelectAllTasks()" style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
<span style="font-weight: 500;">全选</span>
</label>
<span id="taskSelectedTaskCount" style="color: var(--gray-500); font-size: 14px;">已选择 0 个任务</span>
</div>
<table>
<thead><tr><th><input type="checkbox" style="width: 18px; height: 18px; cursor: pointer;" onchange="toggleSelectAllTasks()"></th><th>ID</th><th>品种</th><th>周期</th><th>类型</th><th>状态</th><th>最后执行</th><th>下次执行</th><th>操作</th></tr></thead>
<tbody>
`;
for (const t of allTasks) {
const statusBadge = t.running
? '<span class="badge badge-success">运行中</span>'
: t.enabled
? '<span class="badge badge-warning">已停止</span>'
: '<span class="badge badge-gray">已禁用</span>';
const taskTypeText = t.task_type === 'daily' ? '每天' : t.task_type === 'once' ? '一次' : '循环';
const nextRunText = t.next_run ? new Date(t.next_run).toLocaleString() : '-';
html += `<tr>
<td><input type="checkbox" class="task-checkbox" value="${t.id}" onchange="updateSelectedTaskCount()" style="width: 18px; height: 18px; cursor: pointer;"></td>
<td style="cursor: pointer; color: var(--primary);" onclick="showTaskDetail(${t.id})">${t.id}</td>
<td><code style="color: var(--primary);">${t.symbol}</code></td>
<td>${t.periods ? t.periods.join(', ') : '-'}</td>
<td><span class="badge badge-gray">${taskTypeText}</span></td>
<td>${statusBadge}</td>
<td>${t.last_run ? new Date(t.last_run).toLocaleString() : '-'}</td>
<td>${nextRunText}</td>
<td>
<button class="btn btn-primary btn-sm" onclick="showTaskDetail(${t.id})">详情</button>
${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;
document.getElementById('btnBatchDeleteTasks').style.display = 'inline-flex';
} catch (e) {
addLog(`加载任务失败: ${e.message}`, 'error');
}
}
function toggleSelectAllTasks() {
const selectAll = document.getElementById('selectAllTasks').checked;
const checkboxes = document.querySelectorAll('.task-checkbox');
checkboxes.forEach(cb => cb.checked = selectAll);
updateSelectedTaskCount();
}
function updateSelectedTaskCount() {
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
const count = checkboxes.length;
document.getElementById('taskSelectedTaskCount').textContent = `已选择 ${count} 个任务`;
document.getElementById('btnBatchDeleteTasks').disabled = count === 0;
}
async function batchDeleteTasks() {
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
if (checkboxes.length === 0) {
showToast('请至少选择一个任务', 'error');
return;
}
if (!confirm(`确定删除选中的 ${checkboxes.length} 个任务?`)) return;
const taskIds = Array.from(checkboxes).map(cb => cb.value);
let deletedCount = 0;
for (const id of taskIds) {
try {
await fetch(`${API}/tasks/${id}`, { method: 'DELETE' });
deletedCount++;
} catch (e) {
console.error(`删除任务 ${id} 失败:`, e);
}
}
showToast(`成功删除 ${deletedCount} 个任务`, 'success');
loadTasks();
}
function showTaskDetail(id) {
const task = allTasks.find(t => t.id === id);
if (!task) return;
currentTaskId = id;
const taskTypeText = task.task_type === 'daily' ? '每天定时执行' : task.task_type === 'once' ? '仅执行一次' : '指定周期循环';
const statusText = task.running ? '运行中' : task.enabled ? '已停止' : '已禁用';
let html = `
<div style="display: grid; gap: 16px;">
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">任务ID:</div>
<div>${task.id}</div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">品种:</div>
<div><code style="color: var(--primary);">${task.symbol}</code></div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">数据类型:</div>
<div>${task.data_type === 'futures' ? '期货' : '股票'}</div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">采集周期:</div>
<div>${task.periods ? task.periods.join(', ') : '-'}</div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">任务类型:</div>
<div><span class="badge badge-info">${taskTypeText}</span></div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">轮询间隔:</div>
<div>${task.interval_seconds}秒 (${Math.floor(task.interval_seconds / 60)}分钟)</div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">状态:</div>
<div>${statusText}</div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">创建时间:</div>
<div>${task.created_at ? new Date(task.created_at).toLocaleString() : '-'}</div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">最后执行:</div>
<div>${task.last_run ? new Date(task.last_run).toLocaleString() : '尚未执行'}</div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">下次执行:</div>
<div>${task.next_run ? new Date(task.next_run).toLocaleString() : '-'}</div>
</div>
</div>
`;
document.getElementById('taskDetailContent').innerHTML = html;
document.getElementById('btnEditTask').setAttribute('onclick', `closeTaskDetailModal(); editTask(${id})`);
document.getElementById('taskDetailModal').classList.add('active');
}
function closeTaskDetailModal() {
document.getElementById('taskDetailModal').classList.remove('active');
}
function editTask(id) {
showToast('修改功能开发中...', 'info');
}
// History Tasks
function showHistoryTasks() {
document.getElementById('page-tasks').style.display = 'none';
document.getElementById('page-history-tasks').style.display = 'block';
loadHistoryTasks();
}
function showActiveTasks() {
document.getElementById('page-history-tasks').style.display = 'none';
document.getElementById('page-tasks').style.display = 'block';
}
async function loadHistoryTasks() {
try {
const res = await fetch(`${API}/tasks/history`);
const data = await res.json();
const historyTasks = data.tasks || [];
if (!historyTasks.length) {
document.getElementById('historyTaskTable').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><th>操作</th></tr></thead>
<tbody>
`;
for (const t of historyTasks) {
const statusBadge = t.last_status === 'success'
? '<span class="badge badge-success">已完成</span>'
: '<span class="badge badge-danger">失败</span>';
const taskTypeText = t.task_type === 'daily' ? '每天' : t.task_type === 'once' ? '一次' : '循环';
const finishedTime = t.updated_at ? new Date(t.updated_at).toLocaleString() : '-';
html += `<tr>
<td style="cursor: pointer; color: var(--primary);" onclick="showTaskDetail(${t.id})">${t.id}</td>
<td><code style="color: var(--primary);">${t.symbol}</code></td>
<td>${t.periods ? t.periods.join(', ') : '-'}</td>
<td><span class="badge badge-gray">${taskTypeText}</span></td>
<td>${statusBadge}</td>
<td>${t.last_run ? new Date(t.last_run).toLocaleString() : '-'}</td>
<td>${finishedTime}</td>
<td>
<button class="btn btn-success btn-sm" onclick="rerunTask(${t.id})">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
重新执行
</button>
<button class="btn btn-primary btn-sm" onclick="openEditTaskModal(${t.id})">修改</button>
<button class="btn btn-danger btn-sm" onclick="deleteTask(${t.id})">删除</button>
</td>
</tr>`;
}
html += '</tbody></table>';
document.getElementById('historyTaskTable').innerHTML = html;
} catch (e) {
addLog(`加载历史任务失败: ${e.message}`, 'error');
}
}
async function rerunTask(id) {
if (!confirm('确定重新执行此任务?')) return;
try {
const res = await fetch(`${API}/tasks/${id}/rerun`, { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast('任务已重新开始执行', 'success');
showActiveTasks();
loadTasks();
} else {
showToast(data.detail || '重新执行失败', 'error');
}
} catch (e) {
showToast(`重新执行失败: ${e.message}`, 'error');
}
}
// Edit Task
function openEditTaskModal(id) {
const task = allTasks.find(t => t.id === id);
if (!task) {
showToast('任务不存在', 'error');
return;
}
document.getElementById('editTaskId').value = id;
document.getElementById('editTaskSymbol').value = task.symbol;
document.getElementById('editTaskDataType').value = task.data_type;
document.getElementById('editTaskPeriods').value = task.periods ? task.periods.join(',') : '';
document.getElementById('editTaskType').value = task.task_type || 'interval';
document.getElementById('editTaskInterval').value = task.interval_seconds;
// 如果有run_time则填充
if (task.run_time) {
document.getElementById('editTaskTime').value = task.run_time;
}
onEditTaskTypeChange();
document.getElementById('editTaskModal').classList.add('active');
}
function closeEditTaskModal() {
document.getElementById('editTaskModal').classList.remove('active');
}
function onEditTaskTypeChange() {
const taskType = document.getElementById('editTaskType').value;
document.getElementById('editTaskIntervalGroup').style.display = taskType === 'interval' ? 'block' : 'none';
document.getElementById('editTaskTimeGroup').style.display = (taskType === 'daily' || taskType === 'once') ? 'block' : 'none';
}
async function saveEditTask() {
const id = document.getElementById('editTaskId').value;
const dataType = document.getElementById('editTaskDataType').value;
const periods = document.getElementById('editTaskPeriods').value;
const taskType = document.getElementById('editTaskType').value;
const interval = document.getElementById('editTaskInterval').value;
const time = document.getElementById('editTaskTime').value;
let intervalSeconds = parseInt(interval);
if (taskType === 'daily') {
intervalSeconds = 86400;
}
const body = {
symbol: document.getElementById('editTaskSymbol').value,
data_type: dataType,
periods: periods,
interval_seconds: intervalSeconds,
task_type: taskType
};
if (taskType === 'daily' || taskType === 'once') {
body.run_time = time;
}
try {
// 先停止旧任务
await fetch(`${API}/tasks/${id}/stop`, { method: 'POST' });
// 删除旧任务
await fetch(`${API}/tasks/${id}`, { method: 'DELETE' });
// 创建新任务
const res = await fetch(`${API}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
showToast('任务修改成功', 'success');
closeEditTaskModal();
loadTasks();
} else {
const data = await res.json();
showToast(data.detail || '修改失败', 'error');
}
} catch (e) {
showToast(`修改失败: ${e.message}`, 'error');
}
}
// 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>