<template>
|
<div
|
v-loading.fullscreen.lock="startActionLoading"
|
element-loading-text="正在启动实例,请稍候..."
|
>
|
<div class="page-header">
|
<div class="header-left">
|
<h2>
|
<el-icon :size="24"><Cpu /></el-icon>
|
S7 PLC 模拟器实例
|
</h2>
|
<p class="text-muted">管理和监控 S7 PLC 模拟器实例</p>
|
</div>
|
<div class="header-right">
|
<div class="stats">
|
<span>运行中: {{ runningCount }} | 已停止: {{ stoppedCount }}</span>
|
<span v-if="errorCount > 0" class="error-text">| 错误: {{ errorCount }}</span>
|
</div>
|
<el-button type="primary" @click="$router.push('/create')">
|
<el-icon><Plus /></el-icon>
|
创建实例
|
</el-button>
|
</div>
|
</div>
|
|
<!-- Loading state -->
|
<div v-if="loading && instances.length === 0" class="loading-container">
|
<el-icon class="loading-icon" :size="40"><Loading /></el-icon>
|
<p>正在加载实例列表...</p>
|
</div>
|
|
<!-- Empty state -->
|
<el-empty v-else-if="instances.length === 0" description="暂无实例">
|
<el-button type="primary" @click="$router.push('/create')">创建第一个实例</el-button>
|
</el-empty>
|
|
<!-- Instances grid -->
|
<el-row v-else :gutter="20">
|
<el-col v-for="instance in instances" :key="instance.instanceId" :xs="24" :sm="12" :xl="8">
|
<el-card class="instance-card" :class="getStatusClass(instance.status)" shadow="hover">
|
<template #header>
|
<div class="card-header">
|
<span class="instance-id">{{ instance.instanceId }}</span>
|
<el-tag :type="getStatusTagType(instance.status)">
|
{{ getStatusText(instance.status) }}
|
</el-tag>
|
</div>
|
</template>
|
|
<div class="instance-info">
|
<el-descriptions :column="2" size="small">
|
<el-descriptions-item label="名称">{{ instance.name || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="PLC型号">{{ getPlcTypeText(instance.plcType) }}</el-descriptions-item>
|
<el-descriptions-item label="端口">{{ instance.port || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="客户端">
|
<el-icon><User /></el-icon>
|
{{ instance.clientCount || 0 }}
|
</el-descriptions-item>
|
<el-descriptions-item v-if="instance.startTime" label="启动时间" :span="2">
|
{{ formatDate(instance.startTime) }}
|
</el-descriptions-item>
|
</el-descriptions>
|
|
<el-alert
|
v-if="instance.errorMessage"
|
type="error"
|
:closable="false"
|
class="mt-2"
|
show-icon
|
>
|
{{ instance.errorMessage }}
|
</el-alert>
|
</div>
|
|
<template #footer>
|
<div class="card-footer">
|
<el-button
|
v-if="instance.status === 'Running'"
|
type="warning"
|
@click="handleStop(instance.instanceId)"
|
>
|
<el-icon><VideoPause /></el-icon>
|
停止
|
</el-button>
|
<el-button
|
v-if="instance.status === 'Stopped'"
|
type="success"
|
@click="handleStart(instance.instanceId)"
|
>
|
<el-icon><VideoPlay /></el-icon>
|
启动
|
</el-button>
|
<el-button type="info" @click="$router.push(`/details/${instance.instanceId}`)">
|
<el-icon><InfoFilled /></el-icon>
|
详情
|
</el-button>
|
<el-button type="primary" @click="$router.push(`/edit/${instance.instanceId}`)">
|
<el-icon><Edit /></el-icon>
|
编辑
|
</el-button>
|
<el-button type="danger" @click="handleDelete(instance.instanceId)">
|
<el-icon><Delete /></el-icon>
|
</el-button>
|
</div>
|
</template>
|
</el-card>
|
</el-col>
|
</el-row>
|
</div>
|
</template>
|
|
<script setup lang="ts">
|
import { onMounted, onUnmounted, ref } from 'vue'
|
import { storeToRefs } from 'pinia'
|
import { useInstancesStore } from '../stores/instances'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {
|
Cpu,
|
Plus,
|
Loading,
|
User,
|
VideoPause,
|
VideoPlay,
|
InfoFilled,
|
Edit,
|
Delete
|
} from '@element-plus/icons-vue'
|
|
const store = useInstancesStore()
|
const { instances, loading, runningCount, stoppedCount, errorCount } = storeToRefs(store)
|
const startActionLoading = ref(false)
|
|
onMounted(() => {
|
store.loadInstances()
|
store.startAutoRefresh(2000)
|
})
|
|
onUnmounted(() => {
|
store.stopAutoRefresh()
|
})
|
|
async function handleStart(id: string) {
|
try {
|
await ElMessageBox.confirm(`确定要启动实例 "${id}" 吗?`, '确认', {
|
confirmButtonText: '确定',
|
cancelButtonText: '取消',
|
type: 'info'
|
})
|
|
startActionLoading.value = true
|
await store.startInstance(id)
|
ElMessage.success('启动命令已发送')
|
} catch (err) {
|
if (err !== 'cancel') {
|
ElMessage.error('启动失败,请查看控制台')
|
}
|
} finally {
|
startActionLoading.value = false
|
}
|
}
|
|
function handleStop(id: string) {
|
ElMessageBox.confirm(`确定要停止实例 "${id}" 吗?`, '确认', {
|
confirmButtonText: '确定',
|
cancelButtonText: '取消',
|
type: 'warning'
|
}).then(() => {
|
store.stopInstance(id).then(() => {
|
ElMessage.success('停止命令已发送')
|
}).catch(() => {
|
ElMessage.error('停止失败,请查看控制台')
|
})
|
})
|
}
|
|
function handleDelete(id: string) {
|
ElMessageBox.confirm(`确定要删除实例 "${id}" 吗?此操作不可撤销!`, '警告', {
|
confirmButtonText: '确定',
|
cancelButtonText: '取消',
|
type: 'error'
|
}).then(() => {
|
store.deleteInstance(id).then(() => {
|
ElMessage.success('实例已删除')
|
}).catch(() => {
|
ElMessage.error('删除失败,请查看控制台')
|
})
|
})
|
}
|
|
function getStatusClass(status: string): string {
|
const map: Record<string, string> = {
|
'Stopped': 'status-stopped',
|
'Starting': 'status-starting',
|
'Running': 'status-running',
|
'Stopping': 'status-stopping',
|
'Error': 'status-error'
|
}
|
return map[status] || ''
|
}
|
|
function getStatusTagType(status: string): 'success' | 'info' | 'warning' | 'danger' {
|
const map: Record<string, 'success' | 'info' | 'warning' | 'danger'> = {
|
'Stopped': 'info',
|
'Starting': 'info',
|
'Running': 'success',
|
'Stopping': 'warning',
|
'Error': 'danger'
|
}
|
return map[status] || 'info'
|
}
|
|
function getStatusText(status: string): string {
|
const map: Record<string, string> = {
|
'Stopped': '已停止',
|
'Starting': '启动中',
|
'Running': '运行中',
|
'Stopping': '停止中',
|
'Error': '错误'
|
}
|
return map[status] || status
|
}
|
|
function getPlcTypeText(plcType: string): string {
|
const map: Record<string, string> = {
|
'S7200Smart': 'S7-200 Smart',
|
'S71200': 'S7-1200',
|
'S71500': 'S7-1500',
|
'S7300': 'S7-300',
|
'S7400': 'S7-400'
|
}
|
return map[plcType] || plcType
|
}
|
|
function formatDate(dateString: string | null): string {
|
if (!dateString) return '-'
|
const date = new Date(dateString)
|
return date.toLocaleString('zh-CN', {
|
year: 'numeric',
|
month: '2-digit',
|
day: '2-digit',
|
hour: '2-digit',
|
minute: '2-digit',
|
second: '2-digit'
|
})
|
}
|
</script>
|
|
<style scoped>
|
.page-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: flex-start;
|
margin-bottom: 20px;
|
flex-wrap: wrap;
|
gap: 16px;
|
}
|
|
.header-left h2 {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
margin: 0 0 8px 0;
|
}
|
|
.text-muted {
|
color: #909399;
|
margin: 0;
|
}
|
|
.header-right {
|
display: flex;
|
align-items: center;
|
gap: 16px;
|
flex-wrap: wrap;
|
}
|
|
.stats {
|
color: #606266;
|
font-size: 14px;
|
}
|
|
.error-text {
|
color: #f56c6c;
|
}
|
|
.loading-container {
|
text-align: center;
|
padding: 60px 0;
|
color: #909399;
|
}
|
|
.loading-icon {
|
animation: spin 1s linear infinite;
|
}
|
|
@keyframes spin {
|
from {
|
transform: rotate(0deg);
|
}
|
to {
|
transform: rotate(360deg);
|
}
|
}
|
|
.instance-card {
|
margin-bottom: 20px;
|
transition: all 0.3s;
|
}
|
|
.instance-card:hover {
|
transform: translateY(-4px);
|
}
|
|
.card-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
}
|
|
.instance-id {
|
font-weight: 600;
|
font-size: 16px;
|
}
|
|
.instance-info {
|
margin-bottom: 16px;
|
}
|
|
.card-footer {
|
display: flex;
|
gap: 8px;
|
flex-wrap: wrap;
|
}
|
|
.card-footer .el-button {
|
flex: 1;
|
min-width: 60px;
|
}
|
|
.status-stopped { border-left: 4px solid #909399; }
|
.status-starting { border-left: 4px solid #409eff; }
|
.status-running { border-left: 4px solid #67c23a; }
|
.status-stopping { border-left: 4px solid #e6a23c; }
|
.status-error { border-left: 4px solid #f56c6c; }
|
|
.mt-2 {
|
margin-top: 8px;
|
}
|
</style>
|