wanshenmean
2026-03-17 737dec3c384f394fd6f9849b4480b697d1ba35d5
Code/WCS/WIDESEAWCS_S7Simulator/WIDESEAWCS_S7Simulator.Web/src/views/DetailsView.vue
@@ -1,177 +1,518 @@
<template>
<template>
  <div>
    <div v-if="loading" class="text-center py-5">
      <div class="spinner-border text-primary" role="status">
        <span class="visually-hidden">加载中...</span>
      </div>
    <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">
      <div class="alert alert-danger">{{ errorMsg }}</div>
      <router-link to="/" class="btn btn-primary">返回列表</router-link>
      <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="d-flex justify-content-between align-items-center mb-4">
        <div>
          <h2 class="mb-0">
            <i class="bi bi-info-circle me-2"></i>实例详情
      <div class="page-header">
        <div class="header-left">
          <h2>
            <el-icon :size="24"><InfoFilled /></el-icon>
            实例详情
          </h2>
          <p class="text-muted mb-0 mt-1">{{ instance.name }} ({{ instance.instanceId }})</p>
          <p class="text-muted">{{ instance.name }} ({{ instance.instanceId }})</p>
        </div>
        <router-link to="/" class="btn btn-outline-secondary">
          <i class="bi bi-arrow-left me-1"></i>返回列表
        </router-link>
        <el-button @click="$router.push('/')">
          <el-icon><Back /></el-icon>
          返回列表
        </el-button>
      </div>
      <!-- 状态卡片 -->
      <div class="row mb-4">
        <div class="col-md-3">
          <div class="card text-center">
            <div class="card-body">
              <h6 class="card-subtitle mb-2 text-muted">状态</h6>
              <h4 :class="['mb-0', getStatusClass(instance.status)]">
                {{ getStatusText(instance.status) }}
              </h4>
            </div>
          </div>
        </div>
        <div class="col-md-3">
          <div class="card text-center">
            <div class="card-body">
              <h6 class="card-subtitle mb-2 text-muted">连接客户端</h6>
              <h4 class="mb-0">
                <i class="bi bi-people-fill me-1"></i>{{ instance.clientCount }}
              </h4>
            </div>
          </div>
        </div>
        <div class="col-md-3">
          <div class="card text-center">
            <div class="card-body">
              <h6 class="card-subtitle mb-2 text-muted">总请求数</h6>
              <h4 class="mb-0">{{ instance.totalRequests }}</h4>
            </div>
          </div>
        </div>
        <div class="col-md-3">
          <div class="card text-center">
            <div class="card-body">
              <h6 class="card-subtitle mb-2 text-muted">端口</h6>
              <h4 class="mb-0">{{ instance.port }}</h4>
            </div>
          </div>
        </div>
      </div>
      <el-row :gutter="20" class="status-cards">
        <el-col :xs="12" :sm="6">
          <el-card shadow="hover" class="status-card">
            <el-statistic title="状态">
              <template #default>
                <el-tag :type="getStatusTagType(instance.status)" size="large">
                  {{ getStatusText(instance.status) }}
                </el-tag>
              </template>
            </el-statistic>
          </el-card>
        </el-col>
        <el-col :xs="12" :sm="6">
          <el-card shadow="hover" class="status-card">
            <el-statistic title="连接客户端" :value="instance.clientCount">
              <template #suffix>
                <el-icon><User /></el-icon>
              </template>
            </el-statistic>
          </el-card>
        </el-col>
        <el-col :xs="12" :sm="6">
          <el-card shadow="hover" class="status-card">
            <el-statistic title="总请求数" :value="instance.totalRequests" />
          </el-card>
        </el-col>
        <el-col :xs="12" :sm="6">
          <el-card shadow="hover" class="status-card">
            <el-statistic title="端口" :value="instance.port" />
          </el-card>
        </el-col>
      </el-row>
      <!-- 详细信息 -->
      <div class="card mb-4">
        <div class="card-header">
          <h5 class="mb-0">基本信息</h5>
        </div>
        <div class="card-body">
          <table class="table table-bordered">
            <tbody>
              <tr>
                <th style="width: 30%">实例ID</th>
                <td>{{ instance.instanceId }}</td>
              </tr>
              <tr>
                <th>实例名称</th>
                <td>{{ instance.name }}</td>
              </tr>
              <tr>
                <th>PLC型号</th>
                <td>{{ getPlcTypeText(instance.plcType) }}</td>
              </tr>
              <tr>
                <th>监听端口</th>
                <td>{{ instance.port }}</td>
              </tr>
              <tr v-if="instance.startTime">
                <th>启动时间</th>
                <td>{{ formatDate(instance.startTime) }}</td>
              </tr>
              <tr v-if="instance.lastActivityTime">
                <th>最后活动时间</th>
                <td>{{ formatDate(instance.lastActivityTime) }}</td>
              </tr>
              <tr v-if="instance.errorMessage">
                <th>错误信息</th>
                <td class="text-danger">{{ instance.errorMessage }}</td>
              </tr>
            </tbody>
          </table>
        </div>
      </div>
      <el-card class="mt-4" shadow="never">
        <template #header>
          <span class="card-header-title">基本信息</span>
        </template>
        <el-descriptions :column="2" 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="错误信息" :span="2">
            <el-text type="danger">{{ instance.errorMessage }}</el-text>
          </el-descriptions-item>
        </el-descriptions>
      </el-card>
      <!-- 操作按钮 -->
      <div class="card">
        <div class="card-body">
          <div class="d-flex gap-2">
            <button
              v-if="instance.status === 'Stopped' || instance.status === 'Error'"
              class="btn btn-success"
              @click="handleStart"
            >
              <i class="bi bi-play-fill me-1"></i>启动
            </button>
            <button
              v-if="instance.status === 'Running'"
              class="btn btn-warning"
              @click="handleStop"
            >
              <i class="bi bi-stop-fill me-1"></i>停止
            </button>
            <router-link :to="`/edit/${instance.instanceId}`" class="btn btn-primary">
              <i class="bi bi-pencil-fill me-1"></i>编辑
            </router-link>
            <router-link to="/" class="btn btn-outline-secondary">
              <i class="bi bi-arrow-left me-1"></i>返回列表
            </router-link>
          </div>
      <el-card class="mt-4" 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>
      </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 * 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)
})
@@ -183,58 +524,486 @@
})
async function handleStart() {
  if (confirm(`确定要启动实例 "${id}" 吗?`)) {
    try {
      await api.startInstance(id)
      await loadInstance()
    } catch (err) {
  try {
    await ElMessageBox.confirm(`确定要启动实例 "${id}" 吗?`, '确认', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'info'
    })
    await api.startInstance(id)
    await loadInstance()
    await loadMemoryData(true)
    ElMessage.success('启动命令已发送')
  } catch (err) {
    if (err !== 'cancel') {
      console.error('启动实例失败:', err)
      alert('启动失败,请查看控制台')
      ElMessage.error('启动失败,请查看控制台')
    }
  }
}
async function handleStop() {
  if (confirm(`确定要停止实例 "${id}" 吗?`)) {
    try {
      await api.stopInstance(id)
      await loadInstance()
    } catch (err) {
  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)
      alert('停止失败,请查看控制台')
      ElMessage.error('停止失败,请查看控制台')
    }
  }
}
function getStatusClass(status: InstanceStatus): string {
  const map: Record<InstanceStatus, string> = {
    'Stopped': 'text-secondary',
    'Starting': 'text-info',
    'Running': 'text-success',
    'Stopping': 'text-warning',
    'Error': 'text-danger'
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 map[status] || ''
  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'
  }
  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
}
@@ -254,13 +1023,128 @@
</script>
<style scoped>
.card-subtitle {
  font-size: 0.875rem;
  font-weight: 600;
.page-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  margin-bottom: 20px;
  flex-wrap: wrap;
  gap: 16px;
}
table th {
  background-color: #f8f9fa;
.header-left h2 {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0 0 8px 0;
}
.text-muted {
  color: #909399;
  margin: 0;
}
.loading-container {
  text-align: center;
  padding: 60px 0;
  color: #909399;
}
.loading-icon {
  animation: spin 1s linear infinite;
}
@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
.status-cards {
  margin-bottom: 20px;
}
.status-card {
  text-align: center;
}
.card-header-title {
  font-weight: 600;
  font-size: 16px;
}
.mt-4 {
  margin-top: 16px;
}
.action-buttons {
  display: flex;
  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>