253 lines
10 KiB
HTML
253 lines
10 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>
|
||
<!-- 字体 -->
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||
|
||
<!-- 1. 引入 Vue 3 -->
|
||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||
<!-- 2. 引入 Naive UI -->
|
||
<script src="https://unpkg.com/naive-ui/dist/index.js"></script>
|
||
|
||
<style>
|
||
body { font-family: 'Inter', sans-serif; background-color: #f5f7fa; margin: 0; }
|
||
#app { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||
.log-container { background: #1e1e1e; padding: 15px; border-radius: 4px; height: 400px; overflow: auto; font-family: monospace; font-size: 12px; color: #ddd; }
|
||
.log-line { margin: 2px 0; border-bottom: 1px solid #333; padding-bottom: 2px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="app">
|
||
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
|
||
<n-message-provider>
|
||
<n-dialog-provider>
|
||
<main-layout></main-layout>
|
||
</n-dialog-provider>
|
||
</n-message-provider>
|
||
</n-config-provider>
|
||
</div>
|
||
|
||
<script>
|
||
const { createApp, ref, onMounted, onUnmounted, watch } = Vue;
|
||
const naive = window.naive;
|
||
|
||
// --- 主组件逻辑 ---
|
||
const MainLayout = {
|
||
template: `
|
||
<div class="header">
|
||
<h2 style="margin:0; color: #333;">📈 量化策略控制台</h2>
|
||
<n-space align="center">
|
||
<!-- 1. 刷新频率选择器 -->
|
||
<n-select
|
||
v-model:value="refreshInterval"
|
||
:options="intervalOptions"
|
||
size="small"
|
||
style="width: 130px"
|
||
></n-select>
|
||
|
||
<!-- 2. 手动刷新按钮 -->
|
||
<n-button type="primary" size="small" @click="fetchStatus" :loading="loading">
|
||
刷新状态
|
||
</n-button>
|
||
|
||
<n-tag type="info" size="small">更新于: {{ lastUpdated }}</n-tag>
|
||
</n-space>
|
||
</div>
|
||
|
||
<n-card title="策略列表" hoverable>
|
||
<n-table :single-line="false" striped>
|
||
<thead>
|
||
<tr>
|
||
<th>策略标识</th>
|
||
<th>策略名称</th>
|
||
<th>运行状态</th>
|
||
<th>PID</th>
|
||
<th>运行时长</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(info, key) in strategies" :key="key">
|
||
<td><strong>{{ key }}</strong></td>
|
||
<td>{{ info.config.strategy_name }} <br><small style="color:#999">{{ info.symbol }}</small></td>
|
||
<td>
|
||
<n-tag :type="info.status === 'running' ? 'success' : 'error'" size="small">
|
||
{{ info.status === 'running' ? '运行中' : '已停止' }}
|
||
</n-tag>
|
||
</td>
|
||
<td>{{ info.pid || '-' }}</td>
|
||
<td>{{ info.uptime || '-' }}</td>
|
||
<td>
|
||
<n-space>
|
||
<n-button v-if="info.status === 'stopped'" type="success" size="small" ghost @click="handleAction(key, 'start')">启动</n-button>
|
||
<n-button v-if="info.status === 'running'" type="error" size="small" ghost @click="handleAction(key, 'stop')">停止</n-button>
|
||
<n-button v-if="info.status === 'running'" type="warning" size="small" ghost @click="handleAction(key, 'restart')">重启</n-button>
|
||
<n-button size="small" @click="viewLogs(key)">日志</n-button>
|
||
</n-space>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="Object.keys(strategies).length === 0">
|
||
<td colspan="6" style="text-align: center; padding: 30px; color: #999;">暂无策略</td>
|
||
</tr>
|
||
</tbody>
|
||
</n-table>
|
||
</n-card>
|
||
|
||
<!-- 日志弹窗 -->
|
||
<n-modal v-model:show="showLogModal" style="width: 800px;" preset="card" :title="'📜 实时日志: ' + currentLogKey">
|
||
<div class="log-container" id="logBox">
|
||
<div v-if="logLoading" style="text-align:center; padding:20px;"><n-spin size="medium" /></div>
|
||
<div v-else v-for="(line, index) in logLines" :key="index" class="log-line">{{ line }}</div>
|
||
</div>
|
||
<template #footer>
|
||
<n-space justify="end">
|
||
<n-button size="small" @click="fetchLogs(currentLogKey)">刷新</n-button>
|
||
<n-button size="small" @click="showLogModal = false">关闭</n-button>
|
||
</n-space>
|
||
</template>
|
||
</n-modal>
|
||
`,
|
||
setup() {
|
||
const message = naive.useMessage();
|
||
const dialog = naive.useDialog();
|
||
|
||
const strategies = ref({});
|
||
const loading = ref(false);
|
||
const lastUpdated = ref('-');
|
||
|
||
// --- 修改点:默认值为 0 (仅手动) ---
|
||
const refreshInterval = ref(0);
|
||
|
||
const intervalOptions = [
|
||
{ label: '✋ 仅手动', value: 0 }, // 建议将手动选项放在第一个
|
||
{ label: '⚡ 3秒自动', value: 3000 },
|
||
{ label: '⏱ 5秒自动', value: 5000 },
|
||
{ label: '🐢 10秒自动', value: 10000 }
|
||
];
|
||
let timer = null;
|
||
|
||
// --- 核心数据获取 ---
|
||
const fetchStatus = async () => {
|
||
if (loading.value) return;
|
||
loading.value = true;
|
||
try {
|
||
const res = await fetch('/api/status');
|
||
if (!res.ok) throw new Error("Error");
|
||
const data = await res.json();
|
||
strategies.value = data.strategies;
|
||
lastUpdated.value = new Date().toLocaleTimeString();
|
||
} catch (e) {
|
||
message.error("连接服务器失败");
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
// --- 定时器管理 ---
|
||
const resetTimer = () => {
|
||
if (timer) clearInterval(timer);
|
||
// 只有大于 0 才启动定时器
|
||
if (refreshInterval.value > 0) {
|
||
timer = setInterval(fetchStatus, refreshInterval.value);
|
||
}
|
||
};
|
||
|
||
// 监听下拉框变化
|
||
watch(refreshInterval, () => {
|
||
resetTimer();
|
||
if (refreshInterval.value > 0) {
|
||
message.info(`已切换为 ${refreshInterval.value/1000} 秒自动刷新`);
|
||
fetchStatus();
|
||
} else {
|
||
message.info("已切换为手动刷新模式");
|
||
}
|
||
});
|
||
|
||
// --- 其他逻辑 ---
|
||
const handleAction = (name, action) => {
|
||
const map = { start: '启动', stop: '停止', restart: '重启' };
|
||
dialog.warning({
|
||
title: '确认操作',
|
||
content: `确定要 ${map[action]} ${name} 吗?`,
|
||
positiveText: '确定',
|
||
negativeText: '取消',
|
||
onPositiveClick: async () => {
|
||
try {
|
||
const res = await fetch(`/api/strategy/${name}/${action}`, { method: 'POST' });
|
||
if (res.ok) {
|
||
message.success("操作成功");
|
||
fetchStatus();
|
||
} else {
|
||
message.error("操作失败");
|
||
}
|
||
} catch (e) { message.error("请求错误"); }
|
||
}
|
||
});
|
||
};
|
||
|
||
const showLogModal = ref(false);
|
||
const currentLogKey = ref('');
|
||
const logLines = ref([]);
|
||
const logLoading = ref(false);
|
||
|
||
const fetchLogs = async (name) => {
|
||
logLoading.value = true;
|
||
try {
|
||
const res = await fetch(`/api/logs/${name}?lines=100`);
|
||
const data = await res.json();
|
||
logLines.value = data.lines;
|
||
setTimeout(() => {
|
||
const el = document.getElementById('logBox');
|
||
if(el) el.scrollTop = el.scrollHeight;
|
||
}, 100);
|
||
} catch(e) { message.error("日志获取失败"); }
|
||
finally { logLoading.value = false; }
|
||
}
|
||
|
||
const viewLogs = (name) => {
|
||
currentLogKey.value = name;
|
||
showLogModal.value = true;
|
||
fetchLogs(name);
|
||
};
|
||
|
||
onMounted(() => {
|
||
fetchStatus(); // 页面加载时请求一次
|
||
resetTimer(); // 初始化定时器(当前为0,所以不启动)
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
if (timer) clearInterval(timer);
|
||
});
|
||
|
||
return {
|
||
strategies, loading, lastUpdated,
|
||
refreshInterval, intervalOptions,
|
||
showLogModal, currentLogKey, logLines, logLoading,
|
||
fetchStatus, handleAction, viewLogs, fetchLogs
|
||
};
|
||
}
|
||
};
|
||
|
||
const app = createApp({
|
||
components: { MainLayout },
|
||
setup() {
|
||
return { zhCN: naive.zhCN, dateZhCN: naive.dateZhCN }
|
||
}
|
||
});
|
||
|
||
Object.keys(naive).forEach(key => {
|
||
if (key.startsWith('N') && !key.startsWith('NTh') && !key.startsWith('use')) {
|
||
app.component(key, naive[key]);
|
||
}
|
||
});
|
||
|
||
app.mount('#app');
|
||
</script>
|
||
|
||
</body>
|
||
</html> |