Files
NewStock/qmt/dashboard.html
2025-12-19 14:11:32 +08:00

258 lines
13 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>QMT 实盘监控看板</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
<script src="https://unpkg.com/element-plus"></script>
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
<style>
body { background-color: #f0f2f5; margin: 0; padding: 20px; font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', sans-serif; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.box-card { margin-bottom: 20px; }
.log-box {
background: #1e1e1e;
color: #d4d4d4;
padding: 10px;
border-radius: 4px;
height: 400px;
overflow-y: scroll;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
line-height: 1.5;
}
.log-line { margin: 0; border-bottom: 1px solid #333; white-space: pre-wrap; word-break: break-all; }
.log-line:hover { background-color: #2a2a2a; }
.virtual-item { margin-bottom: 20px; border-left: 4px solid #409EFF; padding-left: 10px; }
.virtual-title { font-weight: bold; font-size: 14px; margin-bottom: 8px; color: #606266; }
.status-badge { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 5px; }
.bg-green { background-color: #67C23A; }
.bg-gray { background-color: #909399; }
</style>
</head>
<body>
<div id="app">
<el-container>
<el-header height="auto" style="padding: 0;">
<el-card class="box-card">
<template #header>
<div class="card-header">
<div style="display:flex; align-items:center;">
<el-icon size="24" style="margin-right: 10px;"><Monitor /></el-icon>
<span style="font-weight: bold; font-size: 20px;">QMT 实盘守护系统</span>
</div>
<div>
<el-tag :type="status.running ? 'success' : 'info'" effect="dark" style="margin-right: 10px;">
API: {{ status.running ? 'Running' : 'Offline' }}
</el-tag>
<el-tag :type="status.qmt_connected ? 'success' : 'danger'" effect="dark">
QMT: {{ status.qmt_connected ? 'Connected' : 'Disconnected' }}
</el-tag>
</div>
</div>
</template>
<el-descriptions border :column="4" size="large">
<el-descriptions-item label="资金账号">{{ status.account_id || '---' }}</el-descriptions-item>
<el-descriptions-item label="启动时间">{{ status.start_time || '---' }}</el-descriptions-item>
<el-descriptions-item label="心跳时间">
<span :style="{color: isHeartbeatStalled ? 'red' : 'green', fontWeight: 'bold'}">
{{ status.last_loop_update || '---' }}
</span>
</el-descriptions-item>
<el-descriptions-item label="控制">
<el-button type="primary" :icon="Refresh" @click="manualRefresh" :loading="loading">手动刷新</el-button>
<div style="margin-left: 15px; display: inline-flex; align-items: center;">
<el-checkbox v-model="autoRefresh" label="自动刷新(1min)" border></el-checkbox>
<span style="font-size: 12px; margin-left: 8px; color: #909399;">
{{ tradingStatusText }}
</span>
</div>
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-header>
<el-main style="padding: 0;">
<el-row :gutter="20">
<el-col :span="12">
<el-card class="box-card" shadow="hover">
<template #header>
<div class="card-header">
<span><span class="status-badge bg-green"></span>实盘真实持仓 (QMT)</span>
</div>
</template>
<el-table :data="positions.real_positions" style="width: 100%" border stripe size="small" empty-text="当前空仓">
<el-table-column prop="code" label="代码" width="100" sortable></el-table-column>
<el-table-column prop="volume" label="总持仓" width="100"></el-table-column>
<el-table-column prop="can_use" label="可用" width="100"></el-table-column>
<el-table-column prop="market_value" label="市值"></el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="box-card" shadow="hover">
<template #header>
<div class="card-header">
<span><span class="status-badge bg-gray"></span>Redis 虚拟账本 (策略隔离)</span>
</div>
</template>
<div v-if="Object.keys(positions.virtual_positions).length === 0" style="color:#909399; text-align:center; padding: 20px;">
暂无策略数据 / Redis未连接
</div>
<div v-for="(posObj, strategyName) in positions.virtual_positions" :key="strategyName" class="virtual-item">
<div class="virtual-title">{{ strategyName }}</div>
<el-table :data="formatVirtual(posObj)" style="width: 100%;" border size="small" empty-text="该策略当前空仓">
<el-table-column prop="code" label="代码"></el-table-column>
<el-table-column prop="vol" label="记账数量">
<template #default="scope"><span style="font-weight: bold;">{{ scope.row.vol }}</span></template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-card class="box-card" shadow="never">
<template #header>
<div class="card-header"><span>系统实时日志 (Last 50 lines)</span></div>
</template>
<div class="log-box" ref="logBox">
<div v-for="(line, index) in logs" :key="index" class="log-line">{{ line }}</div>
</div>
</el-card>
</el-col>
</el-row>
</el-main>
</el-container>
</div>
<script>
const { createApp, ref, onMounted, onUnmounted, computed } = Vue;
const { Monitor, Refresh } = ElementPlusIconsVue;
const app = createApp({
setup() {
const status = ref({});
const positions = ref({ real_positions: [], virtual_positions: {} });
const logs = ref([]);
const autoRefresh = ref(true); // 默认开启自动刷新
const loading = ref(false);
const logBox = ref(null);
let timer = null;
const API_BASE = "";
// === 核心逻辑修改:判断是否为交易时间 ===
const isTradingTime = () => {
const now = new Date();
const day = now.getDay();
const hour = now.getHours();
const minute = now.getMinutes();
const currentTimeVal = hour * 100 + minute;
// 1. 判断是否为周末 (0是周日, 6是周六)
if (day === 0 || day === 6) return false;
// 2. 判断时间段 (09:00 - 15:10)
// 包含集合竞价和收盘清算时间
if (currentTimeVal >= 900 && currentTimeVal <= 1510) {
return true;
}
return false;
};
// 界面显示的提示文本
const tradingStatusText = computed(() => {
if (!autoRefresh.value) return "已关闭";
return isTradingTime() ? "监控中..." : "休市暂停";
});
const isHeartbeatStalled = computed(() => {
if (!status.value.last_loop_update) return true;
return false;
});
const fetchData = async () => {
loading.value = true;
try {
const resStatus = await fetch(`${API_BASE}/api/status`);
if(resStatus.ok) status.value = await resStatus.json();
else status.value = { running: false };
const resPos = await fetch(`${API_BASE}/api/positions`);
if(resPos.ok) positions.value = await resPos.json();
const resLogs = await fetch(`${API_BASE}/api/logs`);
if(resLogs.ok) {
const logData = await resLogs.json();
const needScroll = (logs.value.length !== logData.logs.length);
logs.value = logData.logs;
// 只有在自动刷新且有新日志时才自动滚动
if (needScroll && autoRefresh.value) {
setTimeout(() => {
if(logBox.value) logBox.value.scrollTop = logBox.value.scrollHeight;
}, 100);
}
}
} catch (e) {
console.error("API Error:", e);
status.value.running = false;
} finally {
loading.value = false;
}
};
// 手动刷新按钮:不受时间限制
const manualRefresh = () => {
fetchData();
};
const formatVirtual = (obj) => {
if (!obj) return [];
return Object.keys(obj).map(key => ({ code: key, vol: obj[key] }));
};
onMounted(() => {
// 页面加载时先拉取一次
fetchData();
// === 修改定时器:每 60 秒触发一次 ===
timer = setInterval(() => {
// 只有在 "开关开启" 且 "处于交易时间" 时才请求
if (autoRefresh.value && isTradingTime()) {
fetchData();
}
}, 60000); // 60000ms = 1分钟
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
return {
status, positions, logs, autoRefresh, loading, logBox,
manualRefresh, // 绑定到按钮
fetchData,
formatVirtual,
isHeartbeatStalled,
tradingStatusText, // 绑定到提示文本
Monitor, Refresh
};
}
});
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus);
app.mount('#app');
</script>
</body>
</html>