2025-12-19 14:11:32 +08:00
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
2026-01-10 04:06:35 +08:00
|
|
|
|
<title>QMT 多终端监控看板</title>
|
2025-12-19 14:11:32 +08:00
|
|
|
|
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
2026-01-27 00:52:35 +08:00
|
|
|
|
<script src="https://unpkg.com/naive-ui"></script>
|
2025-12-19 14:11:32 +08:00
|
|
|
|
<style>
|
2026-01-27 00:52:35 +08:00
|
|
|
|
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; }
|
2025-12-19 14:11:32 +08:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
2026-01-27 00:52:35 +08:00
|
|
|
|
<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>
|
2025-12-19 14:11:32 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-27 00:52:35 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-03-01 22:06:42 +08:00
|
|
|
|
<!-- 消息列表 -->
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-01-27 00:52:35 +08:00
|
|
|
|
<!-- 日志 -->
|
|
|
|
|
|
<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>
|
2025-12-19 14:11:32 +08:00
|
|
|
|
</template>
|
2026-01-27 00:52:35 +08:00
|
|
|
|
<div class="log-box" ref="logBox">
|
|
|
|
|
|
<div v-for="(line, index) in logs" :key="index" class="log-line" :class="getLogClass(line)">
|
|
|
|
|
|
{{ formatLogTime(line) }} {{ line }}
|
2026-01-10 04:06:35 +08:00
|
|
|
|
</div>
|
2025-12-19 14:11:32 +08:00
|
|
|
|
</div>
|
2026-01-27 00:52:35 +08:00
|
|
|
|
</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>
|
2025-12-19 14:11:32 +08:00
|
|
|
|
</template>
|
2026-01-27 00:52:35 +08:00
|
|
|
|
<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>
|
2025-12-19 14:11:32 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
2026-03-01 22:06:42 +08:00
|
|
|
|
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;
|
2025-12-19 14:11:32 +08:00
|
|
|
|
|
|
|
|
|
|
const app = createApp({
|
|
|
|
|
|
setup() {
|
2026-01-27 00:52:35 +08:00
|
|
|
|
const status = ref({ terminals: [] });
|
2026-01-10 04:06:35 +08:00
|
|
|
|
const positions = ref({ real_positions: {}, virtual_positions: {} });
|
2025-12-19 14:11:32 +08:00
|
|
|
|
const logs = ref([]);
|
|
|
|
|
|
const loading = ref(false);
|
2026-01-27 00:52:35 +08:00
|
|
|
|
const reconnecting = ref(false);
|
2025-12-19 14:11:32 +08:00
|
|
|
|
const logBox = ref(null);
|
2026-01-27 00:52:35 +08:00
|
|
|
|
const lastUpdateTime = ref("");
|
|
|
|
|
|
const API_BASE = "";
|
2025-12-19 14:11:32 +08:00
|
|
|
|
|
2026-01-27 00:52:35 +08:00
|
|
|
|
// 配置相关
|
|
|
|
|
|
const showConfigModal = ref(false);
|
|
|
|
|
|
const fileConfig = ref({ redis: {}, qmt_terminals: [], strategies: {}, raw_config: "", config_path: "" });
|
|
|
|
|
|
const configLoading = ref(false);
|
2026-03-01 22:06:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 消息相关
|
|
|
|
|
|
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 };
|
|
|
|
|
|
});
|
2026-01-27 00:52:35 +08:00
|
|
|
|
|
|
|
|
|
|
// 表格列(使用简单 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() }
|
|
|
|
|
|
];
|
2025-12-19 14:11:32 +08:00
|
|
|
|
|
2026-01-27 00:52:35 +08:00
|
|
|
|
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';
|
2025-12-19 14:11:32 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-27 00:52:35 +08:00
|
|
|
|
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] : '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 数据获取
|
2025-12-19 14:11:32 +08:00
|
|
|
|
const fetchData = async () => {
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
try {
|
2026-01-27 00:52:35 +08:00
|
|
|
|
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);
|
2025-12-19 14:11:32 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-27 00:52:35 +08:00
|
|
|
|
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);
|
2026-01-10 04:06:35 +08:00
|
|
|
|
};
|
2026-01-27 00:52:35 +08:00
|
|
|
|
|
|
|
|
|
|
// 获取配置文件内容
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2025-12-19 14:11:32 +08:00
|
|
|
|
};
|
2026-01-27 00:52:35 +08:00
|
|
|
|
|
2026-03-01 22:06:42 +08:00
|
|
|
|
// 获取消息列表
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-27 00:52:35 +08:00
|
|
|
|
// 复制配置到剪贴板
|
|
|
|
|
|
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('复制失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-12-19 14:11:32 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-27 00:52:35 +08:00
|
|
|
|
let timer = null;
|
|
|
|
|
|
const unwatch = watch(showConfigModal, (newVal) => {
|
|
|
|
|
|
if (newVal && !fileConfig.value.raw_config) {
|
|
|
|
|
|
fetchConfig();
|
|
|
|
|
|
}
|
2025-12-19 14:11:32 +08:00
|
|
|
|
});
|
2026-01-27 00:52:35 +08:00
|
|
|
|
|
2026-03-01 22:06:42 +08:00
|
|
|
|
onMounted(() => { fetchData(); fetchMessages(); timer = setInterval(() => { fetchData(); fetchMessages(); }, 30000); });
|
2026-01-27 00:52:35 +08:00
|
|
|
|
onUnmounted(() => { if (timer) clearInterval(timer); unwatch(); });
|
2025-12-19 14:11:32 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
2026-01-27 00:52:35 +08:00
|
|
|
|
status, positions, logs, loading, reconnecting, logBox, lastUpdateTime,
|
|
|
|
|
|
connectionStatusText, connectionStatusType,
|
|
|
|
|
|
manualRefresh, manualReconnect,
|
2026-01-10 04:06:35 +08:00
|
|
|
|
getTerminalAlias, getTerminalStatusClass,
|
2026-01-27 00:52:35 +08:00
|
|
|
|
formatNumber, formatVirtual, getLogClass, formatLogTime,
|
|
|
|
|
|
totalPositions, totalVolume, totalMarketValue,
|
|
|
|
|
|
realPosColumns, virtualPosColumns,
|
|
|
|
|
|
// 配置相关
|
|
|
|
|
|
showConfigModal, fileConfig, configLoading,
|
2026-03-01 22:06:42 +08:00
|
|
|
|
fetchConfig, copyConfig,
|
|
|
|
|
|
// 消息相关
|
|
|
|
|
|
messages, messageLoading, messageFilter, messagePagination,
|
|
|
|
|
|
messageColumns, strategyOptions, messageStats, fetchMessages
|
2025-12-19 14:11:32 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-27 00:52:35 +08:00
|
|
|
|
app.use(naive);
|
2025-12-19 14:11:32 +08:00
|
|
|
|
app.mount('#app');
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
2026-03-01 22:06:42 +08:00
|
|
|
|
</html>
|