| | |
| | | <script setup lang="ts"> |
| | | import { toTestDisplayValue } from '@/utils/testDataUnits' |
| | | import { computed, reactive, ref, watch, onMounted } from 'vue' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { use } from 'echarts/core' |
| | | import { LineChart } from 'echarts/charts' |
| | | import { CanvasRenderer } from 'echarts/renderers' |
| | | import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from 'echarts/components' |
| | | import VChart from 'vue-echarts' |
| | | import { getCurrentUser } from '@/utils/auth' |
| | | import { getBatteryGroupFields, type BatteryGroupField } from '@/api/batteryGroupFields' |
| | | import { useSpliceReports, type SpliceReportPayload, type SpliceTestSelection } from '@/stores/spliceReports' |
| | | import { getSpliceSources, getSpliceReport, getSpliceVersion, listSpliceVersions, type SavedSpliceReport } from '@/api/spliceReports' |
| | | import { getUserInfo } from '@/api/auth' |
| | | import { getSpliceSources, previewSpliceReport, createSpliceReport, editSpliceReport, refreshSpliceSource, |
| | | getSpliceReport, getSpliceVersion, listSpliceVersions, spliceStatisticKey, spliceSourceKey, spliceReportDetailColumn, |
| | | type SpliceCandidate, type SpliceMatrix, type SpliceSettings, type SavedSpliceReport } from '@/api/spliceReports' |
| | | import { openSpliceSavedReportPrint } from '@/utils/spliceSavedReport' |
| | | import ProjectCycleCharts from '@/components/ProjectCycleCharts.vue' |
| | | import { spliceProjectSeries } from '@/utils/spliceProjectChart' |
| | | import SpliceSavedReport from '@/components/SpliceSavedReport.vue' |
| | | import { formationFixedReportFields, formationMetricDefinitions, type FormationMetricDefinition, type FormationMetricKey } from '@/utils/formationReportPrint' |
| | | |
| | | use([CanvasRenderer, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent]) |
| | | import ReportTestDetail from '@/components/ReportTestDetail.vue' |
| | | import { snapshotReportDetails } from '@/utils/reportTestDetails' |
| | | |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | | const currentUser = getCurrentUser() |
| | | const { createReport, updateReport } = useSpliceReports() |
| | | const managedGroupFields = ref<BatteryGroupField[]>([]) |
| | | const editingId = computed(() => { |
| | | const value = Number(route.params.id) |
| | | return Number.isFinite(value) && value > 0 ? value : null |
| | | }) |
| | | const editingId = computed(() => Number(route.params.id) > 0 ? Number(route.params.id) : null) |
| | | const readonly = computed(() => route.query.mode === 'view' || Boolean(route.query.version)) |
| | | const savedReport = ref<SavedSpliceReport | null>(null) |
| | | const savedContent = ref('') |
| | | const loading = ref(false) |
| | | const loadError = ref('') |
| | | const saving = ref(false) |
| | | const printing = ref(false) |
| | | const canExport = ref(false) |
| | | const canCreate = ref(false) |
| | | const canEdit = ref(false) |
| | | const canQuery = ref(false) |
| | | const history = ref<Awaited<ReturnType<typeof listSpliceVersions>>>([]) |
| | | const historyVisible = ref(false) |
| | | const statisticMetrics = [{ key: 'finalVoltage1', title: '最终电压1' }, { key: 'energyEfficiency', title: '能量效率' }] as const |
| | | const reportName = ref('') |
| | | const experimentName = ref('') |
| | | const sources = ref<SpliceCandidate[]>([]) |
| | | const selectedTests = ref<SpliceCandidate[]>([]) |
| | | const samples = ref<string[]>([]) |
| | | const statisticValues = ref<Record<string, string>>({}) |
| | | const sourceDetailOverrides = ref<SpliceSettings['sourceDetailOverrides']>({}) |
| | | const hiddenSeries = ref(false) |
| | | const hiddenSeriesKeys = ref<string[]>([]) |
| | | const statisticMetrics = [{ key: 'finalVoltage1', title: '最终电压1', unit: 'V' }, { key: 'energyEfficiency', title: '能量效率', unit: '%' }] as const |
| | | const statisticVisible = reactive({ finalVoltage1: true, energyEfficiency: true }) |
| | | const statisticSettings = computed(() => ({ |
| | | ...savedReport.value?.settings, |
| | | batteryFields: batteryFields.value, specialFields: specialFields.value, detailFieldValues, |
| | | metrics: statisticMetrics.filter(metric => statisticVisible[metric.key]).map(metric => metric.key), |
| | | seriesColors: seriesColors.value, |
| | | const seriesColors = ref(['#3978f6', '#109854', '#d25b45', '#7754a8']) |
| | | const draftMatrix = ref<SpliceMatrix | null>(null) |
| | | const previewing = ref(false) |
| | | const previewError = ref('') |
| | | let previewRequest = 0 |
| | | let reordered = false |
| | | const addDialogVisible = ref(false) |
| | | const selectedCandidateId = ref<string | null>(null) |
| | | const candidateGroupId = ref<number | null>(null) |
| | | const candidateQuery = ref('') |
| | | const candidateTests = computed(() => sources.value.filter(source => |
| | | (!candidateGroupId.value || source.groupId === candidateGroupId.value) && |
| | | (source.name.includes(candidateQuery.value) || String(source.selection.testId).includes(candidateQuery.value) || source.batteryNo.includes(candidateQuery.value)))) |
| | | const candidateGroups = computed(() => [...new Map(sources.value.map(source => [source.groupId, source.group])).entries()]) |
| | | const selections = computed(() => selectedTests.value.map(item => item.selection)) |
| | | const matrix = computed(() => savedReport.value || draftMatrix.value) |
| | | const projectMode = computed(() => !savedReport.value || savedReport.value.spliceMode === 'project-cycles') |
| | | const projectSeries = computed(() => matrix.value ? spliceProjectSeries(matrix.value) : []) |
| | | const detailColumns = computed(() => matrix.value?.segments || []) |
| | | const reportDetailColumns = computed(() => detailColumns.value.map(spliceReportDetailColumn)) |
| | | const statisticSettings = computed<SpliceSettings>(() => ({ |
| | | sourceDetailOverrides: snapshotReportDetails(reportDetailColumns.value, sourceDetailOverrides.value), |
| | | hiddenSeries: hiddenSeries.value, hiddenSeriesKeys: hiddenSeriesKeys.value, |
| | | fieldStructure: [], batteryFields: [], specialFields: [], detailFieldValues: {}, |
| | | statisticValues: statisticValues.value, |
| | | metrics: projectMode.value ? ['finalVoltage1', 'finalVoltage2', 'dischargeCapacityF'] : statisticMetrics.filter(metric => statisticVisible[metric.key]).map(metric => metric.key), seriesColors: seriesColors.value, |
| | | })) |
| | | const statisticPreview = computed(() => savedReport.value ? { ...savedReport.value, name: reportName.value, description: experimentName.value, settings: statisticSettings.value } : null) |
| | | const statisticPreview = computed<SavedSpliceReport | null>(() => matrix.value ? { |
| | | id: 0, createdAt: 0, creator: '', versionNo: 0, operatorName: '', versionCreatedAt: 0, changeSummary: '', |
| | | spliceMode: 'project-cycles', |
| | | selections: selections.value, ...savedReport.value, ...matrix.value, |
| | | name: reportName.value, description: experimentName.value, settings: statisticSettings.value, |
| | | } : null) |
| | | |
| | | function formContent() { |
| | | return JSON.stringify([reportName.value, experimentName.value, selectedTests.value, batteryFields.value, specialFields.value, detailFieldValues, metricVisible, statisticVisible, seriesColors.value]) |
| | | function formContent() { return JSON.stringify([reportName.value, experimentName.value, statisticSettings.value]) } |
| | | function applySaved(saved: SavedSpliceReport) { |
| | | savedReport.value = saved |
| | | reportName.value = saved.name; experimentName.value = saved.description || '' |
| | | statisticValues.value = { ...saved.settings.statisticValues } |
| | | sourceDetailOverrides.value = saved.settings.sourceDetailOverrides === undefined ? undefined : structuredClone(saved.settings.sourceDetailOverrides) |
| | | hiddenSeries.value = saved.settings.hiddenSeries === true |
| | | hiddenSeriesKeys.value = [...(saved.settings.hiddenSeriesKeys || [])] |
| | | seriesColors.value = [...(saved.settings.seriesColors || ['#3978f6', '#109854', '#d25b45', '#7754a8'])] |
| | | selectedTests.value = saved.segments.map(segment => segment.source) |
| | | samples.value = [...new Set(saved.segments.map(segment => segment.source.batteryNo))] |
| | | const keys = saved.settings.metrics |
| | | statisticMetrics.forEach(metric => { statisticVisible[metric.key] = !keys || (keys.length > 0 && !keys.some(key => statisticMetrics.some(item => item.key === key))) || keys.includes(metric.key) }) |
| | | savedContent.value = formContent() |
| | | } |
| | | async function loadPage() { |
| | | loading.value = true; loadError.value = ''; savedReport.value = null; draftMatrix.value = null |
| | | previewRequest++ |
| | | try { |
| | | const permissions = (await getUserInfo()).permissions || [] |
| | | const has = (action: string) => permissions.includes('*:*:*') || permissions.includes(`system:splice-report:${action}`) |
| | | canExport.value = has('export'); canCreate.value = has('add'); canEdit.value = has('edit'); canQuery.value = has('query') |
| | | if (editingId.value) { |
| | | if (!(readonly.value ? canQuery.value : canEdit.value)) throw new Error(readonly.value ? '无查看拼接报表权限' : '无编辑拼接报表权限') |
| | | const version = route.query.version === undefined ? undefined : Number(route.query.version) |
| | | if (version !== undefined && (!Number.isInteger(version) || version < 1)) throw new Error('报表版本号无效') |
| | | applySaved(version === undefined ? await getSpliceReport(editingId.value, !readonly.value) : await getSpliceVersion(editingId.value, version)) |
| | | } else { |
| | | if (!canCreate.value) throw new Error('无新增拼接报表权限') |
| | | selectedTests.value = []; samples.value = []; reportName.value = ''; experimentName.value = '' |
| | | statisticValues.value = {}; reordered = false |
| | | hiddenSeries.value = false |
| | | hiddenSeriesKeys.value = [] |
| | | sourceDetailOverrides.value = {} |
| | | sources.value = await getSpliceSources() |
| | | savedContent.value = formContent() |
| | | } |
| | | } catch (error) { loadError.value = error instanceof Error ? error.message : '拼接报表加载失败' } |
| | | finally { loading.value = false } |
| | | } |
| | | onMounted(loadPage) |
| | | watch(() => route.fullPath, loadPage) |
| | | watch(selections, async selected => { |
| | | if (editingId.value) return |
| | | const request = ++previewRequest |
| | | draftMatrix.value = null; previewError.value = '' |
| | | // 来源移除后同步移除其人工值,仍在范围内的修改继续保留。 |
| | | statisticValues.value = Object.fromEntries(Object.entries(statisticValues.value).filter(([key]) => |
| | | selected.some(selection => key.startsWith(`${selection.testId}|${selection.testBatteryId}|`)))) |
| | | sourceDetailOverrides.value = Object.fromEntries(Object.entries(sourceDetailOverrides.value || {}).filter(([key]) => |
| | | selected.some(selection => key === `${selection.testId}|${selection.testBatteryId}`))) |
| | | if (selected.length < 2) { previewing.value = false; return } |
| | | previewing.value = true |
| | | try { |
| | | const result = await previewSpliceReport(selected) |
| | | if (request === previewRequest) { |
| | | const allowed = new Set(result.segments.flatMap(segment => segment.originalCycleNos.flatMap(cycle => |
| | | statisticMetrics.map(metric => spliceStatisticKey(segment, cycle, metric.key))))) |
| | | statisticValues.value = Object.fromEntries(Object.entries(statisticValues.value).filter(([key]) => allowed.has(key))) |
| | | draftMatrix.value = result |
| | | } |
| | | } catch (error) { |
| | | if (request === previewRequest) previewError.value = error instanceof Error ? error.message : '统计预览失败' |
| | | } finally { if (request === previewRequest) previewing.value = false } |
| | | }, { deep: true }) |
| | | |
| | | function openAddTest() { selectedCandidateId.value = null; candidateQuery.value = ''; addDialogVisible.value = true } |
| | | function addSpliceTest() { |
| | | const candidate = candidateTests.value.find(source => `${source.selection.testId}:${source.selection.testBatteryId}` === selectedCandidateId.value) |
| | | if (!candidate) { ElMessage.warning('请选择需要拼接的测试'); return } |
| | | if (selectedTests.value.some(item => item.selection.testId === candidate.selection.testId && item.selection.testBatteryId === candidate.selection.testBatteryId)) { ElMessage.warning('该测试电池已添加'); return } |
| | | selectedTests.value.push(candidate) |
| | | if (!reordered) selectedTests.value.sort((a, b) => a.startedAt - b.startedAt || a.selection.testId - b.selection.testId) |
| | | samples.value = [...new Set(selectedTests.value.map(item => item.batteryNo))] |
| | | addDialogVisible.value = false |
| | | } |
| | | function removeSpliceTest(index: number) { |
| | | selectedTests.value.splice(index, 1) |
| | | samples.value = [...new Set(selectedTests.value.map(item => item.batteryNo))] |
| | | } |
| | | function moveTest(index: number, direction: number) { |
| | | const item = selectedTests.value[index] |
| | | if (!item) return |
| | | selectedTests.value.splice(index, 1); selectedTests.value.splice(index + direction, 0, item); reordered = true |
| | | } |
| | | async function printReport() { |
| | | if (!savedReport.value) { ElMessage.warning('请先保存报表后再打印'); return } |
| | |
| | | try { history.value = await listSpliceVersions(editingId.value); historyVisible.value = true } |
| | | catch (error) { ElMessage.error(error instanceof Error ? error.message : '历史版本加载失败') } |
| | | } |
| | | async function openVersion(version: number) { |
| | | historyVisible.value = false |
| | | await router.push({ path: route.path, query: { mode: 'view', version } }) |
| | | } |
| | | |
| | | const reportName = ref('') |
| | | const experimentName = ref('SP2连续工况实验') |
| | | const batteryGroup = ref('SP2样品组') |
| | | const samples = ref(['SP26-1', 'SP26-2', 'SP26-3']) |
| | | const saving = ref(false) |
| | | const selectedTests = ref<SpliceTestSelection[]>([ |
| | | { id: 101, name: 'SP2电池化成测试', type: '化成', durationMinutes: 90 }, |
| | | ]) |
| | | const candidateTests = ref<SpliceTestSelection[]>([ |
| | | { id: 102, name: '化成后静置测试', type: '静置', durationMinutes: 60 }, |
| | | { id: 103, name: '首次容量测试', type: '容量', durationMinutes: 75 }, |
| | | { id: 104, name: '常温倍率放电测试', type: '倍率', durationMinutes: 80 }, |
| | | { id: 105, name: '恢复容量测试', type: '容量', durationMinutes: 70 }, |
| | | { id: 106, name: '循环后静置测试', type: '静置', durationMinutes: 45 }, |
| | | ]) |
| | | async function loadPage() { |
| | | loading.value = true |
| | | loadError.value = '' |
| | | savedReport.value = null |
| | | async function openVersion(version: number) { historyVisible.value = false; await router.push({ path: route.path, query: { mode: 'view', version } }) } |
| | | async function refreshSource() { |
| | | if (!savedReport.value) return |
| | | if (formContent() !== savedContent.value) { ElMessage.warning('有未保存修改,请先保存新版本'); return } |
| | | try { |
| | | const info = await getUserInfo() |
| | | canExport.value = Boolean(info.permissions?.some(permission => permission === '*:*:*' || permission === 'system:splice-report:export')) |
| | | if (editingId.value) { |
| | | const version = route.query.version === undefined ? undefined : Number(route.query.version) |
| | | if (version !== undefined && (!Number.isInteger(version) || version < 1)) throw new Error('报表版本号无效') |
| | | const saved = version === undefined ? await getSpliceReport(editingId.value, !readonly.value) : await getSpliceVersion(editingId.value, version) |
| | | savedReport.value = saved |
| | | reportName.value = saved.name |
| | | experimentName.value = saved.description || '' |
| | | batteryGroup.value = saved.segments[0]?.source.group || '' |
| | | samples.value = [...new Set(saved.segments.map(segment => segment.source.batteryNo))] |
| | | const settings = saved.settings || {} |
| | | statisticMetrics.forEach(metric => { |
| | | statisticVisible[metric.key] = settings.metrics?.length === 0 || settings.metrics?.some(key => statisticMetrics.some(item => item.key === key)) |
| | | ? settings.metrics.includes(metric.key) : true |
| | | }) |
| | | selectedTests.value = saved.selections.map(selection => { |
| | | const source = saved.segments.find(item => item.source.selection.testBatteryId === selection.testBatteryId)?.source |
| | | return { id: selection.testId, testBatteryId: selection.testBatteryId, name: source?.name || `测试${selection.testId}`, type: source?.type || '未知', durationMinutes: 0 } |
| | | }) |
| | | const savedSettings = settings as Record<string, unknown> |
| | | batteryFields.value = Array.isArray(savedSettings.batteryFields) ? [...savedSettings.batteryFields as string[]] : batteryFields.value |
| | | specialFields.value = Array.isArray(savedSettings.specialFields) ? [...savedSettings.specialFields as string[]] : specialFields.value |
| | | Object.keys(detailFieldValues).forEach(field => delete detailFieldValues[field]) |
| | | if (savedSettings.detailFieldValues && typeof savedSettings.detailFieldValues === 'object') Object.entries(savedSettings.detailFieldValues as Record<string, Record<string, string>>).forEach(([field, values]) => { detailFieldValues[field] = { ...values } }) |
| | | formationMetricDefinitions.forEach(metric => { metricVisible[metric.key] = Array.isArray(savedSettings.metrics) ? (savedSettings.metrics as string[]).includes(metric.key) : true }) |
| | | if (Array.isArray(savedSettings.seriesColors)) seriesColors.value = [...savedSettings.seriesColors as string[]] |
| | | dynamicDetailFields.value.forEach(ensureDetailField) |
| | | } else { |
| | | const sources = await getSpliceSources() |
| | | candidateTests.value = sources.map(source => ({ id: source.selection.testId, testBatteryId: source.selection.testBatteryId, name: source.name, type: source.type, durationMinutes: 0 })) |
| | | selectedTests.value = candidateTests.value.length ? [candidateTests.value[0]!] : [] |
| | | } |
| | | savedContent.value = formContent() |
| | | } catch (error) { loadError.value = error instanceof Error ? error.message : '拼接报表加载失败' } |
| | | finally { loading.value = false } |
| | | const { value } = await ElMessageBox.prompt('请输入修改说明', '按最新来源生成新版本', { confirmButtonText: '生成新版本', cancelButtonText: '取消', inputValidator: value => Boolean(value?.trim()) || '请输入修改说明' }) |
| | | saving.value = true |
| | | applySaved(await refreshSpliceSource(savedReport.value.id, savedReport.value.versionNo, value.trim())) |
| | | ElMessage.success('已按最新来源生成新版本') |
| | | } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '更新来源失败') } |
| | | finally { saving.value = false } |
| | | } |
| | | onMounted(loadPage) |
| | | watch(() => route.fullPath, loadPage) |
| | | |
| | | const batteryFields = ref(['额定容量', '内阻']) |
| | | const specialFields = ref(['环境温度']) |
| | | const detailFieldValues = reactive<Record<string, Record<string, string>>>({}) |
| | | const metricVisible = reactive<Record<FormationMetricKey, boolean>>({ voltage: true, current: true, positiveVoltage: true, negativeVoltage: true, temperature: true }) |
| | | const seriesColors = ref(['#3978f6', '#7d42d8', '#f08a3e']) |
| | | const addDialogVisible = ref(false) |
| | | const selectedCandidateId = ref<number | null>(null) |
| | | const templateDialogVisible = ref(false) |
| | | const templateMode = ref<'battery' | 'special'>('battery') |
| | | const selectedBatteryFieldIds = ref<number[]>([]) |
| | | const selectedSpecialTemplateKey = ref('cycle') |
| | | const specialTemplateStorageKey = 'battery_detection_splice_special_templates_v1' |
| | | const savedSpecialTemplates = reactive<Record<string, string[]>>((() => { |
| | | try { |
| | | const stored = localStorage.getItem(specialTemplateStorageKey) |
| | | return stored ? JSON.parse(stored) as Record<string, string[]> : {} |
| | | } catch { |
| | | localStorage.removeItem(specialTemplateStorageKey) |
| | | return {} |
| | | } |
| | | })()) |
| | | |
| | | const enabledMetrics = computed(() => formationMetricDefinitions.filter(metric => metricVisible[metric.key])) |
| | | const dynamicDetailFields = computed(() => [...batteryFields.value, ...specialFields.value]) |
| | | const selectedCandidate = computed(() => candidateTests.value.find(item => item.id === selectedCandidateId.value) || null) |
| | | const batteryFieldOptions = computed(() => [...managedGroupFields.value].sort((a, b) => b.createdAt.localeCompare(a.createdAt))) |
| | | const specialTemplates = computed(() => [ |
| | | { key: 'cycle', name: '循环寿命模板', fields: ['容量保持率', '能量效率', '循环次数', '内阻变化'] }, |
| | | { key: 'thermal', name: '热特性模板', fields: ['最高温度', '温差', '温升速率'] }, |
| | | ...Object.entries(savedSpecialTemplates).map(([name, fields]) => ({ key: `saved:${name}`, name, fields })), |
| | | ]) |
| | | const selectedSpecialTemplate = computed(() => specialTemplates.value.find(item => item.key === selectedSpecialTemplateKey.value) ?? specialTemplates.value[0]!) |
| | | const totalDuration = computed(() => selectedTests.value.reduce((total, item) => total + item.durationMinutes, 0)) |
| | | const timeline = computed(() => { |
| | | const labels: string[] = [] |
| | | const segments: Array<{ id: number; name: string; start: number; end: number; duration: number }> = [] |
| | | let elapsed = 0 |
| | | selectedTests.value.forEach((test, testIndex) => { |
| | | const pointCount = 7 |
| | | for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) { |
| | | if (testIndex > 0 && pointIndex === 0) continue |
| | | const localMinutes = Math.round(test.durationMinutes * pointIndex / (pointCount - 1)) |
| | | labels.push(formatMinutes(elapsed + localMinutes)) |
| | | } |
| | | segments.push({ id: test.id, name: test.name, start: elapsed, end: elapsed + test.durationMinutes, duration: test.durationMinutes }) |
| | | elapsed += test.durationMinutes |
| | | }) |
| | | return { labels, segments } |
| | | }) |
| | | |
| | | function formatMinutes(totalMinutes: number) { |
| | | return `${Math.floor(totalMinutes / 60)}:${String(totalMinutes % 60).padStart(2, '0')}` |
| | | } |
| | | |
| | | function ensureDetailField(field: string) { |
| | | if (!detailFieldValues[field]) detailFieldValues[field] = {} |
| | | samples.value.forEach(sample => { |
| | | if (detailFieldValues[field][sample] === undefined) detailFieldValues[field][sample] = '' |
| | | }) |
| | | } |
| | | |
| | | dynamicDetailFields.value.forEach(ensureDetailField) |
| | | watch(dynamicDetailFields, fields => fields.forEach(ensureDetailField)) |
| | | |
| | | function openAddTest() { |
| | | selectedCandidateId.value = null |
| | | addDialogVisible.value = true |
| | | } |
| | | |
| | | function addSpliceTest() { |
| | | const candidate = selectedCandidate.value |
| | | if (!candidate) { ElMessage.warning('请选择需要拼接的测试'); return } |
| | | if (selectedTests.value.some(item => item.id === candidate.id)) { ElMessage.warning('该测试已经加入拼接'); return } |
| | | selectedTests.value.push({ ...candidate }) |
| | | addDialogVisible.value = false |
| | | ElMessage.success('已添加拼接测试,测试详情保持不变') |
| | | } |
| | | |
| | | function removeSpliceTest(index: number) { |
| | | if (index === 0) { ElMessage.warning('基础测试决定测试详情,不能删除'); return } |
| | | selectedTests.value.splice(index, 1) |
| | | } |
| | | |
| | | function rawDetailValue(fieldIndex: number, sampleIndex: number) { |
| | | return (1.02 + fieldIndex * 0.06 + sampleIndex * 0.03).toFixed(2) |
| | | } |
| | | |
| | | function addSpecialField() { |
| | | ElMessageBox.prompt('请输入要添加的字段名称', '添加项', { |
| | | confirmButtonText: '添加', cancelButtonText: '取消', inputPlaceholder: '例如:环境湿度', |
| | | inputValidator: value => value.trim() ? true : '请输入字段名称', |
| | | }).then(({ value }) => { |
| | | const name = value.trim() |
| | | if (dynamicDetailFields.value.includes(name) || formationFixedReportFields.some(field => field.label === name)) { ElMessage.warning('该字段已经存在'); return } |
| | | specialFields.value.push(name) |
| | | ensureDetailField(name) |
| | | ElMessage.success(`已添加字段“${name}”`) |
| | | }).catch(() => { /* 用户取消 */ }) |
| | | } |
| | | |
| | | function removeSpecialField(field: string) { |
| | | specialFields.value = specialFields.value.filter(item => item !== field) |
| | | delete detailFieldValues[field] |
| | | } |
| | | |
| | | async function openFieldTemplate(mode: 'battery' | 'special') { |
| | | templateMode.value = mode |
| | | if (mode === 'battery') { |
| | | try { managedGroupFields.value = await getBatteryGroupFields() } |
| | | catch (error) { ElMessage.error(error instanceof Error ? error.message : '字段加载失败'); return } |
| | | selectedBatteryFieldIds.value = batteryFieldOptions.value.filter(field => batteryFields.value.includes(field.name)).map(field => field.id) |
| | | } else { |
| | | selectedSpecialTemplateKey.value = 'cycle' |
| | | } |
| | | templateDialogVisible.value = true |
| | | } |
| | | |
| | | function applyFieldTemplate() { |
| | | if (templateMode.value === 'battery') { |
| | | batteryFields.value = selectedBatteryFieldIds.value |
| | | .map(id => batteryFieldOptions.value.find(field => field.id === id)?.name) |
| | | .filter((name): name is string => Boolean(name)) |
| | | specialFields.value = specialFields.value.filter(field => !batteryFields.value.includes(field)) |
| | | } else { |
| | | specialFields.value = selectedSpecialTemplate.value.fields.filter(field => !batteryFields.value.includes(field)) |
| | | } |
| | | dynamicDetailFields.value.forEach(ensureDetailField) |
| | | templateDialogVisible.value = false |
| | | ElMessage.success('字段模板已应用') |
| | | } |
| | | |
| | | async function saveSpecialTemplate() { |
| | | if (!specialFields.value.length) { ElMessage.warning('请先添加特殊字段'); return } |
| | | try { |
| | | const result = await ElMessageBox.prompt('请输入模板名称', '另存特殊字段模板', { |
| | | confirmButtonText: '保存', cancelButtonText: '取消', inputPlaceholder: '输入模板名称', |
| | | inputValidator: value => value.trim() ? true : '请输入模板名称', |
| | | }) |
| | | const name = result.value.trim() |
| | | if (specialTemplates.value.some(item => item.name === name)) { ElMessage.warning('模板名称已存在'); return } |
| | | savedSpecialTemplates[name] = [...specialFields.value] |
| | | localStorage.setItem(specialTemplateStorageKey, JSON.stringify(savedSpecialTemplates)) |
| | | ElMessage.success(`已另存为“${name}”`) |
| | | } catch { /* 用户取消 */ } |
| | | } |
| | | |
| | | function updateSeriesColor(index: number, color: string | null) { |
| | | if (color) seriesColors.value[index] = color |
| | | } |
| | | |
| | | function chartOption(metric: FormationMetricDefinition) { |
| | | const names = samples.value.slice(0, 3) |
| | | const values = names.map((_, sampleIndex) => { |
| | | const data: number[] = [] |
| | | selectedTests.value.forEach((_, testIndex) => { |
| | | const source = metric.values[sampleIndex] || metric.values[0] |
| | | source.forEach((value, pointIndex) => { |
| | | if (testIndex > 0 && pointIndex === 0) return |
| | | const segmentOffset = metric.unit === '℃' ? testIndex * 1.2 : testIndex * 0.035 |
| | | data.push(toTestDisplayValue(metric.key, Number((value + segmentOffset).toFixed(3)))) |
| | | }) |
| | | }) |
| | | return data |
| | | }) |
| | | const visibleValues = values.flat() |
| | | const dataMax = Math.max(0, ...visibleValues) |
| | | const axisMax = Math.max(metric.unit === '℃' ? Math.ceil(dataMax / 10) * 10 : Math.ceil(dataMax * 10) / 10, 1) |
| | | const createYAxis = (position: 'left' | 'right') => ({ |
| | | type: 'value', name: metric.unit, position, min: 0, max: axisMax, |
| | | nameTextStyle: { color: '#8795a3', fontSize: 10 }, |
| | | axisLine: { show: true, lineStyle: { color: '#cbd8e4' } }, |
| | | axisTick: { show: true, lineStyle: { color: '#cbd8e4' } }, |
| | | splitLine: { show: position === 'left', lineStyle: { color: '#e8eef4' } }, |
| | | axisLabel: { color: '#8795a3', fontSize: 10 }, |
| | | }) |
| | | const boundaries = timeline.value.segments.slice(0, -1).map(segment => ({ name: segment.name, xAxis: formatMinutes(segment.end) })) |
| | | return { |
| | | animation: false, |
| | | color: seriesColors.value, |
| | | grid: { top: 48, right: 52, bottom: 46, left: 52 }, |
| | | tooltip: { trigger: 'axis' }, |
| | | legend: { top: 6, right: 8, icon: 'roundRect', itemWidth: 14, itemHeight: 8, textStyle: { fontSize: 11, color: '#52606d' } }, |
| | | xAxis: { type: 'category', boundaryGap: false, name: '累计时间/h:mm', nameLocation: 'middle', nameGap: 30, data: timeline.value.labels, axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10, interval: 'auto' } }, |
| | | yAxis: [createYAxis('left'), createYAxis('right')], |
| | | series: names.map((name, index) => ({ |
| | | name, type: 'line', smooth: true, showSymbol: false, lineStyle: { width: 2 }, data: values[index], |
| | | markLine: index === 0 ? { silent: true, symbol: ['none', 'none'], lineStyle: { color: '#9aabbb', type: 'dashed', width: 1 }, label: { show: true, color: '#52606d', fontSize: 10, formatter: '{b}', position: 'insideEndTop' }, data: boundaries } : undefined, |
| | | })), |
| | | } |
| | | } |
| | | |
| | | async function saveReport() { |
| | | if (!reportName.value.trim()) { ElMessage.warning('请输入拼接报表名称'); return } |
| | | if (selectedTests.value.length < 2) { ElMessage.warning('请至少添加一个拼接测试'); return } |
| | | if (!editingId.value && (!draftMatrix.value || previewing.value)) { ElMessage.warning('请选择至少两个测试电池段,并等待统计预览'); return } |
| | | saving.value = true |
| | | try { |
| | | const payload: SpliceReportPayload = { |
| | | name: reportName.value.trim(), |
| | | creator: currentUser?.displayName || 'Admin', |
| | | experimentName: experimentName.value.trim(), |
| | | batteryGroup: batteryGroup.value, |
| | | samples: [...samples.value], |
| | | tests: selectedTests.value.map(item => ({ ...item })), |
| | | batteryFields: [...batteryFields.value], |
| | | specialFields: [...specialFields.value], |
| | | detailFieldValues: Object.fromEntries(Object.entries(detailFieldValues).map(([field, values]) => [field, { ...values }])), |
| | | metrics: savedReport.value ? statisticSettings.value.metrics : enabledMetrics.value.map(metric => metric.key), |
| | | seriesColors: [...seriesColors.value], |
| | | } |
| | | if (editingId.value) { |
| | | if (!savedReport.value) throw new Error('请重新加载报表后编辑') |
| | | await updateReport(editingId.value, savedReport.value.versionNo, payload) |
| | | } else await createReport(payload) |
| | | ElMessage.success(editingId.value ? '拼接报表已更新' : '拼接报表已保存') |
| | | await router.push('/report-splice') |
| | | const payload = { name: reportName.value.trim(), description: experimentName.value, settings: statisticSettings.value } |
| | | if (editingId.value && savedReport.value) await editSpliceReport(editingId.value, { ...payload, baseVersionNo: savedReport.value.versionNo, changeSummary: '更新拼接报表设置' }) |
| | | else await createSpliceReport({ ...payload, selections: selections.value }) |
| | | ElMessage.success(editingId.value ? '拼接报表已更新' : '拼接报表已保存') |
| | | await router.push('/report-splice') |
| | | } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败,请重试') } |
| | | finally { saving.value = false } |
| | | } |
| | |
| | | |
| | | <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" /> |
| | | <p v-if="loading">正在加载拼接报表</p> |
| | | <SpliceSavedReport v-if="readonly && savedReport && !loading && !loadError" :report="savedReport" /> |
| | | <template v-if="!readonly && !loading && !loadError"> |
| | | <SpliceSavedReport v-if="readonly && !projectMode && savedReport && !loading && !loadError" :report="savedReport" /> |
| | | <template v-if="(!readonly || projectMode) && !loading && !loadError"> |
| | | |
| | | <section class="report-name-panel"><label><span>拼接报表名称</span><el-input v-model="reportName" clearable placeholder="请输入拼接报表名称" /></label></section> |
| | | <section class="report-name-panel"><label><span>拼接报表名称</span><el-input v-model="reportName" clearable placeholder="请输入拼接报表名称" /></label><label><span>报表说明</span><el-input v-model="experimentName" /></label></section> |
| | | |
| | | <section class="test-panel"> |
| | | <header class="panel-heading"><div><strong>拼接测试</strong><span>按照列表顺序横向拼接曲线</span></div><small>累计 {{ formatMinutes(totalDuration) }}</small></header> |
| | | <div class="test-table-wrap"><table class="test-table"><thead><tr><th>顺序</th><th>测试名称</th><th>测试类型</th><th>固定电池号</th><th>拼接时长</th><th v-if="!readonly">操作</th></tr></thead><tbody><tr v-for="(item, index) in selectedTests" :key="item.id"><td><span class="order-index">{{ index + 1 }}</span></td><td>{{ item.name }}</td><td>{{ item.type }}</td><td>{{ samples.join('、') }}</td><td>{{ formatMinutes(item.durationMinutes) }}</td><td v-if="!readonly"><span v-if="index === 0" class="base-lock">基础测试</span><button v-else class="delete-link" type="button" @click="removeSpliceTest(index)">删除</button></td></tr></tbody></table></div> |
| | | <div v-if="!readonly" class="panel-actions"><button class="primary-button compact-button" type="button" @click="openAddTest"><el-icon><Plus /></el-icon>添加拼接</button></div> |
| | | <header class="panel-heading"><div><strong>拼接测试</strong></div><small>{{ selectedTests.length }} 个来源段</small></header> |
| | | <div class="test-table-wrap"><table class="test-table"><thead><tr><th>顺序</th><th>测试名称</th><th>测试类型</th><th>电池组</th><th>电池号</th><th v-if="!editingId">操作</th></tr></thead><tbody> |
| | | <tr v-for="(item, index) in selectedTests" :key="spliceSourceKey(item)"><td><span class="order-index">{{ index + 1 }}</span></td><td>{{ item.name }}</td><td>{{ item.type }}</td><td>{{ item.group }}</td><td>{{ item.batteryNo }}</td> |
| | | <td v-if="!editingId && !readonly"><el-tooltip content="上移"><el-button circle aria-label="上移测试" :disabled="index === 0" @click="moveTest(index, -1)"><el-icon><ArrowUp /></el-icon></el-button></el-tooltip><el-tooltip content="下移"><el-button circle aria-label="下移测试" :disabled="index === selectedTests.length - 1" @click="moveTest(index, 1)"><el-icon><ArrowDown /></el-icon></el-button></el-tooltip><button class="delete-link" @click="removeSpliceTest(index)">删除</button></td></tr> |
| | | <tr v-if="!selectedTests.length"><td colspan="6">暂无已选测试</td></tr> |
| | | </tbody></table></div> |
| | | <div v-if="!editingId && !readonly" class="panel-actions"><button class="primary-button compact-button" type="button" @click="openAddTest"><el-icon><Plus /></el-icon>添加拼接</button> |
| | | </div> |
| | | </section> |
| | | |
| | | <section class="detail-panel"> |
| | | <header class="panel-heading"><div><strong>测试详情</strong><span>固定于基础测试,添加拼接不会改变</span></div></header> |
| | | <div class="detail-meta"><label><span>实验</span><el-input v-model="experimentName" /></label><div><span>基础测试</span><strong>{{ selectedTests[0]?.name }}</strong></div></div> |
| | | <div class="detail-table-wrap"><table class="detail-table" :style="{ minWidth: `${Math.max(760, samples.length * 190 + 170)}px` }"><thead><tr><th>电池组</th><th :colspan="samples.length">{{ batteryGroup }}</th></tr><tr><th>电池号</th><th v-for="sample in samples" :key="sample">{{ sample }}</th></tr></thead><tbody><tr v-for="(field, fieldIndex) in (savedReport ? [] : formationFixedReportFields)" :key="field.key"><td>{{ field.label }}</td><td v-for="(sample, sampleIndex) in samples" :key="sample"><span v-if="field.mode === 'raw'" class="raw-value">{{ rawDetailValue(fieldIndex, sampleIndex) }}</span><span v-else class="auto-value">自动计算</span></td></tr><tr v-for="field in dynamicDetailFields" :key="field"><td>{{ field }}</td><td v-for="sample in samples" :key="sample"><el-input v-model="detailFieldValues[field][sample]" size="small" placeholder="请输入" /></td></tr></tbody></table></div> |
| | | <div v-if="!readonly" class="detail-actions"><button class="primary-button" type="button" @click="addSpecialField"><el-icon><Plus /></el-icon>添加项</button><button class="secondary-button" type="button" @click="openFieldTemplate('battery')">电池组字段模板</button><button class="secondary-button" type="button" @click="openFieldTemplate('special')">选择特殊字段模板</button><button class="secondary-button" type="button" @click="saveSpecialTemplate">另存特殊字段模板</button><div v-if="specialFields.length" class="special-field-list"><span v-for="field in specialFields" :key="field">{{ field }}<button type="button" :aria-label="`删除${field}`" @click="removeSpecialField(field)">×</button></span></div></div> |
| | | </section> |
| | | <ReportTestDetail v-model="sourceDetailOverrides" :columns="reportDetailColumns" :disabled="readonly || saving || previewing || !!previewError" /> |
| | | |
| | | <section v-if="statisticPreview" class="chart-panel"> |
| | | <div class="metric-switches"><label v-for="metric in statisticMetrics" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="statisticVisible[metric.key]" /></label></div> |
| | | <div class="chart-legend-bar"><div v-for="(sample, index) in samples" :key="sample" class="chart-legend-item"><el-color-picker :model-value="seriesColors[index]" :aria-label="`修改${sample}颜色`" @change="updateSeriesColor(index, $event)" /><span>{{ sample }}</span></div></div> |
| | | <SpliceSavedReport :report="statisticPreview" /> |
| | | </section> |
| | | <section v-else class="chart-panel"> |
| | | <header class="panel-heading"><div><strong>拼接曲线</strong><span>不同测试沿累计时间轴横向连接</span></div></header> |
| | | <div v-if="!readonly" class="metric-switches"><label v-for="metric in formationMetricDefinitions" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="metricVisible[metric.key]" inline-prompt active-text="显" inactive-text="隐" /></label></div> |
| | | <div class="segment-ruler" aria-label="拼接测试时间段"><div v-for="(segment, index) in timeline.segments" :key="segment.id" class="segment" :class="`segment-${index % 4}`" :style="{ flexBasis: `${segment.duration / Math.max(totalDuration, 1) * 100}%` }"><strong>{{ index + 1 }}. {{ segment.name }}</strong><span>{{ formatMinutes(segment.start) }} - {{ formatMinutes(segment.end) }}</span></div></div> |
| | | <div class="chart-legend-bar"><strong>样品图例</strong><div v-for="(sample, index) in samples.slice(0, 3)" :key="sample" class="chart-legend-item"><el-color-picker v-if="!readonly" class="legend-color-picker" :model-value="seriesColors[index]" :show-alpha="false" popper-class="legend-color-popper" :aria-label="`修改${sample}颜色`" @change="updateSeriesColor(index, $event)" /><span>{{ sample }}</span></div></div> |
| | | <div class="charts-list"><article v-for="metric in enabledMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong><span>{{ metric.unit }}</span></header><VChart class="metric-chart" :option="chartOption(metric)" autoresize /></article></div> |
| | | </section> |
| | | <ProjectCycleCharts v-if="matrix && projectMode" :series="projectSeries" :positions="matrix.cycleNos" :colors="seriesColors" :hidden="hiddenSeriesKeys" spliced :factor-warning="matrix.segments.some(segment => segment.capacityFactor == null)" @hidden-change="!readonly && (hiddenSeriesKeys = $event)" /> |
| | | <SpliceSavedReport v-else-if="statisticPreview" :report="statisticPreview" :charts-only="true" /> |
| | | <el-alert v-if="previewError" :title="previewError" type="error" :closable="false" /> |
| | | <p v-if="previewing">正在加载循环统计</p> |
| | | <el-empty v-else-if="!matrix && !previewError" description="暂无循环统计预览" /> |
| | | |
| | | |
| | | </template> |
| | | <footer class="form-actions"> |
| | | <button v-if="canExport" class="secondary-button" type="button" :disabled="printing || saving || loading || !!loadError" @click="printReport"><el-icon><Printer /></el-icon>{{ printing ? '正在打开打印' : '打印 / 另存 PDF' }}</button> |
| | | <button v-if="savedReport && !loading && !loadError" class="secondary-button" type="button" @click="openHistory">历史版本</button> |
| | | <button v-if="canQuery && savedReport && !loading && !loadError" class="secondary-button" type="button" @click="openHistory">历史版本</button> |
| | | <button class="secondary-button" type="button" @click="router.push('/report-splice')">{{ readonly ? '返回列表' : '取消' }}</button> |
| | | <button v-if="!readonly" class="primary-button" type="button" :disabled="saving || loading || !!loadError" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存修改' : '保存报表' }}</button> |
| | | <button v-if="canEdit && savedReport && !readonly" class="secondary-button" :disabled="saving || loading" @click="refreshSource">按最新来源生成新版本</button> |
| | | <button v-if="!readonly && (editingId ? canEdit : canCreate)" class="primary-button" type="button" :disabled="saving || loading || !!loadError" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存修改' : '保存报表' }}</button> |
| | | </footer> |
| | | <el-dialog v-model="historyVisible" title="历史版本" width="min(720px, 94vw)"> |
| | | <el-table :data="history"><el-table-column prop="versionNo" label="版本" width="70" /><el-table-column prop="operatorName" label="操作人" /><el-table-column label="时间"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString() }}</template></el-table-column><el-table-column prop="changeSummary" label="修改说明" /><el-table-column label="操作" width="80"><template #default="{ row }"><el-button link type="primary" @click="openVersion(row.versionNo)">查看</el-button></template></el-table-column></el-table> |
| | | </el-dialog> |
| | | |
| | | <el-dialog v-model="addDialogVisible" class="add-dialog" title="添加拼接" width="560px" align-center destroy-on-close> |
| | | <div class="dialog-form"><label><span>选择测试</span><el-select v-model="selectedCandidateId" filterable clearable placeholder="输入测试名称或ID搜索"><el-option v-for="item in candidateTests" :key="item.id" :label="`${item.name}(${item.type},${formatMinutes(item.durationMinutes)})`" :value="item.id" :disabled="selectedTests.some(test => test.id === item.id)" /></el-select></label><label><span>电池组</span><div class="fixed-dialog-value">{{ batteryGroup }}</div></label><label><span>电池号</span><div class="fixed-dialog-value">{{ samples.join('、') }}</div></label><small>电池组和电池号由基础测试固定,添加拼接不会修改测试详情。</small></div> |
| | | <el-dialog v-model="addDialogVisible" class="add-dialog" title="添加拼接" width="min(560px, 94vw)" align-center destroy-on-close> |
| | | <div class="dialog-form"> |
| | | <label><span>电池组</span><el-select v-model="candidateGroupId" clearable placeholder="全部"><el-option v-for="[id, name] in candidateGroups" :key="id" :label="name" :value="id" /></el-select></label> |
| | | <label><span>测试名称</span><el-input v-model="candidateQuery" placeholder="输入测试名称或ID搜索" /></label> |
| | | <label><span>选择测试电池</span><el-select v-model="selectedCandidateId" filterable clearable placeholder="选择检测完成测试电池"><el-option v-for="item in candidateTests" :key="`${item.selection.testId}:${item.selection.testBatteryId}`" :label="`${item.name}(${item.group},${item.batteryNo})`" :value="`${item.selection.testId}:${item.selection.testBatteryId}`" :disabled="selectedTests.some(test => test.selection.testId === item.selection.testId && test.selection.testBatteryId === item.selection.testBatteryId)" /></el-select></label> |
| | | </div> |
| | | <template #footer><button class="secondary-button" type="button" @click="addDialogVisible = false">取消</button><button class="primary-button" type="button" @click="addSpliceTest"><el-icon><Plus /></el-icon>添加拼接</button></template> |
| | | </el-dialog> |
| | | |
| | | <el-dialog v-model="templateDialogVisible" class="template-dialog" :title="templateMode === 'battery' ? '电池组字段模板' : '选择特殊字段模板'" :width="templateMode === 'battery' ? '720px' : '600px'" align-center> |
| | | <el-checkbox-group v-if="templateMode === 'battery'" v-model="selectedBatteryFieldIds" class="battery-field-grid"><el-checkbox v-for="field in batteryFieldOptions" :key="field.id" :value="field.id" :disabled="field.status === '禁用'">{{ field.name }}({{ field.type }})</el-checkbox></el-checkbox-group> |
| | | <el-radio-group v-else v-model="selectedSpecialTemplateKey" class="template-options"><el-radio v-for="item in specialTemplates" :key="item.key" :value="item.key" border>{{ item.name }}({{ item.fields.join('、') }})</el-radio></el-radio-group> |
| | | <template #footer><button class="secondary-button" type="button" @click="templateDialogVisible = false">取消</button><button class="primary-button" type="button" @click="applyFieldTemplate"><el-icon><Check /></el-icon>应用模板</button></template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .splice-form-page{width:100%;padding-bottom:78px}.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{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);border-color:var(--blue)}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.primary-button:disabled{opacity:.55;cursor:wait}.report-name-panel,.test-panel,.detail-panel,.chart-panel{margin-bottom:16px;border:1px solid #d8e1e9;background:#fff}.report-name-panel{padding:18px 22px}.report-name-panel label{display:grid;grid-template-columns:100px minmax(280px,560px);align-items:center;gap:12px}.report-name-panel label>span,.detail-meta label>span{color:#52606d;font-size:12px}.report-name-panel :deep(.el-input__wrapper),.detail-meta :deep(.el-input__wrapper){border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.panel-heading{min-height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;border-bottom:1px solid var(--line);background:#fbfdff}.panel-heading>div{display:flex;align-items:center;gap:9px}.panel-heading strong{color:#26333f;font-size:13px}.panel-heading span,.panel-heading small{color:#8795a3;font-size:11px}.test-table-wrap,.detail-table-wrap{overflow-x:auto;padding:18px 24px 0}table{width:100%;border-collapse:collapse;color:#52606d;font-size:12px}th,td{height:40px;padding:0 10px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}th{color:#fff;background:#3c6497;font-weight:600}.test-table{min-width:900px}.test-table th:nth-child(1){width:70px}.test-table th:nth-child(3){width:110px}.test-table th:nth-child(4){width:260px}.test-table th:nth-child(5){width:110px}.test-table th:last-child{width:100px}.order-index{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border:1px solid #aac0d4;color:#405261;background:#f7fafc}.base-lock{color:#647380}.delete-link{border:0;color:#b84e4e;background:transparent;font-size:12px;cursor:pointer}.panel-actions,.detail-actions{display:flex;align-items:center;gap:10px;padding:16px 24px 20px}.compact-button{height:30px;font-size:12px}.detail-meta{display:grid;grid-template-columns:minmax(280px,520px) minmax(260px,1fr);gap:20px;padding:18px 24px 0}.detail-meta label,.detail-meta>div{display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center;gap:10px}.detail-meta>div{min-height:34px;color:#52606d;font-size:12px}.detail-meta>div strong{color:#26333f}.detail-table th:first-child,.detail-table td:first-child{width:150px;text-align:left}.detail-table td:first-child{color:#31404d;background:#f2f7fb;font-weight:600}.detail-table :deep(.el-input__wrapper){min-height:28px;border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#fff}.raw-value{color:#405261;font-family:'Cascadia Mono',Consolas,monospace}.auto-value{color:#2188f5;font-weight:500}.special-field-list{display:flex;flex-wrap:wrap;gap:6px;margin-left:auto}.special-field-list>span{display:inline-flex;align-items:center;gap:5px;padding:4px 7px;border:1px solid #d8e1e9;color:#52606d;background:#f8fafc;font-size:11px}.special-field-list button{border:0;color:#ad4a4a;background:transparent;cursor:pointer}.metric-switches{display:flex;flex-wrap:wrap;align-items:center;gap:20px 28px;min-height:56px;padding:0 24px;border-bottom:1px solid var(--line)}.metric-switches label{display:inline-flex;align-items:center;gap:7px;color:#52606d;font-size:12px}.metric-switches :deep(.el-switch){--el-switch-on-color:#3978f6}.segment-ruler{display:flex;min-width:0;margin:16px 24px 0;border:1px solid #cbd8e4;background:#f8fafc}.segment{min-width:120px;display:flex;justify-content:center;flex-direction:column;gap:3px;height:52px;padding:0 12px;border-right:1px solid #cbd8e4;overflow:hidden}.segment:last-child{border-right:0}.segment strong,.segment span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.segment strong{color:#31404d;font-size:11px}.segment span{color:#71808d;font-size:10px}.segment-0{border-top:3px solid #3978f6}.segment-1{border-top:3px solid #6aa07a}.segment-2{border-top:3px solid #d58b3d}.segment-3{border-top:3px solid #8b69c7}.chart-legend-bar{min-height:50px;display:flex;flex-wrap:wrap;align-items:center;gap:12px 24px;margin-top:14px;padding:0 24px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);color:#52606d;font-size:12px}.chart-legend-bar>strong{color:#26333f}.chart-legend-item{display:inline-flex;align-items:center;gap:7px}.legend-color-picker{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center}.legend-color-picker :deep(.el-color-picker__trigger){width:14px!important;height:14px!important;padding:0!important;border:0!important;border-radius:2px;background:transparent!important;box-shadow:none!important;overflow:hidden}.legend-color-picker :deep(.el-color-picker__color){width:14px;height:14px;border:0;border-radius:2px;box-shadow:0 0 0 1px rgba(36,54,71,.18)}.legend-color-picker :deep(.el-color-picker__icon),.legend-color-picker :deep(.el-color-picker__empty),.legend-color-picker :deep(.el-color-picker__trigger svg){display:none!important}:global(.legend-color-popper.el-popper){padding:0!important;border:1px solid #b9c9d8!important;border-radius:4px!important;box-shadow:0 10px 24px rgba(25,48,69,.18)!important;overflow:hidden}.charts-list{display:grid;grid-template-columns:minmax(0,1fr);gap:14px;padding:18px 24px 24px}.chart-card{min-width:0;border:1px solid #d8e1e9;background:#fff}.chart-card header{height:38px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid #edf1f5}.chart-card header strong{color:#26333f;font-size:13px}.chart-card header span{color:#8795a3;font-size:10px}.metric-chart{width:100%;height:250px}.form-actions{position:sticky;bottom:0;z-index:4;display:flex;justify-content:flex-end;gap:10px;padding:14px 0;border-top:1px solid #d8e1e9;background:rgba(244,248,251,.96)}.dialog-form{display:grid;gap:16px}.dialog-form label{display:grid;grid-template-columns:78px minmax(0,1fr);align-items:center;gap:12px;color:#52606d;font-size:12px}.dialog-form :deep(.el-select){width:100%}.fixed-dialog-value{min-height:34px;display:flex;align-items:center;padding:0 11px;border:1px solid #d8e1e9;background:#f8fafc;color:#405261}.dialog-form small{margin-left:90px;color:#8795a3;font-size:11px}.battery-field-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px 18px;max-height:300px;overflow:auto;padding:14px;border:1px solid #d8e1e9;background:#f8fafc}.template-options{display:flex;flex-direction:column;gap:10px}.template-options :deep(.el-radio){height:auto;min-height:42px;margin-right:0;white-space:normal}@media(max-width:900px){.detail-meta{grid-template-columns:1fr}.segment-ruler{overflow-x:auto}.segment{flex:0 0 180px!important}}@media(max-width:680px){.report-name-panel label{grid-template-columns:1fr}.panel-heading{align-items:flex-start;flex-direction:column;justify-content:center;gap:4px}.detail-actions{align-items:stretch;flex-direction:column}.special-field-list{margin-left:0}.metric-switches{gap:14px;padding:12px}.chart-legend-bar{align-items:flex-start;flex-direction:column;gap:9px;padding:12px}.charts-list{padding:12px}.battery-field-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} |
| | | .splice-form-page{width:100%;padding-bottom:78px}.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{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);border-color:var(--blue)}.secondary-button{color:#52606d;background:#fff;border-color:var(--line-strong)}.primary-button:disabled{opacity:.55;cursor:wait}.report-name-panel,.test-panel,.detail-panel,.chart-panel{margin-bottom:16px;border:1px solid #d8e1e9;background:#fff}.report-name-panel{display:grid;gap:14px;padding:18px 22px}.report-name-panel label{display:grid;grid-template-columns:100px minmax(280px,560px);align-items:center;gap:12px}.report-name-panel label>span,.detail-meta label>span{color:#52606d;font-size:12px}.report-name-panel :deep(.el-input__wrapper),.detail-meta :deep(.el-input__wrapper){border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.panel-heading{min-height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;border-bottom:1px solid var(--line);background:#fbfdff}.panel-heading>div{display:flex;align-items:center;gap:9px}.panel-heading strong{color:#26333f;font-size:13px}.panel-heading span,.panel-heading small{color:#8795a3;font-size:11px}.test-table-wrap,.detail-table-wrap{overflow-x:auto;padding:18px 24px 0}table{width:100%;border-collapse:collapse;color:#52606d;font-size:12px}th,td{height:40px;padding:0 10px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}th{color:#fff;background:#3c6497;font-weight:600}.test-table{min-width:900px}.test-table th:nth-child(1){width:70px}.test-table th:nth-child(3){width:110px}.test-table th:nth-child(4){width:260px}.test-table th:nth-child(5){width:110px}.test-table th:last-child{width:100px}.order-index{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border:1px solid #aac0d4;color:#405261;background:#f7fafc}.base-lock{color:#647380}.delete-link{border:0;color:#b84e4e;background:transparent;font-size:12px;cursor:pointer}.panel-actions,.detail-actions{display:flex;align-items:center;gap:10px;padding:16px 24px 20px}.compact-button{height:30px;font-size:12px}.detail-meta{display:grid;grid-template-columns:minmax(280px,520px) minmax(260px,1fr);gap:20px;padding:18px 24px 0}.detail-meta label,.detail-meta>div{display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center;gap:10px}.detail-meta>div{min-height:34px;color:#52606d;font-size:12px}.detail-meta>div strong{color:#26333f}.detail-table th:first-child,.detail-table td:first-child{width:150px;text-align:left}.detail-table td:first-child{color:#31404d;background:#f2f7fb;font-weight:600}.detail-table :deep(.el-input__wrapper){min-height:28px;border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#fff}.raw-value{color:#405261;font-family:'Cascadia Mono',Consolas,monospace}.auto-value{color:#2188f5;font-weight:500}.special-field-list{display:flex;flex-wrap:wrap;gap:6px;margin-left:auto}.special-field-list>span{display:inline-flex;align-items:center;gap:5px;padding:4px 7px;border:1px solid #d8e1e9;color:#52606d;background:#f8fafc;font-size:11px}.special-field-list button{border:0;color:#ad4a4a;background:transparent;cursor:pointer}.metric-switches{display:flex;flex-wrap:wrap;align-items:center;gap:20px 28px;min-height:56px;padding:0 24px;border-bottom:1px solid var(--line)}.metric-switches label{display:inline-flex;align-items:center;gap:7px;color:#52606d;font-size:12px}.metric-switches :deep(.el-switch){--el-switch-on-color:#3978f6}.segment-ruler{display:flex;min-width:0;margin:16px 24px 0;border:1px solid #cbd8e4;background:#f8fafc}.segment{min-width:120px;display:flex;justify-content:center;flex-direction:column;gap:3px;height:52px;padding:0 12px;border-right:1px solid #cbd8e4;overflow:hidden}.segment:last-child{border-right:0}.segment strong,.segment span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.segment strong{color:#31404d;font-size:11px}.segment span{color:#71808d;font-size:10px}.segment-0{border-top:3px solid #3978f6}.segment-1{border-top:3px solid #6aa07a}.segment-2{border-top:3px solid #d58b3d}.segment-3{border-top:3px solid #8b69c7}.chart-legend-bar{min-height:50px;display:flex;flex-wrap:wrap;align-items:center;gap:12px 24px;margin-top:14px;padding:0 24px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);color:#52606d;font-size:12px}.chart-legend-bar>strong{color:#26333f}.chart-legend-item{display:inline-flex;align-items:center;gap:7px}.legend-color-picker{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center}.legend-color-picker :deep(.el-color-picker__trigger){width:14px!important;height:14px!important;padding:0!important;border:0!important;border-radius:2px;background:transparent!important;box-shadow:none!important;overflow:hidden}.legend-color-picker :deep(.el-color-picker__color){width:14px;height:14px;border:0;border-radius:2px;box-shadow:0 0 0 1px rgba(36,54,71,.18)}.legend-color-picker :deep(.el-color-picker__icon),.legend-color-picker :deep(.el-color-picker__empty),.legend-color-picker :deep(.el-color-picker__trigger svg){display:none!important}:global(.legend-color-popper.el-popper){padding:0!important;border:1px solid #b9c9d8!important;border-radius:4px!important;box-shadow:0 10px 24px rgba(25,48,69,.18)!important;overflow:hidden}.charts-list{display:grid;grid-template-columns:minmax(0,1fr);gap:14px;padding:18px 24px 24px}.chart-card{min-width:0;border:1px solid #d8e1e9;background:#fff}.chart-card header{height:38px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid #edf1f5}.chart-card header strong{color:#26333f;font-size:13px}.chart-card header span{color:#8795a3;font-size:10px}.metric-chart{width:100%;height:250px}.form-actions{position:sticky;bottom:0;z-index:4;display:flex;justify-content:flex-end;gap:10px;padding:14px 0;border-top:1px solid #d8e1e9;background:rgba(244,248,251,.96)}.dialog-form{display:grid;gap:16px}.dialog-form label{display:grid;grid-template-columns:78px minmax(0,1fr);align-items:center;gap:12px;color:#52606d;font-size:12px}.dialog-form :deep(.el-select){width:100%}.fixed-dialog-value{min-height:34px;display:flex;align-items:center;padding:0 11px;border:1px solid #d8e1e9;background:#f8fafc;color:#405261}.dialog-form small{margin-left:90px;color:#8795a3;font-size:11px}.battery-field-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px 18px;max-height:300px;overflow:auto;padding:14px;border:1px solid #d8e1e9;background:#f8fafc}.template-options{display:flex;flex-direction:column;gap:10px}.template-options :deep(.el-radio){height:auto;min-height:42px;margin-right:0;white-space:normal}@media(max-width:900px){.detail-meta{grid-template-columns:1fr}.segment-ruler{overflow-x:auto}.segment{flex:0 0 180px!important}}@media(max-width:680px){.report-name-panel label{grid-template-columns:1fr}.panel-heading{align-items:flex-start;flex-direction:column;justify-content:center;gap:4px}.detail-actions{align-items:stretch;flex-direction:column}.special-field-list{margin-left:0}.metric-switches{gap:14px;padding:12px}.chart-legend-bar{align-items:flex-start;flex-direction:column;gap:9px;padding:12px}.charts-list{padding:12px}.battery-field-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} |
| | | .splice-form-page.is-readonly :deep(.el-input), .splice-form-page.is-readonly :deep(.el-textarea) { pointer-events: none; } |
| | | .splice-form-page.is-readonly :deep(.el-input__wrapper), .splice-form-page.is-readonly :deep(.el-textarea__inner) { background: #f3f6f8; box-shadow: 0 0 0 1px #d8e1e9 inset; } |
| | | .form-actions{flex-wrap:wrap}.splice-form-page{min-width:0} |
| | | .statistics-editor{min-width:1000px;table-layout:fixed}.statistics-editor th:nth-last-child(-n+2){width:250px}.statistics-editor td{padding:8px;white-space:normal;overflow-wrap:anywhere;vertical-align:top}.statistics-editor small{display:block;margin-top:5px;color:#647380;overflow-wrap:anywhere}.test-table th:last-child{width:150px}.panel-actions{flex-wrap:wrap}.test-table .el-button{width:26px;height:26px;padding:0;margin-right:4px;margin-left:0} |
| | | </style> |