<script setup lang="ts">
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { useRoute, useRouter } from 'vue-router'
|
import * as XLSX from 'xlsx'
|
import { Download, EditPen, Refresh, Search, UploadFilled, Clock, Check } from '@element-plus/icons-vue'
|
import { getPersistedTestCycles, getPersistedTestRecords, getPersistedTestSteps, type PersistedTestCycle, type PersistedTestRecord, type PersistedTestStep } from '@/api/tests'
|
import { consoleImportColumns, submitConsoleDataAction, type ConsoleDataType } from '@/api/consoleData'
|
import { formatBusinessTime as formatTime } from '@/utils/businessTime'
|
import { formatTestDisplayValue, toTestApiValue } from '@/utils/testDataUnits'
|
import { createConsoleImportWorkbook } from '@/utils/consoleDataImport'
|
import { parseTestDuration } from '@/utils/testDataDuration'
|
|
type DataTab = 'realtime' | 'step' | 'cycle'
|
interface ConsoleCell {
|
no: string
|
}
|
interface FilterState {
|
batteryNo: string
|
dateRange: [string, string] | null
|
cycleNo: string
|
stepNo: string
|
}
|
interface DataRow {
|
id: string
|
sourceType: string
|
metricCode: string
|
metricValue: string
|
unit: string
|
collectorCode: string
|
channelCode: string
|
recordNo: string
|
batteryNo: string
|
cycleNo: number
|
stepNo: number
|
stepName: string
|
actualStepLabel: string
|
sequenceId: number
|
recordedAt: string
|
relativeTimeSeconds: string
|
absoluteTime: string
|
serverReceivedAt: string
|
stepDuration: string
|
startVoltage: string
|
endVoltage: string
|
chargeMedianVoltage: string
|
dischargeMedianVoltage: string
|
voltage: string
|
current: string
|
capacity: string
|
energy: string
|
temperature: string
|
positivePotential: string
|
negativePotential: string
|
extensionMeasurements: string
|
sourceIdentity: string
|
sourceRecordId: string
|
collectorDeviceTime: string
|
chargeCapacity: string
|
dischargeCapacity: string
|
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()
|
const router = useRouter()
|
const requestedDataType = String(route.query.dataType || 'REALTIME')
|
const activeTab = ref<DataTab>(requestedDataType === 'STEP' ? 'step' : requestedDataType === 'CYCLE' ? 'cycle' : 'realtime')
|
const loading = ref(false)
|
const loadError = ref('')
|
const rows = ref<DataRow[]>([])
|
const stepRows = ref<DataRow[]>([])
|
const cycleRows = ref<DataRow[]>([])
|
const dataTabs = [
|
{ key: 'realtime', label: '实时' },
|
{ 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)
|
const pageSize = ref(20)
|
const selectedIds = ref<Set<string>>(new Set())
|
const editorVisible = ref(false)
|
const editing = ref(false)
|
const singleEditing = ref(false)
|
const draftRows = ref<Record<string, DataRow>>({})
|
const editForm = reactive({ field: 'voltage' as keyof DataRow, value: '', remark: '' })
|
const dataType = computed<ConsoleDataType>(() => (activeTab.value === 'step' ? 'STEP' : activeTab.value === 'cycle' ? 'CYCLE' : 'REALTIME'))
|
const fieldOptions = computed(() =>
|
activeTab.value === 'realtime'
|
? [
|
{ label: '电压(V)', value: 'voltage' },
|
{ label: '电流(mA)', value: 'current' },
|
{ label: '容量(mAh)', value: 'capacity' },
|
{ label: '能量(mWh)', value: 'energy' },
|
{ label: '温度(℃)', value: 'temperature' },
|
{ label: '正电位(V)', value: 'positivePotential' },
|
{ label: '负电位(V)', value: 'negativePotential' },
|
]
|
: activeTab.value === 'step'
|
? [
|
{ label: '工步时间(h:min:s.ms)', value: 'stepDuration' },
|
{ label: '容量(mAh)', value: 'capacity' },
|
{ label: '能量(mWh)', value: 'energy' },
|
{ label: '起始电压(V)', value: 'startVoltage' },
|
{ label: '终止电压(V)', value: 'endVoltage' },
|
{ label: '充电中值电压(V)', value: 'chargeMedianVoltage' },
|
{ label: '放电中值电压(V)', value: 'dischargeMedianVoltage' },
|
]
|
: [
|
{ label: '充电容量(mAh)', value: 'chargeCapacity' },
|
{ label: '放电容量(mAh)', value: 'dischargeCapacity' },
|
{ label: '效率(%)', value: 'efficiency' },
|
{ label: '充电能量(mWh)', value: 'chargeEnergy' },
|
{ label: '放电能量(mWh)', value: 'dischargeEnergy' },
|
],
|
)
|
function emptyRow(base: Partial<DataRow>): DataRow {
|
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)
|
const identity = record.stableIdentity || [record.adapterId, record.devtype, record.deviceId, record.subdevid, record.channelId, record.sequenceId].join(':')
|
const collector = record.sourceType === 'COLLECTOR'
|
const snapshot = record.sourceType === 'SNAPSHOT'
|
const metricCode = String(record.metricCode || '').toUpperCase()
|
const extensionValue = record.metricValue == null ? '-' : String(record.metricValue)
|
const extension = (name: string) => collector && metricCode.includes(name) ? extensionValue : '-'
|
let extensionMeasurements = '-'
|
if (snapshot && record.extensionMeasurementsJson) {
|
try {
|
const parsed = typeof record.extensionMeasurementsJson === 'string' ? JSON.parse(record.extensionMeasurementsJson) : record.extensionMeasurementsJson
|
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 || '-', 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)
|
const identity = record.stableIdentity || `step:${[record.adapterId, record.devtype, record.deviceId, record.subdevid, record.channelId, record.cycleNo, record.stepNo].join(':')}`
|
return emptyRow({ id: identity, recordNo: identity, batteryNo: record.batteryNo, cycleNo: Number(record.cycleNo), stepNo: Number(record.stepNo), stepName: record.stepName || '-', actualStepLabel: record.actualStepLabel || `${record.stepNo} · ${record.stepName || '-'}`, sequenceId: Number(record.sequenceId || 0), recordedAt: startedAt, absoluteTime: formatTime(record.absoluteTime || record.recordedAt), serverReceivedAt: formatTime(record.serverReceivedAt), stepDuration: formatTestDisplayValue('stepDuration', record.stepDuration), capacity: formatTestDisplayValue('capacity', record.capacity), energy: formatTestDisplayValue('energy', record.energy), startVoltage: record.startVoltage == null ? '-' : Number(record.startVoltage).toFixed(4), endVoltage: record.endVoltage == null ? '-' : Number(record.endVoltage).toFixed(4), chargeMedianVoltage: record.chargeMedianVoltage == null ? '-' : Number(record.chargeMedianVoltage).toFixed(4), dischargeMedianVoltage: record.dischargeMedianVoltage == null ? '-' : Number(record.dischargeMedianVoltage).toFixed(4) })
|
}
|
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), sourceKind: cycleSourceLabels[String(record.sourceKind || 'DEVICE')] || CYCLE_SOURCE_UNKNOWN })
|
}
|
async function loadRecords() {
|
loading.value = true
|
loadError.value = ''
|
try {
|
const [records, steps, cycles] = await Promise.all([getPersistedTestRecords(props.testId), getPersistedTestSteps(props.testId), getPersistedTestCycles(props.testId)])
|
// 后端查询接口已经返回当前有效数据,前端不再读取覆盖表或拼接伪造行。
|
rows.value = records.map(mapRecord).sort((a, b) => b.absoluteTime.localeCompare(a.absoluteTime) || b.sequenceId - a.sequenceId)
|
stepRows.value = steps.map(mapStep)
|
cycleRows.value = cycles.map(mapCycle)
|
currentPage.value = Math.min(currentPage.value, Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
|
return true
|
} catch (error) {
|
rows.value = []
|
stepRows.value = []
|
cycleRows.value = []
|
loadError.value = error instanceof Error ? error.message : '测试数据加载失败'
|
return false
|
} finally {
|
loading.value = false
|
}
|
}
|
// 数据列表只展示后端持久化的 record、step、cycle 事实;逻辑工步合并留给统计图表等派生视图。
|
const sourceRows = computed(() => (activeTab.value === 'step' ? stepRows.value : activeTab.value === 'cycle' ? cycleRows.value : rows.value))
|
const singleRow = computed(() => Object.values(draftRows.value)[0] || null)
|
const editorFieldOptions = computed(() => singleRow.value?.sourceType === 'COLLECTOR'
|
? [{ 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(() =>
|
sourceRows.value.filter(row => {
|
const query = appliedFilters.value
|
if (query.batteryNo && row.batteryNo !== query.batteryNo) return false
|
if (query.cycleNo && row.cycleNo !== Number(query.cycleNo)) return false
|
if (activeTab.value !== 'cycle' && query.stepNo && row.stepNo !== Number(query.stepNo)) return false
|
if (query.dateRange) {
|
const date = row.absoluteTime.slice(0, 10)
|
if (date < query.dateRange[0] || date > query.dateRange[1]) return false
|
}
|
return true
|
}),
|
)
|
const pagedRows = computed(() => filteredRows.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value))
|
function applySearch() {
|
appliedFilters.value = { batteryNo: filters.batteryNo, dateRange: filters.dateRange ? ([...filters.dateRange] as [string, string]) : null, cycleNo: filters.cycleNo, stepNo: filters.stepNo }
|
currentPage.value = 1
|
}
|
function resetSearch() {
|
Object.assign(filters, { batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' })
|
Object.assign(appliedFilters.value, { batteryNo: '', dateRange: null, cycleNo: '', stepNo: '' })
|
currentPage.value = 1
|
}
|
function toggleRowSelection(id: string, checked: boolean) {
|
const next = new Set(selectedIds.value)
|
checked ? next.add(id) : next.delete(id)
|
selectedIds.value = next
|
}
|
function togglePageSelection(checked: boolean) {
|
const next = new Set(selectedIds.value)
|
pagedRows.value.forEach(row => (checked ? next.add(row.id) : next.delete(row.id)))
|
selectedIds.value = next
|
}
|
const allPageSelected = computed(() => pagedRows.value.length > 0 && pagedRows.value.every(row => selectedIds.value.has(row.id)))
|
const somePageSelected = computed(() => pagedRows.value.some(row => selectedIds.value.has(row.id)) && !allPageSelected.value)
|
function goToImport(workbook = false) {
|
router.push({ path: `/test-list/${props.testId}/console/data/import`, query: { testName: props.testName, completed: props.canImport ? '1' : '0', dataType: dataType.value, ...(workbook ? { mode: 'workbook' } : {}) } })
|
}
|
function goToRevisions() {
|
router.push({ path: '/data-revision', query: { testId: String(props.testId), dataType: dataType.value } })
|
}
|
function downloadTemplate() {
|
const workbook = createConsoleImportWorkbook(dataType.value)
|
XLSX.writeFile(workbook, `测试${activeTab.value}数据导入模板.xlsx`)
|
ElMessage.success('导入模板已下载')
|
}
|
function openBatchEditor() {
|
singleEditing.value = false
|
editing.value = true
|
draftRows.value = Object.fromEntries(sourceRows.value.map(row => [row.id, { ...row }]))
|
}
|
function openRowEditor(row: DataRow) {
|
singleEditing.value = true
|
editing.value = false
|
draftRows.value = { [row.id]: { ...row } }
|
selectedIds.value = new Set([row.id])
|
editForm.field = String(editorFieldOptions.value[0]?.value || 'voltage') as keyof DataRow
|
editForm.value = String(row[editForm.field] ?? '')
|
editForm.remark = ''
|
editorVisible.value = true
|
}
|
async function saveBatchEdit() {
|
if (!Object.keys(draftRows.value).length) return
|
try {
|
const result = await ElMessageBox.prompt('请输入本次修改原因', '提交数据修改', { inputPlaceholder: '必填,将写入数据修订记录', inputValidator: value => (value.trim() ? true : '修改原因不能为空') })
|
editForm.remark = result.value.trim()
|
await submitBatchEdit()
|
} catch {
|
/* 用户取消原因填写 */
|
}
|
}
|
function isEditableNumericValue(value: unknown, field: string) {
|
// “-”表示设备未提供该指标;允许保留缺失值,以便只修订本次实际填写的字段。
|
const normalized = String(value ?? '').trim()
|
if (field === 'stepDuration') return normalized === '-' || (normalized !== '' && Number.isFinite(parseTestDuration(normalized)))
|
return normalized === '-' || (normalized !== '' && Number.isFinite(Number(normalized)))
|
}
|
async function submitBatchEdit() {
|
if (
|
singleEditing.value &&
|
singleRow.value &&
|
editorFieldOptions.value.some(field => {
|
const value = singleRow.value?.[field.value as keyof DataRow]
|
return !isEditableNumericValue(value, field.value)
|
})
|
) {
|
ElMessage.warning('请填写完整、有效的数据')
|
return
|
}
|
if (!editForm.remark.trim()) {
|
ElMessage.warning('请填写修改原因')
|
return
|
}
|
const originals = new Map(sourceRows.value.map(row => [row.id, row]))
|
const changed = Object.values(draftRows.value).filter(row => JSON.stringify(originals.get(row.id)) !== JSON.stringify(row))
|
if (!changed.length) {
|
ElMessage.warning('没有检测到修改')
|
return
|
}
|
try {
|
const editItems = changed.flatMap(row => {
|
const ids = [row.id]
|
return ids.map(id => {
|
const before = originals.get(id)!
|
const fields = row.sourceType === 'COLLECTOR' ? editorFieldOptions.value : fieldOptions.value
|
const values = Object.fromEntries(fields
|
.filter(item => row[item.value as keyof DataRow] !== before[item.value as keyof DataRow])
|
.map(item => [item.value, row[item.value as keyof DataRow]]))
|
// 编辑框使用毫单位;提交字段和摘要都回到 API 单位,正式修订前值仍由后端读取。
|
const apiValues = Object.fromEntries(Object.entries(values).map(([field, value]) => [field, String(value).trim() === '-' ? null : toTestApiValue(field, String(value))]))
|
const apiBefore = { ...before, ...Object.fromEntries(fields.map(item => [item.value, toTestApiValue(item.value, String(before[item.value as keyof DataRow]))])) }
|
return { identity: id, before: apiBefore, after: { ...apiBefore, ...apiValues }, override: { stableIdentity: id, fieldValues: JSON.stringify(apiValues), deleted: false } }
|
})
|
})
|
await submitConsoleDataAction(props.testId, { dataType: dataType.value, operationType: 'EDIT', scopeSummary: `按格修改${changed.length}条${activeTab.value}数据`, identitySummary: JSON.stringify(editItems.map(item => item.identity)), changesSummary: JSON.stringify(editItems.map(item => ({ identity: item.identity, before: item.before, after: item.after }))), reason: editForm.remark.trim(), overrides: editItems.map(item => item.override) })
|
editorVisible.value = false
|
cancelBatchEdit()
|
await loadRecords()
|
ElMessage.success(`已提交 ${changed.length} 条数据修改`)
|
} catch (error) {
|
ElMessage.error(error instanceof Error ? error.message : '数据修改提交失败')
|
}
|
}
|
function cancelBatchEdit() {
|
editing.value = false
|
singleEditing.value = false
|
draftRows.value = {}
|
selectedIds.value = new Set()
|
}
|
async function deleteSelected(requestedIds?: string[] | Event) {
|
const ids = Array.isArray(requestedIds) && requestedIds.length ? requestedIds : [...selectedIds.value]
|
if (!ids.length) {
|
ElMessage.warning('请先勾选需要删除的数据')
|
return
|
}
|
const physicalIds = ids
|
let result: { value: string }
|
try {
|
result = await ElMessageBox.prompt(`确认物理删除 ${ids.length} 条${activeTab.value}数据?删除前内容会写入数据修订记录。`, '物理删除数据', { inputPlaceholder: '请输入删除原因', inputValidator: value => (value.trim() ? true : '删除原因不能为空') })
|
} catch {
|
return
|
}
|
try {
|
await submitConsoleDataAction(props.testId, { dataType: dataType.value, operationType: 'DELETE', scopeSummary: `物理删除${ids.length}条${activeTab.value}数据`, identitySummary: JSON.stringify(physicalIds), changesSummary: null, reason: result.value.trim() })
|
await loadRecords()
|
selectedIds.value = new Set()
|
ElMessage.success(`已从当前有效视图移除 ${ids.length} 条数据`)
|
} catch (error) {
|
ElMessage.error(error instanceof Error ? error.message : '数据删除失败')
|
}
|
}
|
function changeTab(tab: DataTab) {
|
if (activeTab.value === tab) return
|
if (editing.value) cancelBatchEdit()
|
activeTab.value = tab
|
selectedIds.value = new Set()
|
void router.replace({ query: { ...route.query, tab: 'data-list', dataType: dataType.value } })
|
currentPage.value = 1
|
// 切换数据子页时主动同步一次,除此之外不进行后台轮询。
|
void loadRecords()
|
}
|
function escapeCsv(value: unknown) {
|
return `"${String(value ?? '').replaceAll('"', '""')}"`
|
}
|
async function downloadCsv() {
|
if (!await loadRecords()) return
|
if (!filteredRows.value.length) {
|
ElMessage.warning('没有可下载的数据')
|
return
|
}
|
const columns = consoleImportColumns[dataType.value]
|
// 导出与界面一致地带上来源列:循环的推导值不能在被导出后失去"聚合来源"这个事实;
|
// 实时的"来源定位"是设备侧自己的对照串(如厂商库名与记录号),导出后仍能拿它跟厂商软件核对。
|
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' })
|
const url = URL.createObjectURL(blob)
|
const anchor = document.createElement('a')
|
anchor.href = url
|
anchor.download = `${props.testName}-${activeTab.value}.csv`
|
anchor.click()
|
URL.revokeObjectURL(url)
|
}
|
// 进入页面及接收状态变化时同步;操作后的刷新由各入口触发,不随每次实时快照重复查询。
|
onMounted(loadRecords)
|
watch(
|
() => props.receptionStatus,
|
(next, previous) => {
|
if (next !== previous) void loadRecords()
|
},
|
)
|
</script>
|
<template>
|
<section class="console-data-panel" :class="activeTab">
|
<header class="section-heading">
|
<div><i></i><strong>数据列表</strong><span>TEST DATA</span></div>
|
<nav class="data-tabs" aria-label="数据列表子页">
|
<button v-for="tab in dataTabs" :key="tab.key" type="button" :class="{ active: activeTab === tab.key }" :aria-current="activeTab === tab.key ? 'page' : undefined" @click="changeTab(tab.key)">
|
{{ tab.label }}
|
</button>
|
</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
|
><label><span>设备时间</span><el-date-picker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" /></label
|
><label
|
><span>循环</span><el-select v-model="filters.cycleNo" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="item in cycleOptions" :key="item" :label="`循环 ${item}`" :value="String(item)" /></el-select></label
|
><label
|
><span>工步</span><el-select v-model="filters.stepNo" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="item in stepOptions" :key="item[0]" :label="`${item[0]}-${item[1]}`" :value="String(item[0])" /></el-select
|
></label>
|
<div class="filter-actions">
|
<button class="primary-button" type="button" @click="applySearch">
|
<el-icon><Search /></el-icon>查询</button
|
><button class="secondary-button" type="button" @click="resetSearch">
|
<el-icon><Refresh /></el-icon>重置
|
</button>
|
</div>
|
</div>
|
<div class="data-toolbar">
|
<div class="toolbar-group">
|
<button class="primary-button" type="button" @click="goToImport()">
|
<el-icon><UploadFilled /></el-icon>导入</button
|
><button class="primary-button" type="button" @click="downloadTemplate">
|
<el-icon><Download /></el-icon>下载模板</button
|
><button class="primary-button" type="button" :disabled="!canImport || editing" @click="goToImport(true)">
|
<el-icon><UploadFilled /></el-icon>三 Sheet 一键导入</button
|
><button class="primary-button" type="button" @click="goToRevisions">
|
<el-icon><Clock /></el-icon>修订记录</button
|
><button v-if="!editing" class="primary-button" type="button" @click="openBatchEditor">
|
<el-icon><EditPen /></el-icon>批量修改</button
|
><template v-else
|
><button class="primary-button" type="button" @click="saveBatchEdit">
|
<el-icon><Check /></el-icon>保存修改</button
|
><button class="secondary-button" type="button" @click="cancelBatchEdit">取消</button></template
|
><button class="danger-button" type="button" :disabled="editing" @click="deleteSelected">删除</button>
|
</div>
|
<span v-if="loadError" class="load-error">{{ loadError }}</span
|
><span v-else>{{ editing ? '编辑模式:仅允许修改当前页规定字段,保存后填写原因并一次提交' : activeTab === 'realtime' ? '实时采样记录 · 已持久化 · 当前有效值' : `${activeTab === 'step' ? '工步' : '循环'}记录 · 已持久化 · 当前有效值` }}</span>
|
<div class="toolbar-group">
|
<button class="secondary-button" type="button" :disabled="loading || editing" @click="loadRecords">
|
<el-icon><Refresh /></el-icon>刷新</button
|
><button class="secondary-button" type="button" :disabled="editing" @click="downloadCsv">
|
<el-icon><Download /></el-icon>下载
|
</button>
|
</div>
|
</div>
|
<div class="table-wrap">
|
<table>
|
<thead>
|
<tr>
|
<th class="selection-column">
|
<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 v-if="activeTab === 'realtime'" title="设备侧的对照串(厂商库名与记录号等),用于跟厂商软件核对">来源定位</th>
|
<th class="action-column">操作</th>
|
</tr>
|
</thead>
|
<tbody>
|
<template v-for="row in pagedRows" :key="row.id"
|
><tr v-if="activeTab === 'realtime'">
|
<td class="selection-column">
|
<el-checkbox :model-value="selectedIds.has(row.id)" aria-label="选择记录" @change="(checked: string | number | boolean) => toggleRowSelection(row.id, Boolean(checked))" />
|
</td>
|
<td class="record-code">{{ row.recordNo }}</td>
|
<td>
|
<strong>{{ row.batteryNo }}</strong>
|
</td>
|
<td>{{ row.cycleNo }}</td>
|
<td>{{ row.stepNo }}</td>
|
<td>{{ row.stepName }}</td>
|
<td>{{ row.sequenceId }}</td>
|
<td>{{ row.relativeTimeSeconds }}</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].voltage" size="small" />
|
<span v-else>{{ row.voltage }}</span>
|
</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].current" size="small" />
|
<span v-else>{{ row.current }}</span>
|
</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].capacity" size="small" />
|
<span v-else>{{ row.capacity }}</span>
|
</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].energy" size="small" />
|
<span v-else>{{ row.energy }}</span>
|
</td>
|
<td>{{ row.absoluteTime }}</td>
|
<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>
|
</tr>
|
<tr v-else-if="activeTab === 'step'">
|
<td class="selection-column">
|
<el-checkbox :model-value="selectedIds.has(row.id)" aria-label="选择记录" @change="(checked: string | number | boolean) => toggleRowSelection(row.id, Boolean(checked))" />
|
</td>
|
<td class="record-code">{{ row.recordNo }}</td>
|
<td>
|
<strong>{{ row.batteryNo }}</strong>
|
</td>
|
<td>{{ row.cycleNo }}</td>
|
<td>{{ row.stepNo }}</td>
|
<td>{{ row.stepName }}</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].stepDuration" size="small" />
|
<span v-else>{{ row.stepDuration }}</span>
|
</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].capacity" size="small" />
|
<span v-else>{{ row.capacity }}</span>
|
</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].energy" size="small" />
|
<span v-else>{{ row.energy }}</span>
|
</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].startVoltage" size="small" />
|
<span v-else>{{ row.startVoltage }}</span>
|
</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].endVoltage" size="small" />
|
<span v-else>{{ row.endVoltage }}</span>
|
</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].chargeMedianVoltage" size="small" />
|
<span v-else>{{ row.chargeMedianVoltage }}</span>
|
</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].dischargeMedianVoltage" size="small" />
|
<span v-else>{{ row.dischargeMedianVoltage }}</span>
|
</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>
|
<tr v-else>
|
<td class="selection-column">
|
<el-checkbox :model-value="selectedIds.has(row.id)" aria-label="选择记录" @change="(checked: string | number | boolean) => toggleRowSelection(row.id, Boolean(checked))" />
|
</td>
|
<td class="record-code">{{ row.recordNo }}</td>
|
<td>
|
<strong>{{ row.batteryNo }}</strong>
|
</td>
|
<td>{{ row.cycleNo }}</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].chargeCapacity" size="small" />
|
<span v-else>{{ row.chargeCapacity }}</span>
|
</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].dischargeCapacity" size="small" />
|
<span v-else>{{ row.dischargeCapacity }}</span>
|
</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].efficiency" size="small" />
|
<span v-else>{{ row.efficiency }}</span>
|
</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].chargeEnergy" size="small" />
|
<span v-else>{{ row.chargeEnergy }}</span>
|
</td>
|
<td>
|
<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' ? 18 : activeTab === 'step' ? 14 : 11" class="empty-cell">
|
{{ loadError || '暂无检测柜记录' }}
|
</td>
|
</tr>
|
</tbody>
|
</table>
|
</div>
|
<footer class="pagination-bar">
|
<span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[20, 50, 100]" :total="filteredRows.length" layout="sizes, prev, pager, next" background />
|
</footer>
|
<el-dialog v-model="editorVisible" title="编辑数据" width="860px" align-center>
|
<template v-if="singleRow">
|
<dl class="single-step-summary">
|
<div>
|
<dt>记录编号</dt>
|
<dd>{{ singleRow.recordNo }}</dd>
|
</div>
|
<div>
|
<dt>电池号</dt>
|
<dd>{{ singleRow.batteryNo }}</dd>
|
</div>
|
<div>
|
<dt>循环号</dt>
|
<dd>{{ singleRow.cycleNo }}</dd>
|
</div>
|
<div>
|
<dt>工步号</dt>
|
<dd>{{ singleRow.stepNo }}</dd>
|
</div>
|
<div>
|
<dt>工步名称</dt>
|
<dd>{{ singleRow.stepName }}</dd>
|
</div>
|
<div>
|
<dt>绝对时间</dt>
|
<dd>{{ singleRow.recordedAt }}</dd>
|
</div>
|
<div>
|
<dt>服务器接收</dt>
|
<dd>{{ singleRow.serverReceivedAt }}</dd>
|
</div>
|
</dl>
|
<div class="single-form-grid">
|
<label v-for="field in editorFieldOptions" :key="String(field.value)"
|
><span>{{ field.label }}</span
|
><el-input v-model="singleRow[field.value as keyof DataRow]" inputmode="decimal" /></label
|
><label class="remark-field"><span>修改原因</span><el-input v-model="editForm.remark" type="textarea" :rows="3" maxlength="120" show-word-limit placeholder="请输入数据修改原因,提交后将写入修订记录" /></label>
|
</div>
|
</template>
|
<template #footer
|
><button class="secondary-button" type="button" @click="editorVisible = false">取消</button
|
><button class="primary-button" type="button" @click="submitBatchEdit">
|
<el-icon><Check /></el-icon>提交修改
|
</button></template
|
>
|
</el-dialog>
|
</section>
|
</template>
|
<style scoped>
|
.danger-button {
|
height: 34px;
|
padding: 0 13px;
|
border: 1px solid #ee6666;
|
border-radius: 3px;
|
color: #fff;
|
background: #ee6666;
|
cursor: pointer;
|
}
|
.danger-button:disabled {
|
opacity: 0.5;
|
cursor: not-allowed;
|
}
|
.console-data-panel {
|
min-width: 0;
|
border: 1px solid #d8e1e9;
|
background: #fff;
|
}
|
.section-heading {
|
position: relative;
|
height: 60px;
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
padding: 0 16px;
|
border-bottom: 1px solid var(--line);
|
background: var(--surface-soft);
|
}
|
.section-heading > div {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
}
|
.section-heading i {
|
width: 3px;
|
height: 16px;
|
background: #2b78a3;
|
}
|
.section-heading strong {
|
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;
|
}
|
.section-heading small {
|
color: #647380;
|
font-size: 11px;
|
}
|
.data-tabs {
|
position: absolute;
|
top: 50%;
|
left: 50%;
|
display: flex;
|
justify-content: center;
|
gap: 8px;
|
padding: 0;
|
border: 0;
|
background: transparent;
|
transform: translate(-50%, -50%);
|
}
|
.data-tabs button {
|
height: 32px;
|
min-width: 78px;
|
padding: 0 18px;
|
border: 1px solid #cbd8e5;
|
border-radius: 4px;
|
color: #52606d;
|
background: #fff;
|
box-shadow: 0 1px 4px rgba(40, 75, 105, 0.08);
|
font-size: 13px;
|
font-weight: 600;
|
cursor: pointer;
|
transition:
|
color 0.16s ease,
|
background 0.16s ease,
|
border-color 0.16s ease,
|
box-shadow 0.16s ease;
|
}
|
.data-tabs button:hover {
|
color: #1677dd;
|
border-color: #8ec4f4;
|
box-shadow: 0 3px 8px rgba(36, 128, 220, 0.14);
|
}
|
.data-tabs button.active {
|
color: #fff;
|
background: #1687ed;
|
border-color: #1677dd;
|
box-shadow: 0 3px 8px rgba(26, 112, 220, 0.24);
|
}
|
.filter-grid {
|
display: grid;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
gap: 14px 18px;
|
padding: 16px;
|
}
|
.filter-grid label {
|
min-width: 0;
|
display: grid;
|
grid-template-columns: 68px minmax(0, 1fr);
|
align-items: center;
|
gap: 8px;
|
}
|
.filter-grid label > span {
|
color: #52606d;
|
font-size: 12px;
|
}
|
.filter-grid :deep(.el-select),
|
.filter-grid :deep(.el-date-editor) {
|
width: 100%;
|
}
|
.filter-actions {
|
grid-column: -2/-1;
|
display: flex;
|
justify-content: flex-end;
|
gap: 8px;
|
}
|
.primary-button,
|
.secondary-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;
|
cursor: pointer;
|
white-space: nowrap;
|
}
|
.primary-button {
|
color: #fff;
|
background: var(--blue);
|
}
|
.secondary-button {
|
color: #52606d;
|
background: #fff;
|
border-color: var(--line-strong);
|
}
|
.secondary-button:disabled {
|
opacity: 0.5;
|
cursor: not-allowed;
|
}
|
.data-toolbar {
|
min-height: 52px;
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 12px;
|
margin: 0 16px;
|
border-top: 1px solid #d8e1e9;
|
color: #7b8794;
|
font-size: 11px;
|
}
|
.data-toolbar > div {
|
display: flex;
|
flex-wrap: wrap;
|
max-width: 100%;
|
gap: 8px;
|
}
|
.data-toolbar button {
|
white-space: nowrap;
|
}
|
.load-error {
|
color: #b34f4f;
|
}
|
.table-wrap {
|
width: 100%;
|
overflow-x: auto;
|
}
|
table {
|
width: 100%;
|
min-width: 1260px;
|
table-layout: fixed;
|
border-collapse: collapse;
|
color: #52606d;
|
font-size: 12px;
|
}
|
th,
|
td {
|
height: 44px;
|
padding: 0 10px;
|
border-right: 1px solid #dde7f0;
|
border-bottom: 1px solid #dde7f0;
|
text-align: center;
|
white-space: nowrap;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
}
|
th {
|
height: 40px;
|
color: #52606d;
|
background: #eaf4ff;
|
border-color: #c7e0ff;
|
font-weight: 600;
|
}
|
.record-code {
|
font-family: 'Cascadia Mono', Consolas, monospace;
|
font-size: 11px;
|
}
|
.source-column {
|
width: 280px;
|
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;
|
}
|
.console-data-panel.realtime th:nth-child(8),
|
.console-data-panel.realtime th:nth-child(13) {
|
width: 185px;
|
}
|
.source-column small {
|
display: block;
|
line-height: 1.6;
|
}
|
.empty-cell {
|
height: 150px;
|
color: #8795a3;
|
}
|
.pagination-bar {
|
min-height: 58px;
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 16px;
|
padding: 8px 14px 8px 16px;
|
}
|
.pagination-bar > span {
|
color: #7b8794;
|
font-size: 11px;
|
}
|
.pagination-bar :deep(.el-pagination) {
|
--el-pagination-button-bg-color: #edf4fa;
|
--el-pagination-hover-color: var(--blue);
|
}
|
@media (max-width: 900px) {
|
.section-heading {
|
height: auto;
|
min-height: 90px;
|
flex-wrap: wrap;
|
gap: 8px;
|
padding-top: 10px;
|
padding-bottom: 10px;
|
}
|
.data-tabs {
|
position: static;
|
order: 3;
|
flex-basis: 100%;
|
transform: none;
|
}
|
.filter-grid {
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
}
|
.data-toolbar {
|
align-items: flex-start;
|
flex-direction: column;
|
padding: 10px 0;
|
}
|
}
|
@media (max-width: 600px) {
|
.filter-grid {
|
grid-template-columns: 1fr;
|
padding: 14px 12px;
|
}
|
.filter-actions {
|
grid-column: auto;
|
}
|
.filter-actions button {
|
flex: 1;
|
}
|
.data-tabs {
|
position: static;
|
transform: none;
|
gap: 6px;
|
padding: 0 8px;
|
}
|
.data-tabs button {
|
min-width: 0;
|
flex: 1;
|
padding: 0 12px;
|
}
|
.data-toolbar {
|
margin: 0 12px;
|
}
|
.pagination-bar {
|
align-items: flex-start;
|
flex-direction: column;
|
overflow-x: auto;
|
}
|
}
|
.toolbar-group {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
}
|
.selection-column {
|
width: 46px;
|
padding: 0;
|
}
|
.edit-form {
|
display: grid;
|
gap: 14px;
|
}
|
.edit-form label {
|
display: grid;
|
grid-template-columns: 82px minmax(0, 1fr);
|
align-items: center;
|
gap: 10px;
|
}
|
.edit-form label > span {
|
color: #52606d;
|
font-size: 12px;
|
}
|
.edit-form :deep(.el-select) {
|
width: 100%;
|
}
|
.danger-text {
|
color: #d94b4b;
|
}
|
.action-column {
|
position: sticky;
|
right: 0;
|
z-index: 2;
|
width: 112px;
|
background: #fff;
|
overflow: visible;
|
box-shadow:
|
inset 1px 0 0 #b4c7d9,
|
-8px 0 14px rgba(23, 49, 74, 0.1);
|
}
|
th.action-column {
|
z-index: 3;
|
background: #eaf4ff;
|
}
|
.row-actions {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
gap: 12px;
|
}
|
.row-actions button {
|
border: 0;
|
color: var(--blue);
|
background: transparent;
|
font-size: 12px;
|
cursor: pointer;
|
}
|
.row-actions .delete-action {
|
color: #ad4a4a;
|
}
|
.single-step-summary {
|
display: grid;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
gap: 14px 22px;
|
margin: 0;
|
padding: 4px 0 20px;
|
border-bottom: 1px solid #d8e1e9;
|
}
|
.single-step-summary > div {
|
display: flex;
|
align-items: center;
|
gap: 10px;
|
min-width: 0;
|
}
|
.single-step-summary dt {
|
color: #71808d;
|
font-size: 12px;
|
white-space: nowrap;
|
}
|
.single-step-summary dd {
|
min-width: 0;
|
color: #405261;
|
font-size: 12px;
|
font-weight: 600;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
.single-form-grid {
|
display: grid;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
gap: 18px 24px;
|
padding-top: 22px;
|
}
|
.single-form-grid label {
|
display: grid;
|
gap: 8px;
|
}
|
.single-form-grid label > span {
|
color: #52606d;
|
font-size: 12px;
|
font-weight: 500;
|
}
|
.single-form-grid .remark-field {
|
grid-column: 1/-1;
|
}
|
.single-form-grid :deep(.el-input__wrapper),
|
.single-form-grid :deep(.el-textarea__inner) {
|
border-radius: 2px;
|
background: #f6f8fa;
|
box-shadow: 0 0 0 1px #e0e7ed inset;
|
}
|
@media (max-width: 760px) {
|
.single-step-summary,
|
.single-form-grid {
|
grid-template-columns: 1fr;
|
}
|
}
|
/* 数据子页沿用连续分段控件;窄屏另起一行,避免覆盖标题。 */
|
.data-tabs {
|
width: 228px;
|
gap: 0;
|
padding: 0;
|
border: 1px solid #2188f5;
|
border-radius: 5px;
|
overflow: hidden;
|
background: #fff;
|
box-shadow: none;
|
}
|
.data-tabs button {
|
flex: 1;
|
min-width: 0;
|
height: 30px;
|
padding: 0 8px;
|
border: 0;
|
border-radius: 0;
|
color: #2188f5;
|
background: #fff;
|
box-shadow: none;
|
font-size: 13px;
|
font-weight: 500;
|
}
|
.data-tabs button + button {
|
border-left: 1px solid #2188f5;
|
}
|
.data-tabs button:hover {
|
color: #1677dd;
|
background: #eef7ff;
|
box-shadow: none;
|
transform: none;
|
}
|
.data-tabs button.active {
|
color: #fff;
|
background: #2188f5;
|
border-color: transparent;
|
box-shadow: none;
|
}
|
@media (max-width: 600px) {
|
.data-tabs {
|
width: min(228px, calc(100vw - 32px));
|
}
|
.data-tabs button {
|
padding: 0 6px;
|
font-size: 13px;
|
}
|
}
|
</style>
|