<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">管理和监控 S7 PLC 模拟器实例</p>
|
</div>
|
<div class="header-right">
|
<el-button type="default" @click="handleRefresh">
|
<el-icon><Refresh /></el-icon>
|
重新获取实例
|
</el-button>
|
<el-button type="primary" class="create-btn" @click="$router.push('/create')">
|
<el-icon><Plus /></el-icon>
|
创建实例
|
</el-button>
|
</div>
|
</div>
|
|
<section class="section-block">
|
<div class="section-head">
|
<div>
|
<h3 class="section-title">信息区</h3>
|
<p class="section-desc">实例运行状态总览</p>
|
</div>
|
</div>
|
<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 -->
|
<el-empty v-else-if="instances.length === 0" description="暂无实例">
|
<el-button type="primary" @click="$router.push('/create')">创建第一个实例</el-button>
|
</el-empty>
|
|
<!-- Instances grid -->
|
<section v-else class="section-block">
|
<div class="section-head">
|
<div>
|
<h3 class="section-title">操作区</h3>
|
<p class="section-desc">实例启动、停止、编辑与详情入口</p>
|
</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, ref } from 'vue'
|
import { storeToRefs } from 'pinia'
|
import { useInstancesStore } from '../stores/instances'
|
import api from '../api'
|
import { syncInstances } from '../api'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {
|
Cpu,
|
Plus,
|
Loading,
|
Refresh,
|
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 handleRefresh() {
|
try {
|
await syncInstances()
|
await store.loadInstances()
|
ElMessage.success('已重新获取实例列表')
|
} catch (err) {
|
console.error('同步失败:', err)
|
ElMessage.error('同步失败,请查看控制台')
|
}
|
}
|
|
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>
|
.header-left h2 {
|
margin: 0;
|
}
|
|
.header-right {
|
display: flex;
|
align-items: center;
|
gap: 10px;
|
flex-wrap: wrap;
|
}
|
|
.create-btn {
|
padding-inline: 18px;
|
}
|
|
.summary-row {
|
margin-top: -2px;
|
margin-bottom: 2px;
|
}
|
|
.instances-grid {
|
max-width: 1500px;
|
}
|
|
.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>
|