| | |
| | | <template> |
| | | <div> |
| | | <div |
| | | class="admin-page" |
| | | v-loading.fullscreen.lock="startActionLoading" |
| | | element-loading-text="正在启动实例,请稍候..." |
| | | > |
| | | <div v-if="loading" class="loading-container"> |
| | | <el-icon class="loading-icon" :size="40"><Loading /></el-icon> |
| | | <p>加载中...</p> |
| | |
| | | </el-button> |
| | | </div> |
| | | |
| | | <el-row :gutter="20" class="status-cards"> |
| | | <el-col :xs="12" :sm="6"> |
| | | <el-card shadow="hover" class="status-card"> |
| | | <el-statistic title="状态"> |
| | | <template #default> |
| | | <el-tag :type="getStatusTagType(instance.status)" size="large"> |
| | | {{ getStatusText(instance.status) }} |
| | | </el-tag> |
| | | </template> |
| | | </el-statistic> |
| | | </el-card> |
| | | </el-col> |
| | | <el-col :xs="12" :sm="6"> |
| | | <el-card shadow="hover" class="status-card"> |
| | | <el-statistic title="连接客户端" :value="instance.clientCount"> |
| | | <template #suffix> |
| | | <el-icon><User /></el-icon> |
| | | </template> |
| | | </el-statistic> |
| | | </el-card> |
| | | </el-col> |
| | | <el-col :xs="12" :sm="6"> |
| | | <el-card shadow="hover" class="status-card"> |
| | | <el-statistic title="总请求数" :value="instance.totalRequests" /> |
| | | </el-card> |
| | | </el-col> |
| | | <el-col :xs="12" :sm="6"> |
| | | <el-card shadow="hover" class="status-card"> |
| | | <el-statistic title="端口" :value="instance.port" /> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | <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="status-cards"> |
| | | <el-col :xs="12" :sm="6"> |
| | | <el-card shadow="hover" class="status-card panel-card"> |
| | | <el-statistic title="状态"> |
| | | <template #default> |
| | | <el-tag :type="getStatusTagType(instance.status)" size="large"> |
| | | {{ getStatusText(instance.status) }} |
| | | </el-tag> |
| | | </template> |
| | | </el-statistic> |
| | | </el-card> |
| | | </el-col> |
| | | <el-col :xs="12" :sm="6"> |
| | | <el-card shadow="hover" class="status-card panel-card"> |
| | | <el-statistic title="连接客户端" :value="instance.clientCount"> |
| | | <template #suffix> |
| | | <el-icon><User /></el-icon> |
| | | </template> |
| | | </el-statistic> |
| | | </el-card> |
| | | </el-col> |
| | | <el-col :xs="12" :sm="6"> |
| | | <el-card shadow="hover" class="status-card panel-card"> |
| | | <el-statistic title="总请求数" :value="instance.totalRequests" /> |
| | | </el-card> |
| | | </el-col> |
| | | <el-col :xs="12" :sm="6"> |
| | | <el-card shadow="hover" class="status-card panel-card"> |
| | | <el-statistic title="端口" :value="instance.port" /> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | </div> |
| | | </section> |
| | | |
| | | <el-card class="mt-4" shadow="never"> |
| | | <section class="section-block detail-section"> |
| | | <div class="section-head"> |
| | | <div> |
| | | <h3 class="section-title">操作区</h3> |
| | | <p class="section-desc">左侧实例信息与操作,右侧实时 DB 数据</p> |
| | | </div> |
| | | </div> |
| | | <div class="section-body"> |
| | | <el-row :gutter="16" class="detail-main"> |
| | | <el-col :xs="24" :lg="8"> |
| | | <el-card class="panel-card" shadow="never"> |
| | | <template #header> |
| | | <span class="card-header-title">基本信息</span> |
| | | </template> |
| | | <el-descriptions :column="2" border> |
| | | <el-descriptions :column="1" border> |
| | | <el-descriptions-item label="实例ID">{{ instance.instanceId }}</el-descriptions-item> |
| | | <el-descriptions-item label="实例名称">{{ instance.name }}</el-descriptions-item> |
| | | <el-descriptions-item label="PLC型号">{{ getPlcTypeText(instance.plcType) }}</el-descriptions-item> |
| | |
| | | <el-descriptions-item v-if="instance.lastActivityTime" label="最后活动时间"> |
| | | {{ formatDate(instance.lastActivityTime) }} |
| | | </el-descriptions-item> |
| | | <el-descriptions-item v-if="instance.errorMessage" label="错误信息" :span="2"> |
| | | <el-descriptions-item v-if="instance.errorMessage" label="错误信息"> |
| | | <el-text type="danger">{{ instance.errorMessage }}</el-text> |
| | | </el-descriptions-item> |
| | | </el-descriptions> |
| | | </el-card> |
| | | |
| | | <el-card class="mt-4" shadow="never"> |
| | | <el-card class="panel-card left-actions" shadow="never"> |
| | | <div class="action-buttons"> |
| | | <el-button |
| | | v-if="instance.status === 'Stopped' || instance.status === 'Error'" |
| | | type="success" |
| | | |
| | | @click="handleStart" |
| | | > |
| | | <el-icon><VideoPlay /></el-icon> |
| | |
| | | <el-button |
| | | v-if="instance.status === 'Running'" |
| | | type="warning" |
| | | |
| | | @click="handleStop" |
| | | > |
| | | <el-icon><VideoPause /></el-icon> |
| | |
| | | </div> |
| | | </el-card> |
| | | |
| | | <el-card class="mt-4" shadow="never"> |
| | | </el-col> |
| | | <el-col :xs="24" :lg="16"> |
| | | <el-card class="panel-card" shadow="never"> |
| | | <template #header> |
| | | <div class="db-header"> |
| | | <span class="card-header-title">DB块实时数据</span> |
| | | <div class="db-toolbar"> |
| | | <el-switch v-model="autoRefreshDb" active-text="自动刷新" /> |
| | | <el-button size="small" @click="loadMemoryData(true)">手动刷新</el-button> |
| | | <el-button @click="loadMemoryData(true)">手动刷新</el-button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | |
| | | <template #default> |
| | | <div v-if="deviceDbViews.length === 0" class="text-muted">当前设备模板未匹配到可显示的DB块</div> |
| | | <div v-else> |
| | | <el-tabs type="border-card" class="db-tabs"> |
| | | <el-tabs type="border-card" class="db-tabs"> |
| | | <el-tab-pane |
| | | v-for="view in deviceDbViews" |
| | | :key="view.templateDbNumber" |
| | |
| | | :key="`${view.templateDbNumber}-${group.key}`" |
| | | > |
| | | <template #label> |
| | | <el-tag :type="getFieldGroupTagType(group.key)" size="small">{{ group.key }}</el-tag> |
| | | <el-tag :type="getFieldGroupTagType(group.key)">{{ group.key }}</el-tag> |
| | | </template> |
| | | <div class="field-table-wrap"> |
| | | <el-table |
| | | :data="group.fields" |
| | | border |
| | | size="small" |
| | | class="field-table" |
| | | table-layout="auto" |
| | | empty-text="当前分组无字段映射" |
| | | > |
| | | <el-table-column prop="fieldKey" label="字段" min-width="140" /> |
| | | <el-table-column prop="address" label="地址" width="130" /> |
| | | <el-table-column prop="mappedDb" label="映射块" width="120"> |
| | | <el-table-column prop="fieldKey" label="字段" /> |
| | | <el-table-column prop="address" label="地址" /> |
| | | <el-table-column prop="mappedDb" label="映射块"> |
| | | <template #default="{ row }"> |
| | | <el-tag :type="getDbTagType(row.mappedDb)" size="small">{{ row.mappedDb }}</el-tag> |
| | | <el-tag :type="getDbTagType(row.mappedDb)">{{ row.mappedDb }}</el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="dataType" label="类型" width="90" /> |
| | | <el-table-column prop="direction" label="方向" width="130" /> |
| | | <el-table-column prop="value" label="当前值" min-width="220" /> |
| | | <el-table-column label="修改值" min-width="220"> |
| | | <el-table-column prop="dataType" label="类型" /> |
| | | <el-table-column prop="direction" label="方向" /> |
| | | <el-table-column prop="value" label="当前值" /> |
| | | <el-table-column label="修改值"> |
| | | <template #default="{ row }"> |
| | | <el-switch |
| | | v-if="row.dataType === 'Bool'" |
| | | v-model="fieldEditValues[getFieldEditKey(row)]" |
| | | @change="markFieldDirty(row)" |
| | | :disabled="!isFieldWritable(row)" |
| | | /> |
| | | <el-input-number |
| | | v-else-if="row.dataType === 'Int' || row.dataType === 'DInt' || row.dataType === 'Byte'" |
| | | v-model="fieldEditValues[getFieldEditKey(row)]" |
| | | @change="markFieldDirty(row)" |
| | | :disabled="!isFieldWritable(row)" |
| | | :controls="false" |
| | | style="width: 100%" |
| | | class="editable-control" |
| | | /> |
| | | <el-input |
| | | v-else |
| | | v-model="fieldEditValues[getFieldEditKey(row)]" |
| | | @input="markFieldDirty(row)" |
| | | :disabled="!isFieldWritable(row)" |
| | | class="editable-control" |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="90" fixed="right"> |
| | | <el-table-column label="操作" width="88" fixed="right"> |
| | | <template #default="{ row }"> |
| | | <el-button |
| | | type="primary" |
| | |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </div> |
| | | </el-tab-pane> |
| | | </el-tabs> |
| | | <template v-else-if="view.fields.length > 0"> |
| | | <div class="field-table-wrap"> |
| | | <el-table |
| | | v-else-if="view.fields.length > 0" |
| | | :data="view.fields" |
| | | border |
| | | size="small" |
| | | class="field-table" |
| | | table-layout="auto" |
| | | empty-text="当前DB块无字段映射" |
| | | > |
| | | <el-table-column prop="fieldKey" label="字段" min-width="140" /> |
| | | <el-table-column prop="address" label="地址" width="130" /> |
| | | <el-table-column prop="mappedDb" label="映射块" width="120"> |
| | | <el-table-column prop="fieldKey" label="字段" /> |
| | | <el-table-column prop="address" label="地址" /> |
| | | <el-table-column prop="mappedDb" label="映射块"> |
| | | <template #default="{ row }"> |
| | | <el-tag :type="getDbTagType(row.mappedDb)" size="small">{{ row.mappedDb }}</el-tag> |
| | | <el-tag :type="getDbTagType(row.mappedDb)">{{ row.mappedDb }}</el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="dataType" label="类型" width="90" /> |
| | | <el-table-column prop="direction" label="方向" width="130" /> |
| | | <el-table-column prop="value" label="当前值" min-width="220" /> |
| | | <el-table-column label="修改值" min-width="220"> |
| | | <el-table-column prop="dataType" label="类型" /> |
| | | <el-table-column prop="direction" label="方向" /> |
| | | <el-table-column prop="value" label="当前值" /> |
| | | <el-table-column label="修改值"> |
| | | <template #default="{ row }"> |
| | | <el-switch |
| | | v-if="row.dataType === 'Bool'" |
| | | v-model="fieldEditValues[getFieldEditKey(row)]" |
| | | @change="markFieldDirty(row)" |
| | | :disabled="!isFieldWritable(row)" |
| | | /> |
| | | <el-input-number |
| | | v-else-if="row.dataType === 'Int' || row.dataType === 'DInt' || row.dataType === 'Byte'" |
| | | v-model="fieldEditValues[getFieldEditKey(row)]" |
| | | @change="markFieldDirty(row)" |
| | | :disabled="!isFieldWritable(row)" |
| | | :controls="false" |
| | | style="width: 100%" |
| | | class="editable-control" |
| | | /> |
| | | <el-input |
| | | v-else |
| | | v-model="fieldEditValues[getFieldEditKey(row)]" |
| | | @input="markFieldDirty(row)" |
| | | :disabled="!isFieldWritable(row)" |
| | | class="editable-control" |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="90" fixed="right"> |
| | | <el-table-column label="操作" width="88" fixed="right"> |
| | | <template #default="{ row }"> |
| | | <el-button |
| | | type="primary" |
| | |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </div> |
| | | </template> |
| | | <div v-else class="text-muted">当前DB块无字段映射</div> |
| | | |
| | | <div class="card-header-title field-title">原始数据</div> |
| | |
| | | /> |
| | | </div> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | </div> |
| | | </section> |
| | | </div> |
| | | </div> |
| | | </template> |
| | |
| | | const protocolTemplate = ref<ProtocolTemplate | null>(null) |
| | | const loading = ref(true) |
| | | const errorMsg = ref('') |
| | | const startActionLoading = ref(false) |
| | | |
| | | const memoryLoading = ref(false) |
| | | const autoRefreshDb = ref(true) |
| | |
| | | const expandedDbViews = ref<Record<number, boolean>>({}) |
| | | const fieldEditValues = ref<Record<string, string | number | boolean>>({}) |
| | | const writingFieldKeys = ref<Record<string, boolean>>({}) |
| | | const dirtyFieldKeys = ref<Record<string, boolean>>({}) |
| | | |
| | | let refreshTimer: number | null = null |
| | | |
| | |
| | | }) |
| | | |
| | | watch(parsedFields, () => { |
| | | const next: Record<string, string | number | boolean> = {} |
| | | // 轮询刷新时,保留用户正在编辑的字段,避免输入值被覆盖。 |
| | | const next: Record<string, string | number | boolean> = { ...fieldEditValues.value } |
| | | const validKeys = new Set<string>() |
| | | for (const field of parsedFields.value) { |
| | | const key = buildFieldEditKey(field.templateDbNumber, field.fieldKey) |
| | | validKeys.add(key) |
| | | if (dirtyFieldKeys.value[key] === true) { |
| | | continue |
| | | } |
| | | next[key] = coerceEditValueByType(field.dataType, field.value) |
| | | } |
| | | |
| | | for (const key of Object.keys(next)) { |
| | | if (!validKeys.has(key)) { |
| | | delete next[key] |
| | | } |
| | | } |
| | | for (const key of Object.keys(dirtyFieldKeys.value)) { |
| | | if (!validKeys.has(key)) { |
| | | delete dirtyFieldKeys.value[key] |
| | | } |
| | | } |
| | | |
| | | fieldEditValues.value = next |
| | | }, { immediate: true }) |
| | | |
| | |
| | | cancelButtonText: '取消', |
| | | type: 'info' |
| | | }) |
| | | |
| | | startActionLoading.value = true |
| | | await api.startInstance(id) |
| | | await loadInstance() |
| | | await loadMemoryData(true) |
| | |
| | | console.error('启动实例失败:', err) |
| | | ElMessage.error('启动失败,请查看控制台') |
| | | } |
| | | } finally { |
| | | startActionLoading.value = false |
| | | } |
| | | } |
| | | |
| | |
| | | return buildFieldEditKey(row.templateDbNumber, row.fieldKey) |
| | | } |
| | | |
| | | function markFieldDirty(row: { templateDbNumber: number; fieldKey: string }): void { |
| | | dirtyFieldKeys.value[getFieldEditKey(row)] = true |
| | | } |
| | | |
| | | function clearFieldDirty(row: { templateDbNumber: number; fieldKey: string }): void { |
| | | delete dirtyFieldKeys.value[getFieldEditKey(row)] |
| | | } |
| | | |
| | | function isFieldWritable(row: { resolvedDbNumber: number | null }): boolean { |
| | | return row.resolvedDbNumber !== null |
| | | } |
| | |
| | | const dbBlockCount = instanceConfig.value?.memoryConfig.dbBlockCount || dbBlockNumbers.length || 1 |
| | | const dbBlockSize = instanceConfig.value?.memoryConfig.dbBlockSize || nextBytes.length |
| | | dbBlocks.value = splitDbBlocks(nextBytes, dbBlockCount, dbBlockSize, dbBlockNumbers) |
| | | clearFieldDirty(row) |
| | | ElMessage.success(`字段 ${row.fieldKey} 写入成功`) |
| | | } finally { |
| | | writingFieldKeys.value[editKey] = false |
| | |
| | | </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; |
| | | } |
| | | |
| | | .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); |
| | | } |
| | | } |
| | | |
| | | .status-cards { |
| | | margin-bottom: 20px; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .status-card { |
| | |
| | | } |
| | | |
| | | .mt-4 { |
| | | margin-top: 16px; |
| | | margin-top: 14px; |
| | | } |
| | | |
| | | .action-buttons { |
| | | display: flex; |
| | | gap: 12px; |
| | | gap: 10px; |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .left-actions { |
| | | margin-top: 14px; |
| | | } |
| | | |
| | | .db-header { |
| | |
| | | |
| | | .db-content { |
| | | margin: 0; |
| | | max-height: 180px; |
| | | max-height: 168px; |
| | | overflow: auto; |
| | | white-space: pre-wrap; |
| | | font-family: Consolas, Monaco, 'Courier New', monospace; |
| | |
| | | } |
| | | |
| | | .db-tabs { |
| | | margin-top: 8px; |
| | | margin-top: 10px; |
| | | } |
| | | |
| | | .data-view-tabs { |
| | | margin-top: 8px; |
| | | margin-top: 10px; |
| | | } |
| | | |
| | | .db-raw-toolbar { |
| | |
| | | margin-top: 12px; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .field-table-wrap { |
| | | width: 100%; |
| | | overflow-x: auto; |
| | | } |
| | | |
| | | :deep(.field-table) { |
| | | width: max-content; |
| | | min-width: 100%; |
| | | } |
| | | |
| | | :deep(.editable-control) { |
| | | width: 100%; |
| | | } |
| | | |
| | | :deep(.status-card .el-card__body) { |
| | | padding: 14px 16px; |
| | | } |
| | | |
| | | :deep(.action-buttons .el-button) { |
| | | min-width: 82px; |
| | | } |
| | | |
| | | :deep(.panel-card > .el-card__header) { |
| | | padding: 14px 18px; |
| | | } |
| | | |
| | | :deep(.panel-card > .el-card__body) { |
| | | padding: 14px 18px; |
| | | } |
| | | </style> |
| | | |