| | |
| | | <template> |
| | | <div> |
| | | <div class="d-flex justify-content-between align-items-center mb-4"> |
| | | <div> |
| | | <h2 class="mb-0"> |
| | | <i class="bi bi-cpu-fill me-2"></i>S7 PLC 仿真器实例 |
| | | <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 mb-0 mt-1">管理和监控 S7 PLC 仿真器实例</p> |
| | | <p class="text-muted">管理和监控 S7 PLC 模拟器实例</p> |
| | | </div> |
| | | <div class="d-flex align-items-center gap-3"> |
| | | <div class="text-muted small"> |
| | | 运行中: {{ runningCount }} | 已停止: {{ stoppedCount }} |
| | | <span v-if="errorCount > 0" class="text-danger">| 错误: {{ errorCount }}</span> |
| | | <div class="header-right"> |
| | | <div class="stats"> |
| | | <span>运行中: {{ runningCount }} | 已停止: {{ stoppedCount }}</span> |
| | | <span v-if="errorCount > 0" class="error-text">| 错误: {{ errorCount }}</span> |
| | | </div> |
| | | <router-link to="/create" class="btn btn-primary"> |
| | | <i class="bi bi-plus-lg me-1"></i>创建实例 |
| | | </router-link> |
| | | <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="text-center py-5"> |
| | | <div class="spinner-border text-primary" role="status"> |
| | | <span class="visually-hidden">加载中...</span> |
| | | </div> |
| | | <p class="mt-3 text-muted">正在加载实例列表...</p> |
| | | <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 --> |
| | | <div v-else-if="instances.length === 0" class="empty-state"> |
| | | <i class="bi bi-inbox"></i> |
| | | <h3>暂无实例</h3> |
| | | <p>点击上方"创建实例"按钮来创建您的第一个仿真器实例</p> |
| | | </div> |
| | | <el-empty v-else-if="instances.length === 0" description="暂无实例"> |
| | | <el-button type="primary" @click="$router.push('/create')">创建第一个实例</el-button> |
| | | </el-empty> |
| | | |
| | | <!-- Instances grid --> |
| | | <div v-else class="row row-cols-1 row-cols-md-2 row-cols-xl-3 g-4"> |
| | | <div v-for="instance in instances" :key="instance.instanceId" class="col"> |
| | | <div :class="['card', 'instance-card', 'h-100', getStatusClass(instance.status)]"> |
| | | <div class="card-header d-flex justify-content-between align-items-center"> |
| | | <h5 class="card-title mb-0">{{ instance.instanceId }}</h5> |
| | | <span :class="['badge', getStatusClass(instance.status)]"> |
| | | {{ getStatusText(instance.status) }} |
| | | </span> |
| | | </div> |
| | | <div class="card-body"> |
| | | <div class="instance-info mb-3"> |
| | | <div class="row mb-2"> |
| | | <div class="col-6"> |
| | | <small class="instance-info-label">名称</small> |
| | | <div class="instance-info-value">{{ instance.name || '-' }}</div> |
| | | </div> |
| | | <div class="col-6"> |
| | | <small class="instance-info-label">PLC型号</small> |
| | | <div class="instance-info-value">{{ getPlcTypeText(instance.plcType) }}</div> |
| | | </div> |
| | | </div> |
| | | <div class="row mb-2"> |
| | | <div class="col-6"> |
| | | <small class="instance-info-label">端口</small> |
| | | <div class="instance-info-value">{{ instance.port || '-' }}</div> |
| | | </div> |
| | | <div class="col-6"> |
| | | <small class="instance-info-label">客户端</small> |
| | | <div class="instance-info-value"> |
| | | <i class="bi bi-people-fill me-1"></i>{{ instance.clientCount || 0 }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div v-if="instance.startTime" class="row"> |
| | | <div class="col-12"> |
| | | <small class="instance-info-label">启动时间</small> |
| | | <div class="instance-info-value small">{{ formatDate(instance.startTime) }}</div> |
| | | </div> |
| | | </div> |
| | | <div v-if="instance.errorMessage" class="alert alert-danger alert-sm mt-2 mb-0 py-2 small"> |
| | | <i class="bi bi-exclamation-triangle-fill me-1"></i>{{ instance.errorMessage }} |
| | | </div> |
| | | <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> |
| | | <div class="card-footer bg-white"> |
| | | <div class="action-buttons d-flex gap-2"> |
| | | <button |
| | | |
| | | <template #footer> |
| | | <div class="card-footer"> |
| | | <el-button |
| | | v-if="instance.status === 'Running'" |
| | | class="btn btn-warning btn-sm flex-fill" |
| | | type="warning" |
| | | @click="handleStop(instance.instanceId)" |
| | | > |
| | | <i class="bi bi-stop-fill me-1"></i>停止 |
| | | </button> |
| | | <button |
| | | <el-icon><VideoPause /></el-icon> |
| | | 停止 |
| | | </el-button> |
| | | <el-button |
| | | v-if="instance.status === 'Stopped'" |
| | | class="btn btn-success btn-sm flex-fill" |
| | | type="success" |
| | | @click="handleStart(instance.instanceId)" |
| | | > |
| | | <i class="bi bi-play-fill me-1"></i>启动 |
| | | </button> |
| | | <router-link :to="`/details/${instance.instanceId}`" class="btn btn-info btn-sm text-white flex-fill"> |
| | | <i class="bi bi-info-circle-fill me-1"></i>详情 |
| | | </router-link> |
| | | <router-link :to="`/edit/${instance.instanceId}`" class="btn btn-primary btn-sm flex-fill"> |
| | | <i class="bi bi-pencil-fill me-1"></i>编辑 |
| | | </router-link> |
| | | <button class="btn btn-danger btn-sm" @click="handleDelete(instance.instanceId)"> |
| | | <i class="bi bi-trash-fill"></i> |
| | | </button> |
| | | <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> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup lang="ts"> |
| | | import { onMounted, onUnmounted } from 'vue' |
| | | 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.stopAutoRefresh() |
| | | }) |
| | | |
| | | function handleStart(id: string) { |
| | | if (confirm(`确定要启动实例 "${id}" 吗?`)) { |
| | | store.startInstance(id).catch(() => { |
| | | alert('启动失败,请查看控制台') |
| | | 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) { |
| | | if (confirm(`确定要停止实例 "${id}" 吗?`)) { |
| | | store.stopInstance(id).catch(() => { |
| | | alert('停止失败,请查看控制台') |
| | | ElMessageBox.confirm(`确定要停止实例 "${id}" 吗?`, '确认', { |
| | | confirmButtonText: '确定', |
| | | cancelButtonText: '取消', |
| | | type: 'warning' |
| | | }).then(() => { |
| | | store.stopInstance(id).then(() => { |
| | | ElMessage.success('停止命令已发送') |
| | | }).catch(() => { |
| | | ElMessage.error('停止失败,请查看控制台') |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | function handleDelete(id: string) { |
| | | if (confirm(`确定要删除实例 "${id}" 吗?此操作不可撤销!`)) { |
| | | store.deleteInstance(id).catch(() => { |
| | | alert('删除失败,请查看控制台') |
| | | ElMessageBox.confirm(`确定要删除实例 "${id}" 吗?此操作不可撤销!`, '警告', { |
| | | confirmButtonText: '确定', |
| | | cancelButtonText: '取消', |
| | | type: 'error' |
| | | }).then(() => { |
| | | store.deleteInstance(id).then(() => { |
| | | ElMessage.success('实例已删除') |
| | | }).catch(() => { |
| | | ElMessage.error('删除失败,请查看控制台') |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | function getStatusClass(status: string): string { |
| | |
| | | '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 { |
| | |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .instance-card { |
| | | transition: transform 0.2s, box-shadow 0.2s; |
| | | .page-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: flex-start; |
| | | margin-bottom: 20px; |
| | | flex-wrap: wrap; |
| | | gap: 16px; |
| | | } |
| | | |
| | | .instance-card:hover { |
| | | transform: translateY(-2px); |
| | | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
| | | .header-left h2 { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | margin: 0 0 8px 0; |
| | | } |
| | | |
| | | .instance-info-label { |
| | | color: #6c757d; |
| | | font-size: 0.75rem; |
| | | text-transform: uppercase; |
| | | letter-spacing: 0.5px; |
| | | .text-muted { |
| | | color: #909399; |
| | | margin: 0; |
| | | } |
| | | |
| | | .instance-info-value { |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .action-buttons { |
| | | .header-right { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 16px; |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .status-stopped { border-left: 4px solid #6c757d; } |
| | | .status-starting { border-left: 4px solid #0dcaf0; } |
| | | .status-running { border-left: 4px solid #198754; } |
| | | .status-stopping { border-left: 4px solid #ffc107; } |
| | | .status-error { border-left: 4px solid #dc3545; } |
| | | .stats { |
| | | color: #606266; |
| | | font-size: 14px; |
| | | } |
| | | |
| | | .empty-state { |
| | | .error-text { |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | .loading-container { |
| | | text-align: center; |
| | | padding: 4rem 2rem; |
| | | color: #6c757d; |
| | | padding: 60px 0; |
| | | color: #909399; |
| | | } |
| | | |
| | | .empty-state i { |
| | | font-size: 4rem; |
| | | margin-bottom: 1rem; |
| | | .loading-icon { |
| | | animation: spin 1s linear infinite; |
| | | } |
| | | |
| | | .empty-state h3 { |
| | | margin-bottom: 0.5rem; |
| | | @keyframes spin { |
| | | from { |
| | | transform: rotate(0deg); |
| | | } |
| | | to { |
| | | transform: rotate(360deg); |
| | | } |
| | | } |
| | | |
| | | .alert-sm { |
| | | font-size: 0.875rem; |
| | | .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> |
| | | |
| | | |