864ee2a6c3d86c6bb3af7eaf31b2390d2970c56e..2812ee9935895524e37415a056baa65b845191ff
6 天以前 yeg
实现 Ticket04:采集器登记改为手工填写并链接来源,拆除模拟器残留控制入口
2812ee 对比 | 目录
6 天以前 yeg
实时数据列表新增来源定位列
372137 对比 | 目录
1个文件已添加
7个文件已修改
308 ■■■■■ 已修改文件
AGENTS.md 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/deviceAdapters.ts 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/deviceCollectors.ts 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/tests.ts 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ConsoleDataListPanel.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/stores/deviceCollectors.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/DeviceCollectorForm.vue 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/TestConsole.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
AGENTS.md
@@ -35,5 +35,5 @@
- 修改 API 类型、状态映射或实时流时,检查加载、空数据、断线、重连、无权限和后端异常状态。
- 完成前运行 `npm run build`。若新增测试脚本,再同时运行受影响测试。
- 设备登记浏览器主接缝运行 `npm run test:e2e:ticket04`;该命令必须使用隔离端口和临时 MySQL/Redis,结束后不得遗留进程或容器。
- 设备登记浏览器主接缝运行 `npm run test:e2e:ticket04`;该命令必须使用隔离端口和临时 MySQL/Redis,结束后不得遗留进程或容器。**注意:该编排原本还会启动开发模拟器(后端 `dev-simulator` profile 与 `/dev/bts-simulator` 页面),模拟器已按 Ticket04(2026-10-08)整体拆除**,`tests/e2e/` 下依赖模拟器页面与端点的用例(含设备登记与采集器登记的旧流程)在按真实来源重写之前跑不通,见 `docs/KNOWN-GAPS.md`。
- 需要浏览器交互验证时启动 `npm run dev`,检查控制台和网络请求;完成后说明仍依赖模拟数据的页面。
src/api/deviceAdapters.ts
New file
@@ -0,0 +1,34 @@
import { request, type ApiResponse } from './request'
interface DeviceAdapterDto {
  adapterId: number
  adapterCode: string
  adapterName: string | null
  adapterType: string | null
  host: string | null
  connectionStatus: string | null
}
interface DeviceAdapterListResponse extends ApiResponse {
  rows: DeviceAdapterDto[]
}
export interface DeviceAdapterOption {
  id: number
  code: string
  name: string
  type: string
  connectionStatus: string
}
/** 供采集器登记选择「数据来源」:采集器的辅助测量由所链接来源实例提供(ADR-0064)。 */
export async function getDeviceAdapterOptions(): Promise<DeviceAdapterOption[]> {
  const result = await request<DeviceAdapterListResponse>('/system/deviceAdapter/list?pageNum=1&pageSize=200')
  return (result.rows || []).map(item => ({
    id: item.adapterId,
    code: item.adapterCode,
    name: item.adapterName || item.adapterCode,
    type: item.adapterType || '-',
    connectionStatus: item.connectionStatus || '',
  }))
}
src/api/deviceCollectors.ts
@@ -1,7 +1,6 @@
import type {
  CollectorOnlineStatus,
  CollectorStatus,
  CollectorPayload,
  DeviceCollectorRecord,
} from '@/stores/deviceCollectors'
import { request, type ApiResponse } from './request'
@@ -20,6 +19,7 @@
interface CollectorDto {
  collectorId: number
  adapterId: number | null
  collectorCode: string | null
  collectorName: string | null
  ipAddress: string | null
@@ -43,23 +43,8 @@
  data: CollectorDto
}
interface DiscoveredCollectorDto {
  deviceCode: string
  ip: string
  mac: string
  name: string
  manufacturer: string
  model: string
  channelCount: number
  onlineStatus: CollectorOnlineStatus
}
interface DiscoveredCollectorListResponse extends ApiResponse {
  total: number
  rows: DiscoveredCollectorDto[]
}
export interface DeviceCollectorInput {
  adapterId: number | null
  deviceCode: string
  ip: string
  mac: string
@@ -73,6 +58,7 @@
interface CollectorSaveDto {
  collectorId?: number
  adapterId: number | null
  collectorCode: string
  ipAddress: string
  macAddress: string
@@ -89,17 +75,13 @@
  rows: DeviceCollectorRecord[]
}
export interface DiscoveredCollectorListResult {
  total: number
  rows: CollectorPayload[]
}
const onlineStatusMap: Record<string, CollectorOnlineStatus> = { '0': '离线', '1': '在线' }
const statusMap: Record<string, CollectorStatus> = { '0': '启用', '1': '禁用' }
function mapCollector(item: CollectorDto): DeviceCollectorRecord {
  return {
    id: item.collectorId,
    adapterId: item.adapterId ?? null,
    deviceCode: item.collectorCode || '-',
    ip: item.ipAddress || '-',
    mac: item.macAddress || '-',
@@ -118,6 +100,7 @@
function toSaveDto(input: DeviceCollectorInput, collectorId?: number): CollectorSaveDto {
  return {
    collectorId,
    adapterId: input.adapterId,
    collectorCode: input.deviceCode.trim(),
    ipAddress: input.ip.trim(),
    macAddress: input.mac.trim(),
@@ -146,28 +129,6 @@
export async function getDeviceCollector(collectorId: number): Promise<DeviceCollectorRecord> {
  const result = await request<CollectorDetailResponse>(`/system/collector/${collectorId}`)
  return mapCollector(result.data)
}
export async function getDiscoveredCollectorList(pageNum: number, pageSize: number): Promise<DiscoveredCollectorListResult> {
  const result = await request<DiscoveredCollectorListResponse>(
    `/system/collector/discovered/list?pageNum=${pageNum}&pageSize=${pageSize}`,
  )
  return {
    total: Number(result.total) || 0,
    rows: (result.rows || []).map(item => ({
      deviceCode: item.deviceCode,
      ip: item.ip,
      mac: item.mac,
      name: item.name || '',
      manufacturer: item.manufacturer || '',
      model: item.model || '',
      channelCount: item.channelCount || 1,
      onlineStatus: item.onlineStatus || '离线',
      relatedTests: [],
      status: '启用',
      remark: '',
    })),
  }
}
export function createDeviceCollector(input: DeviceCollectorInput): Promise<ApiResponse> {
src/api/tests.ts
@@ -270,70 +270,6 @@
  return (await request<TestLiveStateResponse>(`/system/test/${testId}/live-state`)).data
}
export interface BtsRecord {
  sequenceId: number
  deviceTime: string
  currentA: number
  voltageV: number
  capacityAh: number
  energyWh: number
  cycleNo: number
  stepNo: number
  stepName: string
  stepType: string
}
export interface BtsStepRecord {
  cycleNo: number
  stepNo: number
  cyclePosition: number
  stepName: string
  stepType: string
  recordCount: number
  startedAt: string
  endedAt: string
  capacityAh: number
  energyWh: number
  durationSeconds: number
  startVoltageV: number
  endVoltageV: number
  chargePeakVoltageV: number
  dischargeValleyVoltageV: number
}
export interface BtsCycleRecord {
  recordNo: string
  cycleNo: number
  recordCount: number
  startedAt: string
  endedAt: string
  chargeCapacityAh: number
  dischargeCapacityAh: number
  chargeEnergyWh: number
  dischargeEnergyWh: number
  efficiency: number
}
async function getSimulatorChannelData<T>(deviceId: number, channelId: number, kind: 'records' | 'steps' | 'cycles'): Promise<T[]> {
  const token = getAuthToken()
  const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || '/dev-api'}/dev/bts-simulator/devices/${deviceId}/channels/${channelId}/${kind}`, { headers: token ? { Authorization: `Bearer ${token}` } : undefined })
  if (!response.ok) throw new Error(`检测柜${kind}数据加载失败(${response.status})`)
  const result = await response.json() as T[]
  return Array.isArray(result) ? result : []
}
export function getBtsRecords(deviceId: number, channelId: number): Promise<BtsRecord[]> {
  return getSimulatorChannelData<BtsRecord>(deviceId, channelId, 'records')
}
export function getBtsSteps(deviceId: number, channelId: number): Promise<BtsStepRecord[]> {
  return getSimulatorChannelData<BtsStepRecord>(deviceId, channelId, 'steps')
}
export function getBtsCycles(deviceId: number, channelId: number): Promise<BtsCycleRecord[]> {
  return getSimulatorChannelData<BtsCycleRecord>(deviceId, channelId, 'cycles')
}
export async function saveTestConsoleDetail(testId: number, detail: TestConsoleDetail): Promise<TestConfigurationDetail> {
  return (await request<TestDetailResponse>(`/system/test/${testId}/console-detail`, { method: 'PUT', body: JSON.stringify(detail) })).data
}
src/components/ConsoleDataListPanel.vue
@@ -54,6 +54,7 @@
  negativePotential: string
  extensionMeasurements: string
  sourceIdentity: string
  sourceRecordId: string
  collectorDeviceTime: string
  chargeCapacity: string
  dischargeCapacity: string
@@ -123,7 +124,7 @@
        ],
)
function emptyRow(base: Partial<DataRow>): DataRow {
  return { sourceIdentity: '-', collectorDeviceTime: '-', id: '', sourceType: 'CABINET', metricCode: '-', metricValue: '-', unit: '-', collectorCode: '-', channelCode: '-', recordNo: '', batteryNo: '', cycleNo: 0, stepNo: 0, stepName: '', actualStepLabel: '-', sequenceId: 0, relativeTimeSeconds: '-', recordedAt: '', absoluteTime: '', serverReceivedAt: '', stepDuration: '-', startVoltage: '-', endVoltage: '-', chargeMedianVoltage: '-', dischargeMedianVoltage: '-', voltage: '-', current: '-', capacity: '-', energy: '-', temperature: '-', positivePotential: '-', negativePotential: '-', extensionMeasurements: '-', chargeCapacity: '-', dischargeCapacity: '-', efficiency: '-', chargeEnergy: '-', dischargeEnergy: '-', sourceKind: '-', ...base }
  return { sourceIdentity: '-', sourceRecordId: '-', collectorDeviceTime: '-', id: '', sourceType: 'CABINET', metricCode: '-', metricValue: '-', unit: '-', collectorCode: '-', channelCode: '-', recordNo: '', batteryNo: '', cycleNo: 0, stepNo: 0, stepName: '', actualStepLabel: '-', sequenceId: 0, relativeTimeSeconds: '-', recordedAt: '', absoluteTime: '', serverReceivedAt: '', stepDuration: '-', startVoltage: '-', endVoltage: '-', chargeMedianVoltage: '-', dischargeMedianVoltage: '-', voltage: '-', current: '-', capacity: '-', energy: '-', temperature: '-', positivePotential: '-', negativePotential: '-', extensionMeasurements: '-', chargeCapacity: '-', dischargeCapacity: '-', efficiency: '-', chargeEnergy: '-', dischargeEnergy: '-', sourceKind: '-', ...base }
}
function mapRecord(record: PersistedTestRecord): DataRow {
  const deviceTime = formatTime(record.deviceTime ?? record.recordedAt)
@@ -140,7 +141,7 @@
      extensionMeasurements = Object.entries(parsed as Record<string, { value?: unknown; metricValue?: unknown; unit?: string; channelCode?: string }>).map(([code, value]) => `${code}${value?.channelCode ? ` (${value.channelCode})` : ''}=${value?.value ?? value?.metricValue ?? '-'}${value?.unit ? ` ${value.unit}` : ''}`).join('; ') || '-'
    } catch { extensionMeasurements = String(record.extensionMeasurementsJson) }
  }
  return emptyRow({ sourceIdentity: record.sourceIdentity || '-', collectorDeviceTime: formatTime(record.collectorDeviceTime), id: identity, sourceType: record.sourceType || 'CABINET', metricCode: record.metricCode || '-', metricValue: extensionValue, unit: record.unit || '-', collectorCode: record.collectorCode || String(record.collectorId ?? '-'), channelCode: record.channelCode || '-', recordNo: identity, batteryNo: record.batteryNo || '-', cycleNo: Number(record.cycleNo || 0), stepNo: Number(record.stepNo || 0), stepName: collector ? '-' : (record.stepName || '-'), sequenceId: Number(record.sequenceId || record.cabinetSequenceId || 0), recordedAt: deviceTime, relativeTimeSeconds: formatTestDisplayValue('relativeTimeSeconds', record.relativeTimeSeconds), absoluteTime: deviceTime, serverReceivedAt: formatTime(record.serverReceivedAt), voltage: collector ? '-' : (record.voltage == null ? '-' : Number(record.voltage).toFixed(4)), current: collector ? '-' : formatTestDisplayValue('current', record.current), capacity: collector ? '-' : formatTestDisplayValue('capacity', record.capacity), energy: collector ? '-' : formatTestDisplayValue('energy', record.energy), temperature: snapshot ? (record.temperature == null ? '-' : Number(record.temperature).toFixed(4)) : extension('TEMPERATURE'), positivePotential: snapshot ? (record.positivePotential == null ? '-' : Number(record.positivePotential).toFixed(4)) : extension('POSITIVE_POTENTIAL'), negativePotential: snapshot ? (record.negativePotential == null ? '-' : Number(record.negativePotential).toFixed(4)) : extension('NEGATIVE_POTENTIAL'), extensionMeasurements })
  return emptyRow({ sourceIdentity: record.sourceIdentity || '-', sourceRecordId: record.sourceRecordId || '-', collectorDeviceTime: formatTime(record.collectorDeviceTime), id: identity, sourceType: record.sourceType || 'CABINET', metricCode: record.metricCode || '-', metricValue: extensionValue, unit: record.unit || '-', collectorCode: record.collectorCode || String(record.collectorId ?? '-'), channelCode: record.channelCode || '-', recordNo: identity, batteryNo: record.batteryNo || '-', cycleNo: Number(record.cycleNo || 0), stepNo: Number(record.stepNo || 0), stepName: collector ? '-' : (record.stepName || '-'), sequenceId: Number(record.sequenceId || record.cabinetSequenceId || 0), recordedAt: deviceTime, relativeTimeSeconds: formatTestDisplayValue('relativeTimeSeconds', record.relativeTimeSeconds), absoluteTime: deviceTime, serverReceivedAt: formatTime(record.serverReceivedAt), voltage: collector ? '-' : (record.voltage == null ? '-' : Number(record.voltage).toFixed(4)), current: collector ? '-' : formatTestDisplayValue('current', record.current), capacity: collector ? '-' : formatTestDisplayValue('capacity', record.capacity), energy: collector ? '-' : formatTestDisplayValue('energy', record.energy), temperature: snapshot ? (record.temperature == null ? '-' : Number(record.temperature).toFixed(4)) : extension('TEMPERATURE'), positivePotential: snapshot ? (record.positivePotential == null ? '-' : Number(record.positivePotential).toFixed(4)) : extension('POSITIVE_POTENTIAL'), negativePotential: snapshot ? (record.negativePotential == null ? '-' : Number(record.negativePotential).toFixed(4)) : extension('NEGATIVE_POTENTIAL'), extensionMeasurements })
}
function mapStep(record: PersistedTestStep): DataRow {
  const startedAt = formatTime(record.recordedAt)
@@ -358,8 +359,10 @@
    return
  }
  const columns = consoleImportColumns[dataType.value]
  // 导出与界面一致地带上来源列:循环的推导值不能在被导出后失去"聚合来源"这个事实。
  const extraColumn = activeTab.value === 'cycle' ? { field: 'sourceKind' as const, label: '数据来源' } : null
  // 导出与界面一致地带上来源列:循环的推导值不能在被导出后失去"聚合来源"这个事实;
  // 实时的"来源定位"是设备侧自己的对照串(如厂商库名与记录号),导出后仍能拿它跟厂商软件核对。
  const extraColumn = activeTab.value === 'cycle' ? { field: 'sourceKind' as const, label: '数据来源' }
    : activeTab.value === 'realtime' ? { field: 'sourceRecordId' as const, label: '来源定位' } : null
  const headers = [...Object.values(columns), ...(extraColumn ? [extraColumn.label] : [])]
  const records = filteredRows.value.map(row => [...Object.keys(columns).map(field => row[field as keyof DataRow]), ...(extraColumn ? [row[extraColumn.field]] : [])])
  const blob = new Blob([`\uFEFF${[headers, ...records].map(item => item.map(escapeCsv).join(',')).join('\n')}`], { type: 'text/csv;charset=utf-8' })
@@ -446,6 +449,7 @@
            </th>
            <th v-for="(label, field) in consoleImportColumns[dataType]" :key="field">{{ label }}</th>
            <th v-if="activeTab === 'cycle'" :title="cycleSourceTitle">数据来源</th>
            <th v-if="activeTab === 'realtime'" title="设备侧的对照串(厂商库名与记录号等),用于跟厂商软件核对">来源定位</th>
            <th class="action-column">操作</th>
          </tr>
        </thead>
@@ -484,6 +488,7 @@
              <td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].temperature" size="small" /><span v-else>{{ row.temperature }}</span></td>
              <td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].positivePotential" size="small" /><span v-else>{{ row.positivePotential }}</span></td>
              <td><el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].negativePotential" size="small" /><span v-else>{{ row.negativePotential }}</span></td>
              <td class="source-cell" :title="row.sourceRecordId">{{ row.sourceRecordId }}</td>
              <td class="action-column">
                <div class="row-actions"><button type="button" @click="openRowEditor(row)">编辑</button><button class="delete-action" type="button" @click="deleteSelected([row.id])">删除</button></div>
              </td>
@@ -567,7 +572,7 @@
            </tr></template
          >
          <tr v-if="!loading && !pagedRows.length">
            <td :colspan="activeTab === 'realtime' ? 17 : activeTab === 'step' ? 14 : 11" class="empty-cell">
            <td :colspan="activeTab === 'realtime' ? 18 : activeTab === 'step' ? 14 : 11" class="empty-cell">
              {{ loadError || '暂无检测柜记录' }}
            </td>
          </tr>
@@ -841,6 +846,14 @@
  white-space: normal;
  overflow-wrap: anywhere;
}
/* 来源定位是设备侧的对照串(可能较长),窄列里允许换行显示 */
.source-cell {
  width: 220px;
  font-family: 'Cascadia Mono', Consolas, monospace;
  font-size: 11px;
  white-space: normal;
  overflow-wrap: anywhere;
}
.console-data-panel.realtime table {
  min-width: 2100px;
}
src/stores/deviceCollectors.ts
@@ -5,6 +5,7 @@
export interface DeviceCollectorRecord {
  id: number
  adapterId: number | null
  deviceCode: string
  ip: string
  mac: string
@@ -19,4 +20,4 @@
  createdAt: string
}
export type CollectorPayload = Pick<DeviceCollectorRecord, 'deviceCode' | 'ip' | 'mac' | 'name' | 'manufacturer' | 'model' | 'channelCount' | 'remark' | 'onlineStatus' | 'relatedTests' | 'status'>
export type CollectorPayload = Pick<DeviceCollectorRecord, 'adapterId' | 'deviceCode' | 'ip' | 'mac' | 'name' | 'manufacturer' | 'model' | 'channelCount' | 'remark' | 'onlineStatus' | 'relatedTests' | 'status'>
src/views/DeviceCollectorForm.vue
@@ -2,7 +2,8 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { createDeviceCollector, getDeviceCollector, getDiscoveredCollectorList, updateDeviceCollector } from '@/api/deviceCollectors'
import { createDeviceCollector, getDeviceCollector, updateDeviceCollector } from '@/api/deviceCollectors'
import { getDeviceAdapterOptions, type DeviceAdapterOption } from '@/api/deviceAdapters'
import type { CollectorPayload } from '@/stores/deviceCollectors'
import DeviceRelatedTests from '@/components/DeviceRelatedTests.vue'
@@ -10,18 +11,13 @@
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const deviceDialogVisible = ref(false)
const deviceLoading = ref(false)
const devicePage = ref(1)
const devicePageSize = 5
const devices = ref<CollectorPayload[]>([])
const deviceTotal = ref(0)
const adapterOptions = ref<DeviceAdapterOption[]>([])
const channelOptions = Array.from({ length: 99 }, (_, index) => index + 1)
const editingId = computed(() => Number(route.params.id) || null)
const isEditing = computed(() => editingId.value !== null)
const form = reactive<CollectorPayload>({
  deviceCode: '', ip: '', mac: '', name: '', manufacturer: '', model: '', channelCount: 1,
  adapterId: null, deviceCode: '', ip: '', mac: '', name: '', manufacturer: '', model: '', channelCount: 1,
  remark: '', onlineStatus: '离线', relatedTests: [], status: '启用',
})
@@ -38,55 +34,27 @@
  }
}
onMounted(loadEditingCollector)
async function loadDiscoveredCollectors() {
  deviceLoading.value = true
/**
 * 采集器不再有发现候选:真实来源不产出采集器候选,登记由操作员手工填写来源身份。
 * 这里只加载可选的数据来源实例(采集器的辅助测量由它提供);读不到时给出可见错误,
 * 不能把权限拒绝或网络失败显示成"没有来源"。
 */
async function loadAdapterOptions() {
  try {
    const result = await getDiscoveredCollectorList(devicePage.value, devicePageSize)
    devices.value = result.rows
    deviceTotal.value = result.total
    adapterOptions.value = await getDeviceAdapterOptions()
  } catch (error) {
    devices.value = []
    deviceTotal.value = 0
    ElMessage.error(error instanceof Error ? error.message : '可发现采集器加载失败')
  } finally {
    deviceLoading.value = false
    adapterOptions.value = []
    ElMessage.error(error instanceof Error ? error.message : '数据来源列表加载失败')
  }
}
async function openDeviceDialog() {
  devicePage.value = 1
  deviceDialogVisible.value = true
  await loadDiscoveredCollectors()
}
async function handleDevicePageChange(page: number) {
  devicePage.value = page
  await loadDiscoveredCollectors()
}
function selectDevice(device: CollectorPayload) {
  Object.assign(form, {
    deviceCode: device.deviceCode,
    ip: device.ip,
    mac: device.mac,
    name: device.name,
    manufacturer: device.manufacturer,
    model: device.model,
    channelCount: device.channelCount,
    onlineStatus: device.onlineStatus,
    relatedTests: [],
  })
  deviceDialogVisible.value = false
  ElMessage.success(`已选择设备 ${device.deviceCode}`)
}
onMounted(async () => {
  await Promise.all([loadEditingCollector(), loadAdapterOptions()])
})
function validate() {
  if (!form.deviceCode) return '请先选择设备'
  if (!form.deviceCode.trim()) return '请输入设备编码'
  if (!form.name.trim()) return '请输入采集器名称'
  if (!form.manufacturer.trim()) return '请输入制造商'
  if (!form.model.trim()) return '请输入型号'
  if (!Number.isInteger(form.channelCount) || form.channelCount < 1 || form.channelCount > 99) return '请选择 1~99 的通道数'
  return ''
}
@@ -118,40 +86,25 @@
    <section v-loading="loading" class="detail-panel">
      <header class="panel-heading"><div><i></i><strong>采集器详情</strong><span>TEST COLLECTOR DETAILS</span></div><small><b>*</b> 为必填项</small></header>
      <div class="device-select-row"><button class="primary-button" type="button" @click="openDeviceDialog"><el-icon><Connection /></el-icon>选择设备</button><span>显示当前网段全部可发现设备</span></div>
      <div class="device-summary">
        <div><span>设备编码</span><strong>{{ form.deviceCode || '-' }}</strong></div>
        <div><span>IP</span><strong class="mono-value">{{ form.ip || '-' }}</strong></div>
        <div><span>MAC</span><strong class="mono-value">{{ form.mac || '-' }}</strong></div>
        <div><span>在线状态</span><strong :class="form.onlineStatus === '在线' ? 'online-value' : 'offline-value'">{{ form.deviceCode ? form.onlineStatus : '-' }}</strong></div>
        <div><span>关联测试</span><DeviceRelatedTests :tests="form.relatedTests" /></div>
      </div>
      <div class="device-select-row"><span>采集器的测量按「同一来源的辅助测量」表达:选择它所属的数据来源,在线状态由该来源的连接结果决定。设备编码由操作员按现场叫法填写,系统不自动改写。</span></div>
      <div class="detail-form">
        <label><span><b>*</b>设备编码</span><el-input v-model="form.deviceCode" maxlength="64" :disabled="isEditing" placeholder="输入设备编码" /></label>
        <label><span>所属数据来源</span><el-select v-model="form.adapterId" clearable placeholder="选择来源实例"><el-option v-for="item in adapterOptions" :key="item.id" :label="`${item.name}(${item.type})`" :value="item.id" /></el-select></label>
        <label><span>IP</span><el-input v-model="form.ip" maxlength="45" placeholder="可留空" /></label>
        <label><span>MAC</span><el-input v-model="form.mac" maxlength="17" placeholder="可留空" /></label>
        <label><span><b>*</b>采集器名称</span><el-input v-model="form.name" maxlength="30" show-word-limit placeholder="输入采集器名称" /></label>
        <label><span><b>*</b>制造商</span><el-input v-model="form.manufacturer" maxlength="30" placeholder="输入制造商" /></label>
        <label><span><b>*</b>型号</span><el-input v-model="form.model" maxlength="30" placeholder="输入型号" /></label>
        <label><span>制造商</span><el-input v-model="form.manufacturer" maxlength="30" placeholder="输入制造商" /></label>
        <label><span>型号</span><el-input v-model="form.model" maxlength="30" placeholder="输入型号" /></label>
        <label><span>通道数</span><el-select v-model="form.channelCount"><el-option v-for="item in channelOptions" :key="item" :label="`${item} 通道`" :value="item" /></el-select></label>
        <label><span>在线状态</span><el-input :model-value="form.deviceCode ? form.onlineStatus : '-'" disabled /></label>
        <label class="related-field"><span>关联测试</span><DeviceRelatedTests :tests="form.relatedTests" /></label>
        <label><span>状态</span><el-radio-group v-model="form.status"><el-radio-button value="启用">启用</el-radio-button><el-radio-button value="禁用">禁用</el-radio-button></el-radio-group></label>
        <label class="remark-field"><span>备注</span><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="输入内容" /></label>
      </div>
      <footer class="form-footer"><button class="secondary-button" type="button" @click="router.push('/device-collector')">取消</button><button class="primary-button save-button" type="button" :disabled="loading || saving" @click="submit"><el-icon><Check /></el-icon>{{ saving ? '保存中' : '保存' }}</button></footer>
    </section>
    <el-dialog v-model="deviceDialogVisible" class="device-dialog" title="选择设备" width="720px" align-center>
      <div v-loading="deviceLoading" class="device-table-wrap">
        <table class="device-table">
          <thead><tr><th>设备编码</th><th>IP</th><th>MAC</th><th>操作</th></tr></thead>
          <tbody>
            <tr v-for="device in devices" :key="device.deviceCode"><td>{{ device.deviceCode }}</td><td>{{ device.ip }}</td><td>{{ device.mac }}</td><td><button type="button" @click="selectDevice(device)">选择</button></td></tr>
            <tr v-if="!deviceLoading && !devices.length"><td colspan="4" class="empty-cell">暂无可发现采集器</td></tr>
          </tbody>
        </table>
      </div>
      <footer class="device-pagination"><span>共 {{ deviceTotal }} 个设备</span><el-pagination :current-page="devicePage" :page-size="devicePageSize" :total="deviceTotal" layout="prev, pager, next" background @current-change="handleDevicePageChange" /></footer>
    </el-dialog>
  </div>
</template>
@@ -176,13 +129,6 @@
.primary-button:disabled { opacity: .55; cursor: wait; }
.secondary-button { color: #52606d; background: #fff; border-color: var(--line-strong); }
.secondary-button:hover { color: var(--blue); border-color: #9dc8f5; }
.device-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px 30px; padding: 16px 18px 20px; border-bottom: 1px solid var(--line); }
.device-summary > div { min-width: 0; display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 8px; align-items: center; }
.device-summary span { color: #7b8794; font-size: 12px; }
.device-summary strong { min-width: 0; color: #44515d; font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.device-summary .online-value { color: #23865e; }
.device-summary .offline-value { color: #a64a4a; }
.mono-value { font-family: 'Cascadia Mono', Consolas, monospace; }
.detail-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: start; gap: 20px 24px; padding: 22px 18px 28px; }
.detail-form label { min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.detail-form label > span { color: #52606d; font-size: 12px; font-weight: 500; }
@@ -190,30 +136,16 @@
.collector-form-page :deep(.el-input__wrapper), .collector-form-page :deep(.el-select__wrapper), .collector-form-page :deep(.el-textarea__inner) { border-radius: 3px; box-shadow: 0 0 0 1px #d8e1e9 inset; background: #f8fafc; }
.collector-form-page :deep(.el-input__wrapper), .collector-form-page :deep(.el-select__wrapper) { min-height: 34px; }
.remark-field { grid-column: span 2; }
.related-field { grid-column: span 2; }
.form-footer { min-height: 70px; display: flex; align-items: center; gap: 10px; padding: 0 18px; border-top: 1px solid var(--line); }
.save-button { min-width: 82px; }
.collector-form-page :deep(.device-dialog) { max-width: calc(100vw - 28px); border-radius: 4px; }
.collector-form-page :deep(.el-dialog__header) { margin: 0; padding: 17px 20px; border-bottom: 1px solid var(--line); }
.collector-form-page :deep(.el-dialog__body) { padding: 20px; }
.device-table-wrap { overflow-x: auto; }
.device-table { width: 100%; min-width: 560px; table-layout: fixed; border-collapse: collapse; color: #52606d; font-size: 13px; }
.device-table th, .device-table td { height: 42px; padding: 0 12px; border: 1px solid #d8e1e9; text-align: left; }
.device-table th { height: 38px; color: #52606d; background: #eaf4ff; font-size: 12px; font-weight: 600; }
.device-table th:last-child, .device-table td:last-child { width: 88px; text-align: center; }
.device-table td:last-child button { border: 0; color: var(--blue); background: transparent; font-size: 12px; font-weight: 500; cursor: pointer; }
.device-table tbody tr:hover { background: #f7fbff; }
.device-table .empty-cell { height: 96px; color: #7b8794; text-align: center; }
.device-pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-top: 18px; }
.device-pagination > span { color: #7b8794; font-size: 11px; }
.device-pagination :deep(.el-pagination) { --el-pagination-button-bg-color: #edf4fa; --el-pagination-hover-color: var(--blue); }
@media (max-width: 960px) { .device-summary, .detail-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 960px) { .detail-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) {
  .panel-heading { padding: 0 12px; }
  .panel-heading span, .panel-heading small, .device-select-row > span { display: none; }
  .device-summary, .detail-form { grid-template-columns: minmax(0, 1fr); padding-left: 12px; padding-right: 12px; }
  .remark-field { grid-column: auto; }
  .detail-form { grid-template-columns: minmax(0, 1fr); padding-left: 12px; padding-right: 12px; }
  .remark-field, .related-field { grid-column: auto; }
  .form-footer { padding: 0 12px; }
  .form-footer button { flex: 1; }
  .collector-form-page :deep(.el-dialog__body) { padding: 14px 12px; }
}
</style>
src/views/TestConsole.vue
@@ -291,6 +291,9 @@
    lastDataAt: cabinetSource?.latestSnapshot?.lastDataAt,
    collectorObservedAt: collectorSource?.latestSnapshot?.observedAt,
    collectorUnit: collectorSource?.latestSnapshot?.units?.join('/') || '-',
    // 来源不提供辅助测量是正常取值(新威实测无辅助数据;推式来源的列语义确认前同样如此),
    // 界面如实说明,不显示成故障,也不用零值顶替。
    collectorAuxiliaryMissing: collectorSource?.latestSnapshot?.auxiliaryDataAvailable === false,
    collectorStatus: connectionStatusLabel(collectorSource?.deviceStatus),
    deviceStatus: connectionStatusLabel(source?.deviceStatus),
  }
@@ -538,7 +541,7 @@
      </header>
      <div class="cell-grid">
        <article v-for="cell in cells" :key="cell.no" class="cell-card">
          <div class="cell-summary"><strong>{{ cell.no }}</strong><span>{{ profile.batteryGroup }}</span><p>{{ cell.cabinet }} · {{ cell.channel }}</p><small>检测柜来源 · 单位:{{ cell.unit }} · 采样时间:{{ formatDateTime(cell.observedAt) }}</small><small>最近数据时间:{{ formatLastDataAt(cell.lastDataAt) }} · 通道工作状态:{{ cell.workStatus }}</small><small>采集器来源 · 状态:{{ cell.collectorStatus }} · 单位:{{ cell.collectorUnit }} · 采样时间:{{ formatDateTime(cell.collectorObservedAt) }}</small><small v-if="receptionState === '暂停接收'">暂停期间停止请求,当前快照不纳入结果</small><small>工步进行时间:{{ elapsedText }}</small></div>
          <div class="cell-summary"><strong>{{ cell.no }}</strong><span>{{ profile.batteryGroup }}</span><p>{{ cell.cabinet }} · {{ cell.channel }}</p><small>检测柜来源 · 单位:{{ cell.unit }} · 采样时间:{{ formatDateTime(cell.observedAt) }}</small><small>最近数据时间:{{ formatLastDataAt(cell.lastDataAt) }} · 通道工作状态:{{ cell.workStatus }}</small><small v-if="cell.collectorAuxiliaryMissing">采集器来源 · 状态:{{ cell.collectorStatus }} · 该来源无辅助测量数据(正常取值,不代表故障)</small><small v-else>采集器来源 · 状态:{{ cell.collectorStatus }} · 单位:{{ cell.collectorUnit }} · 采样时间:{{ formatDateTime(cell.collectorObservedAt) }}</small><small v-if="receptionState === '暂停接收'">暂停期间停止请求,当前快照不纳入结果</small><small>工步进行时间:{{ elapsedText }}</small></div>
          <dl class="metric-list"><div><dt>电压(V)</dt><dd class="voltage">{{ cell.voltage }}</dd></div><div><dt>温度(℃)</dt><dd class="temperature">{{ cell.temperature }}</dd></div><div><dt>正电位</dt><dd class="positive">{{ cell.positive }}</dd></div><div><dt>负电位</dt><dd class="negative">{{ cell.negative }}</dd></div><div><dt>容量(Ah)</dt><dd class="capacity">{{ cell.capacity }}</dd></div><div><dt>电流(A)</dt><dd class="current">{{ cell.current }}</dd></div></dl>
        </article>
      </div>