| | |
| | | <template> |
| | | <template> |
| | | <div> |
| | | <div v-if="loading" class="loading-container"> |
| | | <el-icon class="loading-icon" :size="40"><Loading /></el-icon> |
| | |
| | | </el-button> |
| | | </div> |
| | | |
| | | <!-- 状态卡片 --> |
| | | <el-row :gutter="20" class="status-cards"> |
| | | <el-col :xs="12" :sm="6"> |
| | | <el-card shadow="hover" class="status-card"> |
| | |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <!-- 详细信息 --> |
| | | <el-card class="mt-4" shadow="never"> |
| | | <template #header> |
| | | <span class="card-header-title">基本信息</span> |
| | |
| | | </el-descriptions> |
| | | </el-card> |
| | | |
| | | <!-- 操作按钮 --> |
| | | <el-card class="mt-4" shadow="never"> |
| | | <div class="action-buttons"> |
| | | <el-button |
| | |
| | | </el-button> |
| | | </div> |
| | | </el-card> |
| | | |
| | | <el-card class="mt-4" 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> |
| | | </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)" size="small">{{ group.key }}</el-tag> |
| | | </template> |
| | | <el-table |
| | | :data="group.fields" |
| | | border |
| | | size="small" |
| | | 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"> |
| | | <template #default="{ row }"> |
| | | <el-tag :type="getDbTagType(row.mappedDb)" size="small">{{ 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"> |
| | | <template #default="{ row }"> |
| | | <el-switch |
| | | v-if="row.dataType === 'Bool'" |
| | | v-model="fieldEditValues[getFieldEditKey(row)]" |
| | | :disabled="!isFieldWritable(row)" |
| | | /> |
| | | <el-input-number |
| | | v-else-if="row.dataType === 'Int' || row.dataType === 'DInt' || row.dataType === 'Byte'" |
| | | v-model="fieldEditValues[getFieldEditKey(row)]" |
| | | :disabled="!isFieldWritable(row)" |
| | | :controls="false" |
| | | style="width: 100%" |
| | | /> |
| | | <el-input |
| | | v-else |
| | | v-model="fieldEditValues[getFieldEditKey(row)]" |
| | | :disabled="!isFieldWritable(row)" |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="90" 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> |
| | | </el-tab-pane> |
| | | </el-tabs> |
| | | <el-table |
| | | v-else-if="view.fields.length > 0" |
| | | :data="view.fields" |
| | | border |
| | | size="small" |
| | | 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"> |
| | | <template #default="{ row }"> |
| | | <el-tag :type="getDbTagType(row.mappedDb)" size="small">{{ 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"> |
| | | <template #default="{ row }"> |
| | | <el-switch |
| | | v-if="row.dataType === 'Bool'" |
| | | v-model="fieldEditValues[getFieldEditKey(row)]" |
| | | :disabled="!isFieldWritable(row)" |
| | | /> |
| | | <el-input-number |
| | | v-else-if="row.dataType === 'Int' || row.dataType === 'DInt' || row.dataType === 'Byte'" |
| | | v-model="fieldEditValues[getFieldEditKey(row)]" |
| | | :disabled="!isFieldWritable(row)" |
| | | :controls="false" |
| | | style="width: 100%" |
| | | /> |
| | | <el-input |
| | | v-else |
| | | v-model="fieldEditValues[getFieldEditKey(row)]" |
| | | :disabled="!isFieldWritable(row)" |
| | | /> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="90" 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 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> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup lang="ts"> |
| | | import { ref, onMounted, onUnmounted } from 'vue' |
| | | 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 { InfoFilled, Back, Loading, User, VideoPlay, VideoPause, Edit } from '@element-plus/icons-vue' |
| | | import * as api from '../api' |
| | | import type { InstanceState, InstanceStatus } from '../types' |
| | | 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 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>>({}) |
| | | |
| | | let refreshTimer: number | null = null |
| | | |
| | | const id = route.params.id as string |
| | | 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> = {} |
| | | for (const field of parsedFields.value) { |
| | | const key = buildFieldEditKey(field.templateDbNumber, field.fieldKey) |
| | | next[key] = coerceEditValueByType(field.dataType, field.value) |
| | | } |
| | | fieldEditValues.value = next |
| | | }, { immediate: true }) |
| | | |
| | | async function loadInstance() { |
| | | if (loadingInstanceRef.value) return |
| | | loadingInstanceRef.value = true |
| | | try { |
| | | instance.value = await api.getInstance(id) |
| | | if (!instance.value) { |
| | | 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 |
| | | } |
| | | } |
| | | |
| | | onMounted(() => { |
| | | loadInstance() |
| | | // 每2秒刷新一次状态 |
| | | 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) |
| | | }) |
| | |
| | | }) |
| | | await api.startInstance(id) |
| | | await loadInstance() |
| | | await loadMemoryData(true) |
| | | ElMessage.success('启动命令已发送') |
| | | } catch (err) { |
| | | if (err !== 'cancel') { |
| | |
| | | }) |
| | | await api.stopInstance(id) |
| | | await loadInstance() |
| | | await loadMemoryData(true) |
| | | ElMessage.success('停止命令已发送') |
| | | } catch (err) { |
| | | if (err !== 'cancel') { |
| | |
| | | } |
| | | } |
| | | |
| | | 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 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) |
| | | 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' |
| | | 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': '错误' |
| | | 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' |
| | | S7200Smart: 'S7-200 Smart', |
| | | S71200: 'S7-1200', |
| | | S71500: 'S7-1500', |
| | | S7300: 'S7-300', |
| | | S7400: 'S7-400' |
| | | } |
| | | return map[plcType] || plcType |
| | | } |
| | |
| | | gap: 12px; |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .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: 180px; |
| | | 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: 8px; |
| | | } |
| | | |
| | | .data-view-tabs { |
| | | margin-top: 8px; |
| | | } |
| | | |
| | | .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; |
| | | } |
| | | </style> |