| | |
| | | <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 |
| | | class="admin-page" |
| | | 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> |
| | | <router-link to="/create" class="btn btn-primary"> |
| | | <i class="bi bi-plus-lg me-1"></i>创建实例 |
| | | </router-link> |
| | | <div class="header-right"> |
| | | <el-button type="primary" class="create-btn" @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> |
| | | <section class="section-block"> |
| | | <div class="section-head"> |
| | | <div> |
| | | <h3 class="section-title">信息区</h3> |
| | | <p class="section-desc">实例运行状态总览</p> |
| | | </div> |
| | | </div> |
| | | <p class="mt-3 text-muted">正在加载实例列表...</p> |
| | | <div class="section-body"> |
| | | <el-row :gutter="12" class="summary-row"> |
| | | <el-col :xs="24" :sm="8"> |
| | | <el-card shadow="hover" class="summary-card running-card"> |
| | | <div class="summary-title">运行中</div> |
| | | <div class="summary-value">{{ runningCount }}</div> |
| | | </el-card> |
| | | </el-col> |
| | | <el-col :xs="24" :sm="8"> |
| | | <el-card shadow="hover" class="summary-card stopped-card"> |
| | | <div class="summary-title">已停止</div> |
| | | <div class="summary-value">{{ stoppedCount }}</div> |
| | | </el-card> |
| | | </el-col> |
| | | <el-col :xs="24" :sm="8"> |
| | | <el-card shadow="hover" class="summary-card error-card"> |
| | | <div class="summary-title">错误实例</div> |
| | | <div class="summary-value">{{ errorCount }}</div> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | </div> |
| | | </section> |
| | | |
| | | <!-- 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 --> |
| | | <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> |
| | | </div> |
| | | </div> |
| | | <div class="card-footer bg-white"> |
| | | <div class="action-buttons d-flex gap-2"> |
| | | <button |
| | | v-if="instance.status === 'Running'" |
| | | class="btn btn-warning btn-sm flex-fill" |
| | | @click="handleStop(instance.instanceId)" |
| | | > |
| | | <i class="bi bi-stop-fill me-1"></i>停止 |
| | | </button> |
| | | <button |
| | | v-if="instance.status === 'Stopped'" |
| | | class="btn btn-success btn-sm flex-fill" |
| | | @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> |
| | | </div> |
| | | </div> |
| | | <section v-else class="section-block"> |
| | | <div class="section-head"> |
| | | <div> |
| | | <h3 class="section-title">操作区</h3> |
| | | <p class="section-desc">实例启动、停止、编辑与详情入口</p> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div class="section-body"> |
| | | <el-row :gutter="14" class="instances-grid"> |
| | | <el-col |
| | | v-for="instance in instances" |
| | | :key="instance.instanceId" |
| | | :xs="24" |
| | | :sm="12" |
| | | :md="12" |
| | | :lg="8" |
| | | :xl="6" |
| | | > |
| | | <el-card class="instance-card panel-card" :class="getStatusClass(instance.status)" shadow="hover"> |
| | | <div class="card-glow"></div> |
| | | |
| | | <div class="card-top"> |
| | | <div class="card-title"> |
| | | <div class="instance-id-line"> |
| | | <span class="instance-id">{{ instance.instanceId }}</span> |
| | | <span class="instance-name">{{ instance.name || '未命名实例' }}</span> |
| | | <span class="instance-sub">PLC</span> |
| | | </div> |
| | | </div> |
| | | <el-tag :type="getStatusTagType(instance.status)" effect="dark" round> |
| | | {{ getStatusText(instance.status) }} |
| | | </el-tag> |
| | | </div> |
| | | |
| | | <div class="meta-row"> |
| | | <div class="meta-chip"> |
| | | <span class="chip-label">PLC</span> |
| | | <span class="chip-value">{{ getPlcTypeText(instance.plcType) }}</span> |
| | | </div> |
| | | <div class="meta-chip"> |
| | | <span class="chip-label">端口</span> |
| | | <span class="chip-value">{{ instance.port || '-' }}</span> |
| | | </div> |
| | | <div class="meta-chip"> |
| | | <span class="chip-label">客户端</span> |
| | | <span class="chip-value"> |
| | | <el-icon><User /></el-icon> |
| | | {{ instance.clientCount || 0 }} |
| | | </span> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="time-row"> |
| | | <span class="time-label">启动时间</span> |
| | | <span class="time-value">{{ instance.startTime ? formatDate(instance.startTime) : '-' }}</span> |
| | | </div> |
| | | |
| | | <el-alert |
| | | v-if="instance.errorMessage" |
| | | type="error" |
| | | :closable="false" |
| | | class="mt-2" |
| | | show-icon |
| | | > |
| | | {{ instance.errorMessage }} |
| | | </el-alert> |
| | | |
| | | <div class="card-footer"> |
| | | <div class="main-actions"> |
| | | <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> |
| | | </div> |
| | | <el-button class="delete-btn" type="danger" @click="handleDelete(instance.instanceId)"> |
| | | <el-icon><Delete /></el-icon> |
| | | </el-button> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | </div> |
| | | </section> |
| | | </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; |
| | | .header-left h2 { |
| | | margin: 0; |
| | | } |
| | | |
| | | .instance-card:hover { |
| | | transform: translateY(-2px); |
| | | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
| | | } |
| | | |
| | | .instance-info-label { |
| | | color: #6c757d; |
| | | font-size: 0.75rem; |
| | | text-transform: uppercase; |
| | | letter-spacing: 0.5px; |
| | | } |
| | | |
| | | .instance-info-value { |
| | | font-weight: 500; |
| | | } |
| | | |
| | | .action-buttons { |
| | | .header-right { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | 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; } |
| | | |
| | | .empty-state { |
| | | text-align: center; |
| | | padding: 4rem 2rem; |
| | | color: #6c757d; |
| | | .create-btn { |
| | | padding-inline: 18px; |
| | | } |
| | | |
| | | .empty-state i { |
| | | font-size: 4rem; |
| | | margin-bottom: 1rem; |
| | | .summary-row { |
| | | margin-top: -2px; |
| | | margin-bottom: 2px; |
| | | } |
| | | |
| | | .empty-state h3 { |
| | | margin-bottom: 0.5rem; |
| | | .instances-grid { |
| | | max-width: 1500px; |
| | | } |
| | | |
| | | .alert-sm { |
| | | font-size: 0.875rem; |
| | | .summary-card { |
| | | border-radius: 12px; |
| | | border: 1px solid #dbe2ea; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .summary-title { |
| | | color: #64748b; |
| | | font-size: 13px; |
| | | margin-bottom: 6px; |
| | | } |
| | | |
| | | .summary-value { |
| | | font-size: 28px; |
| | | font-weight: 700; |
| | | line-height: 1; |
| | | } |
| | | |
| | | .running-card { |
| | | background: linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%); |
| | | } |
| | | |
| | | .running-card .summary-value { |
| | | color: #15803d; |
| | | } |
| | | |
| | | .stopped-card { |
| | | background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%); |
| | | } |
| | | |
| | | .stopped-card .summary-value { |
| | | color: #334155; |
| | | } |
| | | |
| | | .error-card { |
| | | background: linear-gradient(180deg, #fef2f2 0%, #ffffff 100%); |
| | | } |
| | | |
| | | .error-card .summary-value { |
| | | color: #b91c1c; |
| | | } |
| | | |
| | | .instance-card { |
| | | margin-bottom: 10px; |
| | | position: relative; |
| | | border-radius: 12px; |
| | | border: 1px solid #dbe2ea; |
| | | transition: all 0.25s; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .instance-card:hover { |
| | | transform: translateY(-3px); |
| | | box-shadow: 0 14px 26px rgba(15, 23, 42, 0.1); |
| | | } |
| | | |
| | | .card-glow { |
| | | position: absolute; |
| | | top: -56px; |
| | | right: -56px; |
| | | width: 100px; |
| | | height: 100px; |
| | | border-radius: 50%; |
| | | background: radial-gradient(circle, rgba(14, 116, 244, 0.16), transparent 70%); |
| | | pointer-events: none; |
| | | } |
| | | |
| | | .card-title { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 1px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .instance-id { |
| | | font-weight: 600; |
| | | font-size: 15px; |
| | | color: #0f172a; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .instance-name { |
| | | color: #64748b; |
| | | font-size: 12px; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .instance-id-line { |
| | | display: flex; |
| | | align-items: baseline; |
| | | gap: 6px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .instance-sub { |
| | | color: #94a3b8; |
| | | font-size: 10px; |
| | | } |
| | | |
| | | .card-top { |
| | | margin-top: 2px; |
| | | display: flex; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | align-items: flex-start; |
| | | } |
| | | |
| | | .meta-row { |
| | | margin-top: 8px; |
| | | display: flex; |
| | | gap: 6px; |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .meta-chip { |
| | | border: 1px solid #e2e8f0; |
| | | border-radius: 999px; |
| | | padding: 4px 8px; |
| | | background: #f8fafc; |
| | | display: inline-flex; |
| | | align-items: center; |
| | | gap: 6px; |
| | | } |
| | | |
| | | .chip-label { |
| | | font-size: 10px; |
| | | line-height: 1; |
| | | color: #64748b; |
| | | padding: 1px 5px; |
| | | border-radius: 999px; |
| | | background: #e2e8f0; |
| | | } |
| | | |
| | | .chip-value { |
| | | font-size: 12px; |
| | | line-height: 1; |
| | | color: #0f172a; |
| | | display: inline-flex; |
| | | align-items: center; |
| | | gap: 4px; |
| | | } |
| | | |
| | | .time-row { |
| | | margin-top: 7px; |
| | | margin-bottom: 8px; |
| | | display: flex; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | font-size: 11px; |
| | | } |
| | | |
| | | .time-label { |
| | | color: #64748b; |
| | | } |
| | | |
| | | .time-value { |
| | | color: #0f172a; |
| | | } |
| | | |
| | | .card-footer { |
| | | border-top: 1px dashed #dbe2ea; |
| | | padding-top: 8px; |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .main-actions { |
| | | display: flex; |
| | | gap: 6px; |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .main-actions .el-button { |
| | | border-radius: 8px; |
| | | height: 28px; |
| | | padding: 0 10px; |
| | | } |
| | | |
| | | .delete-btn { |
| | | border-radius: 8px; |
| | | height: 28px; |
| | | padding: 0 10px; |
| | | } |
| | | |
| | | .mt-2 { |
| | | margin-top: 8px; |
| | | } |
| | | </style> |
| | | |