Files
NewStock/qmt/dashboard.html
liaozhaorun 7bb0a0537b feat: 添加 Redis 消息展示功能到监控面板
- 新增 /api/messages API 接口,支持从 Redis Stream 读取消息
- 支持按策略筛选消息和分页展示
- 前端新增消息列表卡片,展示时间、策略、股票代码、动作、价格和状态
- 自动判断消息处理状态(已处理/待处理)
- 消息列表每30秒自动刷新,支持手动刷新
2026-03-01 22:06:42 +08:00

488 lines
30 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>QMT 多终端监控看板</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/naive-ui"></script>
<style>
body { margin: 0; min-height: 100vh; background: #f5f7fa; }
.app-container { padding: 20px; max-width: 1400px; margin: 0 auto; }
.log-box { height: 300px; overflow-y: auto; font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; background: #fff; border: 1px solid #e8e8e8; border-radius: 6px; }
.log-line { padding: 4px 8px; margin: 2px 0; border-radius: 4px; white-space: pre-wrap; }
.log-error { color: #cf1322; background: rgba(207, 34, 34, 0.08); border-left: 3px solid #cf1322; }
.log-warning { color: #d46b08; background: rgba(212, 107, 8, 0.08); border-left: 3px solid #d46b08; }
.log-success { color: #389e0d; background: rgba(56, 158, 13, 0.08); border-left: 3px solid #389e0d; }
.log-info { color: #096dd9; background: rgba(9, 109, 217, 0.08); border-left: 3px solid #096dd9; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 8px; }
.status-connected { background: #52c41a; box-shadow: 0 0 8px rgba(82, 196, 26, 0.4); }
.status-disconnected { background: #ff4d4f; }
.status-connecting { background: #faad14; animation: pulse 1s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.footer { text-align: center; padding: 20px; color: #8c8c8c; font-size: 12px; }
.config-section { margin-bottom: 16px; padding: 16px; background: #fafafa; border-radius: 6px; border: 1px solid #f0f0f0; }
.config-section-title { font-size: 14px; font-weight: 600; color: #096dd9; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #e8e8e8; }
.config-item { display: flex; padding: 6px 0; font-size: 13px; border-bottom: 1px dashed #f0f0f0; }
.config-item:last-child { border-bottom: none; }
.config-key { color: #666; width: 140px; flex-shrink: 0; }
.config-value { color: #333; flex: 1; word-break: break-all; }
.config-json { background: #fafafa; padding: 12px; border-radius: 6px; border: 1px solid #e8e8e8; font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; line-height: 1.6; max-height: 400px; overflow-y: auto; white-space: pre-wrap; color: #333; }
.terminal-badge { display: inline-block; padding: 2px 8px; background: #e6f7ff; color: #096dd9; border: 1px solid #91d5ff; border-radius: 4px; font-size: 12px; margin-right: 8px; }
.strategy-badge { display: inline-block; padding: 2px 8px; background: #f9f0ff; color: #722ed1; border: 1px solid #d3adf7; border-radius: 4px; font-size: 12px; margin-right: 8px; }
.stat-card-green { background: linear-gradient(135deg, #f6ffed 0%, #d9f7be 100%); color: #389e0d; border: 1px solid #b7eb8f; }
.stat-card-blue { background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%); color: #096dd9; border: 1px solid #91d5ff; }
.stat-card-purple { background: linear-gradient(135deg, #f9f0ff 0%, #efdbff 100%); color: #722ed1; border: 1px solid #d3adf7; }
.stat-card-orange { background: linear-gradient(135deg, #fff7e6 0%, #ffd591 100%); color: #d46b08; border: 1px solid #ffe7ba; }
.title-bar { background: #fff; padding: 16px 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); display: flex; justify-content: space-between; align-items: center; }
.title-text { font-size: 20px; font-weight: 600; color: #1a1a1a; }
</style>
</head>
<body>
<div id="app" class="app-container">
<n-config-provider :theme="darkTheme">
<n-message-provider>
<n-notification-provider>
<n-dialog-provider>
<!-- 顶部状态栏 -->
<div class="title-bar">
<span class="title-text">QMT 多账号实盘守护系统</span>
<div style="display: flex; align-items: center; gap: 12px;">
<n-tag :type="connectionStatusType">{{ connectionStatusText }}</n-tag>
<n-button type="info" @click="showConfigModal = true" size="small">查看配置</n-button>
<n-button type="warning" @click="manualReconnect" :loading="reconnecting" size="small">重连</n-button>
<n-button type="primary" @click="manualRefresh" :loading="loading" size="small">刷新</n-button>
</div>
</div>
<n-card style="margin-bottom: 20px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
<n-grid :cols="4" :x-gap="12">
<n-gi v-for="t in status.terminals" :key="t.qmt_id">
<n-card size="small" :bordered="false">
<n-descriptions :column="1" size="small">
<n-descriptions-item :label="t.alias">
<span :class="['status-dot', t.is_connected ? 'status-connected' : 'status-disconnected']"></span>
{{ t.is_connected ? '已连接' : '已断开' }}
</n-descriptions-item>
<n-descriptions-item label="账户">{{ t.account_id }}</n-descriptions-item>
<n-descriptions-item label="心跳">{{ t.last_heartbeat }}</n-descriptions-item>
</n-descriptions>
</n-card>
</n-gi>
</n-grid>
</n-card>
<!-- 持仓汇总 -->
<n-grid :cols="4" :x-gap="12" style="margin-bottom: 20px;">
<n-gi>
<n-card size="small" class="stat-card-green" style="border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
<n-statistic label="持仓品种" :value="totalPositions" value-style="color: #389e0d; font-size: 24px; font-weight: 600;" label-style="color: #666; font-size: 13px;" />
</n-card>
</n-gi>
<n-gi>
<n-card size="small" class="stat-card-blue" style="border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
<n-statistic label="总持仓" :value="formatNumber(totalVolume)" value-style="color: #096dd9; font-size: 24px; font-weight: 600;" label-style="color: #666; font-size: 13px;" />
</n-card>
</n-gi>
<n-gi>
<n-card size="small" class="stat-card-purple" style="border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
<n-statistic label="总市值" :value="'¥' + formatNumber(totalMarketValue)" value-style="color: #722ed1; font-size: 24px; font-weight: 600;" label-style="color: #666; font-size: 13px;" />
</n-card>
</n-gi>
<n-gi>
<n-card size="small" class="stat-card-orange" style="border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
<n-statistic label="策略数" :value="Object.keys(positions.virtual_positions).length" value-style="color: #d46b08; font-size: 24px; font-weight: 600;" label-style="color: #666; font-size: 13px;" />
</n-card>
</n-gi>
</n-grid>
<!-- 主体区域 -->
<n-grid :cols="2" :x-gap="20">
<!-- 真实持仓 -->
<n-gi>
<n-card title="实盘真实持仓 (按终端分组)" style="border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
<template #header-extra>
<n-tag type="info">{{ Object.keys(positions.real_positions).length }} 终端</n-tag>
</template>
<div v-for="(posList, qmtId) in positions.real_positions" :key="qmtId" style="margin-bottom: 16px;">
<div style="display: flex; align-items: center; margin-bottom: 8px;">
<span :class="['status-dot', getTerminalStatusClass(qmtId)]"></span>
<strong>{{ getTerminalAlias(qmtId) }}</strong>
<n-tag size="small" style="margin-left: 8px;">{{ posList.length }} 只</n-tag>
</div>
<n-data-table :columns="realPosColumns" :data="posList" size="small" :bordered="false" :single-line="false" />
</div>
</n-card>
</n-gi>
<!-- 虚拟账本 -->
<n-gi>
<n-card title="Redis 虚拟账本 (策略隔离)" style="border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
<template #header-extra>
<n-tag type="warning">{{ Object.keys(positions.virtual_positions).length }} 策略</n-tag>
</template>
<div v-for="(posObj, strategyName) in positions.virtual_positions" :key="strategyName" style="margin-bottom: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<strong>{{ strategyName }}</strong>
<n-tag size="small" type="warning">{{ Object.keys(posObj).length }} 只</n-tag>
</div>
<n-data-table :columns="virtualPosColumns" :data="formatVirtual(posObj)" size="small" :bordered="false" :single-line="false" />
</div>
</n-card>
</n-gi>
</n-grid>
<!-- 消息列表 -->
<n-card title="消息列表" style="margin-top: 20px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
<template #header-extra>
<div style="display: flex; align-items: center; gap: 12px;">
<n-select v-model:value="messageFilter.strategy" :options="strategyOptions" size="small" style="width: 120px;" @update:value="fetchMessages" />
<n-tag :type="messageStats.unprocessed > 0 ? 'error' : 'success'">
{{ messageStats.unprocessed }} 未处理 / {{ messages.length }} 总计
</n-tag>
<n-button type="primary" size="small" @click="fetchMessages" :loading="messageLoading">刷新</n-button>
</div>
</template>
<n-data-table
:columns="messageColumns"
:data="messages"
size="small"
:bordered="false"
:single-line="false"
:pagination="messagePagination"
:loading="messageLoading"
/>
</n-card>
<!-- 日志 -->
<n-card title="系统日志" style="margin-top: 20px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);">
<template #header-extra>
<span style="color: #8c8c8c;">{{ logs.length }} 条</span>
</template>
<div class="log-box" ref="logBox">
<div v-for="(line, index) in logs" :key="index" class="log-line" :class="getLogClass(line)">
{{ formatLogTime(line) }} {{ line }}
</div>
</div>
</n-card>
<div class="footer">最后更新: {{ lastUpdateTime }}</div>
<!-- 配置查看模态框 -->
<n-modal v-model:show="showConfigModal" preset="card" title="配置文件内容" style="width: 700px; max-width: 90vw;" :mask-closable="false" :bordered="false">
<template #header-extra>
<n-button type="primary" size="small" @click="copyConfig">复制配置</n-button>
</template>
<n-tabs type="card" animated>
<n-tab-pane name="redis" tab="Redis 配置">
<div class="config-section" v-if="fileConfig.redis && Object.keys(fileConfig.redis).length > 0">
<div v-for="(value, key) in fileConfig.redis" :key="key" class="config-item">
<span class="config-key">{{ key }}:</span>
<span class="config-value">{{ value }}</span>
</div>
</div>
<n-empty v-else description="暂无 Redis 配置" />
</n-tab-pane>
<n-tab-pane name="terminals" tab="QMT 终端">
<div v-for="(terminal, idx) in fileConfig.qmt_terminals" :key="idx" class="config-section">
<div class="config-section-title">
<span class="terminal-badge">终端 {{ idx + 1 }}</span>
{{ terminal.alias || terminal.qmt_id }}
</div>
<div v-for="(value, key) in terminal" :key="key" class="config-item">
<span class="config-key">{{ key }}:</span>
<span class="config-value">{{ value }}</span>
</div>
</div>
<n-empty v-if="!fileConfig.qmt_terminals || fileConfig.qmt_terminals.length === 0" description="暂无终端配置" />
</n-tab-pane>
<n-tab-pane name="strategies" tab="策略配置">
<div v-for="(strategy, name) in fileConfig.strategies" :key="name" class="config-section">
<div class="config-section-title">
<span class="strategy-badge">{{ name }}</span>
终端: {{ strategy.qmt_id }}
</div>
<div v-for="(value, key) in strategy" :key="key" class="config-item">
<template v-if="key !== 'execution'">
<span class="config-key">{{ key }}:</span>
<span class="config-value">{{ value }}</span>
</template>
<template v-else>
<div style="margin-left: 140px; margin-top: 8px;">
<div style="color: #8c8c8c; font-size: 12px; margin-bottom: 4px;">execution:</div>
<div v-for="(execVal, execKey) in value" :key="execKey" class="config-item" style="margin-left: 20px;">
<span class="config-key">{{ execKey }}:</span>
<span class="config-value">{{ execVal }}</span>
</div>
</div>
</template>
</div>
</div>
<n-empty v-if="!fileConfig.strategies || Object.keys(fileConfig.strategies).length === 0" description="暂无策略配置" />
</n-tab-pane>
<n-tab-pane name="json" tab="JSON 源码">
<div class="config-json">{{ fileConfig.raw_config || '暂无配置内容' }}</div>
<div v-if="fileConfig.config_path" style="margin-top: 8px; font-size: 12px; color: #8c8c8c;">
配置文件路径: {{ fileConfig.config_path }}
</div>
</n-tab-pane>
</n-tabs>
</n-modal>
</n-dialog-provider>
</n-notification-provider>
</n-message-provider>
</n-config-provider>
</div>
<script>
const { createApp, ref, onMounted, onUnmounted, computed, watch, h } = Vue;
const { NCard, NButton, NTag, NGrid, NGi, NDescriptions, NDescriptionsItem, NDataTable, NStatistic, NConfigProvider, NMessageProvider, NNotificationProvider, NDialogProvider, NModal, NTabs, NTabPane, NEmpty, NSelect } = naive;
const app = createApp({
setup() {
const status = ref({ terminals: [] });
const positions = ref({ real_positions: {}, virtual_positions: {} });
const logs = ref([]);
const loading = ref(false);
const reconnecting = ref(false);
const logBox = ref(null);
const lastUpdateTime = ref("");
const API_BASE = "";
// 配置相关
const showConfigModal = ref(false);
const fileConfig = ref({ redis: {}, qmt_terminals: [], strategies: {}, raw_config: "", config_path: "" });
const configLoading = ref(false);
// 消息相关
const messages = ref([]);
const messageLoading = ref(false);
const messageFilter = ref({ strategy: 'all' });
const messagePagination = ref({ page: 1, pageSize: 10, showSizePicker: true, pageSizes: [10, 20, 50] });
// 消息表格列
const messageColumns = [
{ title: '时间', key: 'timestamp', width: 150, fixed: 'left' },
{
title: '策略',
key: 'strategy',
width: 120,
render: (row) => row.data.strategy_name || '-'
},
{
title: '股票代码',
key: 'stock_code',
width: 100,
render: (row) => row.data.stock_code || '-'
},
{
title: '动作',
key: 'action',
width: 80,
render: (row) => {
const action = row.data.action;
if (action === 'BUY') return h(NTag, { type: 'error', size: 'small' }, { default: () => '买入' });
if (action === 'SELL') return h(NTag, { type: 'success', size: 'small' }, { default: () => '卖出' });
return action || '-';
}
},
{
title: '价格',
key: 'price',
width: 100,
render: (row) => row.data.price ? '¥' + row.data.price.toFixed(2) : '-'
},
{ title: '状态', key: 'is_processed', width: 100, render: (row) => {
return row.is_processed
? h(NTag, { type: 'success', size: 'small' }, { default: () => '已处理' })
: h(NTag, { type: 'warning', size: 'small' }, { default: () => '待处理' });
}},
{
title: '消息ID',
key: 'message_id',
width: 180,
ellipsis: { tooltip: true }
},
];
// 策略选项
const strategyOptions = computed(() => {
const options = [{ label: '全部策略', value: 'all' }];
if (fileConfig.value.strategies) {
Object.keys(fileConfig.value.strategies).forEach(name => {
options.push({ label: name, value: name });
});
}
return options;
});
// 消息统计
const messageStats = computed(() => {
const unprocessed = messages.value.filter(m => !m.is_processed).length;
return { unprocessed, total: messages.value.length };
});
// 表格列(使用简单 render不依赖 h 函数)
const realPosColumns = [
{ title: '代码', key: 'code', ellipsis: { tooltip: true } },
{ title: '持仓', key: 'volume', render: (row) => row.volume.toLocaleString() },
{ title: '可用', key: 'can_use', render: (row) => row.can_use.toLocaleString() },
{ title: '市值', key: 'market_value', render: (row) => '¥' + row.market_value.toLocaleString() }
];
const virtualPosColumns = [
{ title: '代码', key: 'code', ellipsis: { tooltip: true } },
{ title: '数量', key: 'vol', render: (row) => parseInt(row.vol).toLocaleString() }
];
// 状态
const connectionStatusText = computed(() => {
if (status.value.terminals.length === 0) return '未连接';
return status.value.terminals.every(t => t.is_connected) ? '全部在线' : '部分离线';
});
const connectionStatusType = computed(() => {
if (status.value.terminals.length === 0) return 'error';
return status.value.terminals.every(t => t.is_connected) ? 'success' : 'warning';
});
const getTerminalStatusClass = (qmtId) => {
const t = status.value.terminals.find(x => x.qmt_id === qmtId);
return t?.is_connected ? 'status-connected' : 'status-disconnected';
};
const getTerminalAlias = (qmtId) => status.value.terminals.find(x => x.qmt_id === qmtId)?.alias || qmtId;
// 统计
const totalPositions = computed(() => Object.values(positions.value.real_positions).reduce((sum, list) => sum + list.length, 0));
const totalVolume = computed(() => Object.values(positions.value.real_positions).flat().reduce((sum, p) => sum + (p.volume || 0), 0));
const totalMarketValue = computed(() => Object.values(positions.value.real_positions).flat().reduce((sum, p) => sum + (p.market_value || 0), 0));
// 工具
const formatNumber = (num) => {
if (num === undefined || num === null || isNaN(num)) return '0';
return num >= 10000 ? (num / 10000).toFixed(2) + '万' : num.toLocaleString();
};
const formatVirtual = (obj) => Object.keys(obj).map(k => ({ code: k, vol: parseInt(obj[k]) || 0 }));
const getLogClass = (line) => {
if (line.includes('ERROR') || line.includes('错误')) return 'log-error';
if (line.includes('WARNING') || line.includes('警告')) return 'log-warning';
if (line.includes('SUCCESS') || line.includes('成功')) return 'log-success';
return 'log-info';
};
const formatLogTime = (line) => {
const match = line.match(/\[(.*?)\]/);
return match ? match[1] : '';
};
// 数据获取
const fetchData = async () => {
loading.value = true;
try {
console.log('[DEBUG] 获取数据...');
const [s, p, l] = await Promise.all([
fetch(`${API_BASE}/api/status`).then(r => r.ok ? r.json() : {}),
fetch(`${API_BASE}/api/positions`).then(r => r.ok ? r.json() : {}),
fetch(`${API_BASE}/api/logs`).then(r => r.ok ? r.json() : {})
]);
status.value = s;
positions.value = p;
logs.value = l.logs || [];
if (logBox.value) setTimeout(() => logBox.value.scrollTop = logBox.value.scrollHeight, 100);
lastUpdateTime.value = new Date().toLocaleString('zh-CN');
console.log('[DEBUG] 数据已更新', p);
} finally {
loading.value = false;
}
};
const manualRefresh = () => fetchData();
const manualReconnect = async () => {
reconnecting.value = true;
await fetch(`${API_BASE}/api/reconnect`, { method: 'POST' });
setTimeout(fetchData, 2000);
setTimeout(() => reconnecting.value = false, 5000);
};
// 获取配置文件内容
const fetchConfig = async () => {
configLoading.value = true;
try {
const res = await fetch(`${API_BASE}/api/file_config`).then(r => r.ok ? r.json() : {});
fileConfig.value = res;
} catch (e) {
console.error('[CONFIG] 获取配置失败:', e);
} finally {
configLoading.value = false;
}
};
// 获取消息列表
const fetchMessages = async () => {
messageLoading.value = true;
try {
const strategy = messageFilter.value.strategy;
const url = `${API_BASE}/api/messages?strategy=${strategy}&count=50`;
const res = await fetch(url).then(r => r.ok ? r.json() : { messages: [] });
messages.value = res.messages || [];
console.log('[DEBUG] 消息已更新', messages.value.length);
} catch (e) {
console.error('[MESSAGES] 获取消息失败:', e);
messages.value = [];
} finally {
messageLoading.value = false;
}
};
// 复制配置到剪贴板
const copyConfig = () => {
if (fileConfig.value.raw_config) {
navigator.clipboard.writeText(fileConfig.value.raw_config).then(() => {
// 使用 Naive UI 的 message
if (typeof message !== 'undefined') {
message.success('配置已复制到剪贴板');
} else {
alert('配置已复制到剪贴板');
}
}).catch(() => {
if (typeof message !== 'undefined') {
message.error('复制失败');
} else {
alert('复制失败');
}
});
}
};
let timer = null;
const unwatch = watch(showConfigModal, (newVal) => {
if (newVal && !fileConfig.value.raw_config) {
fetchConfig();
}
});
onMounted(() => { fetchData(); fetchMessages(); timer = setInterval(() => { fetchData(); fetchMessages(); }, 30000); });
onUnmounted(() => { if (timer) clearInterval(timer); unwatch(); });
return {
status, positions, logs, loading, reconnecting, logBox, lastUpdateTime,
connectionStatusText, connectionStatusType,
manualRefresh, manualReconnect,
getTerminalAlias, getTerminalStatusClass,
formatNumber, formatVirtual, getLogClass, formatLogTime,
totalPositions, totalVolume, totalMarketValue,
realPosColumns, virtualPosColumns,
// 配置相关
showConfigModal, fileConfig, configLoading,
fetchConfig, copyConfig,
// 消息相关
messages, messageLoading, messageFilter, messagePagination,
messageColumns, strategyOptions, messageStats, fetchMessages
};
}
});
app.use(naive);
app.mount('#app');
</script>
</body>
</html>