Files
stock/stock-html/templates/admin.html
T
selfrelease bb5a72767f UI/UX优化: CSS变量化 + 错误反馈增强 + 按钮type属性
- CSS: 全部硬编码hex颜色替换为CSS变量(base/scan/pages/responsive/admin)
- CSS: 硬编码字体提取为--font-sans/--font-mono变量
- CSS: 新增打印色/渐变色/辅助色等20+变量定义
- CSS: 修复scan.css lint警告(line-clamp标准属性+空规则集)
- app.js: 16处用户可感知catch块补充showToast错误反馈
- app.js: 全部console.log降级为console.debug
- index.html: 76个button补充type属性
- index.html: 内联hex颜色/SVG stroke/Vue :style替换为CSS变量
- admin.html: 残留hex替换为CSS变量
- 全部CSS版本号更新用于缓存刷新
2026-07-18 09:53:34 +08:00

1396 lines
89 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台 - 股票投资系统</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<style>
:root {
--bg-dark: #020617;
--bg-glass: rgba(255, 255, 255, 0.05);
--bg-glass-hover: rgba(255, 255, 255, 0.08);
--bg-content: #0f172a;
--border-glass: rgba(255, 255, 255, 0.1);
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-muted: rgba(255, 255, 255, 0.4);
--accent: #fbbf24;
--primary: #fbbf24;
--success: #00ff88;
--danger: #ff4444;
--warning: #fbbf24;
--info: #3b82f6;
--info-light: #60a5fa;
--purple: #a855f7;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'PingFang SC', sans-serif;
background: var(--bg-dark);
min-height: 100vh;
color: var(--text-primary);
padding-bottom: env(safe-area-inset-bottom);
}
.admin-wrap { max-width: 800px; margin: 0 auto; padding: 20px 16px; padding-top: max(20px, env(safe-area-inset-top)); background: var(--bg-content); min-height: 100vh; }
/* Header */
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.admin-header h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.5px; display: flex; align-items: center; gap: 8px; }
.admin-header h1 .icon { width: 22px; height: 22px; opacity: 0.9; }
.admin-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 6px; font-size: 10px; font-weight: 700; letter-spacing: 0.5px; background: rgba(251,191,36,0.15); color: var(--accent); border: 1px solid rgba(251,191,36,0.3); }
.back-link { color: var(--text-muted); text-decoration: none; font-size: 13px; display: flex; align-items: center; gap: 4px; transition: color 0.2s; }
.back-link:hover { color: var(--text-primary); }
.back-link svg { width: 14px; height: 14px; }
/* Tabs */
.nav-tabs { display: flex; gap: 2px; margin-bottom: 20px; background: var(--bg-glass); border-radius: 12px; padding: 3px; }
.nav-tab { flex: 1; padding: 8px 4px; border: none; background: transparent; color: var(--text-muted); cursor: pointer; border-radius: 10px; font-size: 13px; font-weight: 500; transition: all 0.2s; display: flex; flex-direction: column; align-items: center; gap: 2px; }
.nav-tab svg { width: 18px; height: 18px; opacity: 0.5; transition: opacity 0.2s; }
.nav-tab.active { background: var(--bg-glass-hover); color: var(--accent); }
.nav-tab.active svg { opacity: 1; }
.nav-tab:hover:not(.active) { color: var(--text-secondary); }
/* Glass Card */
.glass { background: var(--bg-glass); border-radius: 16px; margin-bottom: 12px; overflow: hidden; }
.card-head { padding: 14px 16px 10px; display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; color: var(--text-secondary); }
.card-head svg { width: 16px; height: 16px; opacity: 0.6; }
/* Stats Grid */
.stats-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 16px; }
.stat-card { background: var(--bg-glass); border-radius: 14px; padding: 14px; display: flex; align-items: center; gap: 12px; }
.stat-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.stat-icon svg { width: 18px; height: 18px; }
.stat-icon.blue { background: rgba(59,130,246,0.15); color: var(--info); }
.stat-icon.green { background: rgba(0,255,136,0.15); color: var(--success); }
.stat-icon.orange { background: rgba(255,170,0,0.15); color: var(--warning); }
.stat-icon.red { background: rgba(255,68,68,0.15); color: var(--danger); }
.stat-icon.purple { background: rgba(168,85,247,0.15); color: var(--purple); }
.stat-info .stat-val { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; }
.stat-info .stat-lbl { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
/* Table */
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 8px 12px; color: var(--text-muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border-glass); white-space: nowrap; }
td { padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,0.03); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.02); }
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
/* User Card (mobile-friendly) */
.user-cards { padding: 0 12px 12px; }
.user-card { background: rgba(255,255,255,0.03); border-radius: 12px; padding: 14px; margin-bottom: 8px; }
.user-card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.user-card-email { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 70%; }
.user-card-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 10px; }
.user-card-stat { text-align: center; background: rgba(255,255,255,0.03); border-radius: 8px; padding: 6px 4px; }
.user-card-stat .num { font-size: 16px; font-weight: 700; }
.user-card-stat .txt { font-size: 10px; color: var(--text-muted); margin-top: 1px; }
.user-card-meta { font-size: 11px; color: var(--text-muted); margin-bottom: 8px; }
.user-card-actions { display: flex; gap: 6px; }
/* Data Card (mobile-friendly) */
.data-item { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.03); }
.data-item:last-child { border-bottom: none; }
.data-item-left { flex: 1; }
.data-item-label { font-size: 14px; font-weight: 500; }
.data-item-sub { font-size: 11px; color: var(--text-muted); font-family: SFMono-Regular,Menlo,monospace; margin-top: 2px; }
.data-item-val { font-size: 16px; font-weight: 700; text-align: right; }
/* Log Card (mobile-friendly) */
.log-card { padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.03); }
.log-card:last-child { border-bottom: none; }
.log-card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.log-card-type { font-size: 13px; font-weight: 600; }
.log-card-time { font-size: 11px; color: var(--text-muted); }
.log-card-detail { display: flex; gap: 12px; align-items: center; font-size: 12px; }
.log-card-error { font-size: 11px; color: var(--danger); margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Scan Card (mobile-friendly) */
.scan-item { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.03); }
.scan-item:last-child { border-bottom: none; }
.scan-item-date { font-size: 14px; font-weight: 500; display: flex; flex-direction: column; gap: 2px; }
.scan-time-detail { font-size: 11px; color: var(--text-muted); font-weight: 400; }
.scan-duration { color: var(--primary); }
.scan-item-stats { display: flex; gap: 16px; font-size: 13px; }
.scan-item-stats span { display: flex; flex-direction: column; align-items: center; }
.scan-item-stats .num { font-weight: 600; }
.scan-item-stats .txt { font-size: 10px; color: var(--text-muted); }
/* Badge */
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.badge-admin { background: rgba(59,130,246,0.15); color: var(--info); }
.badge-user { background: rgba(255,255,255,0.05); color: var(--text-muted); }
.badge-ok { background: rgba(0,255,136,0.12); color: var(--success); }
.badge-fail { background: rgba(255,68,68,0.12); color: var(--danger); }
/* Buttons */
.btn { padding: 6px 12px; border: none; border-radius: 8px; cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.2s; }
.btn-primary { background: rgba(59,130,246,0.2); color: var(--info); }
.btn-primary:hover { background: rgba(59,130,246,0.35); }
.btn-danger { background: rgba(255,68,68,0.15); color: var(--danger); }
.btn-danger:hover { background: rgba(255,68,68,0.3); }
.btn-ghost { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
.btn-ghost:hover { background: rgba(255,255,255,0.1); }
.btn:disabled { opacity: 0.3; cursor: not-allowed; }
.btn-group { display: flex; gap: 4px; flex-wrap: wrap; }
/* Modal */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: flex-end; justify-content: center; z-index: 1000; }
@media (min-width: 600px) { .modal-overlay { align-items: center; } }
.modal { background: #111; border-radius: 20px 20px 0 0; padding: 20px 16px; width: 100%; max-width: 700px; max-height: 90vh; overflow-y: auto; }
@media (min-width: 600px) { .modal { border-radius: 20px; } }
.modal-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.modal-head h3 { font-size: 16px; font-weight: 600; }
.modal-close { background: rgba(255,255,255,0.1); border: none; color: var(--text-muted); width: 28px; height: 28px; border-radius: 50%; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; }
/* Detail */
.detail-section { margin-bottom: 18px; }
.detail-section h4 { font-size: 13px; color: var(--text-muted); margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border-glass); display: flex; align-items: center; gap: 6px; }
.detail-section h4 svg { width: 14px; height: 14px; opacity: 0.5; }
.detail-row { display: flex; justify-content: space-between; padding: 5px 0; font-size: 13px; }
.detail-row .lbl { color: var(--text-muted); }
/* Form */
.form-group { margin-bottom: 12px; }
.form-group label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
.form-group input { width: 100%; padding: 10px 12px; background: var(--bg-glass); border: 1px solid var(--border-glass); border-radius: 10px; color: var(--text-primary); font-size: 14px; }
.form-group input:focus { outline: none; border-color: rgba(59,130,246,0.5); }
.empty { text-align: center; padding: 30px; color: var(--text-muted); font-size: 13px; }
.toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); padding: 10px 20px; border-radius: 12px; color: #fff; font-size: 13px; z-index: 2000; backdrop-filter: blur(10px); }
.toast.success { background: rgba(0,255,136,0.2); border: 1px solid rgba(0,255,136,0.3); }
.toast.error { background: rgba(255,68,68,0.2); border: 1px solid rgba(255,68,68,0.3); }
/* Sort Buttons */
.sort-btn { padding: 3px 10px; border: 1px solid var(--border-glass); border-radius: 6px; background: transparent; color: var(--text-muted); font-size: 11px; cursor: pointer; transition: all 0.2s; }
.sort-btn.active { background: rgba(255,255,255,0.1); color: var(--text-primary); border-color: rgba(255,255,255,0.2); }
.sort-btn:hover:not(.active) { color: var(--text-secondary); }
/* Win Analysis */
.win-stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
.win-stat-card { text-align: center; background: rgba(255,255,255,0.03); border-radius: 10px; padding: 10px 4px; }
.win-stat-val { font-size: 18px; font-weight: 700; }
.win-stat-lbl { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
/* Record Card (for detail modal) */
.record-card { padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
.record-card:last-child { border-bottom: none; }
.record-card-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.record-code { font-family: SFMono-Regular,Menlo,monospace; font-size: 13px; font-weight: 600; }
.record-name { font-size: 13px; color: var(--text-secondary); margin-left: 4px; }
.record-card-row { display: flex; gap: 16px; margin-top: 4px; font-size: 12px; color: var(--text-secondary); }
.record-card-row b { color: var(--text-primary); font-weight: 600; }
.record-card-meta { font-size: 11px; color: var(--text-muted); margin-top: 3px; }
.record-card-signal { font-size: 11px; color: var(--success); margin-top: 3px; background: rgba(0,255,136,0.06); padding: 3px 8px; border-radius: 6px; display: inline-block; }
.sys-info-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 16px; font-size: 13px; }
.sys-info-row .lbl { color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
.sys-info-row .lbl svg { width: 14px; height: 14px; opacity: 0.5; }
.sys-info-row .val { font-weight: 500; }
/* Confirm Modal */
.confirm-modal-content { text-align: center; padding: 10px 0 6px; }
.confirm-icon { width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 14px; }
.confirm-icon.warn { background: rgba(255,170,0,0.15); color: var(--warning); }
.confirm-icon svg { width: 24px; height: 24px; }
.confirm-msg { font-size: 15px; font-weight: 500; margin-bottom: 6px; line-height: 1.5; }
.confirm-sub { font-size: 12px; color: var(--text-muted); margin-bottom: 18px; }
.confirm-actions { display: flex; gap: 8px; }
.confirm-actions .btn { flex: 1; padding: 11px; font-size: 14px; border-radius: 10px; }
.btn-cancel { background: rgba(255,255,255,0.06); color: var(--text-secondary); }
.btn-cancel:hover { background: rgba(255,255,255,0.12); }
.btn-confirm { background: rgba(59,130,246,0.25); color: var(--info); }
.btn-confirm:hover { background: rgba(59,130,246,0.4); }
.btn-confirm.danger { background: rgba(255,68,68,0.2); color: var(--danger); }
.btn-confirm.danger:hover { background: rgba(255,68,68,0.35); }
/* Scan Trigger */
.task-exec-btn { display:flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:8px; border:none; cursor:pointer; background:rgba(59,130,246,0.15); color:var(--info); transition:all 0.2s; margin-left:8px; }
.task-exec-btn:hover { background:rgba(59,130,246,0.3); }
.task-inline-progress { padding:6px 0 2px 24px; }
.task-progress-label { font-size:11px; color:var(--text-muted); margin-bottom:4px; font-weight:500; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes progress-pulse { 0%,100% { opacity:0.15; } 50% { opacity:0.35; } }
.scan-trigger-card { padding: 16px; }
.scan-trigger-row { display: flex; align-items: center; gap: 12px; }
.scan-trigger-btn { flex: 1; padding: 12px 16px; border: none; border-radius: 12px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 8px; }
.scan-trigger-btn.primary { background: rgba(59,130,246,0.2); color: var(--info); }
.scan-trigger-btn.primary:hover { background: rgba(59,130,246,0.35); }
.scan-trigger-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.scan-progress-section { margin-top: 14px; }
.scan-progress-bar-wrap { height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; }
.scan-progress-bar-fill { height: 100%; background: linear-gradient(90deg, var(--info), var(--success)); border-radius: 3px; transition: width 0.5s ease; }
.scan-progress-info { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; font-size: 12px; color: var(--text-muted); }
.scan-progress-info .highlight { color: var(--success); font-weight: 600; }
.scan-status-tag { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; border-radius: 8px; font-size: 11px; font-weight: 600; }
.scan-status-tag.running { background: rgba(59,130,246,0.15); color: var(--info); }
.scan-status-tag.complete { background: rgba(0,255,136,0.12); color: var(--success); }
.scan-status-tag.idle { background: rgba(255,255,255,0.06); color: var(--text-muted); }
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.pulse-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; animation: pulse-dot 1.2s infinite; }
/* K线同步 */
.kline-stats-row { display: flex; flex-wrap: wrap; gap: 16px; padding: 4px 0; font-size: 13px; color: var(--text-secondary); }
.kline-stats-row b { color: var(--text-primary); font-weight: 600; }
.kline-progress-section { margin: 10px 0; }
.kline-progress-detail { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; font-size: 12px; color: var(--text-secondary); align-items: center; }
.kline-log-tail { margin-top: 12px; padding: 10px 12px; background: rgba(0,0,0,0.3); border-radius: 8px; font-size: 11px; max-height: 120px; overflow-y: auto; }
.kline-log-tail pre { margin: 0; white-space: pre-wrap; word-break: break-all; color: var(--text-muted); font-family: 'SF Mono', Monaco, Consolas, monospace; line-height: 1.5; }
/* Task Items */
.task-item { padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,0.03); cursor: pointer; transition: background 0.2s; }
.task-item:last-child { border-bottom: none; }
.task-item:hover { background: rgba(255,255,255,0.03); }
.task-item-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.task-item-left { display: flex; align-items: center; gap: 8px; }
.task-item-name { font-size: 14px; font-weight: 600; }
.task-status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.task-status-dot.running { background: var(--info); box-shadow: 0 0 6px var(--info); animation: pulse-dot 1.2s infinite; }
.task-status-dot.success { background: var(--success); }
.task-status-dot.error { background: var(--danger); }
.task-status-dot.stopped { background: var(--danger); }
.task-status-dot.idle { background: var(--text-muted); }
.task-status-dot.no_log { background: var(--warning); }
.task-status-dot.unknown { background: var(--text-muted); }
.task-item-detail { display: flex; gap: 16px; font-size: 12px; color: var(--text-muted); align-items: center; }
.task-schedule { display: flex; align-items: center; gap: 4px; }
.task-last-run { color: var(--text-secondary); }
.task-error { font-size: 11px; color: var(--danger); margin-top: 4px; padding: 4px 8px; background: rgba(255,68,68,0.06); border-radius: 6px; }
</style>
</head>
<body>
{% raw %}
<div id="admin-app">
<div v-if="toast.show" :class="['toast', toast.type]">{{ toast.msg }}</div>
<div class="admin-wrap">
<div class="admin-header">
<h1>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
管理后台
<span class="admin-badge">ADMIN</span>
</h1>
<a class="back-link" href="javascript:void(0)" onclick="if(window.opener||window.history.length<=1){window.close()}else{window.location.href='/'}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
返回
</a>
</div>
<div class="nav-tabs">
<button :class="['nav-tab', {active: tab === 'dashboard'}]" @click="tab='dashboard'; loadDashboard()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
概览
</button>
<button :class="['nav-tab', {active: tab === 'users'}]" @click="tab='users'; loadUsers()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
用户
</button>
<button :class="['nav-tab', {active: tab === 'data'}]" @click="tab='data'; loadDataStats()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
数据
</button>
<button :class="['nav-tab', {active: tab === 'tasks'}]" @click="tab='tasks'; loadScheduledTasks(); loadScanHistory(); checkScanStatus(); checkKlineSyncStatus();">
<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>
<!-- Dashboard -->
<div v-show="tab === 'dashboard'">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon blue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/></svg></div>
<div class="stat-info"><div class="stat-val">{{ dash.user_count || 0 }}</div><div class="stat-lbl">注册用户</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"><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-info"><div class="stat-val">{{ dash.stock_count || 0 }}</div><div class="stat-lbl">股票总数</div></div>
</div>
<div class="stat-card">
<div class="stat-icon orange"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></div>
<div class="stat-info"><div class="stat-val">{{ dash.signal_stock_count || 0 }}</div><div class="stat-lbl">有信号股票</div></div>
</div>
<div class="stat-card">
<div class="stat-icon purple"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
<div class="stat-info"><div class="stat-val">{{ dash.scan_count || 0 }}</div><div class="stat-lbl">扫描记录</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"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v16"/></svg></div>
<div class="stat-info"><div class="stat-val">{{ dash.trade_count || 0 }}</div><div class="stat-lbl">交易记录</div></div>
</div>
<div class="stat-card">
<div class="stat-icon blue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg></div>
<div class="stat-info"><div class="stat-val">{{ dash.watchlist_count || 0 }}</div><div class="stat-lbl">关注记录</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="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
<div class="stat-info"><div class="stat-val">{{ dash.sim_trade_count || 0 }}</div><div class="stat-lbl">模拟交易</div></div>
</div>
<div class="stat-card">
<div class="stat-icon orange"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
<div class="stat-info"><div class="stat-val">{{ dash.fundamental_count || 0 }}</div><div class="stat-lbl">基本面缓存</div></div>
</div>
</div>
<div class="glass" v-if="dash.server_info">
<div class="card-head" style="margin-bottom:12px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;vertical-align:middle;margin-right:4px;"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
服务器信息
</div>
<div class="sys-info-row">
<span class="lbl">🌐 外网IP</span>
<span class="val" style="font-family:monospace;color:var(--info-light);">{{ dash.server_info.external_ip }}</span>
</div>
<div class="sys-info-row">
<span class="lbl">🏠 内网IP</span>
<span class="val" style="font-family:monospace;">{{ dash.server_info.internal_ip }}</span>
</div>
<div class="sys-info-row">
<span class="lbl">🔌 应用端口</span>
<span class="val" style="font-family:monospace;color:var(--accent);">{{ dash.server_info.app_port }}</span>
</div>
<div class="sys-info-row">
<span class="lbl">🌍 访问域名</span>
<span class="val" style="font-family:monospace;color:var(--success);">{{ dash.server_info.scheme }}://{{ dash.server_info.domain }}</span>
</div>
<div class="sys-info-row">
<span class="lbl">🖥️ 主机名</span>
<span class="val">{{ dash.server_info.hostname }}</span>
</div>
<div class="sys-info-row">
<span class="lbl">💻 系统</span>
<span class="val">{{ dash.server_info.os_info }}</span>
</div>
<div class="sys-info-row">
<span class="lbl">🐍 Python</span>
<span class="val">{{ dash.server_info.python_version }}</span>
</div>
</div>
<div class="glass">
<div class="sys-info-row">
<span class="lbl">
<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 class="val">{{ dash.last_scan_date || '-' }}</span>
</div>
</div>
</div>
<!-- Users -->
<div v-show="tab === 'users'">
<div class="glass">
<div class="card-head" style="display:flex; justify-content:space-between; align-items:center;">
<span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>
用户列表 ({{ users.length }})
</span>
<span style="display:flex; gap:4px;">
<button class="sort-btn" :class="{active: userSort === 'id'}" @click="userSort = 'id'">ID</button>
<button class="sort-btn" :class="{active: userSort === 'win_rate'}" @click="userSort = 'win_rate'">胜率</button>
<button class="sort-btn" :class="{active: userSort === 'profit'}" @click="userSort = 'profit'">盈亏</button>
</span>
</div>
<div class="user-cards">
<div class="user-card" v-for="u in sortedUsers" :key="u.id">
<div class="user-card-head">
<span class="user-card-email">{{ u.email }}</span>
<span :class="['badge', u.is_admin ? 'badge-admin' : 'badge-user']">{{ u.is_admin ? '管理员' : '用户' }}</span>
</div>
<div class="user-card-stats">
<div class="user-card-stat"><div class="num">{{ u.watchlist_count }}</div><div class="txt">关注</div></div>
<div class="user-card-stat"><div class="num">{{ u.trade_count }}</div><div class="txt">交易</div></div>
<div class="user-card-stat"><div class="num">{{ u.sim_trade_count }}</div><div class="txt">模拟</div></div>
<div class="user-card-stat">
<div class="num" :style="{color: u.win_rate >= 50 ? 'var(--success)' : u.win_rate > 0 ? 'var(--danger)' : ''}">{{ u.win_rate || 0 }}%</div>
<div class="txt">胜率</div>
</div>
</div>
<div class="user-card-meta">ID: {{ u.id }} · 盈亏: <span :style="{color: (u.total_profit||0) >= 0 ? 'var(--danger)' : 'var(--success)'}">{{ (u.total_profit||0) >= 0 ? '+' : '' }}{{ (u.total_profit||0).toFixed(2) }}</span> · 注册: {{ formatDate(u.created_at) }}</div>
<div class="user-card-actions">
<button class="btn btn-primary" @click="viewUser(u.id)">详情</button>
<button class="btn btn-ghost" @click="showResetPw(u)">重置密码</button>
<button class="btn btn-ghost" @click="toggleAdmin(u)" :disabled="u.id === currentUserId">
{{ u.is_admin ? '取消管理员' : '设为管理员' }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Data -->
<div v-show="tab === 'data'">
<div class="glass">
<div class="card-head">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
数据表统计
</div>
<div v-for="t in dataStats.tables" :key="t.table" class="data-item">
<div class="data-item-left">
<div class="data-item-label">{{ t.label }}</div>
<div class="data-item-sub">{{ t.table }}</div>
</div>
<div class="data-item-val">{{ t.count.toLocaleString() }}</div>
</div>
</div>
<div class="glass">
<div class="card-head">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
更新日志
</div>
<template v-if="dataStats.logs && dataStats.logs.length > 0">
<div v-for="(log, i) in dataStats.logs" :key="i" class="log-card">
<div class="log-card-head">
<span class="log-card-type">{{ log.data_type }}</span>
<span :class="['badge', log.status === 'success' ? 'badge-ok' : 'badge-fail']">{{ log.status }}</span>
</div>
<div class="log-card-detail">
<span>{{ log.records_count }} 条</span>
<span class="log-card-time">{{ formatDate(log.started_at) }}</span>
</div>
<div v-if="log.error_message" class="log-card-error">{{ log.error_message }}</div>
</div>
</template>
<div v-else class="empty">暂无日志</div>
</div>
</div>
<!-- Scheduled Tasks (merged with Scans) -->
<div v-show="tab === 'tasks'">
<div class="glass">
<div class="card-head" style="justify-content:space-between;">
<span style="display:flex; align-items:center; gap:8px;">
<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>
<button class="btn btn-ghost" @click="loadScheduledTasks" style="font-size:11px;padding:4px 10px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:12px;height:12px;"><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 v-for="t in scheduledTasks" :key="t.id" class="task-item">
<div class="task-item-head">
<div class="task-item-left" @click="viewTaskLog(t)" style="cursor:pointer;flex:1;">
<span :class="['task-status-dot', t.is_running ? 'running' : t.status]"></span>
<span class="task-item-name">{{ t.name }}</span>
</div>
<span :class="['badge', taskStatusBadge(t.is_running ? 'running' : t.status)]">{{ taskStatusText(t.is_running ? 'running' : t.status) }}</span>
<button v-if="t.type === 'cron' && !t.is_running && !taskTriggering[t.id]"
class="task-exec-btn" @click.stop="triggerTask(t)"
title="手动执行">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>
<span v-if="taskTriggering[t.id]" class="task-exec-btn" style="opacity:0.5;cursor:default;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;animation:spin 1s linear infinite;"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</span>
</div>
<div class="task-item-detail" @click="viewTaskLog(t)" style="cursor:pointer;">
<span class="task-schedule">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:11px;height:11px;"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{{ t.schedule }}
</span>
<span v-if="t.last_run" class="task-last-run">上次: {{ formatTaskTime(t.last_run) }}</span>
</div>
<!-- K线同步+全景扫描 操作面板和进度 -->
<template v-if="t.id === 'kline_scan'">
<!-- K线数据统计 -->
<div v-if="klineStats.stock_count > 0" class="task-inline-progress" style="padding-top:8px;">
<div class="kline-stats-row" style="margin-bottom:0;">
<span>覆盖 <b>{{ klineStats.stock_count }}</b>/{{ klineStats.total_stocks }}只</span>
<span><b>{{ (klineStats.total_rows || 0).toLocaleString() }}</b></span>
<span>覆盖率 <b>{{ klineStats.coverage }}%</b></span>
</div>
<div v-if="klineStats.min_date" class="kline-stats-row" style="font-size:11px; color:#888; margin-top:2px;">
{{ klineStats.min_date }} ~ {{ klineStats.max_date }}
<span v-if="klineStats.recent_updated > 0" style="color:var(--success);">{{ klineStats.recent_updated }}只近1h更新)</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="task-inline-progress" style="padding-top:6px;">
<div class="scan-trigger-row" style="gap:8px;">
<button class="scan-trigger-btn primary" style="font-size:12px;padding:6px 12px;" @click.stop="triggerKlineSync('incremental')" :disabled="klineSyncRunning || klineSyncTriggering">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px;"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
{{ klineSyncTriggering ? '启动中...' : klineSyncRunning ? '同步中...' : '增量同步' }}
</button>
<button class="scan-trigger-btn primary" style="font-size:12px;padding:6px 12px;background:rgba(0,255,136,0.12);color:var(--success);" @click.stop="triggerFullScan" :disabled="scanRunning || scanTriggering">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
{{ scanTriggering ? '启动中...' : scanRunning ? '扫描中...' : '全景扫描' }}
</button>
<button class="scan-trigger-btn" style="font-size:12px;padding:6px 12px;background:var(--warning);color:#fff;" @click.stop="triggerKlineSync('full')" :disabled="klineSyncRunning || klineSyncTriggering">
全量同步
</button>
</div>
</div>
<!-- K线同步进度条 -->
<template v-if="scanRunning || scanProgress > 0 || klineSyncRunning || t.is_running">
<div v-if="(klineSyncRunning || t.is_running) && klineSyncProgress.percent != null" class="task-inline-progress">
<div class="task-progress-label">K线同步中</div>
<div class="scan-progress-bar-wrap"><div class="scan-progress-bar-fill" :style="{width: klineSyncProgress.percent + '%'}"></div></div>
<div class="kline-progress-detail">
<span>{{ klineSyncProgress.done || 0 }}/{{ klineSyncProgress.total || 0 }}</span>
<span style="color:var(--success);">✓{{ klineSyncProgress.success || 0 }}</span>
<span v-if="klineSyncProgress.failed > 0" style="color:var(--danger);">✗{{ klineSyncProgress.failed }}</span>
<span>{{ klineSyncProgress.speed || 0 }}只/s</span>
<span>剩余 {{ klineSyncProgress.remaining_min || 0 }}分</span>
<span>{{ klineSyncProgress.percent }}%</span>
</div>
</div>
<div v-else-if="t.is_running && !klineSyncProgress.percent" class="task-inline-progress">
<div class="task-progress-label">正在启动K线同步...</div>
<div class="scan-progress-bar-wrap"><div class="scan-progress-bar-fill" style="width:100%;animation:progress-pulse 1.5s ease infinite;"></div></div>
</div>
<div v-if="scanRunning || scanProgress > 0" class="task-inline-progress">
<div class="task-progress-label">全景扫描{{ scanRunning ? '中' : '完成' }}</div>
<div class="scan-progress-bar-wrap"><div class="scan-progress-bar-fill" :style="{width: scanProgress + '%'}"></div></div>
<div class="scan-progress-info">
<span>{{ scanScanned }} / {{ scanTotal }}</span>
<span>信号 <span class="highlight">{{ scanTriggered }}</span></span>
<span>{{ scanProgress }}%</span>
</div>
</div>
</template>
</template>
<!-- 5分钟K线采集 inline progress -->
<template v-if="t.id === 'kline_5min' && (t.is_running || kline5minProgress.percent != null)">
<div v-if="kline5minProgress.percent != null" class="task-inline-progress">
<div class="task-progress-label">采集中</div>
<div class="scan-progress-bar-wrap"><div class="scan-progress-bar-fill" :style="{width: kline5minProgress.percent + '%'}"></div></div>
<div class="kline-progress-detail">
<span>{{ kline5minProgress.done || 0 }}/{{ kline5minProgress.total || 0 }}</span>
<span style="color:var(--success);">✅{{ kline5minProgress.success || 0 }}</span>
<span v-if="kline5minProgress.failed > 0" style="color:var(--danger);">❌{{ kline5minProgress.failed }}</span>
<span v-if="kline5minProgress.skipped > 0" style="color:var(--text-muted);">⏭️{{ kline5minProgress.skipped }}</span>
<span>{{ kline5minProgress.speed || 0 }}只/s</span>
<span>剩余 {{ kline5minProgress.remaining_min || 0 }}分</span>
<span v-if="kline5minProgress.saved_rows">已存{{ kline5minProgress.saved_rows }}条</span>
<span>{{ kline5minProgress.percent }}%</span>
</div>
</div>
<div v-else-if="t.is_running" class="task-inline-progress">
<div class="task-progress-label">正在启动采集...</div>
<div class="scan-progress-bar-wrap"><div class="scan-progress-bar-fill" style="width:100%;animation:progress-pulse 1.5s ease infinite;"></div></div>
</div>
</template>
<div v-if="t.error_msg" class="task-error">{{ t.error_msg }}</div>
</div>
<div v-if="scheduledTasks.length === 0" class="empty">加载中...</div>
</div>
<!-- 扫描历史 -->
<div class="glass" v-if="scanHistory.length > 0">
<div class="card-head" style="cursor:pointer;" @click="showScanHistory = !showScanHistory">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
扫描历史 ({{ scanHistory.length }})
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;margin-left:auto;transition:transform 0.2s;" :style="{transform: showScanHistory ? 'rotate(180deg)' : ''}"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<template v-if="showScanHistory">
<div v-for="s in scanHistory" :key="s.scan_date" class="scan-item">
<div class="scan-item-date">
{{ s.scan_date }}
<span v-if="s.scan_start" class="scan-time-detail">{{ s.scan_start }} ~ {{ s.scan_end }}
<span v-if="s.duration_sec != null" class="scan-duration">{{ formatDuration(s.duration_sec) }}</span>
</span>
</div>
<div class="scan-item-stats">
<span><span class="num">{{ s.total }}</span><span class="txt">总数</span></span>
<span><span class="num" style="color:var(--success);">{{ s.with_signal }}</span><span class="txt">信号</span></span>
<span><span class="num" style="color:var(--warning);">{{ s.max_triggered }}</span><span class="txt">最高</span></span>
<span><span class="num">{{ s.total > 0 ? (s.with_signal / s.total * 100).toFixed(1) + '%' : '-' }}</span><span class="txt">占比</span></span>
</div>
</div>
</template>
</div>
<!-- Crontab 配置 -->
<div class="glass" v-if="crontabContent">
<div class="card-head" style="cursor:pointer;" @click="showCrontab = !showCrontab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Crontab 配置
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;margin-left:auto;transition:transform 0.2s;" :style="{transform: showCrontab ? 'rotate(180deg)' : ''}"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div v-if="showCrontab" style="padding:0 16px 16px;">
<div class="kline-log-tail"><pre>{{ crontabContent }}</pre></div>
</div>
</div>
</div>
<!-- Task Log Modal -->
<div v-if="taskLogModal.show" class="modal-overlay" @click.self="taskLogModal.show = false">
<div class="modal">
<div class="modal-head">
<h3>{{ taskLogModal.name }} - 日志</h3>
<button class="modal-close" @click="taskLogModal.show = false">×</button>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span style="font-size:12px; color:var(--text-muted);">
共 {{ taskLogModal.total_lines }} 行,显示最后 {{ taskLogModal.showing }} 行
</span>
<button class="btn btn-ghost" @click="loadTaskLog(taskLogModal.id, 300)" style="font-size:11px;">加载更多</button>
</div>
<div class="kline-log-tail" style="max-height:60vh;"><pre>{{ taskLogModal.log }}</pre></div>
</div>
</div>
<!-- User Detail Modal -->
<div v-if="userDetail" class="modal-overlay" @click.self="userDetail = null">
<div class="modal">
<div class="modal-head">
<h3>{{ userDetail.user.email }}</h3>
<button class="modal-close" @click="userDetail = null">×</button>
</div>
<div class="detail-section">
<h4><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> 基本信息</h4>
<div class="detail-row"><span class="lbl">用户ID</span><span>{{ userDetail.user.id }}</span></div>
<div class="detail-row"><span class="lbl">邮箱</span><span>{{ userDetail.user.email }}</span></div>
<div class="detail-row"><span class="lbl">角色</span><span>{{ userDetail.user.is_admin ? '管理员' : '普通用户' }}</span></div>
<div class="detail-row"><span class="lbl">注册时间</span><span>{{ formatDate(userDetail.user.created_at) }}</span></div>
</div>
<div v-if="userDetail.win_analysis" class="detail-section">
<h4><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> 胜率分析</h4>
<div class="win-analysis">
<!-- 综合总胜率 -->
<div style="text-align:center; margin-bottom:12px; padding:10px; background:rgba(255,255,255,0.03); border-radius:10px;">
<div :style="{fontSize:'28px', fontWeight:700, color: userDetail.win_analysis.overall_win_rate >= 50 ? 'var(--success)' : 'var(--danger)'}">
{{ userDetail.win_analysis.overall_win_rate }}%
</div>
<div style="font-size:11px; color:var(--text-muted);">综合胜率({{ userDetail.win_analysis.total_completed }}笔)</div>
</div>
<!-- 实盘分析 -->
<div style="font-size:13px; font-weight:600; color:var(--text-secondary); margin: 12px 0 6px; display:flex; align-items:center; gap:4px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px; height:14px;"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>
实盘分析
</div>
<div class="win-stat-grid">
<div class="win-stat-card">
<div class="win-stat-val" :style="{color: userDetail.win_analysis.real.win_rate >= 50 ? 'var(--success)' : 'var(--danger)'}">
{{ userDetail.win_analysis.real.win_rate }}%
</div>
<div class="win-stat-lbl">胜率</div>
</div>
<div class="win-stat-card">
<div class="win-stat-val" :style="{color: userDetail.win_analysis.real.total_profit >= 0 ? 'var(--danger)' : 'var(--success)'}">
{{ userDetail.win_analysis.real.total_profit >= 0 ? '+' : '' }}{{ userDetail.win_analysis.real.total_profit }}
</div>
<div class="win-stat-lbl">总盈亏</div>
</div>
<div class="win-stat-card">
<div class="win-stat-val" style="color:var(--danger);">{{ userDetail.win_analysis.real.win_count }}</div>
<div class="win-stat-lbl">盈利</div>
</div>
<div class="win-stat-card">
<div class="win-stat-val" style="color:var(--success);">{{ userDetail.win_analysis.real.loss_count }}</div>
<div class="win-stat-lbl">亏损</div>
</div>
</div>
<template v-if="userDetail.win_analysis.real.completed.length > 0">
<div style="font-size:12px; color:var(--text-muted); margin: 10px 0 6px;">已完成交易</div>
<div v-for="t in userDetail.win_analysis.real.completed" :key="t.code+t.date" class="record-card" style="padding:6px 0;">
<div class="record-card-top">
<div><span class="record-code">{{ t.code }}</span> <span class="record-name">{{ t.name }}</span></div>
<span :style="{color: t.profit >= 0 ? 'var(--danger)' : 'var(--success)', fontWeight:600, fontSize:'13px'}">{{ t.profit >= 0 ? '+' : '' }}{{ t.profit }}</span>
</div>
<div class="record-card-row">
<span>买: {{ t.buy_price }}</span>
<span>卖: {{ t.sell_price }}</span>
<span :style="{color: t.pct >= 0 ? 'var(--danger)' : 'var(--success)'}">{{ t.pct >= 0 ? '+' : '' }}{{ t.pct }}%</span>
</div>
</div>
</template>
<template v-if="userDetail.win_analysis.real.open.length > 0">
<div style="font-size:12px; color:var(--text-muted); margin: 10px 0 6px;">持仓中</div>
<div v-for="p in userDetail.win_analysis.real.open" :key="p.code" class="record-card" style="padding:6px 0;">
<div class="record-card-top">
<div><span class="record-code">{{ p.code }}</span> <span class="record-name">{{ p.name }}</span></div>
<div :style="{color: p.profit > 0 ? 'var(--danger)' : p.profit < 0 ? 'var(--success)' : 'var(--text-muted)', fontWeight:600}">{{ p.profit > 0 ? '+' : '' }}{{ p.profit }}</div>
</div>
<div class="record-card-row">
<span>成本: {{ p.avg_cost }}</span>
<span>现价: {{ p.current_price }}</span>
<span :style="{color: p.pct > 0 ? 'var(--danger)' : p.pct < 0 ? 'var(--success)' : ''}">{{ p.pct > 0 ? '+' : '' }}{{ p.pct }}%</span>
</div>
</div>
</template>
<!-- 模拟分析 -->
<div style="font-size:13px; font-weight:600; color:var(--text-secondary); margin: 16px 0 6px; display:flex; align-items:center; gap:4px; border-top:1px solid var(--border-glass); padding-top:12px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px; height:14px;"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
模拟分析
</div>
<div class="win-stat-grid">
<div class="win-stat-card">
<div class="win-stat-val" :style="{color: userDetail.win_analysis.sim.win_rate >= 50 ? 'var(--success)' : userDetail.win_analysis.sim.total > 0 ? 'var(--danger)' : 'var(--text-muted)'}">
{{ userDetail.win_analysis.sim.win_rate }}%
</div>
<div class="win-stat-lbl">胜率</div>
</div>
<div class="win-stat-card">
<div class="win-stat-val" :style="{color: userDetail.win_analysis.sim.total_profit >= 0 ? 'var(--danger)' : 'var(--success)'}">
{{ userDetail.win_analysis.sim.total_profit >= 0 ? '+' : '' }}{{ userDetail.win_analysis.sim.total_profit }}
</div>
<div class="win-stat-lbl">总盈亏</div>
</div>
<div class="win-stat-card">
<div class="win-stat-val" style="color:var(--danger);">{{ userDetail.win_analysis.sim.win_count }}</div>
<div class="win-stat-lbl">盈利</div>
</div>
<div class="win-stat-card">
<div class="win-stat-val" style="color:var(--success);">{{ userDetail.win_analysis.sim.loss_count }}</div>
<div class="win-stat-lbl">亏损</div>
</div>
</div>
<template v-if="userDetail.win_analysis.sim.completed.length > 0">
<div style="font-size:12px; color:var(--text-muted); margin: 10px 0 6px;">已完成交易</div>
<div v-for="t in userDetail.win_analysis.sim.completed" :key="'sim'+t.code+t.date" class="record-card" style="padding:6px 0;">
<div class="record-card-top">
<div><span class="record-code">{{ t.code }}</span> <span class="record-name">{{ t.name }}</span></div>
<span :style="{color: t.profit >= 0 ? 'var(--danger)' : 'var(--success)', fontWeight:600, fontSize:'13px'}">{{ t.profit >= 0 ? '+' : '' }}{{ t.profit }}</span>
</div>
<div class="record-card-row">
<span>买: {{ t.buy_price }}</span>
<span>卖: {{ t.sell_price }}</span>
<span :style="{color: t.pct >= 0 ? 'var(--danger)' : 'var(--success)'}">{{ t.pct >= 0 ? '+' : '' }}{{ t.pct }}%</span>
</div>
</div>
</template>
<template v-if="userDetail.win_analysis.sim.unrealized.length > 0">
<div style="font-size:12px; color:var(--text-muted); margin: 10px 0 6px;">持仓中</div>
<div v-for="s in userDetail.win_analysis.sim.unrealized" :key="'simu'+s.code" class="record-card" style="padding:6px 0;">
<div class="record-card-top">
<div><span class="record-code">{{ s.code }}</span> <span class="record-name">{{ s.name }}</span></div>
<span :style="{color: s.profit > 0 ? 'var(--danger)' : s.profit < 0 ? 'var(--success)' : 'var(--text-muted)', fontWeight:600, fontSize:'13px'}">{{ s.profit > 0 ? '+' : '' }}{{ s.profit }}</span>
</div>
<div class="record-card-row">
<span>成本: {{ s.avg_cost }}</span>
<span>现价: {{ s.current_price }}</span>
<span :style="{color: s.pct > 0 ? 'var(--danger)' : s.pct < 0 ? 'var(--success)' : ''}">{{ s.pct > 0 ? '+' : '' }}{{ s.pct }}%</span>
</div>
</div>
</template>
</div>
</div>
<div class="detail-section">
<h4><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg> 关注列表 ({{ userDetail.watchlist.length }})</h4>
<template v-if="userDetail.watchlist.length > 0">
<div v-for="w in userDetail.watchlist" :key="w.stock_code" class="record-card">
<div class="record-card-top">
<div><span class="record-code">{{ w.stock_code }}</span> <span class="record-name">{{ w.stock_name }}</span></div>
</div>
<div class="record-card-meta">{{ formatDate(w.added_time) }}</div>
</div>
</template>
<div v-else class="empty">无关注</div>
</div>
<div class="detail-section">
<h4><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v16"/></svg> 交易记录 ({{ userDetail.trades.length }})</h4>
<template v-if="userDetail.trades.length > 0">
<div v-for="t in userDetail.trades" :key="t.id" class="record-card">
<div class="record-card-top">
<div>
<span :class="['badge', t.trade_type === 'buy' ? 'badge-fail' : 'badge-ok']">{{ t.trade_type === 'buy' ? '买入' : '卖出' }}</span>
<span class="record-code">{{ t.stock_code }}</span>
<span class="record-name">{{ t.stock_name }}</span>
</div>
</div>
<div class="record-card-row">
<span>价格: <b>{{ t.price }}</b></span>
<span>数量: <b>{{ t.quantity }}</b></span>
<span v-if="t.profit_amount" :style="{color: t.profit_amount > 0 ? 'var(--danger)' : 'var(--success)'}">盈亏: <b>{{ t.profit_amount }}</b></span>
</div>
<div class="record-card-meta">{{ t.trade_date }}<span v-if="t.reason"> · {{ t.reason }}</span></div>
</div>
</template>
<div v-else class="empty">无交易</div>
</div>
<div class="detail-section">
<h4><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg> 模拟交易 ({{ userDetail.sim_trades.length }})</h4>
<template v-if="userDetail.sim_trades.length > 0">
<div v-for="s in userDetail.sim_trades" :key="s.id" class="record-card">
<div class="record-card-top">
<div>
<span :class="['badge', s.trade_type === 'buy' ? 'badge-fail' : 'badge-ok']">{{ s.trade_type === 'buy' ? '买入' : '卖出' }}</span>
<span class="record-code">{{ s.stock_code }}</span>
<span class="record-name">{{ s.stock_name }}</span>
</div>
</div>
<div class="record-card-row">
<span>价格: <b>{{ s.price }}</b></span>
<span>数量: <b>{{ s.quantity }}</b></span>
</div>
<div v-if="s.signal_reason" class="record-card-signal">{{ s.signal_reason }}</div>
<div class="record-card-meta">{{ s.trade_date }}</div>
</div>
</template>
<div v-else class="empty">无模拟交易</div>
</div>
<div class="detail-section">
<h4><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> AI调用 ({{ userDetail.ai_calls ? userDetail.ai_calls.length : 0 }})</h4>
<template v-if="userDetail.ai_calls && userDetail.ai_calls.length > 0">
<div v-for="a in userDetail.ai_calls" :key="a.id" class="record-card" style="padding:8px 0;">
<div class="record-card-top">
<div><span class="record-code">{{ a.stock_code }}</span> <span class="record-name">{{ a.stock_name }}</span></div>
<span class="badge badge-admin">{{ a.call_type }}</span>
</div>
<div class="record-card-meta">{{ formatDate(a.created_at) }}</div>
</div>
</template>
<div v-else class="empty">无AI调用</div>
</div>
</div>
</div>
<!-- Reset Password Modal -->
<div v-if="resetPwUser" class="modal-overlay" @click.self="resetPwUser = null">
<div class="modal" style="max-width:400px;">
<div class="modal-head">
<h3>重置密码</h3>
<button class="modal-close" @click="resetPwUser = null">×</button>
</div>
<div style="font-size:13px; color:var(--text-muted); margin-bottom:12px;">{{ resetPwUser.email }}</div>
<div class="form-group">
<label>新密码</label>
<input v-model="newPassword" type="text" placeholder="输入新密码(至少6位)">
</div>
<button class="btn btn-primary" @click="resetPassword" style="width:100%; padding:10px; font-size:14px;">确认重置</button>
</div>
</div>
<!-- Confirm Modal -->
<div v-if="confirmModal.show" class="modal-overlay" @click.self="confirmModal.show = false; if(confirmModal.onCancel) confirmModal.onCancel()">
<div class="modal" style="max-width:380px;">
<div class="confirm-modal-content">
<div :class="['confirm-icon', confirmModal.type || 'warn']">
<svg v-if="confirmModal.type !== 'danger'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</div>
<div class="confirm-msg">{{ confirmModal.title }}</div>
<div v-if="confirmModal.message" class="confirm-sub">{{ confirmModal.message }}</div>
<div class="confirm-actions">
<button class="btn btn-cancel" @click="confirmModal.show = false; if(confirmModal.onCancel) confirmModal.onCancel()">取消</button>
<button :class="['btn', 'btn-confirm', confirmModal.type === 'danger' ? 'danger' : '']" @click="confirmModal.show = false; if(confirmModal.onConfirm) confirmModal.onConfirm()">
{{ confirmModal.confirmText || '确认' }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endraw %}
<script>
const { createApp } = Vue;
createApp({
data() {
return {
tab: 'dashboard',
currentUserId: null,
dash: {},
users: [],
userDetail: null,
resetPwUser: null,
newPassword: '',
dataStats: { tables: [], logs: [] },
scanHistory: [],
toast: { show: false, msg: '', type: 'success' },
userSort: 'id',
// 全景扫描相关
scanRunning: false,
scanTriggering: false,
scanProgress: 0,
scanScanned: 0,
scanTotal: 0,
scanTriggered: 0,
scanDetailProgress: {},
scanPollTimer: null,
// K线同步相关
klineSyncRunning: false,
klineSyncTriggering: false,
klineStats: {},
klineSyncProgress: {},
klineSyncLogTail: '',
klinePollTimer: null,
// 定时任务
scheduledTasks: [],
crontabContent: '',
showCrontab: false,
showScanHistory: false,
taskLogModal: { show: false, id: '', name: '', log: '', total_lines: 0, showing: 0 },
taskPollTimer: null,
taskTriggering: {},
// 5分钟K线采集进度
kline5minProgress: {},
kline5minPollTimer: null,
// 确认模态框
confirmModal: { show: false, title: '', message: '', type: 'warn', confirmText: '确认', onConfirm: null, onCancel: null },
};
},
computed: {
sortedUsers() {
const list = [...this.users];
if (this.userSort === 'win_rate') {
list.sort((a, b) => (b.win_rate || 0) - (a.win_rate || 0));
} else if (this.userSort === 'profit') {
list.sort((a, b) => (b.total_profit || 0) - (a.total_profit || 0));
} else {
list.sort((a, b) => a.id - b.id);
}
return list;
},
scanStatusClass() {
if (this.scanRunning) return 'running';
if (this.scanProgress >= 100) return 'complete';
return 'idle';
},
scanStatusText() {
if (this.scanRunning) return '扫描中';
if (this.scanProgress >= 100) return '已完成';
return '就绪';
},
},
async mounted() {
try {
const resp = await axios.get('/api/me');
if (resp.data.success && resp.data.user) {
this.currentUserId = resp.data.user.id;
this.loadDashboard();
} else {
window.location.href = '/';
}
} catch {
window.location.href = '/';
}
},
methods: {
showToast(msg, type = 'success') {
this.toast = { show: true, msg, type };
setTimeout(() => { this.toast.show = false; }, 2500);
},
formatDate(dt) {
if (!dt) return '-';
return dt.replace('T', ' ').substring(0, 16);
},
formatDuration(sec) {
if (sec == null || sec < 0) return '';
if (sec < 60) return sec + '秒';
const m = Math.floor(sec / 60);
const s = sec % 60;
return m + '分' + (s > 0 ? s + '秒' : '');
},
async loadDashboard() {
try {
const resp = await axios.get('/api/admin/dashboard');
if (resp.data.success) this.dash = resp.data.data;
} catch (e) {
if (e.response?.status === 403) { this.showToast('无管理员权限', 'error'); window.location.href = '/'; }
}
},
async loadUsers() {
try {
const resp = await axios.get('/api/admin/users');
if (resp.data.success) this.users = resp.data.data;
} catch {}
},
async viewUser(id) {
try {
const resp = await axios.get(`/api/admin/users/${id}`);
if (resp.data.success) this.userDetail = resp.data.data;
} catch {}
},
showResetPw(u) { this.resetPwUser = u; this.newPassword = ''; },
async resetPassword() {
if (this.newPassword.length < 6) { this.showToast('密码至少6位', 'error'); return; }
try {
const resp = await axios.post(`/api/admin/users/${this.resetPwUser.id}/reset_password`, { new_password: this.newPassword });
if (resp.data.success) { this.showToast('密码已重置'); this.resetPwUser = null; }
} catch (e) { this.showToast(e.response?.data?.error || '操作失败', 'error'); }
},
async toggleAdmin(u) {
try {
const resp = await axios.post(`/api/admin/users/${u.id}/toggle_admin`);
if (resp.data.success) { u.is_admin = resp.data.is_admin; this.showToast('已更新'); }
} catch (e) { this.showToast(e.response?.data?.error || '操作失败', 'error'); }
},
deleteUser(u) {
this.showConfirm({
title: `删除用户 ${u.email}`,
message: '将删除该用户的所有数据(关注、交易、模拟交易等),此操作不可恢复!',
type: 'danger',
confirmText: '确认删除',
onConfirm: async () => {
try {
const resp = await axios.delete(`/api/admin/users/${u.id}`);
if (resp.data.success) { this.users = this.users.filter(x => x.id !== u.id); this.showToast('用户已删除'); }
} catch (e) { this.showToast(e.response?.data?.error || '删除失败', 'error'); }
},
});
},
async deleteWatchItem(userId, code) {
try {
await axios.delete(`/api/admin/users/${userId}/watchlist/${code}`);
this.userDetail.watchlist = this.userDetail.watchlist.filter(w => w.stock_code !== code);
this.showToast('已删除');
} catch {}
},
async deleteTrade(userId, tradeId) {
try {
await axios.delete(`/api/admin/users/${userId}/trades/${tradeId}`);
this.userDetail.trades = this.userDetail.trades.filter(t => t.id !== tradeId);
this.showToast('已删除');
} catch {}
},
async loadDataStats() {
try {
const resp = await axios.get('/api/admin/data_stats');
if (resp.data.success) this.dataStats = resp.data.data;
} catch {}
},
async loadScanHistory() {
try {
const resp = await axios.get('/api/admin/scan_history');
if (resp.data.success) this.scanHistory = resp.data.data;
} catch {}
// 同时检查当前扫描状态和K线同步状态
this.checkScanStatus();
this.checkKlineSyncStatus();
},
showConfirm({ title, message, type, confirmText, onConfirm, onCancel }) {
this.confirmModal = {
show: true,
title: title || '确认操作',
message: message || '',
type: type || 'warn',
confirmText: confirmText || '确认',
onConfirm: onConfirm || null,
onCancel: onCancel || null,
};
},
triggerFullScan() {
if (this.scanRunning || this.scanTriggering) return;
this.showConfirm({
title: '启动全景扫描',
message: '将强制重新扫描今日全部股票数据,预计耗时 30~60 分钟。',
confirmText: '启动扫描',
onConfirm: () => this._doTriggerScan(),
});
},
async _doTriggerScan() {
this.scanTriggering = true;
try {
const resp = await axios.post('/api/admin/trigger_scan');
if (resp.data.success) {
this.showToast(resp.data.message);
this.scanRunning = true;
this.scanProgress = 0;
this.startScanPolling();
} else {
this.showToast(resp.data.error || '启动失败', 'error');
}
} catch (e) {
const msg = e.response?.data?.error || '启动失败';
this.showToast(msg, 'error');
if (msg.includes('正在进行中')) {
this.scanRunning = true;
this.startScanPolling();
}
} finally {
this.scanTriggering = false;
}
},
async checkScanStatus() {
try {
const resp = await axios.get('/api/admin/scan_status');
if (resp.data.success) {
this.scanScanned = resp.data.scanned;
this.scanTotal = resp.data.total;
this.scanTriggered = resp.data.triggered;
this.scanProgress = resp.data.progress;
this.scanRunning = resp.data.is_running;
this.scanDetailProgress = resp.data.scan_progress || {};
if (this.scanRunning && !this.scanPollTimer) {
this.startScanPolling();
}
}
} catch {}
},
startScanPolling() {
if (this.scanPollTimer) return;
this.scanPollTimer = setInterval(async () => {
await this.checkScanStatus();
if (!this.scanRunning) {
clearInterval(this.scanPollTimer);
this.scanPollTimer = null;
const p = this.scanDetailProgress;
const msg = p.triggered != null
? `全景扫描完成!触发信号${p.triggered}` + (p.failed ? `,失败${p.failed}` : '')
: '全景扫描已完成!';
this.scanDetailProgress = {};
this.showToast(msg);
this.loadScanHistory();
}
}, 3000);
},
// 定时任务
async loadScheduledTasks() {
try {
const resp = await axios.get('/api/admin/scheduled_tasks');
if (resp.data.success) {
this.scheduledTasks = resp.data.tasks;
this.crontabContent = resp.data.crontab || '';
}
} catch {}
// 如果有运行中的任务,启动轮询
if (this.scheduledTasks.some(t => t.is_running)) {
this.startTaskPoll();
// 自动联动对应的专用进度轮询
const klineScan = this.scheduledTasks.find(t => t.id === 'kline_scan' && t.is_running);
if (klineScan && !this.klinePollTimer) {
this.klineSyncRunning = true;
this.startKlinePoll();
this.checkScanStatus();
this.startScanPolling();
}
const kline5min = this.scheduledTasks.find(t => t.id === 'kline_5min' && t.is_running);
if (kline5min && !this.kline5minPollTimer) {
this.checkKline5minProgress();
this.startKline5minPoll();
}
}
},
taskStatusBadge(status) {
const map = { running: 'badge-admin', success: 'badge-ok', error: 'badge-fail', stopped: 'badge-fail', idle: 'badge-user', no_log: 'badge-user', unknown: 'badge-user' };
return map[status] || 'badge-user';
},
taskStatusText(status) {
const map = { running: '运行中', success: '正常', error: '失败', stopped: '已停止', idle: '空闲', no_log: '无日志', unknown: '未知' };
return map[status] || status;
},
formatTaskTime(ts) {
if (!ts) return '-';
// 处理 systemd 时间格式 "Thu 2026-02-26 15:19:11 CST"
const m = ts.match(/(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}(?::\d{2})?)/);
if (m) return m[1] + ' ' + m[2];
return ts.replace('T', ' ').substring(0, 19);
},
async viewTaskLog(task) {
this.taskLogModal = { show: true, id: task.id, name: task.name, log: task.last_log || '无日志', total_lines: 0, showing: 0 };
await this.loadTaskLog(task.id, 100);
},
async loadTaskLog(taskId, lines) {
try {
const resp = await axios.get(`/api/admin/scheduled_tasks/${taskId}/log?lines=${lines}`);
if (resp.data.success) {
this.taskLogModal.log = resp.data.log || '无日志';
this.taskLogModal.total_lines = resp.data.total_lines || 0;
this.taskLogModal.showing = resp.data.showing || 0;
}
} catch {}
},
triggerTask(task) {
this.showConfirm({
title: `手动执行 ${task.name}`,
message: `确认手动执行「${task.name}」?执行过程可在日志中查看。`,
confirmText: '执行',
onConfirm: async () => {
this.taskTriggering = { ...this.taskTriggering, [task.id]: true };
try {
const resp = await axios.post(`/api/admin/trigger_task/${task.id}`);
if (resp.data.success) {
this.showToast(resp.data.message);
// 延迟后刷新状态
setTimeout(() => { this.loadScheduledTasks(); }, 2000);
this.startTaskPoll();
// 针对特定任务启动专用进度轮询
if (task.id === 'kline_scan') {
this.klineSyncRunning = true;
this.klineSyncProgress = {};
this.startKlinePoll();
// 延迟启动扫描轮询(K线同步完成后才会开始扫描)
setTimeout(() => { this.checkScanStatus(); this.startScanPolling(); }, 10000);
}
if (task.id === 'kline_5min') {
this.kline5minProgress = {};
this.startKline5minPoll();
}
} else {
this.showToast(resp.data.error || '执行失败', 'error');
}
} catch (e) {
this.showToast(e.response?.data?.error || '执行失败', 'error');
} finally {
this.taskTriggering = { ...this.taskTriggering, [task.id]: false };
}
},
});
},
startTaskPoll() {
if (this.taskPollTimer) return;
this.taskPollTimer = setInterval(async () => {
if (this.tab !== 'tasks') {
clearInterval(this.taskPollTimer);
this.taskPollTimer = null;
return;
}
await this.loadScheduledTasks();
// 检测到kline_scan运行中时联动K线同步轮询
const klineScanTask = this.scheduledTasks.find(t => t.id === 'kline_scan');
if (klineScanTask && klineScanTask.is_running && !this.klinePollTimer) {
this.klineSyncRunning = true;
this.startKlinePoll();
this.checkScanStatus();
this.startScanPolling();
}
// 检测到kline_5min运行中时联动进度轮询
const kline5minTask = this.scheduledTasks.find(t => t.id === 'kline_5min');
if (kline5minTask && kline5minTask.is_running && !this.kline5minPollTimer) {
this.startKline5minPoll();
}
if (!this.scheduledTasks.some(t => t.is_running && t.type === 'cron')) {
clearInterval(this.taskPollTimer);
this.taskPollTimer = null;
}
}, 5000);
},
// 5分钟K线采集进度
async checkKline5minProgress() {
try {
const resp = await axios.get('/api/admin/kline_5min_progress');
if (resp.data.success) {
if (resp.data.progress && resp.data.progress.percent != null) {
this.kline5minProgress = resp.data.progress;
}
return resp.data.is_running;
}
} catch {}
return false;
},
startKline5minPoll() {
if (this.kline5minPollTimer) return;
this.kline5minPollTimer = setInterval(async () => {
const running = await this.checkKline5minProgress();
if (!running) {
clearInterval(this.kline5minPollTimer);
this.kline5minPollTimer = null;
if (this.kline5minProgress.percent != null) {
const p = this.kline5minProgress;
const msg = p.completed
? `5分钟K线采集完成!成功${p.success || 0}` + (p.failed ? `,失败${p.failed}` : '')
: '5分钟K线采集已结束';
this.showToast(msg);
// 3秒后清除进度显示
setTimeout(() => { this.kline5minProgress = {}; }, 3000);
}
this.loadScheduledTasks();
}
}, 3000);
},
// K线同步
triggerKlineSync(mode) {
if (this.klineSyncRunning || this.klineSyncTriggering) return;
const modeText = mode === 'full' ? '全量同步(180天历史,耗时较长)' : '增量同步(最近5天数据)';
this.showConfirm({
title: 'K线数据同步',
message: `${modeText},同步所有股票的K线数据到本地数据库。`,
confirmText: '开始同步',
onConfirm: () => this._doKlineSync(mode),
});
},
async _doKlineSync(mode) {
this.klineSyncTriggering = true;
try {
const resp = await axios.post('/api/admin/trigger_kline_sync', { mode });
if (resp.data.success) {
this.showToast(resp.data.message);
this.klineSyncRunning = true;
this.startKlinePoll();
} else {
this.showToast(resp.data.error || '启动失败', 'error');
}
} catch (e) {
const msg = e.response?.data?.error || '启动失败';
this.showToast(msg, 'error');
if (msg.includes('正在进行中')) {
this.klineSyncRunning = true;
this.startKlinePoll();
}
} finally {
this.klineSyncTriggering = false;
}
},
async checkKlineSyncStatus() {
try {
const resp = await axios.get('/api/admin/kline_sync_status');
if (resp.data.success) {
this.klineSyncRunning = resp.data.is_running;
this.klineSyncLogTail = resp.data.log_tail || '';
this.klineSyncProgress = resp.data.sync_progress || {};
this.klineStats = {
total_rows: resp.data.total_rows,
stock_count: resp.data.stock_count,
total_stocks: resp.data.total_stocks,
coverage: resp.data.coverage,
min_date: resp.data.min_date,
max_date: resp.data.max_date,
recent_updated: resp.data.recent_updated,
};
if (this.klineSyncRunning && !this.klinePollTimer) {
this.startKlinePoll();
}
}
} catch {}
},
startKlinePoll() {
if (this.klinePollTimer) return;
this.klinePollTimer = setInterval(async () => {
await this.checkKlineSyncStatus();
if (!this.klineSyncRunning) {
clearInterval(this.klinePollTimer);
this.klinePollTimer = null;
this.klineSyncLogTail = '';
const p = this.klineSyncProgress;
const msg = p.success ? `K线同步完成!成功${p.success}` + (p.failed ? `,失败${p.failed}` : '') : 'K线数据同步已完成!';
this.klineSyncProgress = {};
this.showToast(msg);
}
}, 3000);
},
},
beforeUnmount() {
if (this.scanPollTimer) {
clearInterval(this.scanPollTimer);
this.scanPollTimer = null;
}
if (this.klinePollTimer) {
clearInterval(this.klinePollTimer);
this.klinePollTimer = null;
}
if (this.taskPollTimer) {
clearInterval(this.taskPollTimer);
this.taskPollTimer = null;
}
if (this.kline5minPollTimer) {
clearInterval(this.kline5minPollTimer);
this.kline5minPollTimer = null;
}
},
}).mount('#admin-app');
</script>
</body>
</html>