Files
auto-solution/modeler/src/views/ai/applications/gambling.vue

673 lines
24 KiB
Vue
Raw Normal View History

<template>
<Layout>
<a-card class="ks-page-card ks-cards-wrapper">
<template #title>
<a-space>
<span class="point"></span>
<span class="text">博弈竞赛单元运行环境</span>
</a-space>
</template>
<div class="ks-scrollable">
<a-row :gutter="15">
<a-col :span="8">
<!-- 基础配置卡片 -->
<a-card data-step="0" title="基础配置" class="ks-apps-card ks-top-apps-card">
<a-form
:model="modelDeduction"
autocomplete="off"
layout="horizontal"
:label-col="{span: 6}"
name="basic"
>
<a-form-item
label="部署模型选择"
name="deploymentModelPath"
>
<Finder :path="modelDeduction.deploymentModelPath"
@select="(p: string|null) => modelDeduction.deploymentModelPath = p" />
</a-form-item>
<a-form-item
label="对抗轮数配置"
name="competitionRound"
>
<a-input v-model:value="modelDeduction.competitionRound" placeholder="对抗轮数配置" style="width: 100%" />
</a-form-item>
<a-form-item
label="对抗场次配置"
name="competitionSession"
>
<a-input-number min="1" v-model:value="modelDeduction.competitionSession" placeholder="对抗场次配置" style="width: 100%" />
</a-form-item>
<a-form-item
label="推演倍数设置"
name="deductionMultiple"
>
<a-form-item-rest>
<a-flex>
<a-slider v-model:value="modelDeduction.deductionMultiple" :max="1000" :min="0" style="width:100%;" />
<a-input-number v-model:value="modelDeduction.deductionMultiple" :min="0" :max="1000" style="margin-left: 10px; width: 120px;" />
<span style="width: 80px; margin-left: 5px;color:#eee; line-height: 34px;"> / 1000</span>
</a-flex>
</a-form-item-rest>
</a-form-item>
<a-form-item
label="对抗模式选择"
name="competitionMode"
>
<a-select placeholder="请选择对抗模式" v-model:value="modelDeduction.competitionMode"
@change="(v: number | undefined | null)=> modelDeduction.competitionMode = v">
<a-select-option :value="1">循环赛</a-select-option>
<a-select-option :value="2">瑞士轮</a-select-option>
<a-select-option :value="3">单败淘汰赛</a-select-option>
<a-select-option :value="4">双败淘汰赛</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="并发调度配置"
name="balanceStrategy"
>
<a-select placeholder="并发调度配置" v-model:value="modelDeduction.balanceStrategy"
@change="(v: number | undefined | null)=> modelDeduction.balanceStrategy = v">
<a-select-option :value="1">均衡调度策略</a-select-option>
<a-select-option :value="2">集约调度策略</a-select-option>
<a-select-option :value="3">优先调度策略</a-select-option>
<a-select-option :value="4">触发调度策略</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-card>
<!-- 对抗配置卡片 -->
<a-card data-step="0" title="对抗配置" class="ks-apps-card ks-bottom-apps-card">
<a-form
autocomplete="off"
layout="horizontal"
:label-col="{span: 6}"
name="basic"
>
<a-form-item
label="蓝方"
name="taskName"
>
<div class="counter-wrapper">
<div class="counter-wrapper-item" v-for="i in blueCounter" :key="`blue-${i}`">
<a-row :gutter="15">
<a-col :span="19">
<Finder :only-directory="false" @select="(_p: any, f: any)=> blueNames[i] = f.name"/>
</a-col>
<a-col :span="5" v-if="i == 1">
<a-space>
<plus-circle-outlined @click="()=> add('blue')"/>
<minus-circle-outlined @click="()=> minus('blue')"/>
</a-space>
</a-col>
</a-row>
</div>
</div>
</a-form-item>
<a-form-item
label="红方"
name="t"
>
<div class="counter-wrapper">
<div class="counter-wrapper-item" v-for="i in redCounter" :key="`red-${i}`">
<a-row :gutter="15">
<a-col :span="19">
<Finder :only-directory="false" @select="(_p: any, f: any)=> redNames[i] = f.name"/>
</a-col>
<a-col :span="5" v-if="i == 1">
<a-space>
<plus-circle-outlined @click="()=> add('red')"/>
<minus-circle-outlined @click="()=> minus('red')"/>
</a-space>
</a-col>
</a-row>
</div>
</div>
</a-form-item>
</a-form>
</a-card>
</a-col>
<a-col :span="16">
<!-- 智能博弈空战卡片 -->
<a-card data-step="0" class="ks-pks-card ks-pk-apps-card">
<template #title>
<a-flex :gap="8">
<span style="margin-top: 5px;color:#eee;font-size:16px">智能博弈空战</span>
<span style="color:#999;font-size: 15px;line-height: 32px;margin-left: 40px;">
当前场次 {{ deductionPods.length }} 队伍数: {{ deductionPods.length * 2 }}
</span>
<a-button style="margin-left: auto; color:#fff;" @click="()=> startLoop()">
<a-flex>
<PlayCircleOutlined/>
<span style="margin-left: 10px;">对抗开始</span>
</a-flex>
</a-button>
<a-button style="margin-left: 15px;color:#fff;" @click="()=> rankingModelVisible = true">
<a-flex>
<OrderedListOutlined/>
<span style="margin-left: 10px;">排名统计</span>
</a-flex>
</a-button>
</a-flex>
</template>
<div class="w-full mt-2" style="margin-top: 15px;">
<a-row :gutter="15">
<a-col :span="8" v-for="(item,i) in deductionPods" :key="`pod-${item.id}`">
<a-card class="ks-pk-card" hoverable>
<template #title>
<a-flex>
<span class="ks-card-title"> {{i + 1}} </span>
</a-flex>
</template>
<div class="pk-wrapper">
<div class="pk-overlay" @click="()=> handleClickPkCard(item)"></div>
<div class="pk-teams" @click="()=> handleClickPkCard(item)">
<span class="left-team">
<a-tooltip placement="bottom">
<template #title>
{{item.blueName ?? `蓝方${i+1}`}}
</template>
{{getTeamName(item.blueName,'blue', i + 1)}}
</a-tooltip>
</span>
<span class="right-team">
<a-tooltip placement="bottom">
<template #title>
{{item.redName ?? `红方${i+1}`}}
</template>
{{getTeamName(item.redName,'red', i + 1)}}
</a-tooltip>
</span>
</div>
<div class="pk-footer">
<a-flex>
<a-progress
:percent="item.targetPercent"
:stroke-width="6"
style="margin-right: 30px;"
></a-progress>
<a-button :disabled="item.targetPercent < 100" class="pk-details-btn" size="small" @click="()=> handleDetails(item)">
对抗详情
</a-button>
</a-flex>
</div>
</div>
<template #extra class="pk-actions">
<a-space>
<span class="icon-action eye" @click="()=> handleTermStatusPopoverVisible(item)" style="margin-right: -5px;"></span>
<a-popover title="" trigger="click" v-model:open="item.termStatusPopoverVisible">
<template #content>
<a-flex>
<span style="width: 100px;text-align: right;">仿真运行状态: </span>
<span style="margin-left: 10px;">{{item.statusName ?? '-'}}</span>
</a-flex>
</template>
</a-popover>
<!-- <a-popover title="" trigger="click" v-if="item?.metricsParsed">-->
<!-- <span class="icon-action eye"></span>-->
<!-- <template #content v-if="item?.metricsParsed">-->
<!-- <a-flex>-->
<!-- <span style="width: 100px;text-align: right;">本轮总奖励: </span>-->
<!-- <span style="margin-left: 10px;">{{ item?.metricsParsed?.reward ?? '-' }}</span>-->
<!-- </a-flex>-->
<!-- <a-flex>-->
<!-- <span style="width: 100px;text-align: right;">本轮生存步数: </span>-->
<!-- <span style="margin-left: 10px;">{{ item?.metricsParsed?.steps ?? '-' }}</span>-->
<!-- </a-flex>-->
<!-- <template v-if="item?.metricsParsed?.details">-->
<!-- <a-flex>-->
<!-- <span style="width: 100px;text-align: right;">开火奖励累计值: </span>-->
<!-- <span style="margin-left: 10px;">{{ item.metricsParsed?.details.fire_reward ?? '-' }}</span>-->
<!-- </a-flex>-->
<!-- <a-flex>-->
<!-- <span style="width: 100px;text-align: right;">越界惩罚累计值: </span>-->
<!-- <span style="margin-left: 10px;">{{ item?.metricsParsed?.details.boundary_penalty ?? '-' }}</span>-->
<!-- </a-flex>-->
<!-- </template>-->
<!-- </template>-->
<!-- </a-popover>-->
<a-popconfirm
ok-text="确定"
cancel-text="取消"
@confirm="()=> confirmPause(item)"
>
<template #title>
确定{{ item.simulationStatus === 1 ? '暂停' : '开始' }}
</template>
<span :class="['icon-action', item.simulationStatus === 1 ? 'pause' : 'start']">
</span>
</a-popconfirm>
<a-popconfirm
title="确定删除?"
ok-text="确定"
cancel-text="取消"
@confirm="()=> confirmDelete(item)"
>
<span class="icon-action delete"></span>
</a-popconfirm>
</a-space>
</template>
</a-card>
</a-col>
</a-row>
</div>
</a-card>
</a-col>
</a-row>
</div>
</a-card>
<ChartsModal :deduction="selectedDeduction" :visible="chartsModalVisible" @cancel="()=> chartsModalVisible = false" />
<RankingModel :visible="rankingModelVisible" @cancel="()=> rankingModelVisible = false"/>
</Layout>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue';
import {useRouter} from 'vue-router'
import { message } from 'ant-design-vue';
import Layout from '../layout.vue';
import { MinusCircleOutlined, OrderedListOutlined, PlayCircleOutlined, PlusCircleOutlined } from '@ant-design/icons-vue';
import RankingModel from './ranking-modal.vue';
import { createModelDeduction, deleteModelDeduction, findDeductionPodResult, runDeductionPodAfsimControl } from './api';
import type { DeductionPod, DeductionPodMetrics, ModelDeduction } from './types';
import Finder from '../finder.vue';
import { defaultModelDeduction } from './config';
import ChartsModal from './charts/charts-modal.vue';
import type { NullableString } from '@/types';
const chartsModalVisible = ref<boolean>(false);
const router = useRouter();
// 排名弹窗显隐
const rankingModelVisible = ref<boolean>(false)
// 红蓝方计数器
const redCounter = ref<number>(1);
const blueCounter = ref<number>(1);
const blueNames = ref<string[]>([])
const redNames = ref<string[]>([])
// 模型推演配置
const modelDeduction = ref<ModelDeduction>({ ...defaultModelDeduction });
// 推演Pods数据扩展类型增加进度更新定时器
const deductionPods = ref<(DeductionPod & {
animatePercent: number; // 动画进度值
targetPercent: number; // 目标进度值
stopped: boolean; // 是否暂停
progressTimer: any; // 动画定时器
updateTimer: any; // 进度更新定时器
})[]>([]);
// 心跳方法定时器标识
const heartbeatTimer = ref<any | null>(null);
const selectedDeduction = ref<DeductionPod | null | undefined>(null);
const getTeamName = (name: NullableString | undefined, side: string, level: number = 1): string => {
if(name){
if(name.length>3){
return name.substring(0,2) + '...';
}
return name;
}
return `${side === 'red' ? '红方' : '蓝方'}${level}`
}
const parseNumberPercent = (source: number|null , target: number|null) : number => {
if(source && target){
try{
return Number(Number((source/ target) * 100).toFixed(2));
} catch (e: any){
console.error(e);
}
}
return 0;
}
const handleDetails = (item: Partial<DeductionPod>) => {
// chartsModalVisible.value = true;
// selectedDeduction.value = JSON.parse(JSON.stringify(item));
router.push({
path: `/app/ai/applications/gambling/${item.id}/charts`
})
};
/**
* 随机更新进度值核心修改优化初始启动逻辑
* @param pod 单个pod实例
*/
const randomUpdateProgress = (pod: any) => {
// 如果暂停或进度已到100%,直接返回
if (pod.stopped || pod.animatePercent >= 100) {
return;
}
// 随机生成下次更新时间1-3秒
const randomInterval = 1000 + Math.random() * 2000;
// 随机生成进度增长量1-5%
const randomIncrement = 1 + Math.random() * 4;
pod.updateTimer = setTimeout(() => {
// 计算新的目标进度不超过100%
pod.targetPercent = Math.min(pod.animatePercent + randomIncrement, 100);
// 启动进度动画
animateProgress(pod, pod.targetPercent);
// 递归调用,实现持续更新
randomUpdateProgress(pod);
}, randomInterval);
};
/**
* 暂停/开始进度更新
* @param item 单个pod实例
*/
const confirmPause = (item: any) => {
item.simulationStatus = item.simulationStatus === 1 ? 2 : 1;
runDeductionPodAfsimControl({
jobId: item.jobId,
afsimHostIp: item.afsimHostIp,
afsimNodePort81: item.afsimNodePort81,
type: item.simulationStatus, // 1-恢复仿真2-暂停仿真3-查询状态)
}).then(r=> {
message.info(r.msg);
if(r.code === 200) {
item.stopped = !item.stopped;
}
if(r.data?.stateDescription){
item.statusName = r.data.stateDescription;
}
})
// if (item.stopped) {
// // 暂停:清除进度更新定时器
// if (item.updateTimer) {
// clearTimeout(item.updateTimer);
// item.updateTimer = null;
// }
// message.info('已暂停');
// } else {
// // 开始:重新启动进度更新
// randomUpdateProgress(item);
// message.info('已恢复');
// }
};
const handleTermStatusPopoverVisible = (item: Partial<DeductionPod>)=> {
item.termStatusPopoverVisible = ! item.termStatusPopoverVisible;
runDeductionPodAfsimControl({
jobId: item.jobId,
afsimHostIp: item.afsimHostIp,
afsimNodePort81: item.afsimNodePort81,
type: 3, // 1-恢复仿真2-暂停仿真3-查询状态)
}).then(r=> {
message.info(r.msg);
if(r.data.stateDescription){
item.statusName = r.data.stateDescription;
}
})
}
/**
* 删除pod同时清除相关定时器
* @param item 单个pod实例
*/
const confirmDelete = (item: any) => {
console.error('confirmDelete', item);
// 清除该pod的所有定时器
if (item.updateTimer) clearTimeout(item.updateTimer);
if (item.progressTimer) clearInterval(item.progressTimer);
deleteModelDeduction(item.id as number).then(res => {
if (res.code === 200) {
message.info('删除成功.');
loadData();
}
});
};
// 进度条平滑动画函数(保留原有逻辑)
const animateProgress = (pod: any, targetPercent: number) => {
targetPercent = Math.max(0, Math.min(100, Number(targetPercent) || 0));
if (typeof pod.animatePercent !== 'number') {
pod.animatePercent = 0;
}
const step = 0.5;
if (pod.progressTimer) {
clearInterval(pod.progressTimer);
}
if (Math.abs(pod.animatePercent - targetPercent) < step) {
pod.animatePercent = targetPercent;
return;
}
pod.progressTimer = setInterval(() => {
if (pod.animatePercent < targetPercent) {
pod.animatePercent = Math.min(pod.animatePercent + step, targetPercent);
} else {
pod.animatePercent = Math.max(pod.animatePercent - step, targetPercent);
}
if (Math.abs(pod.animatePercent - targetPercent) < step) {
pod.animatePercent = targetPercent;
clearInterval(pod.progressTimer);
pod.progressTimer = null;
}
}, 30);
};
// 点击PK卡片打开链接保留原有逻辑
const handleClickPkCard = (item: DeductionPod) => {
if (item?.afsimHostIp && item?.afsimNodePort6901) {
let url = `${item.afsimHostIp}:${item.afsimNodePort6901}`;
if(!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
const newWindow = window.open(url);
if (!newWindow) {
message.warning('窗口打开失败,请检查浏览器弹窗设置');
}
} else {
message.warning('缺少IP或端口信息无法打开链接');
}
};
const refresh = () => {
if (deductionPods.value) {
deductionPods.value.forEach(podItem => {
console.info('refresh', podItem);
randomUpdateProgress(podItem);
});
}
};
// 加载数据方法核心修改确保每个pod初始化后立即启动进度更新
const loadData = () => {
findDeductionPodResult().then(r => {
let rows: DeductionPod[] = Object.values(r.data ?? []) as DeductionPod[];
const newPods: any[] = [];
rows.forEach(row=> {
let metricsParsed: DeductionPodMetrics | null = null;
try {
const metricsStr = row?.metrics?.toString() ?? '';
metricsParsed = metricsStr ? JSON.parse(metricsStr) as DeductionPodMetrics : null;
} catch (e: any) {
console.warn('解析 metrics 失败:', e);
}
// 初始化进度相关参数默认不暂停进度从0开始
const podItem = {
...row,
metricsParsed,
fake: false,
stopped: false, // 默认不暂停,页面加载后直接运行
targetPercent: parseNumberPercent(row.currentRound, row.totalRound), // 初始目标进度
animatePercent: 0, // 初始动画进度
progressTimer: null, // 动画定时器
updateTimer: null, // 进度更新定时器
statusName: null,
termStatusPopoverVisible: false,
};
newPods.push(podItem);
})
// setTimeout(() => refresh(), 1000);
deductionPods.value = newPods;
console.log('最终 deductionPods 数据:', deductionPods.value);
});
};
// 心跳方法(加载数据)
const heartbeat = () => {
loadData();
};
// 增加红蓝方计数器
const add = (side: string)=> {
if('blue' === side){
blueCounter.value ++;
} else {
redCounter.value ++;
}
};
// 减少红蓝方计数器
const minus = (side: string)=> {
if('blue' === side){
blueCounter.value = Math.max(1, blueCounter.value - 1);
blueNames.value = blueNames.value.splice(blueNames.value.length-1,1);
} else {
redCounter.value = Math.max(1, redCounter.value - 1);
redNames.value = redNames.value.splice(redNames.value.length-1,1);
}
};
// 开始对抗
const startLoop = () => {
modelDeduction.value.redNames = redNames.value.filter(v=> v).join(',')
modelDeduction.value.blueNames = blueNames.value.filter(v=> v).join(',')
createModelDeduction(modelDeduction.value).then(res => {
if (res.code === 200) {
message.success('对抗已开始');
if (heartbeatTimer.value) {
clearInterval(heartbeatTimer.value);
}
heartbeat();
heartbeatTimer.value = setInterval(() => {
heartbeat();
}, 10000);
} else if (res.msg) {
message.error(res.msg);
}
}).catch(err => {
console.error('提交配置失败:', err);
message.error('提交配置失败,请重试');
});
};
// 组件挂载时加载初始数据(页面加载完成后自动执行,触发进度模拟)
onMounted(() => {
// 页面挂载后立即加载数据数据加载完成后自动启动所有Pods的进度更新
loadData();
});
heartbeatTimer.value = setInterval(() => {
loadData();
}, 3000);
// 组件卸载时清除所有定时器(防止内存泄漏,增强版)
onUnmounted(() => {
// 清除心跳定时器
if (heartbeatTimer.value) {
clearInterval(heartbeatTimer.value);
}
// 清除所有pod的定时器
// deductionPods.value.forEach(pod => {
// if (pod.progressTimer) clearInterval(pod.progressTimer);
// if (pod.updateTimer) clearTimeout(pod.updateTimer);
// });
// 清空pods数据避免残留
deductionPods.value = [];
});
</script>
<style lang="less">
.bg-wrapper .ant-card {
&.ks-apps-card{
&.ks-top-apps-card {
margin-bottom:15px!important;
&> .ant-card-body{
height: 35vh;
overflow: hidden;
.ant-form-item{
margin-bottom:15px;
}
}
}
&.ks-bottom-apps-card{
margin-bottom:0px!important;
&> .ant-card-body{
height: 32vh;
overflow: auto;
.ant-form-item{
margin-bottom:15px;
}
}
}
}
&.ks-pk-apps-card{
&> .ant-card-body{
height: 74vh;
}
}
.counter-wrapper{
border: 1px solid #475f71;
padding: 15px;
border-radius: 2px;
.counter-wrapper-item{
margin-bottom:15px;
text-align: left;
.anticon{
color:#a2b1ba;
cursor: pointer;
font-size: 18px;
line-height: 30px;
display: block;
}
&:last-child{
margin-bottom:0;
}
}
}
}
// 进度条动画样式优化(增强平滑度)
.ant-progress-inner {
transition: width 0.05s ease-in-out;
}
.ant-progress-bg {
transition: width 0.05s ease-in-out;
}
</style>