<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 { submitConsoleDataAction, type ConsoleDataType } from '@/api/consoleData'
|
|
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
|
absoluteTime: string
|
serverReceivedAt: string
|
stepDuration: string
|
startVoltage: string
|
endVoltage: string
|
chargePeakVoltage: string
|
dischargeValleyVoltage: string
|
voltage: string
|
current: string
|
capacity: string
|
energy: string
|
temperature: string
|
positivePotential: string
|
negativePotential: string
|
extensionMeasurements: string
|
chargeCapacity: string
|
dischargeCapacity: string
|
efficiency: string
|
chargeEnergy: string
|
dischargeEnergy: 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 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: '电流(A)', value: 'current' },
|
{ label: '容量(Ah)', value: 'capacity' },
|
{ label: '能量(Wh)', value: 'energy' },
|
{ label: '温度(℃)', value: 'temperature' },
|
{ label: '正电位(V)', value: 'positivePotential' },
|
{ label: '负电位(V)', value: 'negativePotential' },
|
]
|
: activeTab.value === 'step'
|
? [
|
{ label: '工步时间', value: 'stepDuration' },
|
{ label: '容量(Ah)', value: 'capacity' },
|
{ label: '能量(Wh)', value: 'energy' },
|
{ label: '起始电压(V)', value: 'startVoltage' },
|
{ label: '终止电压(V)', value: 'endVoltage' },
|
{ label: '充电峰值电压(V)', value: 'chargePeakVoltage' },
|
{ label: '放电谷值电压(V)', value: 'dischargeValleyVoltage' },
|
]
|
: [
|
{ label: '充电容量(Ah)', value: 'chargeCapacity' },
|
{ label: '放电容量(Ah)', value: 'dischargeCapacity' },
|
{ label: '效率(%)', value: 'efficiency' },
|
{ label: '充电能量(Wh)', value: 'chargeEnergy' },
|
{ label: '放电能量(Wh)', value: 'dischargeEnergy' },
|
],
|
)
|
function formatTime(value?: string | null) {
|
return value
|
? value
|
.replace('T', ' ')
|
.replace('Z', '')
|
.replace(/\.\d{3,6}$/, '')
|
: '-'
|
}
|
function emptyRow(base: Partial<DataRow>): DataRow {
|
return { id: '', sourceType: 'CABINET', metricCode: '-', metricValue: '-', unit: '-', collectorCode: '-', channelCode: '-', recordNo: '', batteryNo: '', cycleNo: 0, stepNo: 0, stepName: '', actualStepLabel: '-', sequenceId: 0, recordedAt: '', absoluteTime: '', serverReceivedAt: '', stepDuration: '-', startVoltage: '-', endVoltage: '-', chargePeakVoltage: '-', dischargeValleyVoltage: '-', voltage: '-', current: '-', capacity: '-', energy: '-', temperature: '-', positivePotential: '-', negativePotential: '-', extensionMeasurements: '-', chargeCapacity: '-', dischargeCapacity: '-', efficiency: '-', chargeEnergy: '-', dischargeEnergy: '-', ...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, any>).map(([code, value]) => `${code}=${value?.value ?? value?.metricValue ?? '-'}${value?.unit ? ` ${value.unit}` : ''}`).join('; ') || '-'
|
} catch { extensionMeasurements = String(record.extensionMeasurementsJson) }
|
}
|
return emptyRow({ id: identity, sourceType: record.sourceType || 'CABINET', metricCode: record.metricCode || '-', metricValue: extensionValue, unit: record.unit || '-', collectorCode: record.collectorCode || '-', channelCode: record.channelCode || '-', recordNo: record.recordNo, 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, absoluteTime: deviceTime, serverReceivedAt: formatTime(record.serverReceivedAt), voltage: collector ? '-' : (record.voltage == null ? '-' : Number(record.voltage).toFixed(4)), current: collector ? '-' : (record.current == null ? '-' : Number(record.current).toFixed(4)), capacity: collector ? '-' : (record.capacity == null ? '-' : Number(record.capacity).toFixed(4)), energy: collector ? '-' : (record.energy == null ? '-' : Number(record.energy).toFixed(4)), 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: record.recordNo, 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: record.stepDuration == null ? '-' : String(record.stepDuration), capacity: record.capacity == null ? '-' : Number(record.capacity).toFixed(4), energy: record.energy == null ? '-' : Number(record.energy).toFixed(4), startVoltage: record.startVoltage == null ? '-' : Number(record.startVoltage).toFixed(4), endVoltage: record.endVoltage == null ? '-' : Number(record.endVoltage).toFixed(4), chargePeakVoltage: record.chargePeakVoltage == null ? '-' : Number(record.chargePeakVoltage).toFixed(4), dischargeValleyVoltage: record.dischargeValleyVoltage == null ? '-' : Number(record.dischargeValleyVoltage).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: record.recordNo, 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: record.chargeCapacity == null ? '-' : Number(record.chargeCapacity).toFixed(4), dischargeCapacity: record.dischargeCapacity == null ? '-' : Number(record.dischargeCapacity).toFixed(4), efficiency: record.efficiency == null ? '-' : Number(record.efficiency).toFixed(2), chargeEnergy: record.chargeEnergy == null ? '-' : Number(record.chargeEnergy).toFixed(4), dischargeEnergy: record.dischargeEnergy == null ? '-' : Number(record.dischargeEnergy).toFixed(4) })
|
}
|
async function loadRecords() {
|
loading.value = true
|
loadError.value = ''
|
try {
|
if (props.receptionStatus === 'NOT_STARTED') {
|
rows.value = []
|
stepRows.value = []
|
cycleRows.value = []
|
return
|
}
|
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)
|
} catch (error) {
|
rows.value = []
|
stepRows.value = []
|
cycleRows.value = []
|
loadError.value = error instanceof Error ? error.message : '测试数据加载失败'
|
} 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 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() {
|
router.push({ path: `/test-list/${props.testId}/console/data/import`, query: { testName: props.testName, completed: props.canImport ? '1' : '0', dataType: dataType.value } })
|
}
|
function goToRevisions() {
|
router.push({ path: '/data-revision', query: { testId: String(props.testId), dataType: dataType.value } })
|
}
|
function templateHeaders() {
|
return activeTab.value === 'cycle' ? ['记录编号', '电池号', '循环序号', '充放电容量(Ah)', '效率(%)', '能量(Wh)'] : activeTab.value === 'step' ? ['记录编号', '电池号', '循环序号', '工步序号', '工步', '工步时间', '容量(Ah)', '能量(Wh)', '起止电压(V)', '充电峰值电压(V)', '放电谷值电压(V)'] : ['记录编号', '电池号', '设备时间', '循环序号', '工步序号', '工步名称', '记录序号', '服务器接收时间', '电压(V)', '电流(A)', '容量(Ah)', '能量(Wh)']
|
}
|
function realtimeTemplateHeaders() {
|
return [...templateHeaders(), '温度(℃)', '正电位(V)', '负电位(V)']
|
}
|
function downloadTemplate() {
|
const worksheet = XLSX.utils.aoa_to_sheet([activeTab.value === 'realtime' ? realtimeTemplateHeaders() : templateHeaders()])
|
const workbook = XLSX.utils.book_new()
|
XLSX.utils.book_append_sheet(workbook, worksheet, `${activeTab.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 {
|
/* 用户取消原因填写 */
|
}
|
}
|
async function submitBatchEdit() {
|
if (
|
singleEditing.value &&
|
singleRow.value &&
|
editorFieldOptions.value.some(field => {
|
const value = singleRow.value?.[field.value as keyof DataRow]
|
return !String(value ?? '').trim() || !Number.isFinite(Number(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.map(item => [item.value, row[item.value as keyof DataRow]]))
|
const after = { ...before, ...values }
|
return { identity: id, before, after, override: { stableIdentity: id, fieldValues: JSON.stringify(values), 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(), overrides: physicalIds.map(stableIdentity => ({ stableIdentity, fieldValues: null, deleted: true })) })
|
if (activeTab.value === 'realtime') rows.value = rows.value.filter(row => !ids.includes(row.id))
|
else if (activeTab.value === 'step') stepRows.value = stepRows.value.filter(row => !physicalIds.includes(row.id))
|
else cycleRows.value = cycleRows.value.filter(row => !ids.includes(row.id))
|
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
|
currentPage.value = 1
|
// 切换数据子页时主动同步一次,除此之外不进行后台轮询。
|
void loadRecords()
|
}
|
function escapeCsv(value: unknown) {
|
return `"${String(value ?? '').replaceAll('"', '""')}"`
|
}
|
function downloadCsv() {
|
if (!filteredRows.value.length) {
|
ElMessage.warning('没有可下载的数据')
|
return
|
}
|
const headers = activeTab.value === 'cycle' ? ['记录编号', '电池', '循环号', '充电容量(Ah)', '放电容量(Ah)', '充放电效率(%)', '充电能量(Wh)', '放电能量(Wh)'] : activeTab.value === 'step' ? ['记录编号', '电池', '循环号', '工步序号', '工步', '工步时间', '容量(Ah)', '能量(Wh)', '起始电压(V)', '终止电压(V)', '充电峰值(V)', '放电谷值(V)'] : ['记录编号', '电池', '循环号', '实际工步号', '实际工步名称', '记录序号', '设备时间', '服务器接收时间', '电压(V)', '电流(A)', '容量(Ah)', '能量(Wh)', '温度(℃)', '正电位(V)', '负电位(V)']
|
const records = activeTab.value === 'cycle' ? filteredRows.value.map(row => [row.recordNo, row.batteryNo, row.cycleNo, row.chargeCapacity, row.dischargeCapacity, row.efficiency, row.chargeEnergy, row.dischargeEnergy]) : activeTab.value === 'step' ? filteredRows.value.map(row => [row.recordNo, row.batteryNo, row.cycleNo, row.stepNo, row.stepName, row.stepDuration, row.capacity, row.energy, row.startVoltage, row.endVoltage, row.chargePeakVoltage, row.dischargeValleyVoltage]) : filteredRows.value.map(row => [row.recordNo, row.batteryNo, row.cycleNo, row.stepNo, row.stepName, row.sequenceId, row.recordedAt, row.serverReceivedAt, row.voltage, row.current, row.capacity, row.energy, row.temperature, row.positivePotential, row.negativePotential])
|
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">
|
<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>
|
<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" @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' ? 'record' : activeTab === 'step' ? 'step' : 'cycle'} 数据` }}</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 v-if="activeTab === 'realtime'">
|
<th class="selection-column">
|
<el-checkbox :model-value="allPageSelected" :indeterminate="somePageSelected" aria-label="选择本页" @change="(checked: string | number | boolean) => togglePageSelection(Boolean(checked))" />
|
</th>
|
<th>记录编号</th>
|
<th>电池</th>
|
<th>循环号</th>
|
<th>实际工步号</th>
|
<th>实际工步名称</th>
|
<th>记录序号</th>
|
<th>设备时间</th>
|
<th>服务器接收时间</th>
|
<th>来源</th>
|
<th>温度(℃)</th>
|
<th>正电位(V)</th>
|
<th>负电位(V)</th>
|
<th>电压(V)</th>
|
<th>电流(A)</th>
|
<th>容量(Ah)</th>
|
<th>能量(Wh)</th>
|
<th class="action-column">操作</th>
|
</tr>
|
<tr v-else-if="activeTab === 'step'">
|
<th class="selection-column"></th>
|
<th>记录编号</th>
|
<th>电池</th>
|
<th>循环号</th>
|
<th>工步</th>
|
<th>设备时间</th>
|
<th>服务器接收时间</th>
|
<th>工步时间</th>
|
<th>容量(Ah)</th>
|
<th>能量(Wh)</th>
|
<th>起始电压(V)</th>
|
<th>终止电压(V)</th>
|
<th>充电峰值(V)</th>
|
<th>放电谷值(V)</th>
|
<th class="action-column">操作</th>
|
</tr>
|
<tr v-else>
|
<th class="selection-column"></th>
|
<th>记录编号</th>
|
<th>电池</th>
|
<th>循环号</th>
|
<th>设备时间</th>
|
<th>服务器接收时间</th>
|
<th>充电容量(Ah)</th>
|
<th>放电容量(Ah)</th>
|
<th>充放电效率(%)</th>
|
<th>充电能量(Wh)</th>
|
<th>放电能量(Wh)</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.recordedAt }}</td>
|
<td>{{ row.serverReceivedAt }}</td>
|
<td>{{ row.sourceType === 'COLLECTOR' ? `采集器 ${row.collectorCode}` : row.sourceType === 'SNAPSHOT' ? '检测柜 + 采集器' : '检测柜' }}<small v-if="row.sourceType === 'COLLECTOR'">{{ row.channelCode }}</small></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>
|
<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 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 }} · {{ row.stepName }}</td>
|
<td>{{ row.recordedAt }}</td>
|
<td>{{ row.serverReceivedAt }}</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].chargePeakVoltage" size="small" />
|
<span v-else>{{ row.chargePeakVoltage }}</span>
|
</td>
|
<td>
|
<el-input v-if="editing && draftRows[row.id]" v-model="draftRows[row.id].dischargeValleyVoltage" size="small" />
|
<span v-else>{{ row.dischargeValleyVoltage }}</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>{{ row.recordedAt }}</td>
|
<td>{{ row.serverReceivedAt }}</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 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' ? 19 : activeTab === 'step' ? 15 : 12" 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;
|
}
|
.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;
|
gap: 8px;
|
}
|
.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;
|
}
|
.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) {
|
.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>
|