From 94ad631d316da04c46266ddb1fc6e63e6f8f2fae Mon Sep 17 00:00:00 2001
From: wanshenmean <cathay_xy@163.com>
Date: 星期二, 17 三月 2026 17:34:15 +0800
Subject: [PATCH] feat: 同步协议处理、前端交互与业务联调改动

---
 Code/WCS/WIDESEAWCS_S7Simulator/WIDESEAWCS_S7Simulator.Web/src/views/DetailsView.vue |  944 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 files changed, 907 insertions(+), 37 deletions(-)

diff --git a/Code/WCS/WIDESEAWCS_S7Simulator/WIDESEAWCS_S7Simulator.Web/src/views/DetailsView.vue b/Code/WCS/WIDESEAWCS_S7Simulator/WIDESEAWCS_S7Simulator.Web/src/views/DetailsView.vue
index cf643a5..c336b5a 100644
--- a/Code/WCS/WIDESEAWCS_S7Simulator/WIDESEAWCS_S7Simulator.Web/src/views/DetailsView.vue
+++ b/Code/WCS/WIDESEAWCS_S7Simulator/WIDESEAWCS_S7Simulator.Web/src/views/DetailsView.vue
@@ -1,5 +1,8 @@
-<template>
-  <div>
+锘�<template>
+  <div
+    v-loading.fullscreen.lock="startActionLoading"
+    element-loading-text="姝e湪鍚姩瀹炰緥锛岃绋嶅��..."
+  >
     <div v-if="loading" class="loading-container">
       <el-icon class="loading-icon" :size="40"><Loading /></el-icon>
       <p>鍔犺浇涓�...</p>
@@ -28,7 +31,6 @@
         </el-button>
       </div>
 
-      <!-- 鐘舵�佸崱鐗� -->
       <el-row :gutter="20" class="status-cards">
         <el-col :xs="12" :sm="6">
           <el-card shadow="hover" class="status-card">
@@ -62,7 +64,6 @@
         </el-col>
       </el-row>
 
-      <!-- 璇︾粏淇℃伅 -->
       <el-card class="mt-4" shadow="never">
         <template #header>
           <span class="card-header-title">鍩烘湰淇℃伅</span>
@@ -84,7 +85,6 @@
         </el-descriptions>
       </el-card>
 
-      <!-- 鎿嶄綔鎸夐挳 -->
       <el-card class="mt-4" shadow="never">
         <div class="action-buttons">
           <el-button
@@ -113,56 +113,435 @@
           </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">瀛楁瑙i噴</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)]"
+                                @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%"
+                              />
+                              <el-input
+                                v-else
+                                v-model="fieldEditValues[getFieldEditKey(row)]"
+                                @input="markFieldDirty(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)]"
+                            @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%"
+                          />
+                          <el-input
+                            v-else
+                            v-model="fieldEditValues[getFieldEditKey(row)]"
+                            @input="markFieldDirty(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} 涓瓧娈垫湭鏄犲皠鍒板疄渚嬪唴瀛樺潡锛岃妫�鏌ュ疄渚婦B鍧楅厤缃笌妯℃澘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 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 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, () => {
+  // 杞鍒锋柊鏃讹紝淇濈暀鐢ㄦ埛姝e湪缂栬緫鐨勫瓧娈碉紝閬垮厤杈撳叆鍊艰瑕嗙洊銆�
+  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 {
-    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)
 })
@@ -180,14 +559,19 @@
       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
   }
 }
 
@@ -200,6 +584,7 @@
     })
     await api.stopInstance(id)
     await loadInstance()
+    await loadMemoryData(true)
     ElMessage.success('鍋滄鍛戒护宸插彂閫�')
   } catch (err) {
     if (err !== 'cancel') {
@@ -209,35 +594,458 @@
   }
 }
 
+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'
+    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
 }
@@ -319,4 +1127,66 @@
   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>

--
Gitblit v1.9.3