| | |
| | | cabinetId: number |
| | | cabinetCode: string | null |
| | | cabinetName: string | null |
| | | adapterCode: string | null |
| | | adapterName: string | null |
| | | ipAddress: string | null |
| | | macAddress: string | null |
| | | manufacturer: string | null |
| | |
| | | model: string | null |
| | | channelCount: number |
| | | onlineStatus: CabinetOnlineStatus |
| | | /** 来源里最近一次采样行的设备时间;没有采样行或来源不提供时为空。 */ |
| | | /** 最近数据时间:后端 Instant 属性输出 ISO 文本,手写快照输出毫秒数;没有采样行时为空。 */ |
| | | lastDataAt: string | number | null |
| | | } |
| | | |
| | | export interface DiscoveredCabinetListResult { |
| | |
| | | ip: item.ipAddress || '-', |
| | | mac: item.macAddress || '-', |
| | | name: item.cabinetName || '-', |
| | | // 数据来源只用于区分跨机器的同名柜号;代码装配实例没有数据库记录时退回展示编码。 |
| | | dataSource: item.adapterName || item.adapterCode || '-', |
| | | manufacturer: item.manufacturer || '-', |
| | | model: item.model || '-', |
| | | channelCount: item.channelCount ?? 0, |
| | |
| | | unit?: string |
| | | observedAt?: number |
| | | sampleIntervalMs?: number |
| | | /** 通道工作状态;读库来源下为 UNOBSERVABLE,界面按“无法观测”展示。 */ |
| | | workStatus?: string |
| | | /** 来源里最近一次采样行的设备时间(毫秒);没有采样行时为空。 */ |
| | | lastDataAt?: number | null |
| | | measurements?: Array<Record<string, unknown>> |
| | | units?: string[] |
| | | [key: string]: unknown |
| | |
| | | efficiency: number | string | null |
| | | chargeEnergy: number | string | null |
| | | dischargeEnergy: number | string | null |
| | | /** 来源类别:DEVICE 设备提供、AGGREGATED 本系统按工步层聚合、IMPORTED 授权文件导入。 */ |
| | | sourceKind?: string | null |
| | | } |
| | | |
| | | // 三子页和图表查询持久化当前有效数据,不回放实时监控或触发预警判定。 |
| | |
| | | efficiency: string |
| | | chargeEnergy: string |
| | | dischargeEnergy: string |
| | | sourceKind: string |
| | | } |
| | | const props = defineProps<{ testId: number; testName: string; cells: ConsoleCell[]; canImport?: boolean; receptionStatus?: string; bindings?: Array<{ batteryNo: string; bindingId?: number | null; channelId?: number | null; devid?: number | null; chlid?: number | null }>}>() |
| | | const route = useRoute() |
| | |
| | | { key: 'step', label: '工步' }, |
| | | { key: 'cycle', label: '循环' }, |
| | | ] as const |
| | | // 循环统计的来源类别:设备与文件给的值原样展示,推导值必须说明它是聚合出来的。 |
| | | const cycleSourceLabels: Record<string, string> = { DEVICE: '设备提供', AGGREGATED: '工步层聚合', IMPORTED: '文件导入' } |
| | | const CYCLE_SOURCE_UNKNOWN = '未标注' |
| | | const cycleSourceTitle = '「工步层聚合」表示设备没有提供循环汇总行,该循环的容量、能量与效率由本系统按工步层同方向工步求和得出;推导结果可能带有设备工步行里的偏差。' |
| | | const hasAggregatedCycles = computed(() => cycleRows.value.some(row => row.sourceKind === cycleSourceLabels.AGGREGATED)) |
| | | const filters = reactive<FilterState>({ batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' }) |
| | | const appliedFilters = ref<FilterState>({ batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' }) |
| | | const currentPage = ref(1) |
| | |
| | | ], |
| | | ) |
| | | 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: '-', ...base } |
| | | 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 } |
| | | } |
| | | function mapRecord(record: PersistedTestRecord): DataRow { |
| | | const deviceTime = formatTime(record.deviceTime ?? record.recordedAt) |
| | |
| | | function mapCycle(record: PersistedTestCycle): DataRow { |
| | | const startedAt = formatTime(record.recordedAt) |
| | | const identity = record.stableIdentity || `cycle:${[record.adapterId, record.devtype, record.deviceId, record.subdevid, record.channelId, record.cycleNo].join(':')}` |
| | | return emptyRow({ id: identity, recordNo: identity, batteryNo: record.batteryNo, cycleNo: Number(record.cycleNo), sequenceId: Number(record.sequenceId || 0), recordedAt: startedAt, absoluteTime: formatTime(record.absoluteTime || record.recordedAt), serverReceivedAt: formatTime(record.serverReceivedAt), chargeCapacity: formatTestDisplayValue('chargeCapacity', record.chargeCapacity), dischargeCapacity: formatTestDisplayValue('dischargeCapacity', record.dischargeCapacity), efficiency: record.efficiency == null ? '-' : Number(record.efficiency).toFixed(2), chargeEnergy: formatTestDisplayValue('chargeEnergy', record.chargeEnergy), dischargeEnergy: formatTestDisplayValue('dischargeEnergy', record.dischargeEnergy) }) |
| | | return emptyRow({ id: identity, recordNo: identity, batteryNo: record.batteryNo, cycleNo: Number(record.cycleNo), sequenceId: Number(record.sequenceId || 0), recordedAt: startedAt, absoluteTime: formatTime(record.absoluteTime || record.recordedAt), serverReceivedAt: formatTime(record.serverReceivedAt), chargeCapacity: formatTestDisplayValue('chargeCapacity', record.chargeCapacity), dischargeCapacity: formatTestDisplayValue('dischargeCapacity', record.dischargeCapacity), efficiency: record.efficiency == null ? '-' : Number(record.efficiency).toFixed(2), chargeEnergy: formatTestDisplayValue('chargeEnergy', record.chargeEnergy), dischargeEnergy: formatTestDisplayValue('dischargeEnergy', record.dischargeEnergy), sourceKind: cycleSourceLabels[String(record.sourceKind || 'DEVICE')] || CYCLE_SOURCE_UNKNOWN }) |
| | | } |
| | | async function loadRecords() { |
| | | loading.value = true |
| | |
| | | ? [{ label: '扩展值', value: 'metricValue' as keyof DataRow }] |
| | | : fieldOptions.value) |
| | | const batteryOptions = computed(() => [...new Set(sourceRows.value.map(row => row.batteryNo).filter(Boolean))]) |
| | | // 最近数据时间取本测试已接收实时记录里最新的设备绝对时间,与展示刷新节奏无关。 |
| | | const latestRealtimeDataAt = computed(() => { |
| | | const times = rows.value.map(row => row.absoluteTime).filter(value => value && value !== '-') |
| | | return times.length ? times.reduce((latest, current) => (current > latest ? current : latest)) : '-' |
| | | }) |
| | | const cycleOptions = computed(() => [...new Set(sourceRows.value.map(row => row.cycleNo))].sort((a, b) => a - b)) |
| | | const stepOptions = computed(() => [...new Map(sourceRows.value.map(row => [row.stepNo, row.stepName])).entries()].filter(item => item[0] > 0).sort((a, b) => a[0] - b[0])) |
| | | const filteredRows = computed(() => |
| | |
| | | return |
| | | } |
| | | const columns = consoleImportColumns[dataType.value] |
| | | const headers = Object.values(columns) |
| | | const records = filteredRows.value.map(row => Object.keys(columns).map(field => row[field as keyof DataRow])) |
| | | // 导出与界面一致地带上来源列:循环的推导值不能在被导出后失去"聚合来源"这个事实。 |
| | | const extraColumn = activeTab.value === 'cycle' ? { field: 'sourceKind' 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' }) |
| | | const url = URL.createObjectURL(blob) |
| | | const anchor = document.createElement('a') |
| | |
| | | </nav> |
| | | <small>{{ loading ? '加载中...' : `共 ${filteredRows.length} 条` }}</small> |
| | | </header> |
| | | <p v-if="activeTab === 'realtime'" class="realtime-hint">最近数据时间:{{ latestRealtimeDataAt }} · 设备约 60 秒产生一行新数据,展示刷新比它快是正常的</p> |
| | | <p v-if="activeTab === 'cycle' && hasAggregatedCycles" class="realtime-hint">来源标注「工步层聚合」表示设备没有提供循环汇总行,该循环由本系统按工步层同方向工步求和得出,不是设备事实;界面与导出都带这一列。</p> |
| | | <div class="filter-grid"> |
| | | <label |
| | | ><span>电池</span><el-select v-model="filters.batteryNo" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="item in batteryOptions" :key="item" :label="item" :value="item" /></el-select></label |
| | |
| | | <el-checkbox v-if="activeTab === 'realtime'" :model-value="allPageSelected" :indeterminate="somePageSelected" aria-label="选择本页" @change="(checked: string | number | boolean) => togglePageSelection(Boolean(checked))" /> |
| | | </th> |
| | | <th v-for="(label, field) in consoleImportColumns[dataType]" :key="field">{{ label }}</th> |
| | | <th v-if="activeTab === 'cycle'" :title="cycleSourceTitle">数据来源</th> |
| | | <th class="action-column">操作</th> |
| | | </tr> |
| | | </thead> |
| | |
| | | <el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].dischargeEnergy" size="small" /> |
| | | <span v-else>{{ row.dischargeEnergy }}</span> |
| | | </td> |
| | | <td :title="cycleSourceTitle">{{ row.sourceKind }}</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> |
| | | </tr></template |
| | | > |
| | | <tr v-if="!loading && !pagedRows.length"> |
| | | <td :colspan="activeTab === 'realtime' ? 17 : activeTab === 'step' ? 14 : 10" class="empty-cell"> |
| | | <td :colspan="activeTab === 'realtime' ? 17 : activeTab === 'step' ? 14 : 11" class="empty-cell"> |
| | | {{ loadError || '暂无检测柜记录' }} |
| | | </td> |
| | | </tr> |
| | |
| | | color: var(--ink); |
| | | font-size: 14px; |
| | | } |
| | | .realtime-hint { |
| | | margin: 0; |
| | | padding: 9px 16px; |
| | | border-bottom: 1px solid var(--line); |
| | | color: #71808d; |
| | | background: #f8fbfe; |
| | | font-size: 11px; |
| | | } |
| | | .section-heading span { |
| | | color: #81909d; |
| | | font-size: 10px; |
| | |
| | | return capacity === null ? null : toTestDisplayValue('dischargeCapacity', capacity) / capacityFactor.value |
| | | } |
| | | const hidden = ref<string[]>([]) |
| | | // 循环指标可能来自工步层聚合:曲线出自推导值时,图上看得出它是怎么来的。 |
| | | const aggregatedCycles = computed(() => props.cycles.some(row => String(row.sourceKind || '') === 'AGGREGATED')) |
| | | const projectSeries = computed<ProjectSeries[]>(() => props.cells.map(cell => ({ key: cell.no, name: cell.no, points: cycleLabels.value.map(label => ({ position: Number(label), values: { finalVoltage1: lastStepVoltage(cell.no, Number(label)), dischargeCapacityF: dischargeCapacityPerFactor(cell.no, Number(label)) } })) }))) |
| | | </script> |
| | | <template> |
| | | <ProjectCycleCharts :series="projectSeries" :positions="cycleLabels.map(Number)" :colors="seriesColors" :hidden="hidden" :factor-warning="capacityFactorUnavailable" @hidden-change="hidden = $event" /> |
| | | <ProjectCycleCharts :series="projectSeries" :positions="cycleLabels.map(Number)" :colors="seriesColors" :hidden="hidden" :factor-warning="capacityFactorUnavailable" :aggregated-warning="aggregatedCycles" @hidden-change="hidden = $event" /> |
| | | </template> |
| | |
| | | import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components' |
| | | import VChart from 'vue-echarts' |
| | | import { projectCycleChartOption, projectMetrics, type ProjectSeries } from '@/utils/projectCycleChart' |
| | | defineProps<{ series: ProjectSeries[]; positions: number[]; colors: string[]; hidden?: string[]; spliced?: boolean; factorWarning?: boolean }>() |
| | | defineProps<{ series: ProjectSeries[]; positions: number[]; colors: string[]; hidden?: string[]; spliced?: boolean; factorWarning?: boolean; aggregatedWarning?: boolean }>() |
| | | const emit = defineEmits<{ hiddenChange: [keys: string[]] }>() |
| | | use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]) |
| | | </script> |
| | | <template> |
| | | <section class="project-chart-panel"> |
| | | <p v-if="factorWarning" class="factor-warning" role="status">电池组 F 不可用,未计算对应来源的每循环放电末容量 / F。</p> |
| | | <p v-if="aggregatedWarning" class="factor-warning" role="status">部分循环统计来源为工步层聚合:设备没有提供循环汇总行,容量由本系统按工步层同方向工步求和得出。</p> |
| | | <article v-for="metric in projectMetrics" :key="metric.key" class="project-chart-card"><header><strong>{{ metric.title }}</strong></header><VChart class="project-chart" :option="projectCycleChartOption(metric, series, positions, colors, hidden, spliced)" :update-options="{ notMerge: true }" autoresize @legendselectchanged="emit('hiddenChange', series.filter(item => !$event.selected[item.name]).map(item => item.key))" /></article> |
| | | </section> |
| | | </template> |
| | |
| | | ip: string |
| | | mac: string |
| | | name: string |
| | | /** 数据来源名称,用于区分跨机器的同名柜号。 */ |
| | | dataSource: string |
| | | manufacturer: string |
| | | model: string |
| | | channelCount: number |
| | |
| | | /** 业务接口只接受毫秒时间戳;缺失或非法时间不伪装为当前时间。 */ |
| | | /** |
| | | * 业务时间解析:只接受毫秒时间戳或 ISO-8601 文本,缺失或非法时间不伪装为当前时间。 |
| | | * |
| | | * <p>同时接受两种形态,是因为后端目前并不统一:手写 Map 的时间字段是毫秒数, |
| | | * 而 Instant 类型的 VO 属性走 ISO 文本(见 KNOWN-GAPS「时间字段两种形态」)。 |
| | | */ |
| | | export function businessTime(value: unknown): number | null { |
| | | return typeof value === 'number' && Number.isFinite(value) && Number.isFinite(new Date(value).getTime()) ? value : null |
| | | if (typeof value === 'number') { |
| | | return Number.isFinite(value) && Number.isFinite(new Date(value).getTime()) ? value : null |
| | | } |
| | | if (typeof value === 'string' && value.trim()) { |
| | | const parsed = new Date(value.trim()).getTime() |
| | | return Number.isFinite(parsed) ? parsed : null |
| | | } |
| | | return null |
| | | } |
| | | |
| | | /** 将业务时间按浏览器本地时区展示,保留毫秒精度。 */ |
| | |
| | | const pad = (part: number, length = 2) => String(part).padStart(length, '0') |
| | | return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}` |
| | | } |
| | | |
| | | /** 只展示到分钟的紧凑时间,用于设备列表与候选列表。 */ |
| | | export function formatBusinessMinute(value: unknown): string { |
| | | const date = businessDate(value) |
| | | if (date === null) return '-' |
| | | return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` |
| | | } |
| | | |
| | | /** 只展示月日与时分秒,用于实时监控卡片。 */ |
| | | export function formatBusinessClock(value: unknown): string { |
| | | const date = businessDate(value) |
| | | if (date === null) return '-' |
| | | return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` |
| | | } |
| | | |
| | | function businessDate(value: unknown): Date | null { |
| | | const time = businessTime(value) |
| | | return time === null ? null : new Date(time) |
| | | } |
| | | |
| | | function pad(part: number): string { |
| | | return String(part).padStart(2, '0') |
| | | } |
| | |
| | | <div class="result-toolbar"><div><i></i><strong>检测柜列表</strong><span>{{ total }} RESULTS · 按创建时间倒序</span></div><button class="primary-button" type="button" @click="createCabinet"><el-icon><Plus /></el-icon>手动添加</button></div> |
| | | <div v-loading="loading" class="table-wrap"> |
| | | <table> |
| | | <thead><tr><th>设备编码</th><th>IP</th><th>MAC</th><th>名称</th><th>制造商</th><th>型号</th><th>通道数</th><th>规格/容量</th><th>备注</th><th>在线状态</th><th>关联测试</th><th class="action-column">操作</th></tr></thead> |
| | | <thead><tr><th>设备编码</th><th>数据来源</th><th>IP</th><th>MAC</th><th>名称</th><th>制造商</th><th>型号</th><th>通道数</th><th>规格/容量</th><th>备注</th><th>在线状态</th><th>关联测试</th><th class="action-column">操作</th></tr></thead> |
| | | <tbody> |
| | | <tr v-for="item in records" :key="item.id"> |
| | | <td class="code-cell">{{ item.deviceCode }}</td><td class="code-cell">{{ item.ip }}</td><td class="code-cell">{{ item.mac }}</td><td><strong>{{ item.name }}</strong></td><td>{{ item.manufacturer }}</td><td>{{ item.model }}</td><td>{{ item.channelCount }}</td><td :title="String(item.specs)">{{ item.specs }}</td><td :title="item.remark">{{ item.remark || '-' }}</td> |
| | | <td class="code-cell">{{ item.deviceCode }}</td><td class="code-cell">{{ item.dataSource }}</td><td class="code-cell">{{ item.ip }}</td><td class="code-cell">{{ item.mac }}</td><td><strong>{{ item.name }}</strong></td><td>{{ item.manufacturer }}</td><td>{{ item.model }}</td><td>{{ item.channelCount }}</td><td :title="String(item.specs)">{{ item.specs }}</td><td :title="item.remark">{{ item.remark || '-' }}</td> |
| | | <td><span class="online-state" :class="item.onlineStatus === '在线' ? 'online' : 'offline'"><i></i>{{ item.onlineStatus }}</span></td> |
| | | <td><DeviceRelatedTests :tests="item.relatedTests" /></td> |
| | | <td class="action-column"><div class="row-actions"><button type="button" :disabled="submittingId !== null" :class="item.status === '启用' ? 'disable-action' : 'enable-action'" @click="changeStatus(item)">{{ submittingId === item.id ? '处理中' : item.status === '启用' ? '禁用' : '启用' }}</button><button type="button" :disabled="submittingId !== null" @click="editCabinet(item.id)">编辑</button><button class="delete-action" type="button" :disabled="submittingId !== null" @click="deleteCabinet(item)">删除</button></div></td> |
| | | </tr> |
| | | <tr v-if="!loading && !records.length"><td colspan="12" class="empty-cell">暂无符合条件的检测柜</td></tr> |
| | | <tr v-if="!loading && !records.length"><td colspan="13" class="empty-cell">暂无符合条件的检测柜</td></tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | |
| | | type DiscoveredCabinet, |
| | | } from '@/api/deviceCabinets' |
| | | import type { CabinetPayload } from '@/stores/deviceCabinets' |
| | | import { formatBusinessMinute } from '@/utils/businessTime' |
| | | import DeviceRelatedTests from '@/components/DeviceRelatedTests.vue' |
| | | |
| | | const route = useRoute() |
| | |
| | | loadDevices() |
| | | } |
| | | |
| | | function selectDevice(device: DiscoveredCabinet) { |
| | | Object.assign(form, { |
| | | /** 最近数据时间按业务时区展示;来源不提供或没有采样行时显示占位。 */ |
| | | function formatLastDataAt(value: unknown) { |
| | | return formatBusinessMinute(value) |
| | | } |
| | | |
| | | function selectDevice(device: DiscoveredCabinet) { Object.assign(form, { |
| | | deviceCode: device.deviceCode, |
| | | ip: device.ip, |
| | | mac: device.mac || '', |
| | |
| | | <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> |
| | | <thead><tr><th>设备编码</th><th>IP</th><th>MAC</th><th>最近数据时间</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="device-empty">暂无可发现设备</td></tr> |
| | | <tr v-for="device in devices" :key="device.deviceCode"><td>{{ device.deviceCode }}</td><td>{{ device.ip }}</td><td>{{ device.mac || '-' }}</td><td>{{ formatLastDataAt(device.lastDataAt) }}</td><td><button type="button" @click="selectDevice(device)">选择</button></td></tr> |
| | | <tr v-if="!deviceLoading && !devices.length"><td colspan="5" class="device-empty">暂无可发现设备</td></tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | <p class="device-hint">最近数据时间是来源里最近一次采样行的设备时间,用来判断哪几台柜子现在真的在产出数据;系统不替你判定“在用”,登记哪些候选由你决定。</p> |
| | | <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> |
| | |
| | | .device-table tbody tr:hover { background: #f7fbff; } |
| | | .device-table .device-empty { height: 72px; color: #8a98a5; text-align: center; } |
| | | .device-pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-top: 18px; } |
| | | .device-hint { margin: 12px 0 0; color: #7b8794; font-size: 11px; line-height: 1.7; } |
| | | .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)); } } |
| | |
| | | import ConsoleDataListPanel from '@/components/ConsoleDataListPanel.vue' |
| | | import { endTestReception, getPersistedTestCycles, getPersistedTestRecords, getPersistedTestSteps, getTestDetail, getTestLiveState, openTestLiveStream, pauseTestReception, resumeTestReception, saveLogicStepMappings, startTestReception, type PersistedTestCycle, type PersistedTestRecord, type PersistedTestStep, type TestConfigurationDetail, type TestLiveState, type TestLogicStepMapping } from '@/api/tests' |
| | | import { getBatteryGroupDetail } from '@/api/batteryGroups' |
| | | import { businessTime, formatBusinessTime as formatDateTime } from '@/utils/businessTime' |
| | | import { businessTime, formatBusinessClock, formatBusinessTime as formatDateTime } from '@/utils/businessTime' |
| | | |
| | | type ConsoleState = '待测试' | '检测中' | '暂停' | '检测完成' |
| | | type ReceptionState = '未开始接收' | '接收中' | '暂停接收' | '已结束接收' |
| | |
| | | // 控制台的柜状态和采集器状态只表达连接事实,设备工作状态由设备事实另行保留。 |
| | | function connectionStatusLabel(status?: string) { |
| | | return status === 'ONLINE' ? '在线' : status === 'OFFLINE' ? '离线' : '-' |
| | | } |
| | | |
| | | // 通道工作状态只使用词表里的取值:运行、暂停、结束、异常、无法观测。 |
| | | // 读库路线下该事实不可观测,不可观测是正常取值,界面不把它当异常。 |
| | | function workStatusLabel(status?: string) { |
| | | return { WORKING: '运行', PAUSED: '暂停', FINISHED: '结束', STOPPED: '结束', PROTECTED: '异常' }[status || ''] || '无法观测' |
| | | } |
| | | |
| | | /** 最近数据时间按业务时区展示;来源不提供或没有采样行时显示占位。 */ |
| | | function formatLastDataAt(value: unknown) { |
| | | return formatBusinessClock(value) |
| | | } |
| | | |
| | | function exceptionTitle(item: Record<string, unknown>) { |
| | |
| | | source: source?.latestSnapshot?.source || (source?.sourceType === 'COLLECTOR' ? '采集器' : '检测柜'), |
| | | unit: source?.latestSnapshot?.units?.join('/') || '-', |
| | | observedAt: source?.latestSnapshot?.observedAt, |
| | | workStatus: workStatusLabel(cabinetSource?.latestSnapshot?.workStatus as string | undefined), |
| | | lastDataAt: cabinetSource?.latestSnapshot?.lastDataAt, |
| | | collectorObservedAt: collectorSource?.latestSnapshot?.observedAt, |
| | | collectorUnit: collectorSource?.latestSnapshot?.units?.join('/') || '-', |
| | | collectorStatus: connectionStatusLabel(collectorSource?.deviceStatus), |
| | |
| | | if (measurement?.value !== undefined && measurement?.value !== null) measurements[measurementCode] = String(measurement.value) |
| | | } |
| | | } |
| | | return { no: cell.no, cabinet: cell.cabinet, cabinetState: connectionStatusLabel(cabinet?.deviceStatus), channel: cell.channel, |
| | | return { no: cell.no, cabinet: cell.cabinet, cabinetState: connectionStatusLabel(cabinet?.deviceStatus), |
| | | workStatus: cell.workStatus, channel: cell.channel, |
| | | collector: battery?.collectorName || '-', collectorState: connectionStatusLabel(collector?.deviceStatus), measurements } |
| | | })) |
| | | |
| | |
| | | <div v-show="activeTab === 'realtime'" class="tab-content"> |
| | | <section class="realtime-panel"> |
| | | <header class="realtime-header"> |
| | | <div><div class="monitor-title"><strong>实时监控</strong><small>约 5 秒观测</small></div><span class="cell-count">{{ cells.length }} 个电池</span><span class="running-state" :class="state"><i></i>{{ state }}</span><span class="reception-state">{{ receptionState }}</span><span v-if="runtimeAttach" class="attach-state">运行中接入</span></div> |
| | | <div><div class="monitor-title"><strong>实时监控</strong><small>约 5 秒观测 · 数据源约 60 秒产生一行新数据,值未更新属正常</small></div><span class="cell-count">{{ cells.length }} 个电池</span><span class="running-state" :class="state"><i></i>{{ state }}</span><span class="reception-state">{{ receptionState }}</span><span v-if="runtimeAttach" class="attach-state">运行中接入</span></div> |
| | | <dl><div><dt>测试开始时间</dt><dd>{{ profile.startedAt }}</dd></div><div><dt>测试已进行时间</dt><dd>{{ elapsedText }}</dd></div></dl> |
| | | <div class="test-actions"><button v-if="state === '待测试'" class="primary-button" type="button" @click="startReception"><el-icon><VideoPlay /></el-icon>开始接收</button><button v-else class="primary-button" type="button" :disabled="state === '检测完成'" @click="togglePause"><el-icon><VideoPause v-if="state !== '暂停'" /><VideoPlay v-else /></el-icon>{{ state === '暂停' ? '继续接收' : '暂停接收' }}</button><button class="danger-button" type="button" :disabled="state === '检测完成' || state === '待测试'" @click="endTest"><el-icon><CircleCloseFilled /></el-icon>停止测试</button></div> |
| | | </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>采集器来源 · 状态:{{ 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>采集器来源 · 状态:{{ 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> |
| | |
| | | <main> |
| | | <section class="panel step-panel"><header class="panel-heading"><div><i></i><strong>项目参数</strong><span>STEP PARAMETERS</span></div><div class="step-actions"><template v-if="stepEditing"><button class="primary-button compact-button" type="button" @click="addStepMapping"><el-icon><Plus /></el-icon>合并选中</button><button class="secondary-button compact-button" type="button" @click="cancelStepSettings">取消</button></template><button v-else class="primary-button compact-button" type="button" @click="openStepSettings"><el-icon><Setting /></el-icon>工步设置</button></div></header><div class="project-meta"><span>测试项目 <strong>{{ profile.purpose }}</strong></span><span>当前循环 <strong>{{ currentCycleNo || '-' }} / 计划 {{ plannedCycleCount || '-' }}</strong></span><span>当前工步 <strong>{{ currentStepNo || '-' }}</strong></span><span>执行状态 <strong>{{ testStatusLabel }}</strong></span></div><div class="step-table-wrap"><table><thead><tr><th v-if="stepEditing">选择</th><th>工步号</th><th>工步名称</th><th>时间(h:min:s.ms)</th><th>电压(V)</th><th>执行状态</th></tr></thead><tbody><template v-if="stepEditing"><tr v-for="step in actualPlanRows" :key="`edit-${step.cyclePosition}`" :class="{ active: step.executionStatus === '进行中' }"><td><el-checkbox :model-value="selectedPlanSteps.includes(step.cyclePosition)" @change="(checked: string | number | boolean) => togglePlanStep(step.cyclePosition, checked)" /></td><td>{{ step.stepNo }}</td><td><strong>{{ step.stepName }}</strong></td><td>{{ formatDuration(step.durationSeconds) }}</td><td>{{ step.voltageV == null ? '-' : `${Number(step.voltageV).toFixed(4)} V` }}</td><td><span class="step-state" :class="step.executionStatus">{{ step.executionStatus }}</span></td></tr></template><template v-else><tr v-for="(step, index) in planRows" :key="`${index}-${step.no}-${step.name}`" :class="{ active: step.state === '进行中' }"><td>{{ step.no }}</td><td><strong>{{ step.name }}</strong></td><td>{{ step.time }}</td><td>{{ step.voltage }}</td><td><span class="step-state" :class="step.state">{{ step.state }}</span></td></tr></template><tr v-if="!planRows.length && !actualPlanRows.length"><td :colspan="stepEditing ? 6 : 5" class="empty-cell">暂无后端检测柜工步方案</td></tr></tbody></table></div><div v-if="stepEditing && stepDraft.length" class="mapping-list"><div v-for="(item, index) in stepDraft" :key="index" class="mapping-row"><strong>逻辑工步 {{ item.logicalNo }}</strong><span>第 {{ item.startStepNo }} 至 {{ item.endStepNo }} 个工步</span><button class="secondary-button compact-button" type="button" aria-label="解除合并" @click="removeStepMapping(index)"><el-icon><Delete /></el-icon>解除合并</button></div></div></section> |
| | | <section class="panel alarm-panel"><header class="panel-heading"><div><i></i><strong>异常警告</strong><span>ALARM</span></div></header><div v-if="currentExceptions.length" class="alarm-list"><div v-for="(item, index) in currentExceptions" :key="`${item.exceptionId || item.sourceId || item.sourceType}-${item.channelCode || index}-${index}`"><el-icon><WarningFilled /></el-icon><span><strong>{{ exceptionTitle(item) }}</strong><small>{{ item.measurementName ? `${item.measurementName}受影响 · ` : '' }}{{ exceptionDetail(item) }}</small></span></div></div><div v-else class="alarm-empty"><el-icon><CircleCheckFilled /></el-icon>暂无异常</div></section> |
| | | <section class="panel channel-panel"><header class="panel-heading"><div><i></i><strong>通道</strong><span>CHANNEL STATUS</span></div></header><div class="channel-table-wrap"><table :style="{ minWidth: `${1120 + collectorColumns.length * 120}px` }"><thead><tr><th>电池号</th><th>测试柜</th><th>柜状态</th><th>检测通道</th><th>采集器</th><th>采集器状态</th><th v-for="column in collectorColumns" :key="column.key">{{ column.label }}</th></tr></thead><tbody><tr v-for="item in channels" :key="item.no"><td><strong>{{ item.no }}</strong></td><td>{{ item.cabinet }}</td><td><span class="device-state" :class="item.cabinetState">{{ item.cabinetState }}</span></td><td>{{ item.channel }}</td><td>{{ item.collector }}</td><td><span class="device-state" :class="item.collectorState">{{ item.collectorState }}</span></td><td v-for="column in collectorColumns" :key="column.key">{{ item.measurements[column.measurementCode] || '-' }}</td></tr><tr v-if="!channels.length"><td :colspan="6 + collectorColumns.length" class="empty-cell">暂无通道配置</td></tr></tbody></table></div></section> |
| | | <section class="panel channel-panel"><header class="panel-heading"><div><i></i><strong>通道</strong><span>CHANNEL STATUS</span></div><small class="panel-note">通道工作状态在读库来源下显示“无法观测”,这是正常取值,不代表故障</small></header><div class="channel-table-wrap"><table :style="{ minWidth: `${1200 + collectorColumns.length * 120}px` }"><thead><tr><th>电池号</th><th>测试柜</th><th>柜状态</th><th>通道工作状态</th><th>检测通道</th><th>采集器</th><th>采集器状态</th><th v-for="column in collectorColumns" :key="column.key">{{ column.label }}</th></tr></thead><tbody><tr v-for="item in channels" :key="item.no"><td><strong>{{ item.no }}</strong></td><td>{{ item.cabinet }}</td><td><span class="device-state" :class="item.cabinetState">{{ item.cabinetState }}</span></td><td><span class="device-state" :class="item.workStatus === '无法观测' ? 'unobservable' : item.cabinetState">{{ item.workStatus }}</span></td><td>{{ item.channel }}</td><td>{{ item.collector }}</td><td><span class="device-state" :class="item.collectorState">{{ item.collectorState }}</span></td><td v-for="column in collectorColumns" :key="column.key">{{ item.measurements[column.measurementCode] || '-' }}</td></tr><tr v-if="!channels.length"><td :colspan="7 + collectorColumns.length" class="empty-cell">暂无通道配置</td></tr></tbody></table></div></section> |
| | | </main> |
| | | <aside class="parameter-panel"><header class="panel-heading"><div><i></i><strong>测试参数</strong><span>TEST SETTINGS</span></div></header><dl><div v-for="item in parameters" :key="item[0]"><dt>{{ item[0] }}</dt><dd>{{ item[1] }}</dd></div></dl></aside> |
| | | </div> |
| | |
| | | <style scoped> |
| | | .monitor-title{display:flex;flex-direction:column;gap:5px;flex-shrink:0}.monitor-title small{color:#71808d;font-size:11px}.realtime-header>div:first-child{flex-wrap:wrap} |
| | | .alarm-list > div > span { min-width: 0; overflow-wrap: anywhere; } |
| | | .console-page{width:100%;padding-bottom:24px}.page-tools{height:34px;display:flex;align-items:center;justify-content:space-between}.page-tools>span{color:#7f8d99;font-size:10px;font-weight:600}.back-button{display:inline-flex;align-items:center;gap:6px;border:0;color:#52606d;background:transparent;font-size:13px;cursor:pointer}.back-button:hover{color:var(--blue)}.primary-button,.secondary-button,.danger-button{height:34px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 13px;border:1px solid transparent;border-radius:3px;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap}.primary-button{color:#fff;background:var(--blue);border-color:var(--blue)}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.danger-button{color:#fff;background:#ee6666;border-color:#ee6666}.primary-button:disabled,.danger-button:disabled{opacity:.5;cursor:not-allowed}.realtime-panel,.panel,.parameter-panel{border:1px solid #d8e1e9;background:#fff}.realtime-panel{margin-bottom:16px}.realtime-header{min-height:96px;display:grid;grid-template-columns:minmax(190px,1fr) minmax(360px,2fr) auto;align-items:center;gap:24px;padding:16px 20px;border-bottom:1px solid var(--line)}.realtime-header>div:first-child{display:flex;align-items:center;gap:12px}.realtime-header strong{font-size:15px}.cell-count{color:#71808d;font-size:11px}.running-state{display:inline-flex;align-items:center;gap:6px;padding:4px 7px;border:1px solid;font-size:11px}.running-state i{width:5px;height:5px;border-radius:50%;background:currentColor}.running-state.检测中{color:#23865e;background:#edf9f3;border-color:#b8dfcd}.running-state.暂停{color:#b06a18;background:#fff8e9;border-color:#efd59e}.running-state.已结束{color:#7b8794;background:#f2f4f6;border-color:#d8dde2}.running-state.未开始接收{color:#7b8794;background:#f2f4f6;border-color:#d8dde2}.realtime-header dl{display:flex;gap:44px}.realtime-header dl>div{display:flex;align-items:center;gap:16px}.realtime-header dt{color:#52606d;font-size:12px}.realtime-header dd{color:#7b8794;font-family:'Cascadia Mono',Consolas,monospace;font-size:12px;font-weight:600}.test-actions{display:flex;gap:10px}.cell-grid{display:grid;grid-template-columns:repeat(4,minmax(190px,1fr));gap:24px;padding:22px 36px}.cell-card{min-width:0;border:1px solid #bec9d3;border-radius:6px;background:#f5f7fa;overflow:hidden}.cell-summary{display:flex;flex-direction:column;gap:7px;padding:16px 18px 12px;border-bottom:1px solid #c8d1d9}.cell-summary strong{color:#1268dc;font-size:13px}.cell-summary span{color:#37688e;font-size:12px;font-weight:600}.cell-summary p,.cell-summary small{color:#71808d;font-size:10px}.metric-list{display:grid;gap:12px;padding:14px 18px 16px}.metric-list>div{display:flex;align-items:center;justify-content:space-between}.metric-list dt{color:#52606d;font-size:10px}.metric-list dd{font-size:17px;font-weight:700}.metric-list .voltage{color:#7f35c5}.metric-list .temperature{color:#bf6100}.metric-list .positive{color:#e99f9f}.metric-list .negative{color:#176ee8}.metric-list .capacity{color:#f59a2a}.metric-list .current{color:#21628c}.console-layout{display:grid;grid-template-columns:minmax(0,2.35fr) minmax(270px,1fr);gap:16px;align-items:start}.console-layout main{display:grid;gap:16px}.panel-heading{height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 16px;border-bottom:1px solid var(--line);background:#fbfdff}.panel-heading>div{display:flex;align-items:center;gap:8px}.panel-heading>div>i{width:3px;height:16px;background:#2b78a3}.panel-heading strong{color:#26333f;font-size:14px}.panel-heading span{color:#81909d;font-size:10px}.compact-button{height:30px;font-size:12px}.project-meta{display:flex;gap:32px;padding:14px 16px 0;color:#71808d;font-size:11px}.project-meta strong{margin-left:8px;color:#31404d}.step-table-wrap,.channel-table-wrap{overflow-x:auto;padding:14px 16px 18px}table{width:100%;border-collapse:collapse;color:#52606d;font-size:12px}th,td{height:38px;padding:0 10px;border:1px solid #d5e0e9;text-align:center;white-space:nowrap}th{color:#52606d;background:#eaf4ff;font-weight:600}.step-table tr.active td{background:#eef7ff}.step-table tr.active td:first-child{border-left:3px solid #2188f5}.step-state{font-size:11px}.step-state.进行中{color:#1677dd}.step-state.已完成{color:#23865e}.step-state.待执行{color:#8795a3}.parameter-panel dl{display:grid;gap:0;padding:12px 18px 18px}.parameter-panel dl>div{display:grid;grid-template-columns:88px minmax(0,1fr);gap:10px;padding:9px 0;border-bottom:1px solid #edf1f5}.parameter-panel dl>div:last-child{border-bottom:0}.parameter-panel dt{color:#71808d;font-size:11px}.parameter-panel dd{min-width:0;color:#52606d;font-size:11px;overflow-wrap:anywhere}.alarm-empty{height:68px;display:flex;align-items:center;gap:8px;margin:14px 16px;padding:0 16px;color:#71808d;background:#f4f7fa;font-size:12px}.alarm-empty .el-icon{color:#23865e;font-size:16px}.alarm-list{display:grid;gap:8px;padding:14px 16px}.alarm-list>div{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid #f0c8a3;background:#fff7ef;color:#b35a25}.alarm-list span,.alarm-list strong,.alarm-list small{display:block}.alarm-list strong{font-size:12px}.alarm-list small{margin-top:3px;color:#8c6849;font-size:10px}.channel-table-wrap table{min-width:1120px}.device-state{display:inline-flex;padding:3px 7px;border:1px solid;font-size:10px}.device-state.在线{color:#23865e;background:#edf9f3;border-color:#b8dfcd}.device-state.异常{color:#b34f4f;background:#fff1f1;border-color:#e9bcbc}.step-dialog-content{display:grid;gap:12px}.step-dialog-content>p{color:#71808d;font-size:12px}.step-dialog-content>strong{color:#26333f}.step-dialog-content dl{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.step-dialog-content dl>div{padding:12px;border:1px solid #d8e1e9;background:#f8fafc}.step-dialog-content dt{color:#8795a3;font-size:10px}.step-dialog-content dd{margin-top:5px;color:#405261;font-size:12px}@media(max-width:1280px){.cell-grid{gap:16px;padding:20px}.console-layout{grid-template-columns:minmax(0,1fr) 280px}}@media(max-width:1050px){.cell-grid{grid-template-columns:repeat(2,minmax(210px,1fr))}.realtime-header{grid-template-columns:1fr auto}.realtime-header dl{grid-column:1/-1;grid-row:2}.console-layout{grid-template-columns:1fr}.parameter-panel{grid-row:2}}@media(max-width:680px){.realtime-header{grid-template-columns:1fr}.realtime-header dl{grid-column:auto;grid-row:auto;align-items:flex-start;flex-direction:column;gap:10px}.test-actions{justify-content:stretch}.test-actions button{flex:1}.cell-grid{grid-template-columns:1fr;padding:14px}.project-meta{flex-direction:column;gap:8px}.step-dialog-content dl{grid-template-columns:1fr}} |
| | | .console-page{width:100%;padding-bottom:24px}.page-tools{height:34px;display:flex;align-items:center;justify-content:space-between}.page-tools>span{color:#7f8d99;font-size:10px;font-weight:600}.back-button{display:inline-flex;align-items:center;gap:6px;border:0;color:#52606d;background:transparent;font-size:13px;cursor:pointer}.back-button:hover{color:var(--blue)}.primary-button,.secondary-button,.danger-button{height:34px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 13px;border:1px solid transparent;border-radius:3px;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap}.primary-button{color:#fff;background:var(--blue);border-color:var(--blue)}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.danger-button{color:#fff;background:#ee6666;border-color:#ee6666}.primary-button:disabled,.danger-button:disabled{opacity:.5;cursor:not-allowed}.realtime-panel,.panel,.parameter-panel{border:1px solid #d8e1e9;background:#fff}.realtime-panel{margin-bottom:16px}.realtime-header{min-height:96px;display:grid;grid-template-columns:minmax(190px,1fr) minmax(360px,2fr) auto;align-items:center;gap:24px;padding:16px 20px;border-bottom:1px solid var(--line)}.realtime-header>div:first-child{display:flex;align-items:center;gap:12px}.realtime-header strong{font-size:15px}.cell-count{color:#71808d;font-size:11px}.running-state{display:inline-flex;align-items:center;gap:6px;padding:4px 7px;border:1px solid;font-size:11px}.running-state i{width:5px;height:5px;border-radius:50%;background:currentColor}.running-state.检测中{color:#23865e;background:#edf9f3;border-color:#b8dfcd}.running-state.暂停{color:#b06a18;background:#fff8e9;border-color:#efd59e}.running-state.已结束{color:#7b8794;background:#f2f4f6;border-color:#d8dde2}.running-state.未开始接收{color:#7b8794;background:#f2f4f6;border-color:#d8dde2}.realtime-header dl{display:flex;gap:44px}.realtime-header dl>div{display:flex;align-items:center;gap:16px}.realtime-header dt{color:#52606d;font-size:12px}.realtime-header dd{color:#7b8794;font-family:'Cascadia Mono',Consolas,monospace;font-size:12px;font-weight:600}.test-actions{display:flex;gap:10px}.cell-grid{display:grid;grid-template-columns:repeat(4,minmax(190px,1fr));gap:24px;padding:22px 36px}.cell-card{min-width:0;border:1px solid #bec9d3;border-radius:6px;background:#f5f7fa;overflow:hidden}.cell-summary{display:flex;flex-direction:column;gap:7px;padding:16px 18px 12px;border-bottom:1px solid #c8d1d9}.cell-summary strong{color:#1268dc;font-size:13px}.cell-summary span{color:#37688e;font-size:12px;font-weight:600}.cell-summary p,.cell-summary small{color:#71808d;font-size:10px}.metric-list{display:grid;gap:12px;padding:14px 18px 16px}.metric-list>div{display:flex;align-items:center;justify-content:space-between}.metric-list dt{color:#52606d;font-size:10px}.metric-list dd{font-size:17px;font-weight:700}.metric-list .voltage{color:#7f35c5}.metric-list .temperature{color:#bf6100}.metric-list .positive{color:#e99f9f}.metric-list .negative{color:#176ee8}.metric-list .capacity{color:#f59a2a}.metric-list .current{color:#21628c}.console-layout{display:grid;grid-template-columns:minmax(0,2.35fr) minmax(270px,1fr);gap:16px;align-items:start}.console-layout main{display:grid;gap:16px}.panel-heading{height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 16px;border-bottom:1px solid var(--line);background:#fbfdff}.panel-heading>div{display:flex;align-items:center;gap:8px}.panel-heading>div>i{width:3px;height:16px;background:#2b78a3}.panel-heading strong{color:#26333f;font-size:14px}.panel-heading span{color:#81909d;font-size:10px}.compact-button{height:30px;font-size:12px}.project-meta{display:flex;gap:32px;padding:14px 16px 0;color:#71808d;font-size:11px}.project-meta strong{margin-left:8px;color:#31404d}.step-table-wrap,.channel-table-wrap{overflow-x:auto;padding:14px 16px 18px}table{width:100%;border-collapse:collapse;color:#52606d;font-size:12px}th,td{height:38px;padding:0 10px;border:1px solid #d5e0e9;text-align:center;white-space:nowrap}th{color:#52606d;background:#eaf4ff;font-weight:600}.step-table tr.active td{background:#eef7ff}.step-table tr.active td:first-child{border-left:3px solid #2188f5}.step-state{font-size:11px}.step-state.进行中{color:#1677dd}.step-state.已完成{color:#23865e}.step-state.待执行{color:#8795a3}.parameter-panel dl{display:grid;gap:0;padding:12px 18px 18px}.parameter-panel dl>div{display:grid;grid-template-columns:88px minmax(0,1fr);gap:10px;padding:9px 0;border-bottom:1px solid #edf1f5}.parameter-panel dl>div:last-child{border-bottom:0}.parameter-panel dt{color:#71808d;font-size:11px}.parameter-panel dd{min-width:0;color:#52606d;font-size:11px;overflow-wrap:anywhere}.alarm-empty{height:68px;display:flex;align-items:center;gap:8px;margin:14px 16px;padding:0 16px;color:#71808d;background:#f4f7fa;font-size:12px}.alarm-empty .el-icon{color:#23865e;font-size:16px}.alarm-list{display:grid;gap:8px;padding:14px 16px}.alarm-list>div{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid #f0c8a3;background:#fff7ef;color:#b35a25}.alarm-list span,.alarm-list strong,.alarm-list small{display:block}.alarm-list strong{font-size:12px}.alarm-list small{margin-top:3px;color:#8c6849;font-size:10px}.channel-table-wrap table{min-width:1200px}.panel-note{color:#81909d;font-size:10px}.device-state{display:inline-flex;padding:3px 7px;border:1px solid;font-size:10px}.device-state.在线{color:#23865e;background:#edf9f3;border-color:#b8dfcd}.device-state.异常{color:#b34f4f;background:#fff1f1;border-color:#e9bcbc}.device-state.无法观测{color:#71808d;background:#f5f7fa;border-color:#d8dde2}.step-dialog-content{display:grid;gap:12px}.step-dialog-content>p{color:#71808d;font-size:12px}.step-dialog-content>strong{color:#26333f}.step-dialog-content dl{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.step-dialog-content dl>div{padding:12px;border:1px solid #d8e1e9;background:#f8fafc}.step-dialog-content dt{color:#8795a3;font-size:10px}.step-dialog-content dd{margin-top:5px;color:#405261;font-size:12px}@media(max-width:1280px){.cell-grid{gap:16px;padding:20px}.console-layout{grid-template-columns:minmax(0,1fr) 280px}}@media(max-width:1050px){.cell-grid{grid-template-columns:repeat(2,minmax(210px,1fr))}.realtime-header{grid-template-columns:1fr auto}.realtime-header dl{grid-column:1/-1;grid-row:2}.console-layout{grid-template-columns:1fr}.parameter-panel{grid-row:2}}@media(max-width:680px){.realtime-header{grid-template-columns:1fr}.realtime-header dl{grid-column:auto;grid-row:auto;align-items:flex-start;flex-direction:column;gap:10px}.test-actions{justify-content:stretch}.test-actions button{flex:1}.cell-grid{grid-template-columns:1fr;padding:14px}.project-meta{flex-direction:column;gap:8px}.step-dialog-content dl{grid-template-columns:1fr}} |
| | | |
| | | .reception-state{padding:4px 7px;border:1px solid #b9decf;color:#23865e;background:#edf9f3;font-size:11px}.test-state{padding:4px 7px;border:1px solid #d8dde2;color:#52606d;background:#f5f7fa;font-size:11px}.attach-state{padding:4px 7px;border:1px solid #bdd8f4;color:#1677dd;background:#edf6ff;font-size:11px} |
| | | .live-connection{display:inline-flex;align-items:center;gap:5px;padding:4px 7px;border:1px solid #cbd5df;color:#52606d;background:#f5f7fa;font-size:11px}.live-connection i{width:5px;height:5px;border-radius:50%;background:currentColor}.live-connection.已连接{color:#23865e;background:#edf9f3;border-color:#b8dfcd}.live-connection.权限不足{color:#b34f4f;background:#fff1f1;border-color:#e9bcbc}.live-error{margin:8px 20px 14px;padding:9px 12px;border:1px solid #e9bcbc;color:#a94444;background:#fff5f5;font-size:12px} |
| | |
| | | export default defineConfig({ |
| | | plugins: [vue()], |
| | | server: { |
| | | host: '0.0.0.0', |
| | | port: 6173, |
| | | strictPort: true, |
| | | proxy: { |