<template>
|
<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>
|
</div>
|
|
<div v-else-if="errorMsg">
|
<el-result icon="error" :title="errorMsg">
|
<template #extra>
|
<el-button type="primary" @click="$router.push('/')">返回列表</el-button>
|
</template>
|
</el-result>
|
</div>
|
|
<div v-else-if="instance">
|
<div class="page-header">
|
<div class="header-left">
|
<h2>
|
<el-icon :size="24"><InfoFilled /></el-icon>
|
实例详情
|
</h2>
|
<p class="text-muted">{{ instance.name }} ({{ instance.instanceId }})</p>
|
</div>
|
<el-button @click="$router.push('/')">
|
<el-icon><Back /></el-icon>
|
返回列表
|
</el-button>
|
</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="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>
|
|
<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="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 label="监听端口">{{ instance.port }}</el-descriptions-item>
|
<el-descriptions-item v-if="instance.startTime" label="启动时间">
|
{{ formatDate(instance.startTime) }}
|
</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="错误信息">
|
<el-text type="danger">{{ instance.errorMessage }}</el-text>
|
</el-descriptions-item>
|
</el-descriptions>
|
</el-card>
|
|
<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>
|
<el-button
|
v-if="instance.status === 'Running'"
|
type="warning"
|
|
@click="handleStop"
|
>
|
<el-icon><VideoPause /></el-icon>
|
停止
|
</el-button>
|
<el-button type="primary" @click="$router.push(`/edit/${instance.instanceId}`)">
|
<el-icon><Edit /></el-icon>
|
编辑
|
</el-button>
|
<el-button @click="$router.push('/')">
|
<el-icon><Back /></el-icon>
|
返回列表
|
</el-button>
|
</div>
|
</el-card>
|
|
</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 @click="loadMemoryData(true)">手动刷新</el-button>
|
</div>
|
</div>
|
</template>
|
|
<div v-if="dbBlocks.length === 0" class="text-muted">暂无DB数据</div>
|
<div v-else>
|
<el-skeleton :loading="memoryLoading" animated>
|
<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-tab-pane
|
v-for="view in deviceDbViews"
|
:key="view.templateDbNumber"
|
:label="`DB${view.templateDbNumber}`"
|
>
|
<div class="db-block-title">
|
<span v-if="view.resolvedDbNumber">非零字节: {{ view.nonZeroCount }}</span>
|
<span v-else>未加载到实例内存</span>
|
</div>
|
|
<div class="card-header-title field-title">字段解释</div>
|
<el-tabs v-if="view.fieldGroupEnabled && view.fieldGroups.length > 0" class="field-tabs">
|
<el-tab-pane
|
v-for="group in view.fieldGroups"
|
:key="`${view.templateDbNumber}-${group.key}`"
|
>
|
<template #label>
|
<el-tag :type="getFieldGroupTagType(group.key)">{{ group.key }}</el-tag>
|
</template>
|
<div class="field-table-wrap">
|
<el-table
|
:data="group.fields"
|
border
|
class="field-table"
|
table-layout="auto"
|
empty-text="当前分组无字段映射"
|
>
|
<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)">{{ row.mappedDb }}</el-tag>
|
</template>
|
</el-table-column>
|
<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"
|
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="88" fixed="right">
|
<template #default="{ row }">
|
<el-button
|
type="primary"
|
link
|
:loading="isWritingField(row)"
|
:disabled="!isFieldWritable(row)"
|
@click="handleWriteField(row)"
|
>
|
写入
|
</el-button>
|
</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
|
:data="view.fields"
|
border
|
class="field-table"
|
table-layout="auto"
|
empty-text="当前DB块无字段映射"
|
>
|
<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)">{{ row.mappedDb }}</el-tag>
|
</template>
|
</el-table-column>
|
<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"
|
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="88" fixed="right">
|
<template #default="{ row }">
|
<el-button
|
type="primary"
|
link
|
:loading="isWritingField(row)"
|
:disabled="!isFieldWritable(row)"
|
@click="handleWriteField(row)"
|
>
|
写入
|
</el-button>
|
</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 class="db-raw-toolbar">
|
<el-button
|
v-if="needsExpand(view)"
|
link
|
type="primary"
|
class="expand-btn"
|
@click="toggleExpanded(view.templateDbNumber)"
|
>
|
{{ isExpanded(view.templateDbNumber) ? '收起' : '展开全部' }}
|
</el-button>
|
</div>
|
<el-tabs class="data-view-tabs">
|
<el-tab-pane label="十六进制">
|
<pre class="db-content">{{ getDisplayText(view.hex, view.templateDbNumber) }}</pre>
|
</el-tab-pane>
|
<el-tab-pane label="ASCII">
|
<pre class="db-content">{{ getDisplayText(view.ascii, view.templateDbNumber) }}</pre>
|
</el-tab-pane>
|
</el-tabs>
|
</el-tab-pane>
|
</el-tabs>
|
</div>
|
</template>
|
</el-skeleton>
|
|
<el-alert
|
v-if="unmappedFields.length > 0"
|
type="warning"
|
show-icon
|
:closable="false"
|
style="margin-top: 12px"
|
:title="`有 ${unmappedFields.length} 个字段未映射到实例内存块,请检查实例DB块配置与模板DB号。`"
|
/>
|
</div>
|
</el-card>
|
</el-col>
|
</el-row>
|
</div>
|
</section>
|
</div>
|
</div>
|
</template>
|
|
<script setup lang="ts">
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { useRoute } from 'vue-router'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { InfoFilled, Back, Loading, User, VideoPlay, VideoPause, Edit } from '@element-plus/icons-vue'
|
import * as api from '../api'
|
import type { InstanceConfig, InstanceState, InstanceStatus, ProtocolTemplate } from '../types'
|
|
const route = useRoute()
|
const id = route.params.id as string
|
|
const instance = ref<InstanceState | null>(null)
|
const instanceConfig = ref<InstanceConfig | null>(null)
|
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 dbBlocks = ref<Array<{ dbNumber: number; start: number; end: number; nonZeroCount: number }>>([])
|
const dbBytes = ref<Uint8Array>(new Uint8Array())
|
const lastDbBase64 = ref('')
|
const loadingInstanceRef = ref(false)
|
const loadingMemoryRef = ref(false)
|
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
|
|
const parsedFields = computed(() => {
|
if (!protocolTemplate.value || !instanceConfig.value) return []
|
const blockSize = instanceConfig.value.memoryConfig.dbBlockSize || 0
|
if (blockSize <= 0) return []
|
return protocolTemplate.value.fields
|
.map(field => {
|
const normalizedType = normalizeDataType(field.dataType)
|
const mappedDbNumber = resolveMemoryBlockByTemplateDb(field.dbNumber)
|
return {
|
fieldKey: field.fieldKey,
|
templateDbNumber: field.dbNumber,
|
address: buildAddress(field.dbNumber, field.offset, normalizedType, field.bit ?? 0),
|
mappedDb: mappedDbNumber ? `DB${mappedDbNumber}` : '未映射',
|
dataType: normalizedType,
|
direction: normalizeDirection(field.direction),
|
offset: field.offset,
|
bit: field.bit ?? 0,
|
length: field.length,
|
resolvedDbNumber: mappedDbNumber,
|
value: parseFieldValue(
|
field.dbNumber,
|
mappedDbNumber,
|
field.offset,
|
normalizedType,
|
field.length,
|
field.bit ?? 0
|
)
|
}
|
})
|
.sort((a, b) => a.offset - b.offset)
|
})
|
|
const unmappedFields = computed(() => parsedFields.value.filter(field => !field.resolvedDbNumber))
|
|
const deviceDbViews = computed(() => {
|
const templateDbNumbers = Array.from(new Set(parsedFields.value.map(field => field.templateDbNumber))).sort((a, b) => a - b)
|
if (templateDbNumbers.length === 0) {
|
return []
|
}
|
|
return templateDbNumbers
|
.map(templateDbNumber => {
|
const block = dbBlocks.value.find(x => x.dbNumber === templateDbNumber)
|
const chunk = block ? dbBytes.value.slice(block.start, block.end) : new Uint8Array()
|
return {
|
templateDbNumber,
|
resolvedDbNumber: block?.dbNumber ?? null,
|
nonZeroCount: block?.nonZeroCount ?? 0,
|
hex: block ? formatHex(chunk) : '-',
|
ascii: block ? formatAscii(chunk) : '-',
|
fields: parsedFields.value
|
.filter(field => field.templateDbNumber === templateDbNumber)
|
.map(field => ({
|
fieldKey: field.fieldKey,
|
address: field.address,
|
mappedDb: field.mappedDb,
|
dataType: field.dataType,
|
direction: field.direction,
|
value: field.value,
|
templateDbNumber,
|
resolvedDbNumber: field.resolvedDbNumber,
|
offset: field.offset,
|
bit: field.bit,
|
length: field.length
|
})),
|
...groupFieldsByPrefix(
|
parsedFields.value
|
.filter(field => field.templateDbNumber === templateDbNumber)
|
.map(field => ({
|
fieldKey: field.fieldKey,
|
address: field.address,
|
mappedDb: field.mappedDb,
|
dataType: field.dataType,
|
direction: field.direction,
|
value: field.value,
|
templateDbNumber,
|
resolvedDbNumber: field.resolvedDbNumber,
|
offset: field.offset,
|
bit: field.bit,
|
length: field.length
|
}))
|
)
|
}
|
})
|
.sort((a, b) => a.templateDbNumber - b.templateDbNumber)
|
})
|
|
watch(parsedFields, () => {
|
// 轮询刷新时,保留用户正在编辑的字段,避免输入值被覆盖。
|
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 })
|
|
async function loadInstance() {
|
if (loadingInstanceRef.value) return
|
loadingInstanceRef.value = true
|
try {
|
const latestInstance = await api.getInstance(id)
|
const latestConfig = await api.getInstanceConfig(id)
|
if (latestInstance) {
|
instance.value = latestInstance
|
}
|
if (latestConfig) {
|
const shouldReloadTemplate = latestConfig.protocolTemplateId !== instanceConfig.value?.protocolTemplateId
|
instanceConfig.value = latestConfig
|
if (shouldReloadTemplate || !protocolTemplate.value) {
|
await loadProtocolTemplateForInstance()
|
}
|
}
|
if (!latestInstance) {
|
errorMsg.value = `实例 "${id}" 不存在`
|
}
|
} catch (err) {
|
console.error('加载实例失败:', err)
|
errorMsg.value = '加载实例失败,请查看控制台'
|
} finally {
|
loadingInstanceRef.value = false
|
loading.value = false
|
}
|
}
|
|
async function loadProtocolTemplateForInstance() {
|
if (instanceConfig.value?.protocolTemplateId) {
|
protocolTemplate.value = await api.getProtocolTemplate(instanceConfig.value.protocolTemplateId)
|
if (protocolTemplate.value) {
|
return
|
}
|
}
|
|
const templates = await api.getProtocolTemplates()
|
protocolTemplate.value =
|
templates.find(t => t.id === 'wcs-line-v260202') ??
|
(templates.length > 0 ? templates[0] : null)
|
}
|
|
async function loadMemoryData(showLoading = false) {
|
if (!instance.value || loadingMemoryRef.value) return
|
loadingMemoryRef.value = true
|
|
if (showLoading) {
|
memoryLoading.value = true
|
}
|
try {
|
const memory = await api.readMemory(id)
|
const dbBase64 = memory.DB || memory.db
|
if (!dbBase64) {
|
dbBlocks.value = []
|
dbBytes.value = new Uint8Array()
|
lastDbBase64.value = ''
|
return
|
}
|
|
if (dbBase64 === lastDbBase64.value) {
|
return
|
}
|
|
lastDbBase64.value = dbBase64
|
const decoded = decodeBase64(dbBase64)
|
dbBytes.value = decoded
|
const dbBlockNumbers = instanceConfig.value?.memoryConfig.dbBlockNumbers || []
|
const dbBlockCount = instanceConfig.value?.memoryConfig.dbBlockCount || dbBlockNumbers.length || 1
|
const dbBlockSize = instanceConfig.value?.memoryConfig.dbBlockSize || decoded.length
|
dbBlocks.value = splitDbBlocks(decoded, dbBlockCount, dbBlockSize, dbBlockNumbers)
|
|
} catch (err) {
|
console.error('加载DB数据失败:', err)
|
ElMessage.error('加载DB数据失败')
|
} finally {
|
loadingMemoryRef.value = false
|
if (showLoading) {
|
memoryLoading.value = false
|
}
|
}
|
}
|
|
onMounted(async () => {
|
await loadInstance()
|
await loadMemoryData(true)
|
|
refreshTimer = window.setInterval(() => {
|
if (instance.value && instance.value.status !== 'Stopped') {
|
loadInstance()
|
if (autoRefreshDb.value) {
|
loadMemoryData(false)
|
}
|
}
|
}, 2000)
|
})
|
|
onUnmounted(() => {
|
if (refreshTimer !== null) {
|
clearInterval(refreshTimer)
|
}
|
})
|
|
async function handleStart() {
|
try {
|
await ElMessageBox.confirm(`确定要启动实例 "${id}" 吗?`, '确认', {
|
confirmButtonText: '确定',
|
cancelButtonText: '取消',
|
type: 'info'
|
})
|
|
startActionLoading.value = true
|
await api.startInstance(id)
|
await loadInstance()
|
await loadMemoryData(true)
|
ElMessage.success('启动命令已发送')
|
} catch (err) {
|
if (err !== 'cancel') {
|
console.error('启动实例失败:', err)
|
ElMessage.error('启动失败,请查看控制台')
|
}
|
} finally {
|
startActionLoading.value = false
|
}
|
}
|
|
async function handleStop() {
|
try {
|
await ElMessageBox.confirm(`确定要停止实例 "${id}" 吗?`, '确认', {
|
confirmButtonText: '确定',
|
cancelButtonText: '取消',
|
type: 'warning'
|
})
|
await api.stopInstance(id)
|
await loadInstance()
|
await loadMemoryData(true)
|
ElMessage.success('停止命令已发送')
|
} catch (err) {
|
if (err !== 'cancel') {
|
console.error('停止实例失败:', err)
|
ElMessage.error('停止失败,请查看控制台')
|
}
|
}
|
}
|
|
function decodeBase64(base64: string): Uint8Array {
|
const raw = atob(base64)
|
const result = new Uint8Array(raw.length)
|
for (let i = 0; i < raw.length; i++) {
|
result[i] = raw.charCodeAt(i)
|
}
|
return result
|
}
|
|
function encodeBase64(bytes: Uint8Array): string {
|
let binary = ''
|
const step = 0x8000
|
for (let i = 0; i < bytes.length; i += step) {
|
const chunk = bytes.subarray(i, Math.min(i + step, bytes.length))
|
binary += String.fromCharCode(...chunk)
|
}
|
return btoa(binary)
|
}
|
|
function splitDbBlocks(bytes: Uint8Array, blockCount: number, blockSize: number, blockNumbers: number[]) {
|
const normalizedDbNumbers = blockNumbers.length > 0
|
? blockNumbers.filter(x => Number.isInteger(x) && x > 0)
|
: Array.from({ length: blockCount }, (_, idx) => idx + 1)
|
|
const blocks: Array<{ dbNumber: number; start: number; end: number; nonZeroCount: number }> = []
|
for (let i = 0; i < normalizedDbNumbers.length; i++) {
|
const start = i * blockSize
|
const end = Math.min(start + blockSize, bytes.length)
|
if (start >= bytes.length) break
|
const chunk = bytes.slice(start, end)
|
blocks.push({
|
dbNumber: normalizedDbNumbers[i],
|
start,
|
end,
|
nonZeroCount: chunk.filter(x => x !== 0).length
|
})
|
}
|
return blocks
|
}
|
|
function formatHex(data: Uint8Array): string {
|
const lines: string[] = []
|
for (let i = 0; i < data.length; i += 16) {
|
const line = Array.from(data.slice(i, i + 16))
|
.map(x => x.toString(16).padStart(2, '0').toUpperCase())
|
.join(' ')
|
lines.push(line)
|
}
|
return lines.join('\n')
|
}
|
|
function formatAscii(data: Uint8Array): string {
|
const lines: string[] = []
|
for (let i = 0; i < data.length; i += 16) {
|
const chunk = data.slice(i, i + 16)
|
const line = Array.from(chunk)
|
.map(x => (x >= 32 && x <= 126 ? String.fromCharCode(x) : '·'))
|
.join('')
|
lines.push(line)
|
}
|
return lines.join('\n')
|
}
|
|
function countLines(text: string): number {
|
if (!text) return 0
|
return text.split('\n').length
|
}
|
|
function needsExpand(view: { hex: string; ascii: string }): boolean {
|
return Math.max(countLines(view.hex), countLines(view.ascii)) > 64
|
}
|
|
function isExpanded(dbNumber: number): boolean {
|
return expandedDbViews.value[dbNumber] === true
|
}
|
|
function toggleExpanded(dbNumber: number): void {
|
expandedDbViews.value[dbNumber] = !isExpanded(dbNumber)
|
}
|
|
function getDisplayText(text: string, dbNumber: number): string {
|
if (isExpanded(dbNumber)) {
|
return text
|
}
|
|
const lines = text.split('\n')
|
if (lines.length <= 64) {
|
return text
|
}
|
|
return lines.slice(0, 64).join('\n')
|
}
|
|
function buildFieldEditKey(templateDbNumber: number, fieldKey: string): string {
|
return `${templateDbNumber}:${fieldKey}`
|
}
|
|
function getFieldEditKey(row: { templateDbNumber: number; fieldKey: string }): string {
|
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
|
}
|
|
function isWritingField(row: { templateDbNumber: number; fieldKey: string }): boolean {
|
return writingFieldKeys.value[getFieldEditKey(row)] === true
|
}
|
|
function coerceEditValueByType(dataType: string, value: string): string | number | boolean {
|
if (dataType === 'Bool') {
|
return value === 'true'
|
}
|
|
if (dataType === 'Int' || dataType === 'DInt' || dataType === 'Byte') {
|
const num = Number(value)
|
return Number.isFinite(num) ? num : 0
|
}
|
|
return value === '(空)' ? '' : value
|
}
|
|
async function handleWriteField(row: {
|
templateDbNumber: number
|
resolvedDbNumber: number | null
|
fieldKey: string
|
dataType: string
|
offset: number
|
bit: number
|
length: number
|
}) {
|
if (!row.resolvedDbNumber) {
|
ElMessage.warning('该字段未映射到当前实例 DB 块,无法写入')
|
return
|
}
|
|
const editKey = getFieldEditKey(row)
|
const editedValue = fieldEditValues.value[editKey]
|
const targetBlock = dbBlocks.value.find(x => x.dbNumber === row.resolvedDbNumber)
|
if (!targetBlock) {
|
ElMessage.error('未找到目标 DB 块,无法写入')
|
return
|
}
|
|
const nextBytes = new Uint8Array(dbBytes.value)
|
const absolute = targetBlock.start + row.offset
|
const blockEnd = targetBlock.end
|
if (!tryWriteFieldToBytes(nextBytes, row, editedValue, absolute, blockEnd)) {
|
return
|
}
|
|
const dbBase64 = encodeBase64(nextBytes)
|
writingFieldKeys.value[editKey] = true
|
try {
|
const ok = await api.writeMemory(id, { DB: dbBase64 })
|
if (!ok) {
|
ElMessage.error('写入失败,请查看后台日志')
|
return
|
}
|
|
dbBytes.value = nextBytes
|
lastDbBase64.value = dbBase64
|
const dbBlockNumbers = instanceConfig.value?.memoryConfig.dbBlockNumbers || []
|
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
|
}
|
}
|
|
function tryWriteFieldToBytes(
|
bytes: Uint8Array,
|
row: { dataType: string; fieldKey: string; bit: number; length: number },
|
editedValue: string | number | boolean | undefined,
|
absolute: number,
|
blockEnd: number
|
): boolean {
|
if (absolute < 0 || absolute >= bytes.length || absolute >= blockEnd) {
|
ElMessage.error(`字段 ${row.fieldKey} 偏移越界`)
|
return false
|
}
|
|
if (row.dataType === 'Bool') {
|
if (row.bit < 0 || row.bit > 7) {
|
ElMessage.error(`字段 ${row.fieldKey} 的位偏移无效`)
|
return false
|
}
|
const boolValue = Boolean(editedValue)
|
if (boolValue) {
|
bytes[absolute] = bytes[absolute] | (1 << row.bit)
|
} else {
|
bytes[absolute] = bytes[absolute] & ~(1 << row.bit)
|
}
|
return true
|
}
|
|
if (row.dataType === 'Byte') {
|
const value = Number(editedValue)
|
if (!Number.isInteger(value) || value < 0 || value > 255) {
|
ElMessage.error(`字段 ${row.fieldKey} 仅支持 0-255`)
|
return false
|
}
|
bytes[absolute] = value
|
return true
|
}
|
|
if (row.dataType === 'Int') {
|
const value = Number(editedValue)
|
if (!Number.isInteger(value) || value < -32768 || value > 32767) {
|
ElMessage.error(`字段 ${row.fieldKey} 仅支持 -32768 到 32767`)
|
return false
|
}
|
if (absolute + 1 >= blockEnd) {
|
ElMessage.error(`字段 ${row.fieldKey} 超出 DB 块范围`)
|
return false
|
}
|
const unsigned = value < 0 ? value + 0x10000 : value
|
bytes[absolute] = (unsigned >> 8) & 0xff
|
bytes[absolute + 1] = unsigned & 0xff
|
return true
|
}
|
|
if (row.dataType === 'DInt') {
|
const value = Number(editedValue)
|
if (!Number.isInteger(value) || value < -2147483648 || value > 2147483647) {
|
ElMessage.error(`字段 ${row.fieldKey} 仅支持 32 位有符号整数`)
|
return false
|
}
|
if (absolute + 3 >= blockEnd) {
|
ElMessage.error(`字段 ${row.fieldKey} 超出 DB 块范围`)
|
return false
|
}
|
const unsigned = value < 0 ? value + 0x100000000 : value
|
bytes[absolute] = (unsigned >>> 24) & 0xff
|
bytes[absolute + 1] = (unsigned >>> 16) & 0xff
|
bytes[absolute + 2] = (unsigned >>> 8) & 0xff
|
bytes[absolute + 3] = unsigned & 0xff
|
return true
|
}
|
|
const text = String(editedValue ?? '')
|
const maxLength = Math.max(1, row.length || 32)
|
if (absolute + maxLength > blockEnd) {
|
ElMessage.error(`字段 ${row.fieldKey} 超出 DB 块范围`)
|
return false
|
}
|
for (let i = 0; i < maxLength; i++) {
|
bytes[absolute + i] = i < text.length ? text.charCodeAt(i) & 0xff : 0
|
}
|
return true
|
}
|
|
function getDbTagType(dbTag: string): 'success' | 'info' | 'warning' | 'danger' {
|
if (dbTag === '未映射') {
|
return 'warning'
|
}
|
|
const num = Number(dbTag.replace('DB', ''))
|
if (!Number.isFinite(num) || num <= 0) {
|
return 'info'
|
}
|
|
const palette: Array<'success' | 'info' | 'danger'> = ['success', 'info', 'danger']
|
return palette[num % palette.length]
|
}
|
|
function getFieldGroupTagType(groupKey: string): 'success' | 'info' | 'warning' | 'danger' {
|
const num = Number(groupKey)
|
if (!Number.isFinite(num) || num <= 0) {
|
return 'info'
|
}
|
|
const palette: Array<'success' | 'warning' | 'danger'> = ['success', 'warning', 'danger']
|
return palette[num % palette.length]
|
}
|
|
function groupFieldsByPrefix(fields: Array<{
|
fieldKey: string
|
address: string
|
mappedDb: string
|
dataType: string
|
direction: string
|
value: string
|
}>) {
|
const hasNumericPrefix = fields.some(field => /^(\d+)_/.test(field.fieldKey))
|
if (!hasNumericPrefix) {
|
return {
|
fieldGroupEnabled: false,
|
fieldGroups: []
|
}
|
}
|
|
const groups = new Map<string, typeof fields>()
|
|
for (const field of fields) {
|
const groupKey = resolveFieldGroupKey(field.fieldKey)
|
const current = groups.get(groupKey) ?? []
|
current.push(field)
|
groups.set(groupKey, current)
|
}
|
|
const fieldGroups = Array.from(groups.entries())
|
.map(([key, groupFields]) => ({
|
key,
|
fields: groupFields
|
}))
|
.sort((a, b) => a.key.localeCompare(b.key, 'zh-CN'))
|
|
return {
|
fieldGroupEnabled: true,
|
fieldGroups
|
}
|
}
|
|
function resolveFieldGroupKey(fieldKey: string): string {
|
const match = fieldKey.match(/^(\d+)_/)
|
if (match && match[1]) {
|
return match[1]
|
}
|
|
return '其他'
|
}
|
|
function resolveMemoryBlockByTemplateDb(templateDbNumber: number): number | null {
|
return dbBlocks.value.some(x => x.dbNumber === templateDbNumber) ? templateDbNumber : null
|
}
|
|
function buildAddress(dbNumber: number, offset: number, dataType: string, bit: number): string {
|
switch (dataType) {
|
case 'Int':
|
return `DB${dbNumber}.DBW${offset}`
|
case 'DInt':
|
return `DB${dbNumber}.DBD${offset}`
|
case 'Bool':
|
return `DB${dbNumber}.DBX${offset}.${bit}`
|
default:
|
return `DB${dbNumber}.DBB${offset}`
|
}
|
}
|
|
function parseFieldValue(
|
templateDbNumber: number,
|
resolvedDbNumber: number | null,
|
offset: number,
|
dataType: string,
|
length: number,
|
bit: number
|
): string {
|
if (!resolvedDbNumber) {
|
return `模板地址 DB${templateDbNumber} 未映射到当前实例内存块`
|
}
|
|
const block = dbBlocks.value.find(x => x.dbNumber === resolvedDbNumber)
|
if (!block) {
|
return `模板地址 DB${templateDbNumber} 未映射到当前实例内存块`
|
}
|
|
const absolute = block.start + offset
|
if (absolute < 0 || absolute >= dbBytes.value.length) return '-'
|
|
if (dataType === 'Bool') {
|
if (bit < 0 || bit > 7) return '-'
|
return ((dbBytes.value[absolute] >> bit) & 0x01) === 1 ? 'true' : 'false'
|
}
|
|
if (dataType === 'Byte') {
|
return String(dbBytes.value[absolute])
|
}
|
|
if (dataType === 'Int') {
|
if (absolute + 1 >= dbBytes.value.length) return '-'
|
const value = (dbBytes.value[absolute] << 8) | dbBytes.value[absolute + 1]
|
return String(value > 0x7fff ? value - 0x10000 : value)
|
}
|
|
if (dataType === 'DInt') {
|
if (absolute + 3 >= dbBytes.value.length) return '-'
|
const value =
|
(dbBytes.value[absolute] << 24) |
|
(dbBytes.value[absolute + 1] << 16) |
|
(dbBytes.value[absolute + 2] << 8) |
|
dbBytes.value[absolute + 3]
|
return String(value)
|
}
|
|
if (dataType === 'String') {
|
const len = Math.max(1, length || 32)
|
const end = Math.min(absolute + len, dbBytes.value.length)
|
const chars = Array.from(dbBytes.value.slice(absolute, end)).map(x =>
|
x >= 32 && x <= 126 ? String.fromCharCode(x) : ''
|
)
|
const text = chars.join('').trim()
|
return text || '(空)'
|
}
|
|
return '-'
|
}
|
|
function normalizeDataType(input: string | number): 'Byte' | 'Int' | 'DInt' | 'String' | 'Bool' {
|
if (typeof input === 'number') {
|
return input === 1 ? 'Int' : input === 2 ? 'DInt' : input === 3 ? 'String' : input === 4 ? 'Bool' : 'Byte'
|
}
|
return input as 'Byte' | 'Int' | 'DInt' | 'String' | 'Bool'
|
}
|
|
function normalizeDirection(input: string | number): string {
|
if (typeof input === 'number') {
|
return input === 1 ? 'PlcToWcs' : input === 2 ? 'Bidirectional' : 'WcsToPlc'
|
}
|
return input
|
}
|
|
function getStatusTagType(status: InstanceStatus): 'success' | 'info' | 'warning' | 'danger' {
|
const map: Record<InstanceStatus, 'success' | 'info' | 'warning' | 'danger'> = {
|
Stopped: 'info',
|
Starting: 'info',
|
Running: 'success',
|
Stopping: 'warning',
|
Error: 'danger'
|
}
|
return map[status] || 'info'
|
}
|
|
function getStatusText(status: InstanceStatus): string {
|
const map: Record<InstanceStatus, 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>
|
.status-cards {
|
margin-bottom: 8px;
|
}
|
|
.status-card {
|
text-align: center;
|
}
|
|
.card-header-title {
|
font-weight: 600;
|
font-size: 16px;
|
}
|
|
.mt-4 {
|
margin-top: 14px;
|
}
|
|
.action-buttons {
|
display: flex;
|
gap: 10px;
|
flex-wrap: wrap;
|
}
|
|
.left-actions {
|
margin-top: 14px;
|
}
|
|
.db-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
}
|
|
.db-toolbar {
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
}
|
|
.db-content {
|
margin: 0;
|
max-height: 168px;
|
overflow: auto;
|
white-space: pre-wrap;
|
font-family: Consolas, Monaco, 'Courier New', monospace;
|
font-size: 12px;
|
line-height: 1.5;
|
}
|
|
.db-block-panel {
|
margin-bottom: 18px;
|
}
|
|
.db-block-title {
|
font-weight: 500;
|
margin-bottom: 8px;
|
color: #606266;
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
}
|
|
.expand-btn {
|
margin-left: auto;
|
}
|
|
.db-tabs {
|
margin-top: 10px;
|
}
|
|
.data-view-tabs {
|
margin-top: 10px;
|
}
|
|
.db-raw-toolbar {
|
display: flex;
|
justify-content: flex-end;
|
margin-bottom: 4px;
|
}
|
|
.field-tabs {
|
margin-top: 10px;
|
}
|
|
.field-title {
|
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>
|