918bbed0fc
- 修复 deps.py 中 get_vn_service 的引用错误 (vn_service.vn_service -> vn_service) - 移除登录页面上的明文密码提示 - 改进前端错误处理,避免数据加载失败导致登录显示错误
583 lines
18 KiB
JavaScript
583 lines
18 KiB
JavaScript
/**
|
|
* Sanguo VeighNa Web 应用
|
|
* Vue.js 3 单页应用
|
|
*/
|
|
|
|
const { createApp, ref, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
|
|
|
|
createApp({
|
|
setup() {
|
|
// ============================================
|
|
// 状态定义
|
|
// ============================================
|
|
|
|
// 认证状态
|
|
const isLoggedIn = ref(false);
|
|
const currentUser = ref({ username: '' });
|
|
const loginForm = ref({ username: '', password: '' });
|
|
const loginError = ref('');
|
|
const isLoading = ref(false);
|
|
|
|
// 当前页面
|
|
const currentPage = ref('dashboard');
|
|
|
|
// 导航菜单
|
|
const navItems = [
|
|
{ id: 'dashboard', label: '总览', icon: '' },
|
|
{ id: 'market', label: '行情', icon: '' },
|
|
{ id: 'trading', label: '交易', icon: '' },
|
|
{ id: 'position', label: '持仓', icon: '' },
|
|
{ id: 'strategy', label: '策略', icon: '' },
|
|
{ id: 'log', label: '日志', icon: '' }
|
|
];
|
|
|
|
// WebSocket 连接状态
|
|
const wsConnected = ref(false);
|
|
|
|
// 系统状态
|
|
const systemStatus = ref({
|
|
vn_ready: false,
|
|
main_engine: false,
|
|
trading_engine: false
|
|
});
|
|
|
|
// 网关状态
|
|
const gatewayStatus = ref({ online: false });
|
|
|
|
// 行情数据
|
|
const marketSearch = ref('');
|
|
const contracts = ref([]);
|
|
const ticks = ref([]);
|
|
const subscribedSymbols = ref(new Set());
|
|
|
|
// 账户数据
|
|
const accounts = ref([]);
|
|
|
|
// 持仓数据
|
|
const positions = ref([]);
|
|
|
|
// 订单数据
|
|
const allOrders = ref([]);
|
|
const activeOrders = ref([]);
|
|
|
|
// 策略数据
|
|
const strategies = ref([]);
|
|
|
|
// 日志数据
|
|
const logs = ref([]);
|
|
const autoScroll = ref(true);
|
|
const logLevelFilter = ref('');
|
|
const logsContainer = ref(null);
|
|
|
|
// 下单表单
|
|
const orderForm = ref({
|
|
symbol: '',
|
|
direction: 'buy',
|
|
order_type: 'limit',
|
|
price: 0,
|
|
volume: 1
|
|
});
|
|
const orderError = ref('');
|
|
const isOrdering = ref(false);
|
|
|
|
// ============================================
|
|
// 计算属性
|
|
// ============================================
|
|
|
|
const currentPageTitle = computed(() => {
|
|
const item = navItems.find(i => i.id === currentPage.value);
|
|
return item ? item.label : 'Sanguo VeighNa';
|
|
});
|
|
|
|
const filteredTicks = computed(() => {
|
|
if (!marketSearch.value) {
|
|
return ticks.value;
|
|
}
|
|
const search = marketSearch.value.toLowerCase();
|
|
return ticks.value.filter(t =>
|
|
t.vt_symbol?.toLowerCase().includes(search)
|
|
);
|
|
});
|
|
|
|
const recentLogs = computed(() => {
|
|
return logs.value.slice(-5);
|
|
});
|
|
|
|
const filteredLogs = computed(() => {
|
|
if (!logLevelFilter.value) {
|
|
return logs.value;
|
|
}
|
|
return logs.value.filter(l => l.level === logLevelFilter.value);
|
|
});
|
|
|
|
const totalPnL = computed(() => {
|
|
return positions.value.reduce((sum, p) => sum + (p.pnl || 0), 0);
|
|
});
|
|
|
|
// ============================================
|
|
// 格式化函数
|
|
// ============================================
|
|
|
|
const formatNumber = (num) => {
|
|
if (num === null || num === undefined) return '-';
|
|
return Number(num).toFixed(2);
|
|
};
|
|
|
|
const formatPercent = (num) => {
|
|
if (num === null || num === undefined) return '-';
|
|
return (Number(num) * 100).toFixed(2) + '%';
|
|
};
|
|
|
|
const formatTime = (timestamp) => {
|
|
if (!timestamp) return '-';
|
|
const date = new Date(timestamp);
|
|
return date.toLocaleString('zh-CN');
|
|
};
|
|
|
|
const getPriceClass = (current, previous) => {
|
|
if (current === null || current === undefined) return '';
|
|
if (previous === null || previous === undefined) return '';
|
|
if (current > previous) return 'price-up';
|
|
if (current < previous) return 'price-down';
|
|
return 'price-flat';
|
|
};
|
|
|
|
const getPnLClass = (pnl) => {
|
|
if (pnl === null || pnl === undefined) return '';
|
|
if (pnl > 0) return 'pnl-positive';
|
|
if (pnl < 0) return 'pnl-negative';
|
|
return '';
|
|
};
|
|
|
|
// ============================================
|
|
// 行情相关
|
|
// ============================================
|
|
|
|
const isSubscribed = (symbol) => {
|
|
return subscribedSymbols.value.has(symbol);
|
|
};
|
|
|
|
const subscribeSymbol = (symbol) => {
|
|
if (subscribedSymbols.value.has(symbol)) {
|
|
// 取消订阅
|
|
subscribedSymbols.value.delete(symbol);
|
|
wsClient.unsubscribeSymbol([symbol]);
|
|
} else {
|
|
// 订阅
|
|
subscribedSymbols.value.add(symbol);
|
|
wsClient.subscribeSymbol([symbol]);
|
|
}
|
|
};
|
|
|
|
const refreshMarket = async () => {
|
|
try {
|
|
ticks.value = await api.getTicks();
|
|
} catch (e) {
|
|
console.error('Failed to refresh market data:', e);
|
|
}
|
|
};
|
|
|
|
// ============================================
|
|
// 交易相关
|
|
// ============================================
|
|
|
|
const canCancel = (status) => {
|
|
return ['submitted', 'pending', 'partial_filled'].includes(
|
|
status?.toLowerCase()
|
|
);
|
|
};
|
|
|
|
const sendOrder = async () => {
|
|
orderError.value = '';
|
|
isOrdering.value = true;
|
|
|
|
try {
|
|
const result = await api.sendOrder({
|
|
symbol: orderForm.value.symbol.split('.')[0],
|
|
exchange: orderForm.value.symbol.split('.')[1] || 'SIM',
|
|
direction: orderForm.value.direction,
|
|
order_type: orderForm.value.order_type,
|
|
volume: orderForm.value.volume,
|
|
price: orderForm.value.order_type === 'limit' ? orderForm.value.price : null
|
|
});
|
|
|
|
// 刷新订单列表
|
|
await refreshOrders();
|
|
|
|
// 重置表单
|
|
orderForm.value = {
|
|
symbol: '',
|
|
direction: 'buy',
|
|
order_type: 'limit',
|
|
price: 0,
|
|
volume: 1
|
|
};
|
|
} catch (e) {
|
|
orderError.value = e.message || '下单失败';
|
|
} finally {
|
|
isOrdering.value = false;
|
|
}
|
|
};
|
|
|
|
const cancelOrder = async (orderId) => {
|
|
try {
|
|
await api.cancelOrder(orderId);
|
|
await refreshOrders();
|
|
} catch (e) {
|
|
console.error('Failed to cancel order:', e);
|
|
}
|
|
};
|
|
|
|
const refreshOrders = async () => {
|
|
try {
|
|
const [all, active] = await Promise.all([
|
|
api.getOrders(),
|
|
api.getActiveOrders()
|
|
]);
|
|
allOrders.value = all;
|
|
activeOrders.value = active;
|
|
} catch (e) {
|
|
console.error('Failed to refresh orders:', e);
|
|
}
|
|
};
|
|
|
|
// ============================================
|
|
// 持仓相关
|
|
// ============================================
|
|
|
|
const refreshPositions = async () => {
|
|
try {
|
|
positions.value = await api.getPositions();
|
|
} catch (e) {
|
|
console.error('Failed to refresh positions:', e);
|
|
}
|
|
};
|
|
|
|
// ============================================
|
|
// 策略相关
|
|
// ============================================
|
|
|
|
const refreshStrategies = async () => {
|
|
try {
|
|
strategies.value = await api.getStrategies();
|
|
} catch (e) {
|
|
console.error('Failed to refresh strategies:', e);
|
|
}
|
|
};
|
|
|
|
const startStrategy = async (name) => {
|
|
try {
|
|
await api.startStrategy(name);
|
|
await refreshStrategies();
|
|
} catch (e) {
|
|
console.error('Failed to start strategy:', e);
|
|
}
|
|
};
|
|
|
|
const stopStrategy = async (name) => {
|
|
try {
|
|
await api.stopStrategy(name);
|
|
await refreshStrategies();
|
|
} catch (e) {
|
|
console.error('Failed to stop strategy:', e);
|
|
}
|
|
};
|
|
|
|
// ============================================
|
|
// 日志相关
|
|
// ============================================
|
|
|
|
const addLog = (logData) => {
|
|
logs.value.push({
|
|
time: logData.time || new Date().toISOString(),
|
|
level: logData.level || 'INFO',
|
|
message: logData.message || ''
|
|
});
|
|
|
|
// 限制日志数量
|
|
if (logs.value.length > 1000) {
|
|
logs.value = logs.value.slice(-1000);
|
|
}
|
|
|
|
// 自动滚动
|
|
if (autoScroll.value) {
|
|
nextTick(() => {
|
|
if (logsContainer.value) {
|
|
logsContainer.value.scrollTop = logsContainer.value.scrollHeight;
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const clearLogs = () => {
|
|
logs.value = [];
|
|
};
|
|
|
|
// ============================================
|
|
// 认证相关
|
|
// ============================================
|
|
|
|
const login = async () => {
|
|
loginError.value = '';
|
|
isLoading.value = true;
|
|
|
|
try {
|
|
await api.login(loginForm.value.username, loginForm.value.password);
|
|
isLoggedIn.value = true;
|
|
currentUser.value = { username: loginForm.value.username };
|
|
|
|
// 连接 WebSocket
|
|
if (api.token) {
|
|
wsClient.connect(api.token);
|
|
}
|
|
|
|
// 加载初始数据(不阻塞登录成功)
|
|
loadInitialData().catch(err => {
|
|
console.warn('部分数据加载失败:', err);
|
|
addLog({
|
|
level: 'WARNING',
|
|
message: '部分功能数据加载失败,请检查服务连接'
|
|
});
|
|
});
|
|
} catch (e) {
|
|
loginError.value = e.message || '登录失败';
|
|
throw e;
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const logout = async () => {
|
|
try {
|
|
await api.logout();
|
|
} catch (e) {
|
|
console.error('Logout error:', e);
|
|
} finally {
|
|
isLoggedIn.value = false;
|
|
currentUser.value = { username: '' };
|
|
wsClient.disconnect();
|
|
}
|
|
};
|
|
|
|
// ============================================
|
|
// 初始数据加载
|
|
// ============================================
|
|
|
|
const loadInitialData = async () => {
|
|
try {
|
|
// 并行加载所有数据
|
|
const [
|
|
sysInfo,
|
|
contractsData,
|
|
accountsData,
|
|
positionsData,
|
|
ordersData,
|
|
strategiesData
|
|
] = await Promise.all([
|
|
api.getSystemInfo().catch(() => null),
|
|
api.getContracts().catch(() => []),
|
|
api.getAccounts().catch(() => []),
|
|
api.getPositions().catch(() => []),
|
|
api.getOrders().catch(() => []),
|
|
api.getStrategies().catch(() => [])
|
|
]);
|
|
|
|
// 更新系统状态
|
|
if (sysInfo) {
|
|
systemStatus.value = {
|
|
vn_ready: sysInfo.vn_ready || false,
|
|
main_engine: sysInfo.main_engine || false,
|
|
trading_engine: sysInfo.trading_engine || false
|
|
};
|
|
}
|
|
|
|
contracts.value = contractsData;
|
|
accounts.value = accountsData;
|
|
positions.value = positionsData;
|
|
allOrders.value = ordersData;
|
|
activeOrders.value = ordersData.filter(o =>
|
|
['submitted', 'pending', 'partial_filled'].includes(o.status?.toLowerCase())
|
|
);
|
|
strategies.value = strategiesData;
|
|
|
|
// 获取行情数据
|
|
const ticksData = await api.getTicks().catch(() => []);
|
|
ticks.value = ticksData;
|
|
|
|
// 订阅 WebSocket 消息
|
|
wsClient.subscribe(['tick', 'order', 'trade', 'position', 'account', 'log']);
|
|
} catch (e) {
|
|
console.error('Failed to load initial data:', e);
|
|
}
|
|
};
|
|
|
|
// ============================================
|
|
// WebSocket 消息处理
|
|
// ============================================
|
|
|
|
const setupWebSocketHandlers = () => {
|
|
// 连接状态
|
|
wsClient.on('connected', () => {
|
|
wsConnected.value = true;
|
|
});
|
|
|
|
wsClient.on('disconnected', () => {
|
|
wsConnected.value = false;
|
|
});
|
|
|
|
// 行情推送
|
|
wsClient.on('tick', (data) => {
|
|
// 更新或添加行情数据
|
|
const index = ticks.value.findIndex(t => t.vt_symbol === data.vt_symbol);
|
|
if (index >= 0) {
|
|
ticks.value[index] = data;
|
|
} else {
|
|
ticks.value.push(data);
|
|
}
|
|
});
|
|
|
|
// 订单推送
|
|
wsClient.on('order', (data) => {
|
|
// 更新订单列表
|
|
const index = allOrders.value.findIndex(o => o.order_id === data.order_id);
|
|
if (index >= 0) {
|
|
allOrders.value[index] = data;
|
|
} else {
|
|
allOrders.value.push(data);
|
|
}
|
|
|
|
// 更新活动订单
|
|
if (['submitted', 'pending', 'partial_filled'].includes(data.status?.toLowerCase())) {
|
|
const activeIndex = activeOrders.value.findIndex(o => o.order_id === data.order_id);
|
|
if (activeIndex >= 0) {
|
|
activeOrders.value[activeIndex] = data;
|
|
} else {
|
|
activeOrders.value.push(data);
|
|
}
|
|
} else {
|
|
activeOrders.value = activeOrders.value.filter(o => o.order_id !== data.order_id);
|
|
}
|
|
});
|
|
|
|
// 成交推送
|
|
wsClient.on('trade', (data) => {
|
|
// 可以添加成交记录
|
|
console.log('Trade update:', data);
|
|
});
|
|
|
|
// 持仓推送
|
|
wsClient.on('position', (data) => {
|
|
const index = positions.value.findIndex(
|
|
p => p.symbol === data.symbol && p.exchange === data.exchange
|
|
);
|
|
if (index >= 0) {
|
|
positions.value[index] = data;
|
|
} else {
|
|
positions.value.push(data);
|
|
}
|
|
});
|
|
|
|
// 账户推送
|
|
wsClient.on('account', (data) => {
|
|
if (accounts.value.length > 0) {
|
|
accounts.value[0] = data;
|
|
} else {
|
|
accounts.value.push(data);
|
|
}
|
|
});
|
|
|
|
// 日志推送
|
|
wsClient.on('log', (data) => {
|
|
addLog(data);
|
|
});
|
|
};
|
|
|
|
// ============================================
|
|
// 生命周期
|
|
// ============================================
|
|
|
|
onMounted(() => {
|
|
// 检查是否已登录
|
|
if (api.isTokenValid()) {
|
|
isLoggedIn.value = true;
|
|
const tokenPayload = JSON.parse(atob(api.token.split('.')[1]));
|
|
currentUser.value = { username: tokenPayload.sub };
|
|
|
|
// 连接 WebSocket
|
|
wsClient.connect(api.token);
|
|
|
|
// 加载初始数据
|
|
loadInitialData();
|
|
}
|
|
|
|
// 设置 WebSocket 处理器
|
|
setupWebSocketHandlers();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
wsClient.disconnect();
|
|
});
|
|
|
|
// ============================================
|
|
// 返回
|
|
// ============================================
|
|
|
|
return {
|
|
// 状态
|
|
isLoggedIn,
|
|
currentUser,
|
|
loginForm,
|
|
loginError,
|
|
isLoading,
|
|
currentPage,
|
|
navItems,
|
|
wsConnected,
|
|
systemStatus,
|
|
gatewayStatus,
|
|
marketSearch,
|
|
contracts,
|
|
ticks,
|
|
accounts,
|
|
positions,
|
|
allOrders,
|
|
activeOrders,
|
|
strategies,
|
|
logs,
|
|
autoScroll,
|
|
logLevelFilter,
|
|
logsContainer,
|
|
orderForm,
|
|
orderError,
|
|
isOrdering,
|
|
|
|
// 计算属性
|
|
currentPageTitle,
|
|
filteredTicks,
|
|
recentLogs,
|
|
filteredLogs,
|
|
totalPnL,
|
|
|
|
// 方法
|
|
login,
|
|
logout,
|
|
formatNumber,
|
|
formatPercent,
|
|
formatTime,
|
|
getPriceClass,
|
|
getPnLClass,
|
|
isSubscribed,
|
|
subscribeSymbol,
|
|
refreshMarket,
|
|
canCancel,
|
|
sendOrder,
|
|
cancelOrder,
|
|
refreshOrders,
|
|
refreshPositions,
|
|
refreshStrategies,
|
|
startStrategy,
|
|
stopStrategy,
|
|
clearLogs
|
|
};
|
|
}
|
|
}).mount('#app');
|