1394 lines
89 KiB
HTML
1394 lines
89 KiB
HTML
<!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;
|
||
}
|
||
* { 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: #3b82f6; }
|
||
.stat-icon.green { background: rgba(0,255,136,0.15); color: #00ff88; }
|
||
.stat-icon.orange { background: rgba(255,170,0,0.15); color: #ffaa00; }
|
||
.stat-icon.red { background: rgba(255,68,68,0.15); color: #ff4444; }
|
||
.stat-icon.purple { background: rgba(168,85,247,0.15); color: #a855f7; }
|
||
.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:#60a5fa;">{{ 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:#fbbf24;">{{ dash.server_info.app_port }}</span>
|
||
</div>
|
||
<div class="sys-info-row">
|
||
<span class="lbl">🌍 访问域名</span>
|
||
<span class="val" style="font-family:monospace;color:#34d399;">{{ 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>
|