From 528d52f2c7c6f911c54cae6e3f25500eab4384fe Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期三, 09 九月 2026 02:32:43 +0800
Subject: [PATCH] 统一报表图表并接通首页与负责人待办

---
 src/utils/spliceSavedReport.ts                     |   22 
 src/views/SpliceReportForm.vue                     |   47 
 src/utils/reportMeasurementChart.ts                |   30 -
 src/components/ConsoleChartPanel.vue               |   64 --
 src/views/TodoList.vue                             |  135 +---
 src/views/Dashboard.vue                            |  205 ++----
 src/utils/stepCurveChart.ts                        |   19 
 src/views/ReportCompareForm.vue                    |   15 
 tests/test-console-project-chart.contract.test.mjs |    7 
 tests/test-console-step-chart.contract.test.mjs    |    2 
 src/views/FormationReport.vue                      |   59 -
 src/layout/Header.vue                              |   35 
 src/components/NotificationCenter.vue              |   19 
 src/components/ConsoleProjectChartPanel.vue        |   39 -
 tests/load-typescript.cjs                          |   12 
 src/stores/dashboard.ts                            |   30 +
 src/views/FormationReportForm.vue                  |  514 ++++++++---------
 src/components/ProjectCycleCharts.vue              |   20 
 src/stores/formationReports.ts                     |   54 +
 src/api/todos.ts                                   |   12 
 tests/report-chart-behavior.test.mjs               |   41 +
 src/utils/formationReportPrint.ts                  |    6 
 src/utils/spliceProjectChart.ts                    |   19 
 tests/report-test-detail.ui.test.mjs               |    2 
 src/api/dashboard.ts                               |   12 
 src/utils/auth.ts                                  |    6 
 src/layout/Sidebar.vue                             |    7 
 src/utils/projectCycleChart.ts                     |   25 
 tests/refinement.ui.test.mjs                       |  115 ++++
 src/api/spliceReports.ts                           |    6 
 src/stores/todos.ts                                |   34 
 src/views/OperationLog.vue                         |    9 
 tests/test-chart-display-units.test.mjs            |   34 
 33 files changed, 882 insertions(+), 774 deletions(-)

diff --git a/src/api/dashboard.ts b/src/api/dashboard.ts
new file mode 100644
index 0000000..92c7e65
--- /dev/null
+++ b/src/api/dashboard.ts
@@ -0,0 +1,12 @@
+import { request, type ApiResponse } from './request'
+export interface DashboardTest { id: number; name: string; type: string; status: string; receptionStatus: string; step: string; warning: number }
+export interface DashboardCabinet { id: number; name: string; capacity: number | null; channelCount: number; usedChannels: number; testCount: number; batteryCount: number; onlineStatus: string }
+export interface DashboardSnapshot {
+  summary: { testCount: number; batteryGroupCount: number; todayCount: number; monthCount: number }
+  trends: Record<'week' | 'month' | 'year', { labels: string[]; values: number[] }>
+  tests: DashboardTest[]
+  cabinets: DashboardCabinet[]
+  warnings: Array<{ id: number; testId: number; testName: string; item: string; occurredAt: number }>
+}
+/** 首页统计只读后端聚合,既不使用测试种子,也不在浏览器重算日期口径。 */
+export async function getDashboard() { return (await request<ApiResponse & { data: DashboardSnapshot }>('/system/dashboard')).data }
diff --git a/src/api/spliceReports.ts b/src/api/spliceReports.ts
index e985208..8ad03f2 100644
--- a/src/api/spliceReports.ts
+++ b/src/api/spliceReports.ts
@@ -4,11 +4,11 @@
 export interface SpliceCandidate { selection: SpliceSelection; name: string; type: string; groupId: number; group: string; batteryNo: string; startedAt: number }
 export interface SpliceField { name: string; category: 'battery' | 'special'; displayName: string; unit: string; fieldDefinitionId?: number | null }
 export interface SpliceBusinessField { fieldDefinitionId: number; fieldName: string; fieldValue: string | null }
-export interface SpliceSegment { source: SpliceCandidate; offset: number; originalCycleNos: number[]; statistics: Record<string, Record<string, number | null>>; businessFields?: SpliceBusinessField[]; consoleDetail?: { experimentName?: string; batteryFields: string[]; specialFields: string[]; fieldValues: Record<string, string> } }
+export interface SpliceSegment { source: SpliceCandidate; offset: number; cyclePositions?: Record<string, number>; capacityFactor?: number | null; originalCycleNos: number[]; statistics: Record<string, Record<string, number | null>>; businessFields?: SpliceBusinessField[]; consoleDetail?: { experimentName?: string; batteryFields: string[]; specialFields: string[]; fieldValues: Record<string, string> } }
 export interface SpliceMatrix { segments: SpliceSegment[]; cycleNos: number[] }
-export interface SpliceSettings { sourceDetailOverrides?: ReportTestDetails; sourceExperimentNames?: Record<string,string>; hiddenSeries?: boolean; fieldStructure?: SpliceField[]; statisticValues?: Record<string, string>; batteryFields?: string[]; specialFields?: string[]; detailFieldValues?: Record<string, Record<string, string>>; metrics?: string[]; seriesColors?: string[] }
+export interface SpliceSettings { sourceDetailOverrides?: ReportTestDetails; sourceExperimentNames?: Record<string,string>; hiddenSeries?: boolean; hiddenSeriesKeys?: string[]; fieldStructure?: SpliceField[]; statisticValues?: Record<string, string>; batteryFields?: string[]; specialFields?: string[]; detailFieldValues?: Record<string, Record<string, string>>; metrics?: string[]; seriesColors?: string[] }
 export interface SpliceSummary { id: number; name: string; creator: string; createdAt: number }
-export interface SavedSpliceReport extends SpliceSummary { spliceMode?: 'battery-segments'; description?: string; selections: SpliceSelection[]; segments: SpliceSegment[]; cycleNos: number[]; settings: SpliceSettings; versionNo: number; operatorName: string; versionCreatedAt: number; changeSummary: string }
+export interface SavedSpliceReport extends SpliceSummary { spliceMode?: 'battery-segments' | 'project-cycles'; description?: string; selections: SpliceSelection[]; segments: SpliceSegment[]; cycleNos: number[]; settings: SpliceSettings; versionNo: number; operatorName: string; versionCreatedAt: number; changeSummary: string }
 const root='/system/splice-report'
 async function data<T>(path:string, method='GET', body?:unknown){return (await request<ApiResponse & {data:T}>(`${root}${path}`,{method,...(body===undefined?{}:{body:JSON.stringify(body)})})).data}
 export function listSpliceReports(){return request<ApiResponse & {rows:SpliceSummary[]}>(`${root}/list`).then(r=>r.rows)}
diff --git a/src/api/todos.ts b/src/api/todos.ts
index 5ffe960..fe18359 100644
--- a/src/api/todos.ts
+++ b/src/api/todos.ts
@@ -1,6 +1,8 @@
 import { request, type ApiResponse } from './request'
-export interface TodoDto { id:number; content:string; ownerId:number; owner:string; status:'待处理'|'已处理'; creator:string; createdAt:number; operatedAt:number; handleRemark:string }
-export function listTodos(){ return request<ApiResponse & {rows:TodoDto[]}>('/system/todo/list').then(r=>r.rows || []) }
-export function createTodo(body:{content:string;ownerId:number;ownerName:string}){ return request<ApiResponse & {data:TodoDto}>('/system/todo',{method:'POST',body:JSON.stringify(body)}).then(r=>r.data) }
-export function handleTodo(id:number,remark:string){ return request<ApiResponse>(`/system/todo/${id}/handle`,{method:'PUT',body:JSON.stringify({remark})}) }
-export function removeTodo(id:number){ return request<ApiResponse>(`/system/todo/${id}`,{method:'DELETE'}) }
+export interface TodoDto { id: number; content: string; ownerId: number; owner: string; status: '待处理' | '已处理'; creator: string; createdAt: number; operatedAt: number; handleRemark: string | null }
+export interface TodoOwner { id: number; name: string }
+export async function listTodos() { return (await request<ApiResponse & { rows: TodoDto[] }>('/system/todo/list')).rows }
+export async function getTodoOwners() { return (await request<ApiResponse & { data: TodoOwner[] }>('/system/todo/owners')).data }
+export async function createTodo(body: { content: string; ownerId: number }) { return (await request<ApiResponse & { data: number }>('/system/todo', { method: 'POST', body: JSON.stringify(body) })).data }
+export function handleTodo(id: number, remark: string) { return request<ApiResponse>('/system/todo/' + id + '/handle', { method: 'PUT', body: JSON.stringify({ remark }) }) }
+export function removeTodo(id: number) { return request<ApiResponse>('/system/todo/' + id, { method: 'DELETE' }) }
diff --git a/src/components/ConsoleChartPanel.vue b/src/components/ConsoleChartPanel.vue
index d8b8d02..0ea26a5 100644
--- a/src/components/ConsoleChartPanel.vue
+++ b/src/components/ConsoleChartPanel.vue
@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import { stepCurveChartOption } from '@/utils/stepCurveChart'
 import { toTestDisplayValue } from '@/utils/testDataUnits'
 import { computed, reactive, ref, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
@@ -415,68 +416,9 @@
     const zero = points[0]?.time
     return points.map(point => ({ ...point, time: zero == null ? null : (point.time as number) - zero }))
   })
-  const timestamps = [...new Set(seriesData.flat().map(point => point.time as number))].sort((a, b) => a - b)
-  const values = seriesData.flat().map(point => point.value)
-  const dataMin = values.length ? Math.min(...values) : 0
-  const dataMax = values.length ? Math.max(...values) : 1
-  const margin = Math.max((dataMax - dataMin) * 0.16, metric.key === 'temperature' ? 1 : toTestDisplayValue(metric.key, 0.08))
-  const createYAxis = (position: 'left' | 'right') => ({
-    type: 'value',
-    name: metric.unit,
-    position,
-    nameLocation: 'middle',
-    nameGap: 42,
-    nameRotate: position === 'left' ? 90 : -90,
-    min: Number((dataMin - margin).toFixed(2)),
-    max: Number((dataMax + margin).toFixed(2)),
-    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, margin: 8 },
-  })
-
-  return {
-    animation: false,
-    color: cellIndexes.map(index => seriesColors.value[index]),
-    grid: { top: 62, right: 62, bottom: 46, left: 62 },
-    tooltip: { trigger: 'axis' },
-    legend: {
-      top: 4,
-      right: 6,
-      type: 'scroll',
-      icon: 'roundRect',
-      itemWidth: 14,
-      itemHeight: 8,
-      itemGap: 12,
-      formatter: (name: string) => name.split('·').pop()?.trim() || name,
-      textStyle: { fontSize: 10, color: '#52606d' },
-    },
-    xAxis: {
-      type: 'category',
-      boundaryGap: false,
-      name: '设备时间',
-      nameLocation: 'middle',
-      nameGap: 29,
-      data: timestamps.map(value => `${(value / 3600000).toFixed(3)} h`),
-      axisLine: { lineStyle: { color: '#cbd8e4' } },
-      axisLabel: { color: '#8795a3', fontSize: 10, interval: 4 },
-      nameTextStyle: { color: '#52606d', fontSize: 10 },
-    },
-    yAxis: [createYAxis('left'), createYAxis('right')],
-    series: selectedCells.value.map((cell, index) => ({
-      name: cell.no,
-      type: 'line',
-      smooth: false,
-      showSymbol: false,
-      lineStyle: { width: 2 },
-      data: timestamps.map(timestamp => {
-        const point = seriesData[index].find(item => item.time === timestamp)
-        return point ? point.value : null
-      }),
-    })),
-  }
+  return stepCurveChartOption(metric, selectedCells.value.map((cell, index) => ({ name: cell.no, points: seriesData[index].map(point => [(point.time as number) / 1000, point.value]) })), cellIndexes.map(index => seriesColors.value[index]))
 }
+
 </script>
 
 <template>
diff --git a/src/components/ConsoleProjectChartPanel.vue b/src/components/ConsoleProjectChartPanel.vue
index b6ca276..87813c1 100644
--- a/src/components/ConsoleProjectChartPanel.vue
+++ b/src/components/ConsoleProjectChartPanel.vue
@@ -1,31 +1,18 @@
 <script setup lang="ts">
+import ProjectCycleCharts from './ProjectCycleCharts.vue'
+import type { ProjectSeries } from '@/utils/projectCycleChart'
 import { toTestDisplayValue } from '@/utils/testDataUnits'
 import { computed, ref, watch } from 'vue'
-import { use } from 'echarts/core'
-import { LineChart } from 'echarts/charts'
-import { CanvasRenderer } from 'echarts/renderers'
-import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
-import VChart from 'vue-echarts'
 import type { PersistedTestCycle, PersistedTestStep } from '@/api/tests'
 
 interface ConsoleCell { no: string }
-type MetricKey = 'finalVoltage1' | 'finalVoltage2' | 'dischargeCapacityF'
-interface ProjectMetric { key: MetricKey; title: string; unit: string }
 const props = defineProps<{ cells: ConsoleCell[]; steps: PersistedTestStep[]; cycles: PersistedTestCycle[]; capacityFactor: string }>()
-use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
-const metrics: ProjectMetric[] = [
-  { key: 'finalVoltage1', title: '最终电压1', unit: 'V' },
-  { key: 'finalVoltage2', title: '最终电压2', unit: 'V' },
-  { key: 'dischargeCapacityF', title: '每循环放电末容量 / F', unit: 'mAh / F' },
-]
 const palette = ['#3978f6', '#7d42d8', '#f08a3e', '#23a983', '#e15268', '#1b9ac8', '#b5892f', '#6957c8', '#4f8d44', '#d264a3', '#46748e', '#a95d37']
 const seriesColors = ref<string[]>(props.cells.map((_, index) => palette[index % palette.length]))
-const chartUpdateOptions = { notMerge: true, lazyUpdate: false }
 watch(() => props.cells, cells => { seriesColors.value = cells.map((_, index) => seriesColors.value[index] || palette[index % palette.length]) }, { deep: true })
 const cycleLabels = computed(() => [...new Set([...props.steps, ...props.cycles].map(row => Number(row.cycleNo)).filter(value => Number.isFinite(value) && value > 0))].sort((a, b) => a - b).map(String))
 const capacityFactor = computed(() => { const value = Number(props.capacityFactor); return Number.isFinite(value) && value !== 0 ? value : null })
 const capacityFactorUnavailable = computed(() => capacityFactor.value === null)
-const chartStateKey = computed(() => `${props.cells.map(cell => cell.no).join('|')}:${cycleLabels.value.join('|')}:${props.steps.length}:${props.cycles.length}:${props.capacityFactor}`)
 function numericValue(value: unknown): number | null {
   if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return null
   const number = Number(value)
@@ -43,25 +30,9 @@
   const capacity = row ? numericValue(row.dischargeCapacity) : null
   return capacity === null ? null : toTestDisplayValue('dischargeCapacity', capacity) / capacityFactor.value
 }
-function seriesValues(cell: ConsoleCell, metric: ProjectMetric): Array<number | null> {
-  return cycleLabels.value.map(label => metric.key === 'finalVoltage1' ? lastStepVoltage(cell.no, Number(label)) : dischargeCapacityPerFactor(cell.no, Number(label)))
-}
-function chartOption(metric: ProjectMetric) {
-  const hasDataSeries = metric.key !== 'finalVoltage2'
-  const seriesData = hasDataSeries ? props.cells.map(cell => seriesValues(cell, metric)) : []
-  const values = seriesData.flat().filter((value): value is number => value !== null)
-  const dataMin = values.length ? Math.min(...values) : undefined
-  const dataMax = values.length ? Math.max(...values) : undefined
-  const margin = dataMin === undefined || dataMax === undefined ? undefined : Math.max((dataMax - dataMin) * 0.15, 0.01)
-  return { animation: false, color: seriesColors.value, grid: { top: 62, right: 34, bottom: 48, left: 62 }, tooltip: { trigger: 'axis' }, legend: { top: 8, left: 12, right: 12, type: 'scroll', icon: 'roundRect', itemWidth: 14, itemHeight: 8, itemGap: 16, textStyle: { fontSize: 10, color: '#52606d' } }, xAxis: { type: 'category', boundaryGap: false, name: '循环次数', nameLocation: 'middle', nameGap: 30, data: cycleLabels.value, axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10 }, nameTextStyle: { color: '#52606d', fontSize: 10 } }, yAxis: { type: 'value', name: metric.unit, nameLocation: 'middle', nameGap: metric.unit ? 42 : 0, min: margin === undefined ? undefined : dataMin! - margin, max: margin === undefined ? undefined : dataMax! + margin, axisLabel: { color: '#8795a3', fontSize: 10, formatter: (value: number) => String(Number(value.toFixed(metric.key === 'dischargeCapacityF' ? 2 : 4))) }, splitLine: { lineStyle: { color: '#e8eef4' } } }, series: hasDataSeries ? props.cells.map((cell, index) => ({ name: cell.no, type: 'line', showSymbol: false, connectNulls: false, lineStyle: { width: 2 }, data: seriesData[index] })) : [] }
-}
+const hidden = ref<string[]>([])
+const projectSeries = computed<ProjectSeries[]>(() => props.cells.map(cell => ({ key: cell.no, name: cell.no, points: cycleLabels.value.map(label => ({ position: Number(label), values: { finalVoltage1: lastStepVoltage(cell.no, Number(label)), dischargeCapacityF: dischargeCapacityPerFactor(cell.no, Number(label)) } })) })))
 </script>
 <template>
-  <section class="project-chart-panel">
-    <p v-if="capacityFactorUnavailable" class="factor-warning" role="status">电池组 F 不可用,未计算每循环放电末容量 / F。</p>
-    <article v-for="metric in metrics" :key="metric.key" class="project-chart-card"><header><strong>{{ metric.title }}</strong></header><VChart :key="`${metric.key}-${chartStateKey}`" class="project-chart" :option="chartOption(metric)" :update-options="chartUpdateOptions" autoresize /></article>
-  </section>
+  <ProjectCycleCharts :series="projectSeries" :positions="cycleLabels.map(Number)" :colors="seriesColors" :hidden="hidden" :factor-warning="capacityFactorUnavailable" @hidden-change="hidden = $event" />
 </template>
-<style scoped>
-.project-chart-panel{display:grid;gap:16px;min-width:0}.project-chart-card{min-width:0;border:1px solid #d8e1e9;background:#fff}.project-chart-card>header{height:44px;display:flex;align-items:center;padding:0 18px;border-bottom:1px solid #edf1f5}.project-chart-card>header strong{color:#26333f;font-size:13px}.project-chart{width:100%;height:270px}.factor-warning{margin:0;padding:10px 14px;border:1px solid #efd59e;color:#8b5a14;background:#fff8e9;font-size:12px}@media(max-width:680px){.project-chart{height:250px}.project-chart-card>header{padding:0 12px}}
-</style>
diff --git a/src/components/NotificationCenter.vue b/src/components/NotificationCenter.vue
index 7ffa1e8..8f5e377 100644
--- a/src/components/NotificationCenter.vue
+++ b/src/components/NotificationCenter.vue
@@ -1,8 +1,9 @@
 <script setup lang="ts">
-import { computed, ref } from 'vue'
+import { computed, ref, onMounted, watch } from 'vue'
 import { useRouter } from 'vue-router'
 import { useExceptions } from '@/stores/exceptions'
-import { useTodos } from '@/stores/todos'
+import { getUserInfo } from '@/api/auth'
+import { useTodos, clearTodos } from '@/stores/todos'
 import { useTests } from '@/stores/tests'
 
 interface NotificationItem {
@@ -18,7 +19,18 @@
 const router = useRouter()
 const visible = ref(false)
 const { records: exceptions } = useExceptions()
-const { records: todos } = useTodos()
+const { records: todos, load: loadTodos } = useTodos()
+const todoError = ref('')
+async function refreshTodos() {
+  todoError.value = ''
+  try {
+    const permissions = (await getUserInfo()).permissions || []
+    if (permissions.some(value => ['*:*:*', 'todo:list:list', 'todo:list:query'].includes(value))) await loadTodos()
+    else clearTodos()
+  } catch (error) { clearTodos(); todoError.value = error instanceof Error ? error.message : '待办通知加载失败' }
+}
+onMounted(refreshTodos)
+watch(visible, value => { if (value) void refreshTodos() })
 const { records: tests } = useTests()
 
 function loadReadIds() {
@@ -77,6 +89,7 @@
     <template #header>
       <div class="drawer-heading"><div><strong>消息通知</strong><span>{{ unreadCount }} 条未读</span></div><button v-if="unreadCount" type="button" @click="markAllRead"><el-icon><Check /></el-icon>全部已读</button></div>
     </template>
+    <el-alert v-if="todoError" :title="todoError" type="error" :closable="false" />
     <div v-if="notifications.length" class="notification-list">
       <button v-for="item in notifications" :key="item.id" type="button" class="notification-item" :class="[{ unread: !readIds.has(item.id) }, item.type]" @click="openNotification(item)">
         <span class="type-icon"><el-icon><Warning v-if="item.type === 'exception'" /><List v-else-if="item.type === 'todo'" /><Monitor v-else /></el-icon></span>
diff --git a/src/components/ProjectCycleCharts.vue b/src/components/ProjectCycleCharts.vue
new file mode 100644
index 0000000..61af209
--- /dev/null
+++ b/src/components/ProjectCycleCharts.vue
@@ -0,0 +1,20 @@
+<script setup lang="ts">
+import { use } from 'echarts/core'
+import { LineChart } from 'echarts/charts'
+import { CanvasRenderer } from 'echarts/renderers'
+import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
+import VChart from 'vue-echarts'
+import { projectCycleChartOption, projectMetrics, type ProjectSeries } from '@/utils/projectCycleChart'
+defineProps<{ series: ProjectSeries[]; positions: number[]; colors: string[]; hidden?: string[]; spliced?: boolean; factorWarning?: boolean }>()
+const emit = defineEmits<{ hiddenChange: [keys: string[]] }>()
+use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
+</script>
+<template>
+  <section class="project-chart-panel">
+    <p v-if="factorWarning" class="factor-warning" role="status">电池组 F 不可用,未计算对应来源的每循环放电末容量 / F。</p>
+    <article v-for="metric in projectMetrics" :key="metric.key" class="project-chart-card"><header><strong>{{ metric.title }}</strong></header><VChart class="project-chart" :option="projectCycleChartOption(metric, series, positions, colors, hidden, spliced)" :update-options="{ notMerge: true }" autoresize @legendselectchanged="emit('hiddenChange', series.filter(item => !$event.selected[item.name]).map(item => item.key))" /></article>
+  </section>
+</template>
+<style scoped>
+.project-chart-panel{display:grid;gap:16px;min-width:0}.project-chart-card{min-width:0;border:1px solid #d8e1e9;background:#fff}.project-chart-card>header{height:44px;display:flex;align-items:center;padding:0 18px;border-bottom:1px solid #edf1f5}.project-chart-card>header strong{color:#26333f;font-size:13px}.project-chart{width:100%;height:270px}.factor-warning{margin:0;padding:10px 14px;border:1px solid #efd59e;color:#8b5a14;background:#fff8e9;font-size:12px}@media(max-width:680px){.project-chart{height:250px}.project-chart-card>header{padding:0 12px}}
+</style>
diff --git a/src/layout/Header.vue b/src/layout/Header.vue
index 7a63173..e6d85c8 100644
--- a/src/layout/Header.vue
+++ b/src/layout/Header.vue
@@ -1,12 +1,23 @@
 <script setup lang="ts">
-import { computed } from 'vue'
+import { computed, onMounted } from 'vue'
 import { useRoute } from 'vue-router'
+import { getUserInfo } from '@/api/auth'
+import { useDashboard, clearDashboard } from '@/stores/dashboard'
+import { formatBusinessTime } from '@/utils/businessTime'
 import GlobalSearch from '@/components/GlobalSearch.vue'
 import NotificationCenter from '@/components/NotificationCenter.vue'
 
 defineProps<{ sidebarCollapsed: boolean }>()
 defineEmits<{ toggleMenu: []; toggleSidebar: [] }>()
 
+const { snapshot, loadedAt, error, load } = useDashboard()
+onMounted(async () => {
+  try {
+    const permissions = (await getUserInfo()).permissions || []
+    if (permissions.some(value => ['*:*:*', 'dashboard:workbench:list', 'dashboard:workbench:query'].includes(value))) await load()
+    else clearDashboard()
+  } catch { /* 共享查询错误由顶部状态和首页错误提示展示。 */ }
+})
 const route = useRoute()
 const pageTitle = computed(() => String(route.meta.title || '工作台'))
 </script>
@@ -31,9 +42,9 @@
     </div>
 
     <div class="topbar-actions">
-      <div class="sync-state">
+      <div v-if="loadedAt" class="sync-state">
         <el-icon><Refresh /></el-icon>
-        <span>10 秒前同步</span>
+        <span>{{ formatBusinessTime(loadedAt) }} 更新</span>
       </div>
       <GlobalSearch />
       <NotificationCenter />
@@ -41,11 +52,13 @@
   </header>
 
   <div v-if="!route.path.startsWith('/test-list')" class="system-strip">
-    <div><span>系统状态</span><strong class="healthy">NORMAL</strong></div>
-    <div><span>在线检测柜</span><strong class="healthy">18 / 36</strong></div>
-    <div><span>活动测试</span><strong class="active">12</strong></div>
-    <div><span>未处理告警</span><strong class="warning">3</strong></div>
-    <div><span>采集延迟</span><strong>26 ms</strong></div>
+    <div v-if="error"><span role="alert">概览加载失败</span></div>
+    <template v-else-if="snapshot">
+      <div><span>在线检测柜</span><strong class="healthy">{{ snapshot.cabinets.filter(item => item.onlineStatus === '在线').length }} / {{ snapshot.cabinets.length }}</strong></div>
+      <div><span>检测中测试</span><strong class="active">{{ snapshot.tests.filter(item => item.status === 'RUNNING').length }}</strong></div>
+      <div><span>开放异常</span><strong class="warning">{{ snapshot.warnings.length }}</strong></div>
+    </template>
+    <div v-else><span>暂无已加载概览</span></div>
   </div>
 </template>
 
@@ -68,10 +81,10 @@
   align-items: center;
 }
 
-.title-group { gap: 12px; }
-.title-group h1 { margin: 0; font-size: 20px; line-height: 1; font-weight: 600; color: var(--ink); }
+.title-group { gap: 12px; min-width:0; }
+.title-group h1 { overflow:hidden;white-space:nowrap;text-overflow:ellipsis;margin: 0; font-size: 20px; line-height: 1; font-weight: 600; color: var(--ink); }
 .title-group > span { color: #7b8794; font-size: 10px; font-weight: 500; }
-.topbar-actions { gap: 14px; }
+.topbar-actions { gap: 14px; flex-shrink:0; }
 .sync-state { gap: 6px; color: #52606d; font-size: 12px; }
 .sync-state .el-icon { color: var(--blue); font-size: 14px; }
 
diff --git a/src/layout/Sidebar.vue b/src/layout/Sidebar.vue
index 70e07e3..933fdaf 100644
--- a/src/layout/Sidebar.vue
+++ b/src/layout/Sidebar.vue
@@ -25,7 +25,7 @@
     { path: '/report-compare', title: '对比报表' }, { path: '/report-formation', title: '化成报表' },
     { path: '/report-splice', title: '拼接报表' },
   ]},
-  { title: '代办管理', icon: 'List', children: [{ path: '/todo-list', title: '待办列表' }] },
+  { title: '待办管理', icon: 'List', children: [{ path: '/todo-list', title: '待办列表' }] },
   { title: '角色与权限', icon: 'UserFilled', children: [{ path: '/account', title: '账号管理' }, { path: '/role', title: '角色管理' }, { path: '/operation-log', title: '操作日志' }] },
 ]
 
@@ -86,8 +86,7 @@
     </div>
 
     <section class="environment">
-      <div><span>运行环境</span><strong>生产区 A</strong></div>
-      <p><i></i>系统连接正常</p>
+      <div><span>当前账号</span><strong>{{ currentUser?.username || '—' }}</strong></div>
     </section>
 
     <nav class="navigation">
@@ -141,7 +140,7 @@
 
     <button class="sidebar-account" type="button" aria-label="退出当前账号" @click="handleLogout">
       <span class="avatar">{{ currentUser?.displayName.slice(0, 1) || 'A' }}</span>
-      <div><strong>{{ currentUser?.displayName || 'Admin' }}</strong><small>{{ currentUser?.role || '系统管理员' }}</small></div>
+      <div><strong>{{ currentUser?.displayName || 'Admin' }}</strong><small>{{ currentUser?.role || '—' }}</small></div>
     </button>
   </aside>
 </template>
diff --git a/src/stores/dashboard.ts b/src/stores/dashboard.ts
new file mode 100644
index 0000000..3f941d1
--- /dev/null
+++ b/src/stores/dashboard.ts
@@ -0,0 +1,30 @@
+import { computed, ref } from 'vue'
+import { getDashboard, type DashboardSnapshot } from '@/api/dashboard'
+import { getAuthToken } from '@/api/request'
+
+const value = ref<DashboardSnapshot | null>(null)
+const token = ref<string | null>(null)
+const error = ref('')
+const loadedAt = ref<number | null>(null)
+let pending: { token: string | null; promise: Promise<void> } | undefined
+const snapshot = computed(() => token.value === getAuthToken() ? value.value : null)
+/** 首页与顶部概览使用同一响应,会话切换时清除旧账号可见的数据。 */
+export function clearDashboard() { value.value = null; token.value = null; loadedAt.value = null; error.value = ''; pending = undefined }
+export function useDashboard() {
+  function load(): Promise<void> {
+    const current = getAuthToken()
+    if (pending?.token === current) return pending.promise
+    if (token.value !== current) { value.value = null; loadedAt.value = null }
+    token.value = current; error.value = ''
+    const request = { token: current, promise: Promise.resolve() }
+    request.promise = getDashboard().then(result => {
+      if (pending === request && current === getAuthToken()) { value.value = result; loadedAt.value = Date.now() }
+    }).catch(cause => {
+      if (pending === request) { value.value = null; loadedAt.value = null; error.value = cause instanceof Error ? cause.message : '首页数据加载失败' }
+      throw cause
+    }).finally(() => { if (pending === request) pending = undefined })
+    pending = request
+    return request.promise
+  }
+  return { snapshot, error, loadedAt, load }
+}
diff --git a/src/stores/formationReports.ts b/src/stores/formationReports.ts
index 3f37c98..ab849c2 100644
--- a/src/stores/formationReports.ts
+++ b/src/stores/formationReports.ts
@@ -1,5 +1,4 @@
 import { ref } from 'vue'
-import { listFormationReports, deleteFormationReport, type FormationSummary } from '@/api/formationReports'
 
 export interface FormationReportCycle {
   cycle: number
@@ -30,14 +29,51 @@
   description: string
 }
 
-const records = ref<FormationSummary[]>([])
+export type FormationReportPayload = Pick<FormationReportRecord, 'name' | 'creator' | 'testName' | 'batteryGroup' | 'samples' | 'cycles' | 'cycleFieldValues' | 'reportFieldValues' | 'batteryFields' | 'specialFields' | 'metrics' | 'seriesColors' | 'description'>
 
-/** 只缓存正式列表结果,旧本地报表不进入当前流程。 */
-export function useFormationReports() {
-  async function refreshReports() { records.value = await listFormationReports() }
-  async function removeReport(id: number) {
-    await deleteFormationReport(id)
-    records.value = records.value.filter(item => item.id !== id)
+const STORAGE_KEY = 'battery_detection_formation_reports_v1'
+
+const seedRecords: FormationReportRecord[] = [
+  { id: 7003, name: 'SP2化成过程分析报表', creator: 'Admin', createdAt: '2026-08-06 10:18', testName: 'SP2电池化成测试', batteryGroup: 'SP2样品组', samples: ['SP26-1', 'SP26-2', 'SP26-3'], cycles: [{ cycle: 1, method: '恒流充电 / 恒流放电', chargeVoltage: '4.20', dischargeCapacity: '98.6%', retention: '100%', result: '合格' }, { cycle: 2, method: '恒流充电 / 恒流放电', chargeVoltage: '4.20', dischargeCapacity: '98.1%', retention: '99.5%', result: '合格' }], description: '查看化成阶段容量、内阻和温升变化。' },
+  { id: 7002, name: '高温化成批次报表', creator: '李工', createdAt: '2026-08-05 16:22', testName: '高温化成测试', batteryGroup: '高温A批', samples: ['HT-01', 'HT-02'], cycles: [{ cycle: 1, method: '恒流充电 / 静置', chargeVoltage: '4.25', dischargeCapacity: '96.4%', retention: '100%', result: '关注' }], description: '高温环境下的化成数据汇总。' },
+  { id: 7001, name: '首充容量一致性报表', creator: '王工', createdAt: '2026-08-04 09:36', testName: '首充化成测试', batteryGroup: '一致性样品组', samples: ['SP26-4', 'SP26-5', 'SP26-6'], cycles: [{ cycle: 1, method: '恒流充电 / 恒流放电', chargeVoltage: '4.20', dischargeCapacity: '99.2%', retention: '100%', result: '合格' }], description: '首充阶段的容量一致性比较。' },
+]
+
+function loadRecords() {
+  try {
+    const stored = localStorage.getItem(STORAGE_KEY)
+    if (stored) return JSON.parse(stored) as FormationReportRecord[]
+  } catch {
+    localStorage.removeItem(STORAGE_KEY)
   }
-  return { records, refreshReports, removeReport }
+  return seedRecords
+}
+
+const records = ref<FormationReportRecord[]>(loadRecords())
+
+function persist() { localStorage.setItem(STORAGE_KEY, JSON.stringify(records.value)) }
+function nowString() {
+  const date = new Date()
+  const pad = (value: number) => String(value).padStart(2, '0')
+  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
+}
+
+export function useFormationReports() {
+  function createReport(data: FormationReportPayload) {
+    const id = Math.max(7000, ...records.value.map(item => item.id)) + 1
+    records.value.unshift({ ...data, id, createdAt: nowString() })
+    persist()
+  }
+  function updateReport(id: number, data: FormationReportPayload) {
+    const target = records.value.find(item => item.id === id)
+    if (!target) return false
+    Object.assign(target, data)
+    persist()
+    return true
+  }
+  function removeReport(id: number) {
+    records.value = records.value.filter(item => item.id !== id)
+    persist()
+  }
+  return { records, createReport, updateReport, removeReport }
 }
diff --git a/src/stores/todos.ts b/src/stores/todos.ts
index f8edc9a..74e899e 100644
--- a/src/stores/todos.ts
+++ b/src/stores/todos.ts
@@ -1,16 +1,26 @@
-import { ref } from 'vue'
-import { listTodos, createTodo as createTodoApi, handleTodo as handleTodoApi, removeTodo as removeTodoApi } from '@/api/todos'
+import { computed, ref } from 'vue'
+import { listTodos, createTodo as createTodoApi, handleTodo as handleTodoApi, removeTodo as removeTodoApi, type TodoDto } from '@/api/todos'
+import { getAuthToken } from '@/api/request'
 import { formatBusinessTime } from '@/utils/businessTime'
+
 export type TodoStatus = '待处理' | '已处理'
-export interface TodoRecord { id:number; content:string; ownerId:number; owner:string; status:TodoStatus; creator:string; createdAt:string; operatedAt:string; handleRemark:string }
-export interface TodoPayload { content:string; ownerId:number; owner:string }
-const records = ref<TodoRecord[]>([])
-const loaded = ref(false)
-function map(item: any): TodoRecord { return {...item, createdAt: formatBusinessTime(item.createdAt), operatedAt: formatBusinessTime(item.operatedAt)} }
+export interface TodoRecord extends Omit<TodoDto, 'createdAt' | 'operatedAt' | 'handleRemark'> { createdAt: string; operatedAt: string; handleRemark: string }
+const cache = ref<TodoRecord[]>([])
+const session = ref<string | null>(null)
+let requestNo = 0
+// 缓存仅属于当前登录会话;切换账号及退出登录后不能重用其他负责人的记录。
+const records = computed(() => session.value === getAuthToken() ? cache.value : [])
+function map(item: TodoDto): TodoRecord { return { ...item, createdAt: formatBusinessTime(item.createdAt), operatedAt: formatBusinessTime(item.operatedAt), handleRemark: item.handleRemark ?? '' } }
+export function clearTodos() { requestNo++; cache.value = []; session.value = '' }
 export function useTodos() {
-  async function load(){ records.value=(await listTodos()).map(map); loaded.value=true }
-  async function createTodo(payload:TodoPayload){ const item=await createTodoApi({content:payload.content,ownerId:payload.ownerId,ownerName:payload.owner}); records.value.unshift(map(item)) }
-  async function handleTodo(id:number,remark:string){ await handleTodoApi(id,remark); await load() }
-  async function removeTodo(id:number){ await removeTodoApi(id); records.value=records.value.filter(item=>item.id!==id) }
-  return {records,loaded,load,createTodo,handleTodo,removeTodo}
+  async function load() {
+    const token = getAuthToken(); const current = ++requestNo
+    if (session.value !== token) { cache.value = []; session.value = token }
+    try { const rows = await listTodos(); if (current === requestNo && token === getAuthToken()) cache.value = rows.map(map) }
+    catch (error) { if (current === requestNo) cache.value = []; throw error }
+  }
+  async function createTodo(payload: { content: string; ownerId: number }) { await createTodoApi(payload); await load() }
+  async function handleTodo(id: number, remark: string) { await handleTodoApi(id, remark); await load() }
+  async function removeTodo(id: number) { await removeTodoApi(id); cache.value = cache.value.filter(item => item.id !== id) }
+  return { records, load, createTodo, handleTodo, removeTodo }
 }
diff --git a/src/utils/auth.ts b/src/utils/auth.ts
index e3113bf..4ed5005 100644
--- a/src/utils/auth.ts
+++ b/src/utils/auth.ts
@@ -1,3 +1,5 @@
+import { clearDashboard } from '@/stores/dashboard'
+import { clearTodos } from '@/stores/todos'
 import { getUserInfo, loginApi, logoutApi, type LoginParams } from '../api/auth'
 
 export interface AuthUser {
@@ -10,11 +12,15 @@
 const AUTH_USER_KEY = 'battery_detection_auth_user'
 
 function clearSession() {
+  clearTodos()
+  clearDashboard()
   localStorage.removeItem(AUTH_TOKEN_KEY)
   localStorage.removeItem(AUTH_USER_KEY)
 }
 
 export async function login(params: LoginParams): Promise<AuthUser> {
+  clearTodos()
+  clearDashboard()
   const loginResult = await loginApi(params)
   localStorage.setItem(AUTH_TOKEN_KEY, loginResult.token)
 
diff --git a/src/utils/formationReportPrint.ts b/src/utils/formationReportPrint.ts
index 0b8c918..238c4f9 100644
--- a/src/utils/formationReportPrint.ts
+++ b/src/utils/formationReportPrint.ts
@@ -1,4 +1,3 @@
-import { toTestDisplayValue } from './testDataUnits'
 import type { FormationReportCycle, FormationReportMetricKey, FormationReportRecord } from '@/stores/formationReports'
 
 export type FormationMetricKey = FormationReportMetricKey
@@ -34,10 +33,9 @@
   description: string
 }
 
-// 演示数组仍为内部单位,在页面和打印输出时统一换算。
 export const formationMetricDefinitions: FormationMetricDefinition[] = [
   { key: 'voltage', title: '电压', unit: 'V', values: [[3.18, 3.35, 3.62, 3.86, 4.02, 4.18, 4.2], [3.2, 3.37, 3.64, 3.88, 4.04, 4.2, 4.22], [3.16, 3.33, 3.6, 3.84, 4.0, 4.16, 4.18]] },
-  { key: 'current', title: '电流', unit: 'mA', values: [[1.2, 1.18, 1.14, 1.08, 0.92, 0.6, 0.2], [1.24, 1.21, 1.17, 1.11, 0.95, 0.63, 0.23], [1.18, 1.16, 1.12, 1.06, 0.9, 0.58, 0.18]] },
+  { key: 'current', title: '电流', unit: 'A', values: [[1.2, 1.18, 1.14, 1.08, 0.92, 0.6, 0.2], [1.24, 1.21, 1.17, 1.11, 0.95, 0.63, 0.23], [1.18, 1.16, 1.12, 1.06, 0.9, 0.58, 0.18]] },
   { key: 'positiveVoltage', title: '正电位', unit: 'V', values: [[1.82, 1.94, 2.18, 2.42, 2.66, 2.88, 3.02], [1.84, 1.96, 2.2, 2.44, 2.68, 2.9, 3.04], [1.8, 1.92, 2.16, 2.4, 2.64, 2.86, 3.0]] },
   { key: 'negativeVoltage', title: '负电位', unit: 'V', values: [[1.36, 1.41, 1.44, 1.46, 1.48, 1.5, 1.52], [1.38, 1.43, 1.46, 1.48, 1.5, 1.52, 1.54], [1.34, 1.39, 1.42, 1.44, 1.46, 1.48, 1.5]] },
   { key: 'temperature', title: '温度', unit: '℃', values: [[25, 26, 27, 29, 31, 33, 34], [25, 26.5, 27.5, 29.5, 31.5, 33.5, 34.5], [24.5, 25.5, 26.5, 28.5, 30.5, 32.5, 33.5]] },
@@ -115,7 +113,7 @@
   const series = samples.map((sample, index) => ({
     name: sample,
     color: safeColor(data.seriesColors[index], index),
-    values: (metric.values[index] || metric.values[0]).map(value => toTestDisplayValue(metric.key, Number((value + cycleOffset).toFixed(3)))),
+    values: (metric.values[index] || metric.values[0]).map(value => Number((value + cycleOffset).toFixed(3))),
   }))
   const values = series.flatMap(item => item.values)
   const dataMax = Math.max(0, ...values)
diff --git a/src/utils/projectCycleChart.ts b/src/utils/projectCycleChart.ts
new file mode 100644
index 0000000..cc4108c
--- /dev/null
+++ b/src/utils/projectCycleChart.ts
@@ -0,0 +1,25 @@
+export const projectMetrics = [
+  { key: 'finalVoltage1', title: '最终电压1', unit: 'V' },
+  { key: 'finalVoltage2', title: '最终电压2', unit: 'V' },
+  { key: 'dischargeCapacityF', title: '每循环放电末容量 / F', unit: 'mAh / F' },
+] as const
+export type ProjectMetric = typeof projectMetrics[number]
+export interface ProjectPoint { position: number; source?: string; values: Partial<Record<ProjectMetric['key'], number | null>> }
+export interface ProjectSeries { key: string; name: string; points: ProjectPoint[] }
+export const projectPalette = ['#3978f6', '#7d42d8', '#f08a3e', '#23a983', '#e15268', '#1b9ac8', '#b5892f', '#6957c8']
+
+/** 控制台、拼接预览和打印共享图表样式;来源提示与横轴位置分开,不插值或补循环。 */
+export function projectCycleChartOption(metric: ProjectMetric, series: ProjectSeries[], positions: number[], colors: string[], hidden: string[] = [], spliced = false) {
+  const values = series.flatMap(item => item.points.map(point => point.values[metric.key])).filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
+  const low = values.length ? Math.min(...values) : undefined
+  const high = values.length ? Math.max(...values) : undefined
+  const margin = low === undefined || high === undefined ? undefined : Math.max((high - low) * 0.15, 0.01)
+  return { animation: false, color: colors, grid: { top: 62, right: 34, bottom: 48, left: 62 },
+    tooltip: { trigger: 'axis', renderMode: 'richText', formatter: (params: Array<{ seriesName: string; data: { value: [number | string, number | null]; source?: string } }>) => params.map(item => `${item.seriesName}\n${item.data.source || ''}\n${spliced ? '累计位置' : '循环'} ${item.data.value[0]}:${item.data.value[1] ?? '缺失'} ${metric.unit}`).join('\n\n') },
+    legend: { top: 8, left: 12, right: 12, type: 'scroll', icon: 'roundRect', itemWidth: 14, itemHeight: 8, itemGap: 16, textStyle: { fontSize: 10, color: '#52606d' }, selected: Object.fromEntries(series.map(item => [item.name, !hidden.includes(item.key)])) },
+    xAxis: { type: spliced ? 'value' : 'category', boundaryGap: false, data: spliced ? undefined : positions.map(String), min: spliced ? positions[0] ?? 1 : undefined, max: spliced ? (positions.at(-1) === positions[0] ? (positions[0] ?? 1) + 1 : positions.at(-1)) : undefined, minInterval: 1, name: spliced ? '累计位置' : '循环次数', nameLocation: 'middle', nameGap: 30, axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10 }, nameTextStyle: { color: '#52606d', fontSize: 10 } },
+    yAxis: { type: 'value', name: metric.unit, nameLocation: 'middle', nameGap: 42, min: margin === undefined ? undefined : low! - margin, max: margin === undefined ? undefined : high! + margin, axisLabel: { color: '#8795a3', fontSize: 10, formatter: (value: number) => String(Number(value.toFixed(metric.key === 'dischargeCapacityF' ? 2 : 4))) }, splitLine: { lineStyle: { color: '#e8eef4' } } },
+    // 最终电压2没有确认公式,保留空图。单点保留标记,缺测空点不连线。
+    series: metric.key === 'finalVoltage2' ? [] : series.map((item, index) => ({ name: item.name, type: 'line', showSymbol: item.points.length === 1, connectNulls: false, lineStyle: { width: 2 }, itemStyle: { color: colors[index] || projectPalette[index % projectPalette.length] }, data: item.points.map(point => ({ value: [spliced ? point.position : String(point.position), point.values[metric.key] ?? null], source: point.source })) })),
+  }
+}
diff --git a/src/utils/reportMeasurementChart.ts b/src/utils/reportMeasurementChart.ts
index 54b1f08..9a0e21a 100644
--- a/src/utils/reportMeasurementChart.ts
+++ b/src/utils/reportMeasurementChart.ts
@@ -1,3 +1,4 @@
+import { stepCurveChartOption } from './stepCurveChart'
 import type { PersistedTestRecord } from '@/api/tests'
 import { toTestDisplayValue } from './testDataUnits'
 
@@ -14,32 +15,5 @@
     const value = row[field[metric.key]]
     return [[(Number(time) - item.zeroTime) / 1000, value == null ? null : toTestDisplayValue(metric.key, Number(value))] as [number, number | null]]
   }).sort((left, right) => left[0] - right[0]))
-  const [dataMin, dataMax] = series.reduce<[number, number]>(([min, max], points) => points.reduce<[number, number]>(([low, high], [, value]) =>
-    value === null ? [low, high] : [Math.min(low, value), Math.max(high, value)], [min, max]), [0, 0])
-  const range = Math.max(dataMax - dataMin, 1)
-  const timeMax = series.reduce((max, points) => points.reduce((value, [time]) => Math.max(value, time), max), 0)
-  const timeUnit = timeMax >= 3600 ? 3600 : timeMax >= 180 ? 60 : 1
-  const timeLabel = timeUnit === 3600 ? 'h' : timeUnit === 60 ? 'min' : 's'
-  const axisMax = Math.max(metric.unit === '℃' ? Math.ceil(dataMax / 10) * 10 : Math.ceil(dataMax * 10) / 10, 1)
-  const yAxis = (position: 'left' | 'right') => ({
-    type: 'value', name: metric.unit, position, min: dataMin - range * 0.05, 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, formatter: (value: number) => Number(value.toPrecision(6)).toString() },
-  })
-  return {
-    animation: false, color: colors, grid: { top: 40, right: 32, bottom: 42, left: 32, containLabel: true },
-    tooltip: { trigger: 'axis' }, legend: { show: false },
-    xAxis: { type: 'value', min: 0, max: timeMax > 0 ? timeMax : undefined, splitNumber: 4, name: `相对时间 / ${timeLabel}`, nameLocation: 'middle', nameGap: 28,
-      axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10, hideOverlap: true,
-        formatter: (value: number) => Number((value / timeUnit).toFixed(3)).toString() },
-      nameTextStyle: { color: '#26333f', fontSize: 11, fontWeight: 600 } },
-    yAxis: [yAxis('left'), yAxis('right')],
-    // 全部冻结点先参与坐标计算;隐藏仅控制渲染,不重算零点、不缩短范围、不移动其他来源颜色。
-    series: items.flatMap((item, index) => item.hidden ? [] : [{ name: item.name, type: 'line', smooth: false,
-      connectNulls: false, showSymbol: true, symbolSize: 5, itemStyle: { color: colors[index % colors.length] },
-      lineStyle: { width: 2, color: colors[index % colors.length] }, data: series[index] || [] }]),
-  }
+  return stepCurveChartOption(metric, items.map((item, index) => ({ name: item.name, hidden: item.hidden, points: series[index] || [] })), colors)
 }
diff --git a/src/utils/spliceProjectChart.ts b/src/utils/spliceProjectChart.ts
new file mode 100644
index 0000000..8ab1005
--- /dev/null
+++ b/src/utils/spliceProjectChart.ts
@@ -0,0 +1,19 @@
+import type { SpliceMatrix } from '@/api/spliceReports'
+import type { ProjectSeries } from './projectCycleChart'
+
+/** 同组同电池号跨测试接成一条线;跨组同名电池不能混成同一业务身份。 */
+export function spliceProjectSeries(matrix: SpliceMatrix): ProjectSeries[] {
+  const result = new Map<string, ProjectSeries>()
+  for (const segment of matrix.segments) {
+    const source = segment.source
+    const key = JSON.stringify([source.groupId, source.batteryNo])
+    let series = result.get(key)
+    if (!series) { series = { key, name: `${source.group} (#${source.groupId}) · ${source.batteryNo}`, points: [] }; result.set(key, series) }
+    for (const no of segment.originalCycleNos) {
+      const position = segment.cyclePositions?.[String(no)]
+      if (position === undefined) continue
+      series.points.push({ position, source: `${source.name} (#${source.selection.testId}) · ${source.batteryNo} · 原循环${no}`, values: segment.statistics[String(no)] || {} })
+    }
+  }
+  return [...result.values()]
+}
diff --git a/src/utils/spliceSavedReport.ts b/src/utils/spliceSavedReport.ts
index 667748e..ce38c8c 100644
--- a/src/utils/spliceSavedReport.ts
+++ b/src/utils/spliceSavedReport.ts
@@ -1,13 +1,15 @@
 import { reportDetailsHtml } from './reportTestDetails'
 import { init, use } from 'echarts/core'
 import { LineChart } from 'echarts/charts'
-import { GridComponent, MarkLineComponent } from 'echarts/components'
+import { GridComponent, MarkLineComponent, LegendComponent, TooltipComponent } from 'echarts/components'
+import { projectMetrics, projectPalette, projectCycleChartOption } from './projectCycleChart'
+import { spliceProjectSeries } from './spliceProjectChart'
 import { SVGRenderer } from 'echarts/renderers'
 import { getSplicePrintVersion, spliceFields, spliceFieldValue, spliceStatisticKey, spliceReportDetailColumn, type SavedSpliceReport } from '@/api/spliceReports'
 import { openSavedReportPrint } from './savedReportPrint'
 import css from './spliceSavedReport.css?inline'
 
-use([LineChart, GridComponent, MarkLineComponent, SVGRenderer])
+use([LineChart, GridComponent, MarkLineComponent, LegendComponent, TooltipComponent, SVGRenderer])
 const metrics = [
   { key: 'finalVoltage1', title: '最终电压1', unit: 'V' },
   { key: 'energyEfficiency', title: '能量效率', unit: '%' },
@@ -22,6 +24,7 @@
 
 /** 重开与打印共用冻结统计及人工字段,绝不重算或读取来源测试。 */
 export function spliceSavedReportBody(record: SavedSpliceReport, chartsOnly = false) {
+  if (record.spliceMode === 'project-cycles') return projectReportBody(record, chartsOnly)
   if (record.spliceMode === 'battery-segments') return segmentReportBody(record, chartsOnly)
   const sources = [...new Set(record.segments.map(segment => segment.source.selection.testId))]
   const batteries = [...new Set(record.segments.map(segment => segment.source.batteryNo))]
@@ -90,6 +93,21 @@
   return `<div class="splice-snapshot"><header><h1>${cell(record.name)}</h1>${heading}</header><section><h2>来源测试顺序与循环对应</h2><table><thead><tr><th>顺序</th><th>来源测试</th><th>电池组</th><th>参与电池号</th><th>原循环号 → 拼接循环号</th></tr></thead><tbody>${sourceRows}</tbody></table></section><section><h2>报表字段</h2>${fieldRows ? `<table><thead><tr><th>电池号</th><th>字段</th><th>保存值</th></tr></thead><tbody>${fieldRows}</tbody></table>` : '<p>无已保存字段</p>'}</section><section><h2>统计图表</h2>${charts.join('') || `<p>${cycles.length ? '当前未启用统计图指标' : '无已保存循环统计'}</p>`}</section>${statistics}<section><h2>报表说明</h2><div class="splice-note">${cell(record.description)}</div></section></div>`
 }
 
+/** 新版沿用项目图表指标和布局,指定版本打印始终使用保存的点位与来源对应。 */
+function projectReportBody(record: SavedSpliceReport, chartsOnly: boolean) {
+  const series = spliceProjectSeries(record)
+  const charts = projectMetrics.map(metric => {
+    const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 900, height: 270 })
+    try {
+      chart.setOption(projectCycleChartOption(metric, series, record.cycleNos, record.settings.seriesColors || projectPalette, record.settings.hiddenSeriesKeys, true))
+      return `<article class="splice-chart"><h3>${metric.title}</h3>${chart.renderToSVGString()}</article>`
+    } finally { chart.dispose() }
+  }).join('')
+  if (chartsOnly) return `<div class="splice-snapshot">${charts}</div>`
+  const rows = record.segments.map((segment, index) => `<tr><td>${index + 1}</td><td>${cell(segment.source.name)}</td><td>${cell(segment.source.group)}</td><td>${cell(segment.source.batteryNo)}</td><td>${segment.originalCycleNos.map(no => `${segment.cyclePositions?.[String(no)]}:原循环${no}`).join('、') || '无循环统计'}</td></tr>`).join('')
+  return `<div class="splice-snapshot"><header><h1>${cell(record.name)}</h1><p>版本 V${record.versionNo} · ${cell(record.operatorName)}</p><p>${cell(record.description)}</p></header><section><h2>拼接测试</h2><table><thead><tr><th>顺序</th><th>测试</th><th>电池组</th><th>电池号</th><th>累计位置与来源循环</th></tr></thead><tbody>${rows}</tbody></table></section><section><h2>测试详情</h2>${reportDetailsHtml(record.segments.map(spliceReportDetailColumn), record.settings.sourceDetailOverrides)}</section>${charts}</div>`
+}
+
 /** 新版每指标一条完整接续线;旧版仍走原快照呈现,不静默重算历史偏移。 */
 function segmentReportBody(record: SavedSpliceReport, chartsOnly: boolean) {
   const segments = record.segments
diff --git a/src/utils/stepCurveChart.ts b/src/utils/stepCurveChart.ts
new file mode 100644
index 0000000..fe0c816
--- /dev/null
+++ b/src/utils/stepCurveChart.ts
@@ -0,0 +1,19 @@
+import { toTestDisplayValue } from './testDataUnits'
+
+export interface StepCurveSeries { name: string; points: Array<[number, number | null]>; hidden?: boolean }
+/** 工步图表及对比报表共享显示设置;每根曲线独立相对零点,绝不累加前一根的横轴长度。 */
+export function stepCurveChartOption(metric: { key: string; unit: string }, series: StepCurveSeries[], colors: string[]) {
+  const values = series.flatMap(item => item.points.map(point => point[1])).filter((value): value is number => value !== null && Number.isFinite(value))
+  const low = values.length ? Math.min(...values) : 0
+  const high = values.length ? Math.max(...values) : 1
+  const margin = Math.max((high - low) * 0.16, metric.key === 'temperature' ? 1 : toTestDisplayValue(metric.key, 0.08))
+  const maxTime = series.reduce((max, item) => item.points.reduce((value, point) => Math.max(value, point[0]), max), 0)
+  const unit = maxTime >= 3600 ? 3600 : maxTime >= 180 ? 60 : 1
+  const axis = (position: 'left' | 'right') => ({ type: 'value', name: metric.unit, position, nameLocation: 'middle', nameGap: 42, nameRotate: position === 'left' ? 90 : -90, min: low - margin, max: high + margin, 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, margin: 8, formatter: (value: number) => String(Number(value.toPrecision(6))) } })
+  return { animation: false, color: colors, grid: { top: 62, right: 62, bottom: 46, left: 62 }, tooltip: { trigger: 'axis' },
+    legend: { top: 4, right: 6, left: 6, type: 'scroll', icon: 'roundRect', itemWidth: 14, itemHeight: 8, itemGap: 12, textStyle: { fontSize: 10, color: '#52606d' }, selected: Object.fromEntries(series.map(item => [item.name, !item.hidden])) },
+    xAxis: { type: 'value', min: 0, max: maxTime || undefined, name: `相对时间 / ${unit === 3600 ? 'h' : unit === 60 ? 'min' : 's'}`, nameLocation: 'middle', nameGap: 29, axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10, hideOverlap: true, formatter: (value: number) => String(Number((value / unit).toFixed(3))) }, nameTextStyle: { color: '#52606d', fontSize: 10 } },
+    yAxis: [axis('left'), axis('right')],
+    series: series.map((item, index) => ({ name: item.name, type: 'line', smooth: false, connectNulls: false, showSymbol: item.points.length === 1, lineStyle: { width: 2 }, itemStyle: { color: colors[index % colors.length] }, data: item.points })),
+  }
+}
diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue
index 37f6dca..a866b6d 100644
--- a/src/views/Dashboard.vue
+++ b/src/views/Dashboard.vue
@@ -6,144 +6,76 @@
 import { BarChart } from 'echarts/charts'
 import { GridComponent, TooltipComponent } from 'echarts/components'
 import VChart from 'vue-echarts'
-import { useTests, type TestRecord } from '@/stores/tests'
-import { useTodos } from '@/stores/todos'
-import { getTestList } from '@/api/tests'
-import { getBatteryGroupList } from '@/api/batteryGroups'
+import type { DashboardTest } from '@/api/dashboard'
+import { useDashboard } from '@/stores/dashboard'
+import { getUserInfo } from '@/api/auth'
 import { getCurrentUser } from '@/utils/auth'
-
+import { useTodos, clearTodos } from '@/stores/todos'
+import { formatBusinessTime } from '@/utils/businessTime'
 use([CanvasRenderer, BarChart, GridComponent, TooltipComponent])
-
-const activeTab = ref('running')
-const chartPeriod = ref('week')
 const router = useRouter()
-const { records } = useTests()
+const activeTab = ref('running'); const chartPeriod = ref<'week' | 'month' | 'year'>('week')
+const { snapshot, load: loadDashboard } = useDashboard()
+const dashboardError = ref(''); const todoError = ref(''); const loading = ref(false)
 const { records: todoRecords, load: loadTodos } = useTodos()
-const currentOwner = computed(() => getCurrentUser()?.displayName || getCurrentUser()?.username || 'Admin')
-const dashboardError = ref('')
-const hongKongKey = (date: Date) => {
-  const parts = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Hong_Kong', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date)
-  const value = (type: string) => parts.find(item => item.type === type)?.value || ''
-  return `${value('year')}-${value('month')}-${value('day')}`
-}
-onMounted(async () => {
+const permissions = ref<string[]>([])
+const has = (permission: string) => permissions.value.includes('*:*:*') || permissions.value.includes(permission)
+const displayName = computed(() => getCurrentUser()?.displayName || getCurrentUser()?.username || '')
+async function loadPage() {
+  loading.value = true; dashboardError.value = ''; todoError.value = ''
   try {
-    await loadTodos()
-    const [tests, groups] = await Promise.all([
-      getTestList({ pageNum: 1, pageSize: 1000 }),
-      getBatteryGroupList({ pageNum: 1, pageSize: 1000 }),
-    ])
-    records.value = tests.rows
-    // 首页的电池组总数来自后端列表,避免用页面种子推断业务事实。
-    batteryGroupCount.value = groups.total
-  } catch {
-    dashboardError.value = '首页数据加载失败,请刷新后重试'
-    records.value = []
-    batteryGroupCount.value = 0
-  }
-})
-const batteryGroupCount = ref(0)
-const testFilterVisible = ref(false)
-const testType = ref('全部')
-const testTypeDraft = ref('全部')
-const cabinetFilterVisible = ref(false)
-const cabinetKeyword = ref('')
-const cabinetKeywordDraft = ref('')
-
-const testTypeOptions = computed(() => [...new Set(records.value.map(item => item.type))].sort((a, b) => a.localeCompare(b, 'zh-CN')))
-
-const testTabs = computed(() => [
-  { k: 'running', l: `进行中 (${records.value.filter(item => item.status === '检测中').length})` },
-  { k: 'pending', l: `待测试 (${records.value.filter(item => item.status === '待测试').length})` },
-  { k: 'paused', l: `暂停 (${records.value.filter(item => item.status === '暂停').length})` },
-  { k: 'done', l: `最近完成 (${records.value.filter(item => item.status === '检测完成').length})` },
-])
-
-const tests = computed(() => records.value.filter(item => {
-  const statusMatches = activeTab.value === 'running' ? item.status === '检测中'
-    : activeTab.value === 'pending' ? item.status === '待测试'
-      : activeTab.value === 'paused' ? item.status === '暂停' : item.status === '检测完成'
-  return statusMatches && (testType.value === '全部' || item.type === testType.value)
-}).slice(0, 8).map((item, index) => ({ ...item, displayStatus: item.status, step: `工步 ${8 + index * 2} / 循环 ${3 + index}`, warning: 0 })))
-
-const cabinets = [
-  { id: 1, name: 'A-01 检测柜', capacity: '120 kWh', port: '24 / 32', test: 8, battery: 96, status: '在线' },
-  { id: 2, name: 'A-02 检测柜', capacity: '96 kWh', port: '30 / 32', test: 12, battery: 120, status: '在线' },
-  { id: 3, name: 'B-01 检测柜', capacity: '80 kWh', port: '18 / 24', test: 6, battery: 72, status: '离线' },
-]
-const visibleCabinets = computed(() => {
-  const keyword = cabinetKeyword.value.trim().toLocaleLowerCase()
-  return cabinets.filter(item => !keyword || item.name.toLocaleLowerCase().includes(keyword))
-})
-const runningTestCount = computed(() => records.value.filter(item => item.status === '检测中').length)
-const pendingTestCount = computed(() => records.value.filter(item => item.status === '待测试').length)
-
-const cabinetStatusSummary = computed(() => [
-  { label: '在线', value: cabinets.filter(item => item.status === '在线').length, color: '#23865e' },
-  { label: '离线', value: cabinets.filter(item => item.status === '离线').length, color: '#8795a3' },
-  { label: '待测试', value: records.value.filter(item => item.status === '待测试').length, color: '#b77a24' },
-  { label: '进行中', value: records.value.filter(item => item.status === '检测中').length, color: '#2188f5' },
-  { label: '暂停', value: records.value.filter(item => item.status === '暂停').length, color: '#b34f4f' },
-])
-
-const todoList = computed(() => todoRecords.value.filter(item => item.owner === currentOwner.value && item.status === '待处理').slice(0, 6))
-
-const periodData = computed(() => {
-  const now = new Date()
-  const dayKey = (date: Date) => hongKongKey(date)
-  const count = (key: string) => records.value.filter(item => item.createdAt.slice(0, key.length) === key).length
-  const weekLabels: string[] = []; const weekValues: number[] = []
-  for (let index = 6; index >= 0; index -= 1) { const date = new Date(now); date.setDate(now.getDate() - index); const key = dayKey(date); weekLabels.push(`${date.getMonth() + 1}/${date.getDate()}`); weekValues.push(count(key)) }
-  const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
-  const monthDays = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate(); const monthLabels: string[] = []; const monthValues: number[] = []
-  for (let day = 1; day <= Math.min(now.getDate(), monthDays); day += 1) { const key = `${monthKey}-${String(day).padStart(2, '0')}`; monthLabels.push(`${day}日`); monthValues.push(count(key)) }
-  const yearLabels: string[] = []; const yearValues: number[] = []
-  for (let month = 1; month <= now.getMonth() + 1; month += 1) { const key = `${now.getFullYear()}-${String(month).padStart(2, '0')}`; yearLabels.push(`${month}月`); yearValues.push(count(key)) }
-  return { week: { labels: weekLabels, values: weekValues }, month: { labels: monthLabels, values: monthValues }, year: { labels: yearLabels, values: yearValues } }
-})
-
-const chartOption = computed(() => {
-  const data = periodData.value[chartPeriod.value as keyof typeof periodData.value]
-  const values = data.values
-  return {
-    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
-    grid: { left: 8, right: 10, bottom: 4, top: 22, containLabel: true },
-    xAxis: {
-      type: 'category', data: data.labels,
-      axisLine: { lineStyle: { color: '#d8e6f1' } }, axisTick: { show: false },
-      axisLabel: { color: '#7b8794', fontSize: 11 },
-    },
-    yAxis: {
-      type: 'value', axisLine: { show: false }, axisTick: { show: false },
-      splitLine: { lineStyle: { color: '#e6edf3' } }, axisLabel: { color: '#7b8794', fontSize: 11 },
-    },
-    series: [{
-      type: 'bar', barMaxWidth: 28,
-      data: values.map((value, index) => ({ value, itemStyle: { color: index === values.length - 1 ? '#2188f5' : '#b9d8f8' } })),
-      itemStyle: { borderRadius: 0 },
-    }],
-  }
-})
-
-function createTest() {
-  router.push('/test-list/new')
+    permissions.value = (await getUserInfo()).permissions || []
+    const results = await Promise.allSettled([loadDashboard(), has('todo:list:query') || has('todo:list:list') ? loadTodos() : Promise.resolve()])
+    const main = results[0]; const todos = results[1]
+    if (main.status === 'rejected') dashboardError.value = main.reason instanceof Error ? main.reason.message : '首页数据加载失败'
+    if (todos.status === 'rejected') todoError.value = todos.reason instanceof Error ? todos.reason.message : '待办加载失败'
+  } catch (error) { clearTodos(); dashboardError.value = error instanceof Error ? error.message : '首页加载失败' }
+  finally { loading.value = false }
 }
-
+onMounted(loadPage)
+const testFilterVisible = ref(false); const testType = ref('全部'); const testTypeDraft = ref('全部')
+const cabinetFilterVisible = ref(false); const cabinetKeyword = ref(''); const cabinetKeywordDraft = ref('')
+const records = computed(() => snapshot.value?.tests || [])
+const testTypeOptions = computed(() => [...new Set(records.value.map(item => item.type))].filter(Boolean).sort((a,b) => a.localeCompare(b,'zh-CN')))
+const statusLabels: Record<string,string> = { WAITING:'待测试', RUNNING:'检测中', PAUSED:'暂停', COMPLETED:'检测完成' }
+const tabs = [{ k:'running', code:'RUNNING', name:'进行中' }, { k:'pending', code:'WAITING', name:'待测试' }, { k:'paused', code:'PAUSED', name:'暂停' }, { k:'done', code:'COMPLETED', name:'最近完成' }]
+const testTabs = computed(() => tabs.map(tab => ({ k:tab.k, l:tab.name + ' (' + records.value.filter(item => item.status === tab.code).length + ')' })))
+const tests = computed(() => records.value.filter(item => item.status === tabs.find(tab => tab.k === activeTab.value)?.code && (testType.value === '全部' || item.type === testType.value)).slice(0,8).map(item => ({ ...item, displayStatus: statusLabels[item.status] || '未知' })))
+const cabinets = computed(() => snapshot.value?.cabinets || [])
+const visibleCabinets = computed(() => cabinets.value.filter(item => item.name.toLocaleLowerCase().includes(cabinetKeyword.value.trim().toLocaleLowerCase())))
+const runningTestCount = computed(() => records.value.filter(item => item.status === 'RUNNING').length)
+const pendingTestCount = computed(() => records.value.filter(item => item.status === 'WAITING').length)
+const cabinetStatusSummary = computed(() => [
+  {label:'在线',value:cabinets.value.filter(item => item.onlineStatus === '在线').length,color:'#23865e'},
+  {label:'离线',value:cabinets.value.filter(item => item.onlineStatus === '离线').length,color:'#8795a3'},
+])
+const pendingTodos = computed(() => (has('todo:list:query') || has('todo:list:list') ? todoRecords.value : []).filter(item => item.status === '待处理'))
+const todoList = computed(() => pendingTodos.value.slice(0,6))
+const warnings = computed(() => snapshot.value?.warnings || [])
+const chartOption = computed(() => {
+  const data = snapshot.value?.trends[chartPeriod.value] || { labels:[],values:[] }
+  return { animation:false, tooltip:{trigger:'axis'}, grid:{left:8,right:10,bottom:4,top:22,containLabel:true},
+    xAxis:{type:'category',data:data.labels,axisLine:{lineStyle:{color:'#d8e6f1'}},axisTick:{show:false},axisLabel:{color:'#7b8794',fontSize:11}},
+    yAxis:{type:'value',minInterval:1,axisLine:{show:false},axisTick:{show:false},splitLine:{lineStyle:{color:'#e6edf3'}},axisLabel:{color:'#7b8794',fontSize:11}},
+    series:[{type:'bar',barMaxWidth:28,data:data.values.map((value,index) => ({value,itemStyle:{color:index === data.values.length-1 ? '#2188f5' : '#b9d8f8'}}))}] }
+})
+function createTest() { router.push('/test-list/new') }
 function applyTestFilter() { testType.value = testTypeDraft.value; testFilterVisible.value = false }
 function applyCabinetFilter() { cabinetKeyword.value = cabinetKeywordDraft.value.trim(); cabinetFilterVisible.value = false }
-function openTest(item: TestRecord & { warning: number }) { router.push(item.warning ? '/exception' : `/test-list/${item.id}/console`) }
+function openTest(item: DashboardTest) { router.push(item.warning ? '/exception' : '/test-list/' + item.id + '/console') }
 function openCabinets() { router.push('/device-cabinet') }
 </script>
 
 <template>
   <div class="dashboard">
     <el-alert v-if="dashboardError" :title="dashboardError" type="error" :closable="false" show-icon />
+    <el-button :loading="loading" @click="loadPage">刷新</el-button>
     <section class="welcome-row">
       <div>
-        <h2>欢迎回来,Admin</h2>
+        <h2>欢迎回来,{{ displayName }}</h2>
         <p>当前 {{ runningTestCount }} 项测试进行中,{{ pendingTestCount }} 项等待执行</p>
       </div>
-      <button class="primary-action" type="button" @click="createTest"><el-icon><Plus /></el-icon>新建测试</button>
+      <button v-if="has('system:test:add')" class="primary-action" type="button" @click="createTest"><el-icon><Plus /></el-icon>新建测试</button>
     </section>
 
     <div class="dashboard-grid">
@@ -158,12 +90,12 @@
           </div>
           <div class="table-scroll">
             <table>
-              <thead><tr><th>测试名称</th><th>状态</th><th>工步 / 循环</th><th>警告</th><th>操作</th></tr></thead>
+              <thead><tr><th>测试名称</th><th>状态</th><th>最近记录工步 / 循环</th><th>警告</th><th>操作</th></tr></thead>
               <tbody>
-                <tr v-for="item in tests" :key="item.name">
+                <tr v-for="item in tests" :key="item.id">
                   <td>{{ item.name }}</td>
-                  <td><span class="state" :class="item.status === '暂停' ? 'paused' : ''"><i></i>{{ item.displayStatus }}</span></td>
-                  <td>{{ item.step }}</td>
+                  <td><span class="state" :class="item.status === 'PAUSED' ? 'paused' : ''"><i></i>{{ item.displayStatus }}</span></td>
+                  <td>{{ item.step || '—' }}</td>
                   <td :class="{ danger: item.warning }">{{ item.warning }}</td>
                   <td><button class="text-action" type="button" @click="openTest(item)">{{ item.warning ? '处理' : '查看' }}</button></td>
                 </tr>
@@ -179,17 +111,17 @@
           </header>
           <div class="table-scroll">
             <table>
-              <thead><tr><th>测试柜</th><th>容量</th><th>端口</th><th>测试</th><th>电池</th><th>操作</th></tr></thead>
-              <tbody><tr v-for="item in visibleCabinets" :key="item.name"><td>{{ item.name }}</td><td>{{ item.capacity }}</td><td>{{ item.port }}</td><td>{{ item.test }}</td><td>{{ item.battery }}</td><td><button class="text-action" type="button" @click="openCabinets">查看</button></td></tr></tbody>
+              <thead><tr><th>测试柜</th><th>规格</th><th>已绑定 / 总通道</th><th>测试</th><th>电池</th><th>操作</th></tr></thead>
+              <tbody><tr v-for="item in visibleCabinets" :key="item.name"><td>{{ item.name }}</td><td>{{ item.capacity ?? '—' }}</td><td>{{ item.usedChannels }} / {{ item.channelCount }}</td><td>{{ item.testCount }}</td><td>{{ item.batteryCount }}</td><td><button class="text-action" type="button" @click="openCabinets">查看</button></td></tr></tbody>
             </table>
           </div>
         </section>
 
         <div class="metrics">
-          <article><span>总测试数</span><strong>{{ records.length }}</strong></article>
-          <article><span>总电池组数</span><strong>{{ batteryGroupCount }}</strong></article>
-          <article><span>日新增测试</span><strong>{{ records.filter(item => item.createdAt.slice(0, 10) === hongKongKey(new Date())).length }}</strong></article>
-          <article><span>月新增测试</span><strong>{{ records.filter(item => item.createdAt.slice(0, 7) === hongKongKey(new Date()).slice(0, 7)).length }}</strong></article>
+          <article><span>总测试数</span><strong>{{ snapshot?.summary.testCount ?? '—' }}</strong></article>
+          <article><span>总电池组数</span><strong>{{ snapshot?.summary.batteryGroupCount ?? '—' }}</strong></article>
+          <article><span>日新增测试</span><strong>{{ snapshot?.summary.todayCount ?? '—' }}</strong></article>
+          <article><span>月新增测试</span><strong>{{ snapshot?.summary.monthCount ?? '—' }}</strong></article>
         </div>
 
         <section class="panel chart-panel">
@@ -205,18 +137,19 @@
 
       <aside class="secondary-column">
         <section class="panel alert-panel">
-          <header class="panel-header"><div class="panel-title"><i></i><h3>预警通知</h3></div><span class="open-count">0 OPEN</span></header>
-          <div class="alert-empty"><el-icon><CircleCheck /></el-icon><strong>暂无异常</strong><span>当前检测任务运行稳定</span></div>
+          <header class="panel-header"><div class="panel-title"><i></i><h3>预警通知</h3></div><span class="open-count">{{ warnings.length }} OPEN</span></header>
+          <div v-if="warnings.length" class="todo-list"><div v-for="item in warnings.slice(0,6)" :key="item.id"><span><strong>{{ item.testName }}</strong><small>{{ item.item }} · {{ formatBusinessTime(item.occurredAt) }}</small></span><button class="text-action" @click="router.push('/exception')">查看</button></div></div>
+          <div v-else class="alert-empty"><el-icon><CircleCheck /></el-icon><strong>{{ snapshot ? '暂无待处理异常' : '暂无已加载数据' }}</strong></div>
         </section>
 
         <section class="panel cabinet-panel">
-          <header class="panel-header"><div class="panel-title"><i></i><h3>测试柜情况</h3></div><span class="panel-code">36 UNITS</span></header>
+          <header class="panel-header"><div class="panel-title"><i></i><h3>测试柜情况</h3></div><span class="panel-code">{{ cabinets.length }} UNITS</span></header>
           <div class="cabinet-list"><div v-for="item in cabinetStatusSummary" :key="item.label"><span><i :style="{ background: item.color }"></i>{{ item.label }}</span><strong :style="{ color: item.color }">{{ item.value }}</strong></div></div>
         </section>
 
         <section class="panel todo-panel">
-          <header class="panel-header"><div class="panel-title"><i></i><h3>待办 ({{ todoList.length }})</h3></div><button class="text-action" type="button" @click="router.push('/todo-list')">全部</button></header>
-          <div class="todo-list"><div v-for="item in todoList" :key="item.id"><span><strong>{{ item.content }}</strong><small>{{ item.operatedAt }} · 待处理</small></span><b class="pending">待处理</b></div></div>
+          <header class="panel-header"><div class="panel-title"><i></i><h3>待办 ({{ pendingTodos.length }})</h3></div><button class="text-action" type="button" @click="router.push('/todo-list')">全部</button></header>
+          <p v-if="todoError" role="alert">{{ todoError }}</p><p v-else-if="!has('todo:list:query') && !has('todo:list:list')">无待办查看权限</p><div v-else class="todo-list"><div v-for="item in todoList" :key="item.id"><span><strong>{{ item.content }}</strong><small>{{ item.operatedAt }} · 待处理</small></span></div></div>
         </section>
       </aside>
     </div>
@@ -292,7 +225,7 @@
 .todo-list strong { color: #35414d; font-size: 13px; font-weight: 500; }
 .todo-list small { margin-top: 4px; color: #8a96a1; font-size: 11px; }
 .todo-list b { padding: 2px 5px; border: 1px solid; font-size: 10px; font-weight: 600; }
-.todo-list b.p1 { color: #b34f4f; }.todo-list b.p2 { color: #b77a24; }
+
 
 @media (max-width: 1100px) {
   .dashboard-grid { grid-template-columns: 1fr; }
diff --git a/src/views/FormationReport.vue b/src/views/FormationReport.vue
index 4f71e30..ece2e5f 100644
--- a/src/views/FormationReport.vue
+++ b/src/views/FormationReport.vue
@@ -1,35 +1,13 @@
 <script setup lang="ts">
-import { computed, onMounted, reactive, ref } from 'vue'
+import { computed, reactive, ref } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { useRouter } from 'vue-router'
-import { useFormationReports } from '@/stores/formationReports'
-import type { FormationSummary } from '@/api/formationReports'
-import { getUserInfo } from '@/api/auth'
+import { useFormationReports, type FormationReportRecord } from '@/stores/formationReports'
+import { createFormationReportPrintData, openFormationReportPrint } from '@/utils/formationReportPrint'
 
 interface FilterState { reportName: string; creator: string; dateRange: [string, string] | null }
 const router = useRouter()
-const { records, refreshReports, removeReport } = useFormationReports()
-const loading = ref(false)
-const loadError = ref('')
-const canCreate = ref(false)
-const canEdit = ref(false)
-const canQuery = ref(false)
-const canDelete = ref(false)
-const deletingId = ref<number | null>(null)
-const dateText = (time: number) => new Date(time).toLocaleString()
-const dateKey = (time: number) => new Date(time).toLocaleDateString('sv-SE')
-onMounted(async () => {
-  loading.value = true
-  records.value = []
-  try {
-    const permissions = (await getUserInfo()).permissions || []
-    const has = (action: string) => permissions.includes('*:*:*') || permissions.includes(`system:formation-report:${action}`)
-    canCreate.value = has('add'); canEdit.value = has('edit'); canQuery.value = has('query'); canDelete.value = has('remove')
-    if (canQuery.value) await refreshReports()
-    else loadError.value = '无查看化成报表权限'
-  } catch (error) { loadError.value = error instanceof Error ? error.message : '化成报表列表加载失败' }
-  finally { loading.value = false }
-})
+const { records, removeReport } = useFormationReports()
 const filters = reactive<FilterState>({ reportName: '', creator: '', dateRange: null })
 const appliedFilters = ref<FilterState>({ reportName: '', creator: '', dateRange: null })
 const currentPage = ref(1)
@@ -40,32 +18,29 @@
   const keyword = query.reportName.trim().toLowerCase()
   return records.value.filter(item => !keyword || item.name.toLowerCase().includes(keyword) || String(item.id).includes(keyword))
     .filter(item => !query.creator || query.creator === '全部' || item.creator === query.creator)
-    .filter(item => !query.dateRange || (dateKey(item.createdAt) >= query.dateRange[0] && dateKey(item.createdAt) <= query.dateRange[1]))
-    .sort((a, b) => b.createdAt - a.createdAt)
+    .filter(item => !query.dateRange || (item.createdAt.slice(0, 10) >= query.dateRange[0] && item.createdAt.slice(0, 10) <= query.dateRange[1]))
+    .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
 })
 const pagedRecords = computed(() => filteredRecords.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value))
 function applySearch() { appliedFilters.value = { reportName: filters.reportName, creator: filters.creator, dateRange: filters.dateRange ? [...filters.dateRange] as [string, string] : null }; currentPage.value = 1; ElMessage.success(`已找到 ${filteredRecords.value.length} 份化成报表`) }
 function resetSearch() { Object.assign(filters, { reportName: '', creator: '', dateRange: null }); appliedFilters.value = { reportName: '', creator: '', dateRange: null }; currentPage.value = 1 }
 function openCreate() { router.push('/report-formation/new') }
-function openEdit(item: FormationSummary) { router.push({ path: `/report-formation/${item.id}/edit`, query: { mode: 'edit' } }) }
-function openView(item: FormationSummary) { router.push({ path: `/report-formation/${item.id}/edit`, query: { mode: 'view' } }) }
-async function deleteReport(item: FormationSummary) {
-  try {
-    await ElMessageBox.confirm(`删除“${item.name}”及全部历史版本后无法恢复,是否继续?`, '确认删除化成报表', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
-    deletingId.value = item.id
-    await removeReport(item.id)
-    currentPage.value = Math.min(currentPage.value, Math.max(1, Math.ceil(filteredRecords.value.length / pageSize.value)))
-    ElMessage.success('化成报表已删除')
-  } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '删除失败') }
-  finally { deletingId.value = null }
+function openEdit(item: FormationReportRecord) { router.push(`/report-formation/${item.id}/edit`) }
+function openView(item: FormationReportRecord) { router.push({ path: `/report-formation/${item.id}/edit`, query: { mode: 'view' } }) }
+function downloadPdf(item: FormationReportRecord) {
+  const opened = openFormationReportPrint(createFormationReportPrintData(item))
+  if (!opened) { ElMessage.warning('浏览器阻止了报表窗口,请允许弹出窗口后重试'); return }
+  ElMessage.info('已打开打印版报表,请在打印窗口选择“另存为 PDF”')
+}
+async function deleteReport(item: FormationReportRecord) {
+  try { await ElMessageBox.confirm(`删除“${item.name}”后无法恢复,是否继续?`, '确认删除化成报表', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }); removeReport(item.id); ElMessage.success('化成报表已删除') } catch { /* 用户取消 */ }
 }
 </script>
 
 <template>
-  <div class="formation-page" v-loading="loading">
-    <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" />
+  <div class="formation-page">
     <section class="filter-panel"><header class="section-heading"><div><i></i><strong>查询报表</strong><span>FORMATION REPORT QUERY</span></div><small>共 {{ records.length }} 份报表</small></header><div class="filter-grid"><label><span>报表名称</span><el-input v-model="filters.reportName" clearable placeholder="输入报表名称或ID" @keyup.enter="applySearch" /></label><label><span>创建人</span><el-select v-model="filters.creator" clearable placeholder="全部"><el-option label="全部" value="全部" /><el-option v-for="item in creatorOptions" :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="至" unlink-panels /></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><RefreshLeft /></el-icon>重置</button></div></div></section>
-    <section class="result-panel"><div class="result-toolbar"><div><i></i><strong>化成报表列表</strong><span>{{ filteredRecords.length }} RESULTS · 按创建时间倒序</span></div><div class="toolbar-actions"><button v-if="canCreate" class="primary-button create-button" type="button" @click="openCreate"><el-icon><Plus /></el-icon>新建报表</button></div></div><div class="table-wrap"><table><thead><tr><th>报表ID</th><th>报表名称</th><th>测试名称</th><th>电池组</th><th>创建时间</th><th>创建人</th><th class="action-column">操作</th></tr></thead><tbody><tr v-for="item in pagedRecords" :key="item.id"><td class="id-cell">#{{ item.id }}</td><td><strong>{{ item.name }}</strong></td><td>{{ item.testName }}</td><td>{{ item.batteryGroup }}</td><td>{{ dateText(item.createdAt) }}</td><td>{{ item.creator }}</td><td class="action-column"><div class="row-actions"><button v-if="canQuery" type="button" @click="openView(item)">查看报表</button><button v-if="canEdit" type="button" @click="openEdit(item)">编辑</button><button v-if="canDelete" :disabled="deletingId !== null" class="delete-action" type="button" @click="deleteReport(item)">删除</button></div></td></tr><tr v-if="!pagedRecords.length && !loading && !loadError"><td colspan="7" class="empty-cell">暂无符合条件的化成报表</td></tr></tbody></table></div><footer class="pagination-bar"><span>显示 {{ pagedRecords.length }} 条,共 {{ filteredRecords.length }} 条</span><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]" :total="filteredRecords.length" layout="sizes, prev, pager, next" background /></footer></section>
+    <section class="result-panel"><div class="result-toolbar"><div><i></i><strong>化成报表列表</strong><span>{{ filteredRecords.length }} RESULTS · 按创建时间倒序</span></div><div class="toolbar-actions"><button class="primary-button create-button" type="button" @click="openCreate"><el-icon><Plus /></el-icon>新建报表</button></div></div><div class="table-wrap"><table><thead><tr><th>报表ID</th><th>报表名称</th><th>测试名称</th><th>电池组</th><th>创建时间</th><th>创建人</th><th class="action-column">操作</th></tr></thead><tbody><tr v-for="item in pagedRecords" :key="item.id"><td class="id-cell">#{{ item.id }}</td><td><strong>{{ item.name }}</strong></td><td>{{ item.testName }}</td><td>{{ item.batteryGroup }}</td><td>{{ item.createdAt }}</td><td>{{ item.creator }}</td><td class="action-column"><div class="row-actions"><button type="button" @click="openView(item)">查看报表</button><button type="button" @click="openEdit(item)">编辑</button><button type="button" @click="downloadPdf(item)">下载PDF</button><button class="delete-action" type="button" @click="deleteReport(item)">删除</button></div></td></tr><tr v-if="!pagedRecords.length"><td colspan="7" class="empty-cell">暂无符合条件的化成报表</td></tr></tbody></table></div><footer class="pagination-bar"><span>显示 {{ pagedRecords.length }} 条,共 {{ filteredRecords.length }} 条</span><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]" :total="filteredRecords.length" layout="sizes, prev, pager, next" background /></footer></section>
   </div>
 </template>
 
diff --git a/src/views/FormationReportForm.vue b/src/views/FormationReportForm.vue
index d3eb961..27c139d 100644
--- a/src/views/FormationReportForm.vue
+++ b/src/views/FormationReportForm.vue
@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, ref, watch } from 'vue'
+import { computed, reactive, ref, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { useRoute, useRouter } from 'vue-router'
 import { use } from 'echarts/core'
@@ -7,298 +7,267 @@
 import { CanvasRenderer } from 'echarts/renderers'
 import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
 import VChart from 'vue-echarts'
-import { getUserInfo } from '@/api/auth'
-import { getFormationSources, previewFormationReport, formationValueKey, type FormationCandidate, type FormationItem, type FormationField } from '@/api/formationReports'
-import { defaultFormationFields, formationFieldLabel, formationSourceValue, formationChargeChoice } from '@/utils/formationReportFields'
-import { formationChartOption, formationCurveMetrics, formationSeriesKey, formationSeriesName, formationSeriesColor } from '@/utils/formationCycleChart'
-import type { ReportMetricKey } from '@/utils/reportMeasurementChart'
-import { openFormationSavedReportPrint } from '@/utils/formationSavedReportPrint'
-import FormationFieldControls from '@/components/FormationFieldControls.vue'
-import ReportTestDetail from '@/components/ReportTestDetail.vue'
-import { reportDetailColumn, snapshotReportDetails } from '@/utils/reportTestDetails'
-import FormationStepCurves from '@/components/FormationStepCurves.vue'
-import { formationChargeSteps, formationCycleKey } from '@/utils/formationStepChart'
-import type { FormationSettings, FormationStepSelection } from '@/api/formationReports'
-import { createFormationReport, getFormationReport, editFormationReport, refreshFormationSource, listFormationVersions,
-  type SavedFormationReport, type FormationVersion } from '@/api/formationReports'
+import { getCurrentUser } from '@/utils/auth'
+import { useFormationReports, type FormationReportCycle } from '@/stores/formationReports'
+import { useBatteryGroupFields } from '@/stores/batteryGroupFields'
+import { formationCycleFields as cycleFields, formationFixedReportFields as fixedReportFields, formationMetricDefinitions, openFormationReportPrint, type FormationMetricDefinition } from '@/utils/formationReportPrint'
 
 use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
-const route = useRoute()
-const router = useRouter()
-const reportName = ref('')
-const description = ref('')
-const loadError = ref('')
-const previewError = ref('')
-const loading = ref(false)
-const previewLoading = ref(false)
-const canCreate = ref(false)
-const canEdit = ref(false)
-const canQuery = ref(false)
-const canExport = ref(false)
-const printing = ref(false)
-const editingId = computed(() => Number(route.params.id) || null)
-const readonly = computed(() => Boolean(editingId.value) && (route.query.mode !== 'edit' || !canEdit.value || Boolean(route.query.version)))
-const saved = ref<SavedFormationReport | null>(null)
-const savedContent = ref('')
-const saving = ref(false)
-const changeSummary = ref('')
-const historyVisible = ref(false)
-const history = ref<FormationVersion[]>([])
-const historyLoading = ref(false)
-const historyError = ref('')
-const candidates = ref<FormationCandidate[]>([])
-const selectedKeys = ref<string[]>([])
-const items = ref<FormationItem[]>([])
-const cycleNos = ref<number[]>([])
-const fields = ref<FormationField[]>(structuredClone(defaultFormationFields.filter(field => field.scope === 'cycle')))
-const values = ref<Record<string, string>>({})
-const sourceDetailOverrides = ref<NonNullable<FormationSettings['sourceDetailOverrides']>>({})
-const chargeStepSelections = ref<Record<string, number>>({})
-const metrics = ref<ReportMetricKey[]>(formationCurveMetrics.map(metric => metric.key))
-const seriesColors = ref<Record<string, string>>({})
-const activeCycle = ref<number | null>(null)
-const manualChargeSteps = ref<Record<string, number[]>>({})
-const curveStepSelections = ref<Record<string, FormationStepSelection>>({})
-const hiddenSeriesKeys = ref<string[]>([])
-const actualSteps = computed(() => !saved.value || saved.value.curveMode === 'actual-steps')
-const stepSettings = computed<FormationSettings>(() => ({ fields: fields.value, values: values.value, sourceDetailOverrides: snapshotReportDetails(reportDetailColumns.value, sourceDetailOverrides.value), chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value, manualChargeSteps: manualChargeSteps.value, curveStepSelections: curveStepSelections.value, hiddenSeriesKeys: hiddenSeriesKeys.value, activeCycle: activeCycle.value }))
-function changeStepSettings(settings: Partial<FormationSettings>) {
-  if (settings.curveStepSelections) curveStepSelections.value = settings.curveStepSelections
-  if (settings.hiddenSeriesKeys) hiddenSeriesKeys.value = settings.hiddenSeriesKeys
-  if (settings.seriesColors) seriesColors.value = settings.seriesColors
+const route = useRoute(); const router = useRouter(); const currentUser = getCurrentUser(); const { records, createReport, updateReport } = useFormationReports()
+const { fields: managedGroupFields } = useBatteryGroupFields()
+const editingId = computed(() => { const value = Number(route.params.id); return Number.isFinite(value) && value > 0 ? value : null })
+const editingRecord = computed(() => editingId.value ? records.value.find(item => item.id === editingId.value) || null : null)
+const readonly = computed(() => route.query.mode === 'view')
+const reportName = ref(''); const testName = ref('SP2电池化成测试'); const batteryGroup = ref('SP2样品组'); const saving = ref(false)
+const cycles = ref<FormationReportCycle[]>([
+  { cycle: 1, method: '恒流充电 / 恒流放电', chargeVoltage: '4.20', dischargeCapacity: '98.6%', retention: '100%', result: '合格' },
+  { cycle: 2, method: '恒流充电 / 恒流放电', chargeVoltage: '4.20', dischargeCapacity: '98.1%', retention: '99.5%', result: '合格' },
+  { cycle: 3, method: '恒流充电 / 恒流放电', chargeVoltage: '4.20', dischargeCapacity: '97.6%', retention: '99.0%', result: '合格' },
+])
+const description = ref('汇总化成阶段的充放电数据、容量保持率和温升情况。')
+const seriesColors = ref(['#3978f6', '#7d42d8', '#f08a3e'])
+const formationMetricVisible = reactive<Record<FormationMetricDefinition['key'], boolean>>({ voltage: true, current: true, positiveVoltage: true, negativeVoltage: true, temperature: true })
+const visibleFormationMetrics = computed(() => formationMetricDefinitions.filter(metric => formationMetricVisible[metric.key]))
+const activeCycle = ref(1)
+const reportColumns = reactive([
+  { id: 1, name: 'SP2电池化成测试', samples: ['样品1', '样品2', '样品3'] },
+  { id: 2, name: '高温化成测试', samples: ['样品1', '样品2', '样品3'] },
+])
+const batteryFields = ref<string[]>([])
+const specialFields = ref<string[]>([])
+const reportFieldValues = reactive<Record<string, string>>({})
+const templateDialogVisible = ref(false)
+const templateMode = ref<'battery' | 'special'>('battery')
+const selectedTemplateKey = ref('cycle')
+const batteryTemplateStorageKey = 'battery_detection_group_field_templates_v1'
+const presetBatteryTemplateLabels: Record<string, string[]> = {
+  '标准容量样品': ['额定容量', '电流', '内阻'],
+  '标准循环样品': ['额定容量', '循环温度', '内阻'],
+  '低温性能样品': ['目标温度', '静置时间', '环境备注'],
 }
-function unknownSteps(item: FormationItem, cycle: number) { return (item.chargeSteps?.[cycle] ?? []).filter(step => step.direction === 'UNKNOWN') }
-function terminationRows(cycle: number) { return Math.max(1, ...items.value.map(item => formationChargeSteps(item, cycle, manualChargeSteps.value).length)) }
-function termination(item: FormationItem, cycle: number, index: number) {
-  if (!item.cycleNos.includes(cycle)) return '无该循环'
-  const step = formationChargeSteps(item, cycle, manualChargeSteps.value)[index]
-  if (!step) return index ? '—' : (item.chargeSteps?.[cycle]?.length ? '未识别充电工步' : '缺工步')
-  return `工步${step.stepNo} · ${step.name || '未命名工步'} · ${step.endVoltage ?? '缺失'}${step.endVoltage == null ? '' : ' V'}`
+const batteryTemplateSelections = reactive<Record<string, number[]>>((() => {
+  try {
+    const stored = localStorage.getItem(batteryTemplateStorageKey)
+    return stored ? JSON.parse(stored) as Record<string, number[]> : {}
+  } catch {
+    localStorage.removeItem(batteryTemplateStorageKey)
+    return {}
+  }
+})())
+const selectedBatteryTemplateName = ref('标准容量样品')
+const selectedBatteryTemplateFieldIds = ref<number[]>([])
+const specialTemplateStorageKey = 'battery_detection_compare_special_templates_v2'
+const specialTemplateSelections = 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 fieldTemplates = computed(() => [
+  { key: 'cycle', name: '循环寿命模板', fields: ['容量保持率', '能量效率', '循环次数', '内阻变化'] },
+  { key: 'thermal', name: '热特性模板', fields: ['最高温度', '温差', '温升速率'] },
+  ...Object.entries(specialTemplateSelections).map(([name, fields]) => ({ key: `saved:${name}`, name, fields })),
+])
+const reportSamples = computed(() => reportColumns.flatMap(column => column.samples))
+const enabledGroupFields = computed(() => managedGroupFields.value.filter(field => field.status === '启用'))
+const batteryTemplateFieldOptions = computed(() => [...managedGroupFields.value].sort((a, b) => b.createdAt.localeCompare(a.createdAt)))
+const batteryTemplateNames = computed(() => [
+  '自定义',
+  '标准容量样品',
+  '标准循环样品',
+  '低温性能样品',
+  ...Object.keys(batteryTemplateSelections).filter(name => !['自定义', '标准容量样品', '标准循环样品', '低温性能样品'].includes(name)),
+])
+const selectedTemplate = computed(() => fieldTemplates.value.find(item => item.key === selectedTemplateKey.value) || fieldTemplates.value[0])
+const reportDetailFields = computed(() => [
+  ...fixedReportFields,
+  ...batteryFields.value.map(field => ({ key: `battery:${field}`, label: field, mode: 'input', unit: '' })),
+  ...specialFields.value.map(field => ({ key: `special:${field}`, label: field, mode: 'input', unit: '' })),
+])
+watch(testName, value => { reportColumns[0].name = value })
+const cycleFieldValues = reactive<Record<string, string>>({})
+
+function cycleValueKey(cycle: number, field: string, sample: string) { return `${cycle}|${field}|${sample}` }
+function rawValue(cycle: number, fieldIndex: number, sampleIndex: number) {
+  return (3.76 + cycle * 0.03 + fieldIndex * 0.018 + sampleIndex * 0.012).toFixed(3)
 }
-const visibleMetrics = computed(() => formationCurveMetrics.filter(metric => metrics.value.includes(metric.key)))
-const hasCurves = computed(() => items.value.some(item => item.curves?.[activeCycle.value ?? -1]?.records.length))
-const cycleFields = computed(() => fields.value.filter(field => field.scope === 'cycle'))
-const reportColumns = computed(() => [...new Set(items.value.map(item => item.source.selection.testId))].map(id => ({
-  id, name: items.value.find(item => item.source.selection.testId === id)!.source.name,
-  items: items.value.filter(item => item.source.selection.testId === id),
-})))
-const reportItems = computed(() => reportColumns.value.flatMap(column => column.items))
-const reportDetailColumns = computed(() => reportItems.value.map(item => reportDetailColumn(item.source)))
-const tableStyle = computed(() => ({ minWidth: `${Math.max(720, reportItems.value.length * 160 + 180)}px` }))
-function selectionKey(candidate: FormationCandidate) { return `${candidate.selection.testId}|${candidate.selection.testBatteryId}` }
-function inputValue(item: FormationItem, cycle: number, field: FormationField) {
-  return values.value[formationValueKey(item, cycle, field)] ?? formationSourceValue(item, cycle, field)
+
+function loadEditingRecord() {
+  const record = editingRecord.value
+  if (!record) return
+  reportName.value = record.name; testName.value = record.testName; batteryGroup.value = record.batteryGroup; cycles.value = record.cycles.map(item => ({ ...item })); description.value = record.description
+  reportColumns[0].name = record.testName
+  reportColumns[1].name = `${record.testName} · 对照`
+  const restoredSamples = record.samples.slice(0, 3)
+  if (restoredSamples.length) reportColumns.forEach(column => { column.samples = [...restoredSamples] })
+  Object.assign(cycleFieldValues, record.cycleFieldValues || {})
+  Object.assign(reportFieldValues, record.reportFieldValues || {})
+  if (record.batteryFields) batteryFields.value = [...record.batteryFields]
+  if (record.specialFields) specialFields.value = [...record.specialFields]
+  if (record.metrics) formationMetricDefinitions.forEach(metric => { formationMetricVisible[metric.key] = record.metrics!.includes(metric.key) })
+  if (record.seriesColors?.length) seriesColors.value = [...record.seriesColors]
 }
-function setValue(item: FormationItem, cycle: number, field: FormationField, value: string) {
-  values.value[formationValueKey(item, cycle, field)] = value
+loadEditingRecord()
+function reportValueKey(field: string, sample: string) { return `${field}|${sample}` }
+function rawReportValue(fieldIndex: number, sampleIndex: number) { return (1.02 + fieldIndex * 0.06 + sampleIndex * 0.03).toFixed(2) }
+function addReportField() {
+  ElMessageBox.prompt('请输入要添加的字段名称', '添加项', { confirmButtonText: '添加', cancelButtonText: '取消', inputPlaceholder: '例如:环境备注', inputValidator: value => value.trim() ? true : '请输入字段名称' }).then(({ value }) => {
+    const name = value.trim()
+    if (reportDetailFields.value.some(field => field.label === name)) { ElMessage.warning('该字段已经存在'); return }
+    specialFields.value.push(name)
+    ElMessage.success(`已添加字段“${name}”`)
+  }).catch(() => { /* 用户取消 */ })
 }
-function chargeChoices(item: FormationItem, cycle: number) { return item.chargeSteps?.[String(cycle)] || [] }
-function chargeChoice(item: FormationItem, cycle: number) {
-  return formationChargeChoice(item, cycle, chargeStepSelections.value)
+function persistBatteryTemplates() { localStorage.setItem(batteryTemplateStorageKey, JSON.stringify(batteryTemplateSelections)) }
+function resolveBatteryTemplateFieldIds(name: string) {
+  const savedIds = batteryTemplateSelections[name]
+  if (savedIds) return savedIds.filter(id => enabledGroupFields.value.some(field => field.id === id))
+  const labels = presetBatteryTemplateLabels[name] || []
+  return enabledGroupFields.value.filter(field => labels.includes(field.name)).map(field => field.id)
 }
-function chargeKey(item: FormationItem, cycle: number) { return `${item.source.selection.testId}|${item.source.selection.testBatteryId}|${cycle}` }
-function updateSeriesColor(item: FormationItem, color: string | null) { if (color) seriesColors.value[formationSeriesKey(item)] = color }
-function formContent() { return JSON.stringify({ name: reportName.value, description: description.value, settings: actualSteps.value ? stepSettings.value : { fields: fields.value, values: values.value, sourceDetailOverrides: snapshotReportDetails(reportDetailColumns.value, sourceDetailOverrides.value), chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } }) }
-function restore(record: SavedFormationReport) {
-  saved.value = record; reportName.value = record.name; description.value = record.description || ''
-  items.value = record.items; cycleNos.value = record.cycleNos
-  fields.value = record.settings.fields.filter(field => field.scope === 'cycle'); values.value = record.settings.values; chargeStepSelections.value = record.settings.chargeStepSelections || {}
-  sourceDetailOverrides.value = record.settings.sourceDetailOverrides ?? {}
-  metrics.value = record.settings.metrics ?? formationCurveMetrics.map(metric => metric.key)
-  seriesColors.value = record.settings.seriesColors ?? {}
-  manualChargeSteps.value = record.settings.manualChargeSteps ?? {}; curveStepSelections.value = record.settings.curveStepSelections ?? {}
-  hiddenSeriesKeys.value = record.settings.hiddenSeriesKeys ?? []; activeCycle.value = record.settings.activeCycle ?? record.cycleNos[0] ?? null
-  changeSummary.value = ''; savedContent.value = formContent()
+function loadBatteryTemplate(name: string) { selectedBatteryTemplateFieldIds.value = resolveBatteryTemplateFieldIds(name) }
+function applyBatteryTemplateFields() {
+  const fixedLabels = fixedReportFields.map(field => field.label)
+  batteryFields.value = selectedBatteryTemplateFieldIds.value
+    .map(id => enabledGroupFields.value.find(field => field.id === id)?.name)
+    .filter((name): name is string => typeof name === 'string')
+    .filter(name => !fixedLabels.includes(name))
+  specialFields.value = specialFields.value.filter(field => !batteryFields.value.includes(field) && !fixedLabels.includes(field))
 }
-function pruneValues() {
-  const allowed = new Set(items.value.flatMap(item => fields.value.flatMap(field =>
-    (field.scope === 'detail' ? [0] : item.cycleNos).map(cycle => formationValueKey(item, cycle, field)))))
-  values.value = Object.fromEntries(Object.entries(values.value).filter(([key]) => allowed.has(key)))
-  const sourceKeys = new Set(items.value.flatMap(item => [formationSeriesKey(item), ...Object.entries(item.chargeSteps ?? {}).flatMap(([cycle, steps]) => steps.map(step => `${formationSeriesKey(item)}|${cycle}|${step.stepNo}`))]))
-  sourceDetailOverrides.value = Object.fromEntries(Object.entries(sourceDetailOverrides.value).filter(([key]) => sourceKeys.has(key)))
-  hiddenSeriesKeys.value = hiddenSeriesKeys.value.filter(key => sourceKeys.has(key))
-  const cycleKeys = new Set(items.value.flatMap(item => item.cycleNos.map(cycle => formationCycleKey(item, cycle))))
-  manualChargeSteps.value = Object.fromEntries(Object.entries(manualChargeSteps.value).filter(([key]) => cycleKeys.has(key)))
-  curveStepSelections.value = Object.fromEntries(Object.entries(curveStepSelections.value).filter(([key]) => cycleKeys.has(key)))
-  seriesColors.value = Object.fromEntries(Object.entries(seriesColors.value).filter(([key]) => sourceKeys.has(key)))
+function switchBatteryTemplate(name: string) { loadBatteryTemplate(name); applyBatteryTemplateFields() }
+function saveBatteryTemplate() { applyBatteryTemplateFields(); batteryTemplateSelections[selectedBatteryTemplateName.value] = [...selectedBatteryTemplateFieldIds.value]; persistBatteryTemplates(); ElMessage.success(`模板“${selectedBatteryTemplateName.value}”已保存`) }
+async function saveBatteryTemplateAs() {
+  try {
+    const result = await ElMessageBox.prompt('请输入新模板名称,当前字段勾选会保存为新模板。', '另存电池组字段模板', { confirmButtonText: '保存', cancelButtonText: '取消', inputPlaceholder: '输入模板名称', inputValidator: value => value.trim() ? true : '请输入模板名称' })
+    const name = result.value.trim()
+    if (batteryTemplateNames.value.includes(name)) { ElMessage.warning('模板名称已存在,请使用其他名称'); return }
+    selectedBatteryTemplateName.value = name
+    applyBatteryTemplateFields()
+    batteryTemplateSelections[name] = [...selectedBatteryTemplateFieldIds.value]
+    persistBatteryTemplates()
+    ElMessage.success(`已另存为“${name}”`)
+  } catch { /* 用户取消 */ }
 }
-function applyFields(next: FormationField[]) {
-  // 模板仅改变草稿结构;同名换共享身份时不能继承原字段的人工调整。
-  const unchanged = new Set(next.filter(field => fields.value.some(previous => previous.key === field.key && previous.scope === field.scope
-    && previous.kind === field.kind && (previous.fieldDefinitionId ?? null) === (field.fieldDefinitionId ?? null))).map(field => `${field.scope}|${field.key}`))
-  values.value = Object.fromEntries(Object.entries(values.value).filter(([key]) => {
-    const parts = key.split('|')
-    return unchanged.has(`${parts[2] === '0' ? 'detail' : 'cycle'}|${parts[3]}`)
-  }))
-  fields.value = next
-  pruneValues()
+function openFieldTemplate(mode: 'battery' | 'special') {
+  templateMode.value = mode
+  if (mode === 'battery') {
+    if (!batteryTemplateNames.value.includes(selectedBatteryTemplateName.value)) selectedBatteryTemplateName.value = '自定义'
+    loadBatteryTemplate(selectedBatteryTemplateName.value)
+  } else {
+    selectedTemplateKey.value = 'cycle'
+  }
+  templateDialogVisible.value = true
 }
+function applyFieldTemplate() {
+  const fixedLabels = fixedReportFields.map(field => field.label)
+  if (templateMode.value === 'battery') {
+    applyBatteryTemplateFields()
+  } else {
+    specialFields.value = selectedTemplate.value.fields.filter(field => !fixedLabels.includes(field) && !batteryFields.value.includes(field))
+  }
+  templateDialogVisible.value = false
+  ElMessage.success(`${templateMode.value === 'battery' ? '电池组字段' : '特殊字段'}模板已应用`)
+}
+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 (fieldTemplates.value.some(item => item.name === name)) { ElMessage.warning('特殊字段模板名称已存在,请使用其他名称'); return }
+    specialTemplateSelections[name] = [...specialFields.value]
+    localStorage.setItem(specialTemplateStorageKey, JSON.stringify(specialTemplateSelections))
+    selectedTemplateKey.value = `saved:${name}`
+    ElMessage.success(`已另存为“${name}”`)
+  } catch { /* 用户取消 */ }
+}
+function updateSeriesColor(index: number, color: string | null) { if (color) seriesColors.value[index] = color }
+function chartOption(metric: FormationMetricDefinition) {
+  const names = reportSamples.value.slice(0, 3)
+  const cycleOffset = Math.max(0, activeCycle.value - 1) * 0.012
+  const visibleSeries = metric.values.slice(0, Math.max(1, names.length)).map(series => series.map(value => Number((value + cycleOffset).toFixed(3))))
+  const visibleValues = visibleSeries.flat()
+  const dataMax = Math.max(0, ...visibleValues)
+  const axisMax = metric.unit === '℃' ? Math.ceil(dataMax / 10) * 10 : Math.ceil(dataMax * 10) / 10
+  const maxDataLength = Math.max(1, ...visibleSeries.map(series => series.length))
+  const timeLabels = Array.from({ length: maxDataLength }, (_, index) => `${Math.floor(index / 2)}:${index % 2 ? '30' : '00'}`)
+  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 } })
+  return { animation: false, color: seriesColors.value, grid: { top: 40, right: 46, bottom: 42, left: 46 }, tooltip: { trigger: 'axis' }, legend: { top: 4, right: 6, icon: 'roundRect', itemWidth: 14, itemHeight: 8, textStyle: { fontSize: 11, color: '#52606d' } }, xAxis: { type: 'category', boundaryGap: false, name: '时间/h:mm', nameLocation: 'middle', nameGap: 28, data: timeLabels, axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10, interval: 'auto' }, nameTextStyle: { color: '#26333f', fontSize: 11, fontWeight: 600 } }, yAxis: [createYAxis('left'), createYAxis('right')], series: names.map((name, index) => ({ name, type: 'line', smooth: true, showSymbol: false, lineStyle: { width: 2 }, data: visibleSeries[index] || visibleSeries[0] })) }
+}
+function exportPdf() {
+  if (!reportName.value.trim()) {
+    ElMessage.warning('请先输入报表名称')
+    return
+  }
+  const opened = openFormationReportPrint({
+    name: reportName.value.trim(),
+    reportId: editingRecord.value?.id,
+    creator: editingRecord.value?.creator || currentUser?.displayName || 'Admin',
+    createdAt: editingRecord.value?.createdAt,
+    testName: testName.value,
+    batteryGroup: batteryGroup.value,
+    samples: [...new Set(reportSamples.value)],
+    cycles: cycles.value.map(item => ({ ...item })),
+    cycleFieldValues: { ...cycleFieldValues },
+    reportFieldValues: { ...reportFieldValues },
+    batteryFields: [...batteryFields.value],
+    specialFields: [...specialFields.value],
+    metrics: visibleFormationMetrics.value.map(item => item.key),
+    seriesColors: [...seriesColors.value],
+    description: description.value.trim(),
+  })
+  if (!opened) {
+    ElMessage.warning('浏览器阻止了报表窗口,请允许弹出窗口后重试')
+    return
+  }
+  ElMessage.info('已打开打印版报表,请在打印窗口选择“另存为 PDF”')
+}
+
 async function saveReport() {
   if (!reportName.value.trim()) { ElMessage.warning('请输入报表名称'); return }
-  if (!items.value.length || previewLoading.value || previewError.value) { ElMessage.warning('请先选择有效化成来源'); return }
-  if (editingId.value && !changeSummary.value.trim()) { ElMessage.warning('请输入修改说明'); return }
+  if (!testName.value || !batteryGroup.value) { ElMessage.warning('请选择测试和电池组'); return }
   saving.value = true
-  try {
-    pruneValues()
-    const content = { name: reportName.value.trim(), description: description.value, settings: actualSteps.value ? stepSettings.value : { fields: fields.value, values: values.value, sourceDetailOverrides: snapshotReportDetails(reportDetailColumns.value, sourceDetailOverrides.value), chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } }
-    const result = editingId.value && saved.value
-      ? await editFormationReport(editingId.value, { ...content, baseVersionNo: saved.value.versionNo, changeSummary: changeSummary.value.trim() })
-      : await createFormationReport({ ...content, selections: items.value.map(item => item.source.selection) })
-    restore(result)
-    ElMessage.success('化成报表已保存')
-    if (canQuery.value) await router.replace({ path: `/report-formation/${result.id}/edit`, query: { mode: 'view' } })
-    else if (canEdit.value) await router.replace({ path: `/report-formation/${result.id}/edit`, query: { mode: 'edit' } })
-    else { selectedKeys.value = []; values.value = {}; reportName.value = ''; description.value = ''; saved.value = null }
-  } catch (error) { ElMessage.error(error instanceof Error ? error.message : '化成报表保存失败') }
-  finally { saving.value = false }
+  await new Promise(resolve => window.setTimeout(resolve, 220))
+  const payload = {
+    name: reportName.value.trim(),
+    creator: currentUser?.displayName || 'Admin',
+    testName: testName.value,
+    batteryGroup: batteryGroup.value,
+    samples: [...new Set(reportSamples.value)],
+    cycles: cycles.value.map(item => ({ ...item })),
+    cycleFieldValues: { ...cycleFieldValues },
+    reportFieldValues: { ...reportFieldValues },
+    batteryFields: [...batteryFields.value],
+    specialFields: [...specialFields.value],
+    metrics: visibleFormationMetrics.value.map(item => item.key),
+    seriesColors: [...seriesColors.value],
+    description: description.value.trim(),
+  }
+  editingId.value ? updateReport(editingId.value, payload) : createReport(payload)
+  saving.value = false
+  ElMessage.success(editingId.value ? '化成报表已更新' : '化成报表已保存')
+  await router.push('/report-formation')
 }
-async function refreshSource() {
-  if (!saved.value || !editingId.value) return
-  if (formContent() !== savedContent.value) { ElMessage.warning('请先保存当前修改'); return }
-  try {
-    const { value } = await ElMessageBox.prompt('按最新来源生成新版本,保留仍属于当前循环的人工调整;已消失循环的人工值仅保留在历史版。', '按最新来源生成新版本', {
-      inputPlaceholder: '请输入修改说明', inputValidator: value => Boolean(value?.trim()) || '请输入修改说明', confirmButtonText: '生成', cancelButtonText: '取消',
-    })
-    saving.value = true
-    const result = await refreshFormationSource(editingId.value, saved.value.versionNo, value.trim())
-    restore(result); ElMessage.success('已按最新来源生成新版本')
-  } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '更新来源失败') }
-  finally { saving.value = false }
-}
-async function openHistory() {
-  if (!editingId.value) return
-  historyVisible.value = true; historyLoading.value = true; historyError.value = ''
-  try { history.value = await listFormationVersions(editingId.value) }
-  catch (error) { historyError.value = error instanceof Error ? error.message : '历史版本加载失败' }
-  finally { historyLoading.value = false }
-}
-async function openVersion(versionNo: number) {
-  historyVisible.value = false
-  await router.push({ path: `/report-formation/${editingId.value}/edit`, query: { mode: 'view', version: versionNo } })
-}
-async function printReport(versionNo = saved.value?.versionNo) {
-  if (printing.value || saving.value || loading.value || loadError.value || !canExport.value) return
-  if (!saved.value || versionNo === undefined) { ElMessage.warning('请先保存报表后再打印'); return }
-  if (!readonly.value && (formContent() !== savedContent.value || changeSummary.value.trim())) { ElMessage.warning('有未保存修改,请先保存新版本后再打印'); return }
-  printing.value = true
-  try { await openFormationSavedReportPrint(saved.value.id, versionNo) }
-  catch (error) { ElMessage.error(error instanceof Error ? error.message : '打印失败,请重试') }
-  finally { printing.value = false }
-}
-// 快速切换来源时只接受最后一次预览,避免旧响应把矩阵归入错误的选择。
-let previewSequence = 0
-async function preview() {
-  const sequence = ++previewSequence
-  previewError.value = ''
-  if (!selectedKeys.value.length) { items.value = []; cycleNos.value = []; previewLoading.value = false; return }
-  previewLoading.value = true
-  try {
-    const selections = candidates.value.filter(candidate => selectedKeys.value.includes(selectionKey(candidate))).map(candidate => candidate.selection)
-    const result = await previewFormationReport(selections)
-    if (sequence !== previewSequence) return
-    items.value = result.items
-    cycleNos.value = result.cycleNos
-    fields.value = fields.value.map(field => field.kind === 'battery' && field.fieldDefinitionId == null
-      ? { ...field, fieldDefinitionId: items.value.flatMap(item => item.businessFields).find(value => value.fieldName === field.key)?.fieldDefinitionId ?? null } : field)
-    pruneValues()
-  } catch (error) {
-    if (sequence !== previewSequence) return
-    items.value = []; cycleNos.value = []
-    previewError.value = error instanceof Error ? error.message : '化成数据加载失败'
-  } finally { if (sequence === previewSequence) previewLoading.value = false }
-}
-async function load() {
-  loading.value = true; loadError.value = ''
-  previewSequence++; previewLoading.value = false; previewError.value = ''
-  saved.value = null; items.value = []; cycleNos.value = []; values.value = {}; chargeStepSelections.value = {}; selectedKeys.value = []
-  sourceDetailOverrides.value = {}
-  try {
-    const permissions = (await getUserInfo()).permissions || []
-    const has = (action: string) => permissions.includes('*:*:*') || permissions.includes(`system:formation-report:${action}`)
-    canCreate.value = has('add'); canEdit.value = has('edit'); canQuery.value = has('query'); canExport.value = has('export')
-    if (!editingId.value) {
-      if (!canCreate.value) throw new Error('无新增化成报表权限')
-      reportName.value = ''; description.value = ''; fields.value = structuredClone(defaultFormationFields.filter(field => field.scope === 'cycle')); chargeStepSelections.value = {}
-      metrics.value = formationCurveMetrics.map(metric => metric.key); seriesColors.value = {}
-      manualChargeSteps.value = {}; curveStepSelections.value = {}; hiddenSeriesKeys.value = []; activeCycle.value = null
-      candidates.value = await getFormationSources()
-    } else {
-      const editing = route.query.mode === 'edit' && !route.query.version
-      if (editing ? !canEdit.value : !canQuery.value) throw new Error(editing ? '无编辑化成报表权限' : '无查看化成报表权限')
-      restore(await getFormationReport(editingId.value, route.query.version ? Number(route.query.version) : undefined, editing))
-    }
-  } catch (error) { loadError.value = error instanceof Error ? error.message : '化成报表加载失败'; ElMessage.error(loadError.value) }
-  finally { loading.value = false }
-}
-watch(selectedKeys, () => { if (!editingId.value) void preview() })
-watch(cycleNos, cycles => { if (activeCycle.value === null || !cycles.includes(activeCycle.value)) activeCycle.value = cycles[0] ?? null })
-watch(() => route.fullPath, () => { void load() }, { immediate: true })
 </script>
 
 <template>
-  <div class="formation-form-page" :class="{ 'is-readonly': readonly }" v-loading="loading">
-    <div class="page-tools"><button class="back-button" type="button" @click="router.push('/report-formation')"><el-icon><ArrowLeft /></el-icon>返回化成报表</button><span>{{ readonly ? '查看化成报表' : editingId ? '编辑化成报表' : '新建化成报表' }}</span></div>
-    <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" />
-    <template v-else>
-      <section class="report-name-panel"><label><span>报表名称</span><el-input v-model="reportName" :disabled="readonly" maxlength="100" clearable placeholder="请输入报表名称" /></label></section>
-      <div v-if="saved" class="version-bar"><span>版本 {{ saved.versionNo }} · {{ saved.operatorName }} · {{ new Date(saved.versionCreatedAt).toLocaleString() }}</span><span>{{ saved.changeSummary }}</span><button v-if="canQuery" class="secondary-button" @click="openHistory">历史版本</button><button v-if="readonly && canEdit" class="secondary-button" @click="router.push({ path: `/report-formation/${editingId}/edit`, query: { mode: 'edit' } })">编辑</button><button v-if="!readonly" class="secondary-button" :disabled="saving" @click="refreshSource">按最新来源生成新版本</button></div>
-      <section v-if="!editingId" class="source-panel">
-        <label><span>化成测试 / 电池号</span><el-select v-model="selectedKeys" multiple filterable collapse-tags collapse-tags-tooltip placeholder="选择化成测试及电池号" :disabled="loading" aria-label="化成来源">
-          <el-option v-for="candidate in candidates" :key="selectionKey(candidate)" :value="selectionKey(candidate)" :label="`${candidate.name} / ${candidate.batteryGroup} / ${candidate.batteryNo}`" />
-        </el-select></label>
-        <el-empty v-if="!loading && !candidates.length" description="暂无检测完成的化成测试" :image-size="60" />
-      </section>
-      <el-alert v-if="previewError" :title="previewError" type="error" :closable="false" />
-      <div v-loading="previewLoading">
-        <ReportTestDetail v-model="sourceDetailOverrides" :columns="reportDetailColumns" :readonly="readonly" :disabled="saving || previewLoading || !!previewError" />
-        <section v-if="!readonly" class="report-data-panel"><header class="panel-heading"><strong>循环指标设置</strong></header><FormationFieldControls :fields="fields" :items="items" :can-create="canCreate" :can-edit="canEdit" @apply="applyFields" /></section>
-        <section class="cycle-panel"><div class="cycle-list">
-          <article v-for="cycle in cycleNos" :key="cycle" class="cycle-card" :data-cycle="cycle">
-            <h3>循环{{ cycle }}</h3>
-            <div class="cycle-table-wrap"><table class="cycle-table cycle-data-table" :style="tableStyle">
-              <thead><tr><th>测试</th><th v-for="column in reportColumns" :key="column.id" :colspan="column.items.length">{{ column.name }}</th></tr>
-                <tr><th>指标</th><th v-for="item in reportItems" :key="selectionKey(item.source)">{{ item.source.batteryGroup }} / {{ item.source.batteryNo }}</th></tr></thead>
-              <tbody><template v-if="!actualSteps"><tr><td>充电工步号</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><template v-if="item.cycleNos.includes(cycle)"><el-select v-if="chargeChoices(item, cycle).length && !chargeChoice(item, cycle)" :model-value="chargeStepSelections[chargeKey(item, cycle)]" :disabled="readonly" placeholder="选择工步" size="small" @update:model-value="chargeStepSelections[chargeKey(item, cycle)] = $event"><el-option v-for="step in chargeChoices(item, cycle)" :key="step.stepNo" :label="`${step.stepNo} ${step.name || ''}`" :value="step.stepNo" /></el-select><span v-else>{{ chargeChoice(item, cycle)?.stepNo ?? '未识别' }}</span></template><span v-else class="missing-cycle">无该循环</span></td></tr>
-                <tr><td>充电工步名称</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><span v-if="!item.cycleNos.includes(cycle)" class="missing-cycle">无该循环</span><span v-else>{{ chargeChoice(item, cycle)?.name || '未选择' }}</span></td></tr>
-                <tr><td>终止电压 (V)</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><span v-if="!item.cycleNos.includes(cycle)" class="missing-cycle">无该循环</span><span v-else>{{ chargeChoice(item, cycle)?.endVoltage ?? '缺失' }}</span></td></tr>
-                </template><template v-else>
-                  <tr v-for="index in terminationRows(cycle)" :key="`termination-${index}`" class="charge-termination-row"><td>充电工步{{ index }} / 终止电压 (V)</td><td v-for="item in reportItems" :key="selectionKey(item.source)">{{ termination(item, cycle, index - 1) }}</td></tr>
-                  <tr v-if="!readonly && reportItems.some(item => unknownSteps(item, cycle).length)"><td>人工指定未知充电工步</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><el-select v-if="unknownSteps(item, cycle).length" v-model="manualChargeSteps[formationCycleKey(item, cycle)]" multiple :aria-label="`未知充电工步 ${item.source.name} ${item.source.batteryNo} 循环${cycle}`" placeholder="可选择多个实际工步"><el-option v-for="step in unknownSteps(item, cycle)" :key="step.stepNo" :value="step.stepNo" :label="`${step.stepNo} ${step.name || '未命名工步'}`" /></el-select><span v-else>—</span></td></tr>
-                </template>
-                <tr v-for="field in cycleFields" :key="field.key"><td>{{ formationFieldLabel(field) }}</td><td v-for="item in reportItems" :key="selectionKey(item.source)">
-                <span v-if="!item.cycleNos.includes(cycle)" class="missing-cycle">无该循环</span>
-                <el-input v-else :model-value="inputValue(item, cycle, field)" @update:model-value="setValue(item, cycle, field, $event)" :disabled="readonly" :aria-label="`${item.source.name} ${item.source.batteryNo} 循环${cycle} ${field.displayName}`" :placeholder="field.kind === 'manual' ? '人工填写' : '指标缺失'" size="small" />
-              </td></tr></tbody>
-            </table></div>
-          </article>
-          <el-empty v-if="reportItems.length && !cycleNos.length && !previewLoading" description="所选电池暂无循环记录" :image-size="60" />
-        </div></section>
-        <section class="metrics-panel">
-          <header class="panel-heading"><div><strong>循环曲线</strong></div><label><el-select v-model="activeCycle" class="curve-cycle-select" aria-label="曲线循环" :disabled="!cycleNos.length"><el-option v-for="cycle in cycleNos" :key="cycle" :label="`循环${cycle}`" :value="cycle" /></el-select></label></header>
-          <el-checkbox-group v-if="!readonly" v-model="metrics" class="metric-switches"><el-checkbox v-for="metric in formationCurveMetrics" :key="metric.key" :value="metric.key">{{ metric.title }}</el-checkbox></el-checkbox-group>
-          <FormationStepCurves v-if="actualSteps" :items="items" :cycle="activeCycle" :settings="stepSettings" :readonly="readonly" @change="changeStepSettings" />
-          <template v-else><div class="chart-legend-bar"><div v-for="(item, index) in items" :key="formationSeriesKey(item)" class="chart-legend-item">
-            <el-color-picker v-if="!readonly" :model-value="formationSeriesColor(item, index, seriesColors)" :aria-label="`${formationSeriesName(item)}颜色`" @change="updateSeriesColor(item, $event)" />
-            <i v-else class="series-swatch" :style="{ background: formationSeriesColor(item, index, seriesColors) }" /><span>{{ formationSeriesName(item) }}</span>
-          </div></div>
-          <div v-if="hasCurves && activeCycle !== null" class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }} ({{ metric.unit }})</strong></header><VChart class="metric-chart" :option="formationChartOption(metric, items, activeCycle, seriesColors)" :update-options="{ notMerge: true }" autoresize /></article></div>
-          <el-empty v-if="!hasCurves" :description="saved && !items.some(item => item.curves) ? '该版本未保存曲线数据' : '该循环暂无已保存实时数据'" :image-size="60" />
-          <el-empty v-else-if="!visibleMetrics.length" description="当前未启用曲线指标" :image-size="60" /></template>
-        </section>
-      </div>
-      <section class="note-panel"><label><span>报表说明</span><el-input v-model="description" :disabled="readonly" type="textarea" :rows="3" maxlength="2000" show-word-limit /></label></section>
-      <section v-if="editingId && !readonly" class="note-panel"><label><span>修改说明</span><el-input v-model="changeSummary" maxlength="500" placeholder="请输入修改说明" /></label></section>
-      <footer class="form-actions"><button v-if="canExport" class="secondary-button" type="button" :disabled="printing || saving || loading || previewLoading" @click="printReport()"><el-icon><Printer /></el-icon>{{ printing ? '正在打开打印' : '打印 / 另存 PDF' }}</button><button class="secondary-button" type="button" @click="router.push('/report-formation')">返回列表</button><button v-if="!readonly && (editingId ? canEdit : canCreate)" class="primary-button" type="button" :disabled="saving || loading || previewLoading || !items.length" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存新版本' : '保存报表' }}</button></footer>
-    </template>
-    <el-dialog v-model="historyVisible" title="化成报表历史版本" width="min(760px, calc(100vw - 28px))"><el-alert v-if="historyError" :title="historyError" type="error" :closable="false" /><el-table v-loading="historyLoading" :data="history"><el-table-column prop="versionNo" label="版本" width="70" /><el-table-column prop="operatorName" label="操作人" width="110" /><el-table-column label="时间" width="180"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString() }}</template></el-table-column><el-table-column prop="changeSummary" label="修改说明" /><el-table-column label="操作" width="70"><template #default="{ row }"><el-button link type="primary" @click="openVersion(row.versionNo)">查看</el-button></template></el-table-column></el-table></el-dialog>
+  <div class="formation-form-page" :class="{ 'is-readonly': readonly }"><div class="page-tools"><button class="back-button" type="button" @click="router.push('/report-formation')"><el-icon><ArrowLeft /></el-icon>返回化成报表</button><span>{{ readonly ? 'VIEW FORMATION REPORT' : editingId ? 'EDIT FORMATION REPORT' : 'NEW FORMATION REPORT' }}</span></div>
+    <section class="report-name-panel"><label><span>报表名称</span><el-input v-model="reportName" clearable placeholder="请输入报表名称" /></label></section>
+    <section class="report-data-panel"><header class="panel-heading"><div><strong>报表数据</strong><span>测试详情字段</span></div></header><div class="report-data-table-wrap"><table class="report-data-table" :style="{ minWidth: `${Math.max(980, reportSamples.length * 150 + 160)}px` }"><thead><tr><th>电池组</th><th v-for="column in reportColumns" :key="column.id" :colspan="column.samples.length"><span class="group-index">{{ column.id }}</span>{{ column.name }}</th></tr><tr><th>电池号</th><template v-for="column in reportColumns" :key="`samples-${column.id}`"><th v-for="(sample, sampleIndex) in column.samples" :key="`${column.id}-${sampleIndex}`"><span class="fixed-sample">{{ sample }}</span></th></template></tr></thead><tbody><tr v-for="(field, fieldIndex) in reportDetailFields" :key="field.key"><td>{{ field.label }}</td><template v-for="(column, columnIndex) in reportColumns" :key="`fields-${column.id}`"><td v-for="(sample, sampleIndex) in column.samples" :key="`${field.key}-${column.id}-${sampleIndex}`"><el-input v-if="field.mode === 'input'" v-model="reportFieldValues[reportValueKey(field.key, `${column.id}-${sample}`)]" size="small" placeholder="请输入" /><span v-else-if="field.mode === 'raw'" class="raw-value">{{ rawReportValue(fieldIndex, sampleIndex + columnIndex) }}</span><span v-else class="auto-value">自动计算</span></td></template></tr></tbody></table></div><div v-if="!readonly" class="report-data-actions"><button class="primary-button" type="button" @click="addReportField"><el-icon><Plus /></el-icon>添加项</button><button class="secondary-button" type="button" @click="openFieldTemplate('battery')"><el-icon><DocumentCopy /></el-icon>电池组字段模板</button><button class="secondary-button" type="button" @click="openFieldTemplate('special')"><el-icon><DocumentCopy /></el-icon>选择特殊字段模板</button><button class="secondary-button" type="button" @click="saveSpecialTemplate"><el-icon><DocumentCopy /></el-icon>另存特殊字段模板</button></div></section>
+    <section class="cycle-panel"><header class="panel-heading"><div><strong>化成循环明细</strong></div></header><div class="cycle-list"><article v-for="cycle in cycles" :key="cycle.cycle" class="cycle-block"><div class="cycle-block-heading"><strong>循环{{ cycle.cycle }}</strong></div><div class="cycle-table-wrap"><table class="cycle-matrix" :style="{ minWidth: `${Math.max(920, reportSamples.length * 170 + 190)}px` }"><thead><tr><th>指标字段</th><th v-for="(sample, sampleIndex) in reportSamples" :key="`${sample}-${sampleIndex}`">{{ sample }}</th></tr></thead><tbody><tr class="method-row"><td>测试方法</td><td v-for="(sample, sampleIndex) in reportSamples" :key="`${sample}-${sampleIndex}`"><el-input v-model="cycle.method" size="small" /></td></tr><tr v-for="(field, fieldIndex) in cycleFields" :key="field.key"><td>{{ field.label }}</td><td v-for="(sample, sampleIndex) in reportSamples" :key="`${field.key}-${sample}-${sampleIndex}`"><el-input v-if="field.mode === 'input'" v-model="cycleFieldValues[cycleValueKey(cycle.cycle, field.key, `${sample}-${sampleIndex}`)]" size="small" placeholder="请输入" /><span v-else-if="field.mode === 'raw'" class="raw-value">{{ rawValue(cycle.cycle, fieldIndex, sampleIndex) }}</span><span v-else class="auto-value">自动计算</span></td></tr></tbody></table></div></article></div></section>
+    <section class="metrics-panel"><div v-if="!readonly" class="metric-switches"><label v-for="metric in formationMetricDefinitions" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="formationMetricVisible[metric.key]" inline-prompt active-text="显" inactive-text="隐" /></label></div><div class="cycle-chart-tabs"><button v-for="cycle in cycles" :key="cycle.cycle" type="button" :class="{ active: activeCycle === cycle.cycle }" @click="activeCycle = cycle.cycle">循环{{ cycle.cycle }}</button></div><div class="chart-legend-bar"><strong>化成图例</strong><div v-for="(sample, index) in reportSamples.slice(0, 3)" :key="`${sample}-${index}`" 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-grid"><article v-for="metric in visibleFormationMetrics" :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>
+    <section class="note-panel"><label><span>报表说明</span><el-input v-model="description" type="textarea" :rows="3" maxlength="300" show-word-limit /></label></section><footer class="form-actions"><button class="secondary-button" type="button" @click="exportPdf"><el-icon><Download /></el-icon>导出PDF</button><button class="secondary-button" type="button" @click="router.push('/report-formation')">{{ readonly ? '返回列表' : '取消' }}</button><button v-if="!readonly" class="primary-button" type="button" :disabled="saving" @click="saveReport"><el-icon><Check /></el-icon>{{ saving ? '保存中' : editingId ? '保存修改' : '保存报表' }}</button></footer>
+    <el-dialog v-model="templateDialogVisible" class="formation-template-dialog" :title="templateMode === 'battery' ? '选择电池组字段模板' : '选择特殊字段模板'" :width="templateMode === 'battery' ? '720px' : '560px'" align-center>
+      <div v-if="templateMode === 'battery'" class="battery-template-editor"><label class="battery-template-select"><span>选择模板</span><el-select v-model="selectedBatteryTemplateName" @change="switchBatteryTemplate"><el-option v-for="name in batteryTemplateNames" :key="name" :label="name" :value="name" /></el-select></label><el-checkbox-group v-if="batteryTemplateFieldOptions.length" v-model="selectedBatteryTemplateFieldIds" class="battery-template-fields" @change="applyBatteryTemplateFields"><el-checkbox v-for="field in batteryTemplateFieldOptions" :key="field.id" :value="field.id" :disabled="field.status === '禁用'" :title="`${field.name}(${field.type},${field.status})`">{{ field.name }}</el-checkbox></el-checkbox-group><div v-else class="template-empty">暂无字段,请先到电池组字段管理中添加字段</div></div>
+      <el-radio-group v-else v-model="selectedTemplateKey" class="template-options"><el-radio v-for="item in fieldTemplates" :key="item.key" :value="item.key" border>{{ item.name }}({{ item.fields.join('、') }})</el-radio></el-radio-group>
+      <template #footer><div v-if="templateMode === 'battery'" class="battery-template-actions"><button class="primary-button" type="button" @click="applyFieldTemplate"><el-icon><Check /></el-icon>确认</button><button class="primary-button" type="button" @click="saveBatteryTemplate"><el-icon><Check /></el-icon>保存</button><button class="secondary-button" type="button" @click="saveBatteryTemplateAs"><el-icon><DocumentCopy /></el-icon>另存模板</button></div><div v-else><button class="secondary-button" type="button" @click="templateDialogVisible = false">取消</button><button class="primary-button" type="button" @click="applyFieldTemplate">应用模板</button></div></template>
+    </el-dialog>
   </div>
 </template>
 
 <style scoped>
-.charge-termination-row td{white-space:normal!important;padding:8px 10px!important}.metrics-panel .panel-heading{flex-wrap:wrap}
-.curve-cycle-select{width:130px;flex:0 0 130px;margin:8px 0}.metrics-panel .chart-legend-item span{max-width:100%;overflow:visible;white-space:normal;overflow-wrap:anywhere}.series-swatch{flex:0 0 14px;height:14px}.metric-switches .el-checkbox{margin-right:0}.metrics-panel .chart-legend-item{max-width:100%;align-items:flex-start}.metrics-panel .chart-legend-item .el-color-picker{flex-shrink:0}.metrics-panel .metric-chart{height:260px}.metrics-panel .chart-legend-bar{padding-top:12px;padding-bottom:12px}
-.version-bar{display:flex;align-items:center;flex-wrap:wrap;gap:10px;padding:8px 0 16px;color:#52606d;font-size:12px}.version-bar span{overflow-wrap:anywhere}
-.source-panel{padding:16px;background:#fff;border:1px solid #d8e1e9;margin-bottom:16px}.source-panel label{display:flex;align-items:center;gap:16px}.source-panel .el-select{flex:1;min-width:0}.formation-form-page{min-width:0}.missing-cycle{color:#7b8794}.cycle-card h3{margin:0;padding:12px 16px;font-size:14px}.cycle-data-table,.report-data-table{table-layout:fixed}.cycle-data-table th,.report-data-table th{white-space:normal;overflow-wrap:anywhere}.cycle-data-table th:first-child,.report-data-table th:first-child{width:180px}.cycle-data-table td,.report-data-table td{overflow-wrap:anywhere}.formation-form-page :deep(.el-alert){margin-bottom:12px}@media(max-width:680px){.source-panel label{align-items:stretch;flex-direction:column;gap:8px}}
 .formation-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}.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)}.report-name-panel,.setup-panel,.cycle-panel,.metrics-panel,.note-panel{background:#fff;border:1px solid #d8e1e9;margin-bottom:16px}.report-name-panel{padding:18px 22px}.report-name-panel label,.note-panel label{display:grid;grid-template-columns:72px minmax(260px,520px);align-items:center;gap:12px}.report-name-panel label>span,.note-panel label>span,.setup-grid label>span{color:#52606d;font-size:12px;font-weight:500}.report-name-panel :deep(.el-input__wrapper),.setup-grid :deep(.el-input__wrapper),.setup-grid :deep(.el-select__wrapper),.note-panel :deep(.el-textarea__inner),.cycle-table :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)}.panel-heading>div{display:flex;align-items:center;gap:9px}.panel-heading>strong,.panel-heading>div>strong{color:#26333f;font-size:13px}.panel-heading span{color:#8795a3;font-size:11px;margin-left:10px}.setup-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:18px;padding:20px 24px 12px}.setup-grid label{display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center;gap:10px}.sample-toolbar{display:flex;align-items:center;justify-content:space-between;padding:4px 24px 10px;color:#52606d;font-size:12px}.sample-list{display:flex;flex-wrap:wrap;gap:10px;padding:0 24px 20px}.sample-chip{display:flex;align-items:center;gap:6px}.sample-chip :deep(.el-select){width:150px}.delete-link{border:0;color:#c45454;background:transparent;font-size:12px;cursor:pointer}.cycle-tabs{display:flex;gap:0;padding:16px 24px 0}.cycle-tabs button{min-width:84px;height:30px;color:#1677dd;background:#fff;border:1px solid #78b2f9;font-size:12px;cursor:pointer}.cycle-tabs button:first-child{border-radius:3px 0 0 3px}.cycle-tabs button:last-child{border-radius:0 3px 3px 0}.cycle-tabs button+button{border-left:0}.cycle-tabs button.active{color:#fff;background:#2188f5}.cycle-table-wrap{overflow-x:auto;padding:14px 24px 22px}.cycle-table{width:100%;min-width:900px;border-collapse:collapse;color:#52606d;font-size:12px}.cycle-table th,.cycle-table td{height:42px;padding:0 10px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}.cycle-table th{color:#fff;background:#3c6497;font-weight:600}.cycle-table th:nth-child(1){width:90px}.cycle-table th:nth-child(2){width:240px}.cycle-table th:nth-child(3){width:150px}.cycle-table th:nth-child(4),.cycle-table th:nth-child(5){width:130px}.charts-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;padding:18px 24px 24px}.chart-legend-bar{display:flex;align-items:center;gap:20px;padding:12px 24px;border-bottom:1px solid var(--line);color:#52606d;font-size:12px}.legend-item{display:inline-flex;align-items:center;gap:7px}.legend-color{width:14px;height:14px;border-radius:2px}.chart-card{border:1px solid #d8e1e9;background:#fff}.chart-card header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;color:#26333f;font-size:12px}.chart-card header span{color:#8795a3;font-size:10px}.metric-chart{width:100%;height:230px}.note-panel{padding:18px 24px}.note-panel :deep(.el-textarea){min-width:260px}.form-actions{position:sticky;bottom:0;z-index:4;display:flex;justify-content:flex-end;gap:10px;padding:14px 0;background:rgba(244,248,251,.96);border-top:1px solid #d8e1e9}@media(max-width:900px){.setup-grid,.charts-grid{grid-template-columns:1fr}.report-name-panel label,.note-panel label{grid-template-columns:1fr}.report-name-panel label>span,.note-panel label>span{margin-bottom:6px}.form-actions{position:static}}
 .cycle-matrix{width:100%;border-collapse:collapse;color:#52606d;font-size:12px}.cycle-matrix th,.cycle-matrix td{height:40px;padding:0 10px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}.cycle-matrix th{color:#fff;background:#3c6497;font-weight:600}.cycle-matrix th:first-child{width:190px}.cycle-matrix th:last-child{width:90px}.cycle-matrix td:first-child{color:#31404d;background:#f2f7fb;font-weight:500;text-align:left}.cycle-matrix td:last-child{color:#8795a3;background:#fafcfe}.cycle-matrix :deep(.el-input__wrapper){border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.method-row td:first-child{border-left:3px solid #2188f5}.raw-value{color:#405261;font-family:'Cascadia Mono',Consolas,monospace}.auto-value{color:#2188f5;font-weight:500}.cycle-summary{display:flex;align-items:center;gap:28px;margin:0 24px 20px;padding:12px 14px;border:1px solid #d8e1e9;background:#f7fafc}.cycle-summary>div{display:flex;flex-direction:column;gap:3px}.cycle-summary span{color:#8795a3;font-size:10px}.cycle-summary strong{color:#33414e;font-size:12px}.cycle-summary>button{margin-left:auto}@media(max-width:900px){.cycle-summary{align-items:flex-start;flex-wrap:wrap}.cycle-summary>button{margin-left:0}}
 .report-data-panel{background:#fff;border:1px solid #d8e1e9;margin-bottom:16px}.report-data-meta{display:flex;align-items:center;gap:12px}.report-data-meta label{display:flex;align-items:center;gap:6px;color:#52606d;font-size:11px;font-weight:500}.report-data-meta :deep(.el-select){width:190px}.report-data-meta :deep(.el-input){width:150px}.report-data-meta :deep(.el-input__wrapper),.report-data-meta :deep(.el-select__wrapper){min-height:30px;border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#f8fafc}.report-data-table-wrap{overflow-x:auto;padding:18px 24px 0}.report-data-table{width:100%;border-collapse:collapse;color:#52606d;font-size:12px}.report-data-table th,.report-data-table td{height:38px;padding:0 10px;border:1px solid #cbd8e4;text-align:center;white-space:nowrap}.report-data-table th{color:#fff;background:#3c6497;font-weight:600}.report-data-table th:first-child,.report-data-table td:first-child{width:150px}.report-data-table td:first-child{color:#31404d;background:#f7fafc;font-weight:500}.report-data-table td:last-child,.report-data-table th:last-child{width:90px;color:#8795a3;background:#fafcfe}.report-data-table :deep(.el-select){width:100px}.report-data-table :deep(.el-select__wrapper),.report-data-table :deep(.el-input__wrapper){min-height:28px;border-radius:3px;box-shadow:0 0 0 1px #d8e1e9 inset;background:#fff}.group-index{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;margin-right:8px;border:1px solid #cbd8e4;border-radius:50%;color:#647380;background:#fff;font-size:10px}.report-data-actions{display:flex;align-items:center;gap:10px;padding:18px 24px 20px}.report-data-actions .primary-button{height:30px;padding:0 12px;font-size:12px}@media(max-width:900px){.report-data-meta{align-items:flex-start;flex-direction:column;gap:6px}.report-data-actions{flex-wrap:wrap}}
@@ -311,5 +280,4 @@
 @media(max-width:900px){.metrics-panel .charts-grid{grid-template-columns:minmax(0,1fr)}}@media(max-width:680px){.metric-switches{gap:14px;padding:0 12px}.metrics-panel>.chart-legend-bar{align-items:flex-start;flex-direction:column;gap:10px;padding:12px 12px}.cycle-chart-tabs{padding-left:12px}}
 .formation-form-page.is-readonly :deep(.el-input), .formation-form-page.is-readonly :deep(.el-textarea) { pointer-events: none; }
 .formation-form-page.is-readonly :deep(.el-input__wrapper), .formation-form-page.is-readonly :deep(.el-textarea__inner) { background: #f3f6f8; box-shadow: 0 0 0 1px #d8e1e9 inset; }
-.note-panel :deep(.el-textarea){min-width:0}.report-data-table th,.cycle-data-table th{white-space:normal;overflow-wrap:anywhere}.report-data-table td:last-child,.report-data-table th:last-child{width:auto}.report-data-table th:last-child{color:#fff;background:#3c6497}.cycle-data-table th:nth-child(n){width:auto}.cycle-data-table th:first-child{width:180px}.cycle-data-table td:first-child{white-space:normal;width:180px}.report-data-actions,.form-actions{flex-wrap:wrap}.report-data-table-wrap,.cycle-table-wrap{max-width:100%;box-sizing:border-box}
 </style>
diff --git a/src/views/OperationLog.vue b/src/views/OperationLog.vue
index bfda1e6..61d21e5 100644
--- a/src/views/OperationLog.vue
+++ b/src/views/OperationLog.vue
@@ -16,12 +16,15 @@
 const operationLogRecords = ref<OperationLogRecord[]>([])
 const loading = ref(false)
 const loadError = ref('')
-onMounted(async () => {
+async function loadLogs() {
+  loadError.value = ''
   loading.value = true
   try { operationLogRecords.value = await listOperationLogs() } catch (error) {
+    operationLogRecords.value = []
     loadError.value = error instanceof Error ? error.message : '操作日志加载失败'
   } finally { loading.value = false }
-})
+}
+onMounted(loadLogs)
 const operatorOptions = computed(() => [...new Set(operationLogRecords.value.map(item => item.operator))].sort((a, b) => a.localeCompare(b, 'zh-CN')))
 
 const filteredRecords = computed(() => {
@@ -57,7 +60,7 @@
 
 <template>
   <div class="operation-log-page">
-    <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon />
+    <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon /><el-button v-if="loadError" @click="loadLogs">重试</el-button>
     <section class="filter-panel" aria-label="操作日志查询条件">
       <header class="section-heading"><div><i></i><strong>查询条件</strong><span>OPERATION LOG QUERY</span></div><small>共 {{ operationLogRecords.length }} 条日志</small></header>
       <div class="filter-grid">
diff --git a/src/views/ReportCompareForm.vue b/src/views/ReportCompareForm.vue
index 8a6d360..ddbba9c 100644
--- a/src/views/ReportCompareForm.vue
+++ b/src/views/ReportCompareForm.vue
@@ -13,7 +13,7 @@
 import { compareSelectionKey, createCompareReport, editCompareReport, getCompareReport, getCompareSources, previewCompareReport, refreshCompareSource,
   type CompareCandidate, type CompareItem, type SavedCompareReport } from '@/api/compareReports'
 import { compareMetricDefinitions as metricDefinitions, openCompareReportPrint } from '@/utils/compareReportPrint'
-import { compareChartOption } from '@/utils/compareReportChart'
+import { compareChartOption, compareSeriesName } from '@/utils/compareReportChart'
 import { compareDetailSources } from '@/utils/compareSourceDetails'
 import ReportTestDetail from '@/components/ReportTestDetail.vue'
 import { reportDetailColumn, snapshotReportDetails } from '@/utils/reportTestDetails'
@@ -39,6 +39,7 @@
 const changeSummary = ref('')
 const loadError = ref('')
 const previewLoading = ref(false)
+let previewRequest = 0
 const previewError = ref('')
 const reportLoading = ref(false)
 const saving = ref(false)
@@ -170,19 +171,21 @@
 }, { immediate: true })
 
 async function refreshPreview() {
+  const current = ++previewRequest
   const selections = selectedTests.value.map(item => item.selection)
   previewLoading.value = true
   previewError.value = ''
   sourceItems.value = []
   try {
     const items = await previewCompareReport(selections)
-    if (editingId.value) return
+    if (editingId.value || current !== previewRequest) return
     sourceItems.value = items
-  } catch (error) { previewError.value = error instanceof Error ? error.message : '曲线预览失败' }
-  finally { previewLoading.value = false }
+  } catch (error) { if (current === previewRequest) previewError.value = error instanceof Error ? error.message : '曲线预览失败' }
+  finally { if (current === previewRequest) previewLoading.value = false }
 }
 
 function openAddTest() {
+  void loadCandidates()
   selectedCandidateId.value = null
   selectedBatteryNo.value = ''
   selectedCycle.value = null
@@ -349,7 +352,7 @@
       <div v-if="chartSeries.length" class="chart-legend-bar"><strong>对比图例</strong><div v-for="(item, index) in chartSeries" :key="compareSelectionKey(item.selection)" 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="`修改${item.name}颜色`" @change="updateSeriesColor(index, $event)" /><i v-else class="series-swatch" :style="{ background: seriesColors[index] }" /><button type="button" class="legend-name" :class="{ 'is-hidden': hiddenSeriesKeys.includes(compareSelectionKey(item.selection)) }" :aria-pressed="!hiddenSeriesKeys.includes(compareSelectionKey(item.selection))" :disabled="readonly" @click="toggleSeries(item)">{{ item.name }} (#{{ item.id }}) · {{ item.batteryGroup }} · {{ item.batteryNo }} · 循环{{ item.cycle }} · {{ item.process }}</button></div></div>
       <!-- 报表切换和比对项移除时完整替换配置,避免图表库将颜色数组缩短误判为组件合并。 -->
       <div v-if="!visibleMetrics.length" class="chart-empty">当前未启用曲线指标</div>
-      <div class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong></header><VChart class="metric-chart" :option="compareChartOption(metric, sourceItems, seriesColors, hiddenSeriesKeys)" :update-options="{ notMerge: true }" autoresize /><div v-if="!sourceItems.some(item => !hiddenSeriesKeys.includes(compareSelectionKey(item.source.selection)) && item.records.some(row => (metric.key === 'voltage' || metric.key === 'current' ? row.deviceTime : row.collectorDeviceTime) != null && (metric.key === 'positiveVoltage' ? row.positivePotential : metric.key === 'negativeVoltage' ? row.negativePotential : row[metric.key]) != null))" class="chart-empty">{{ sourceItems.length && hiddenSeriesKeys.length === sourceItems.length ? '当前全部曲线已隐藏' : '所选范围暂无该指标数据' }}</div></article></div>
+      <div class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong></header><VChart class="metric-chart" :option="compareChartOption(metric, sourceItems, seriesColors, hiddenSeriesKeys)" :update-options="{ notMerge: true }" autoresize @legendselectchanged="!readonly && (hiddenSeriesKeys = sourceItems.filter(item => !$event.selected[compareSeriesName(item)]).map(item => compareSelectionKey(item.source.selection)))" /><div v-if="!sourceItems.some(item => !hiddenSeriesKeys.includes(compareSelectionKey(item.source.selection)) && item.records.some(row => (metric.key === 'voltage' || metric.key === 'current' ? row.deviceTime : row.collectorDeviceTime) != null && (metric.key === 'positiveVoltage' ? row.positivePotential : metric.key === 'negativeVoltage' ? row.negativePotential : row[metric.key]) != null))" class="chart-empty">{{ sourceItems.length && hiddenSeriesKeys.length === sourceItems.length ? '当前全部曲线已隐藏' : '所选范围暂无该指标数据' }}</div></article></div>
     </section>
 
     <footer class="form-actions">
@@ -450,7 +453,7 @@
 .chart-card { min-width: 0; border: 1px solid #edf1f5; background: #fff; }
 .chart-card header { height: 38px; display: flex; align-items: center; gap: 18px; padding: 0 14px; color: #52606d; font-size: 11px; }
 .chart-card header strong { margin-right: auto; color: #26333f; font-size: 13px; }
-.metric-chart { width: 100%; height: 210px; }
+.metric-chart { width: 100%; height: 230px; }
 .form-actions { position: sticky; bottom: 0; z-index: 20; min-height: 62px; display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; margin-top: 16px; padding: 10px 16px; background: rgba(234, 240, 246, .96); border-top: 1px solid #cbd9e5; }
 .form-actions .primary-button, .form-actions .secondary-button { min-width: 110px; }
 .save-button { min-width: 110px; }
diff --git a/src/views/SpliceReportForm.vue b/src/views/SpliceReportForm.vue
index 7036d86..aa0384a 100644
--- a/src/views/SpliceReportForm.vue
+++ b/src/views/SpliceReportForm.vue
@@ -7,6 +7,8 @@
   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 ReportTestDetail from '@/components/ReportTestDetail.vue'
 import { snapshotReportDetails } from '@/utils/reportTestDetails'
@@ -35,6 +37,7 @@
 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 seriesColors = ref(['#3978f6', '#109854', '#d25b45', '#7754a8'])
@@ -53,19 +56,20 @@
 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 segmentMode = computed(() => !savedReport.value || savedReport.value.spliceMode === 'battery-segments')
+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,
+  hiddenSeries: hiddenSeries.value, hiddenSeriesKeys: hiddenSeriesKeys.value,
   fieldStructure: [], batteryFields: [], specialFields: [], detailFieldValues: {},
   statisticValues: statisticValues.value,
-  metrics: statisticMetrics.filter(metric => statisticVisible[metric.key]).map(metric => metric.key), seriesColors: seriesColors.value,
+  metrics: projectMode.value ? ['finalVoltage1', 'finalVoltage2', 'dischargeCapacityF'] : statisticMetrics.filter(metric => statisticVisible[metric.key]).map(metric => metric.key), seriesColors: seriesColors.value,
 }))
 const statisticPreview = computed<SavedSpliceReport | null>(() => matrix.value ? {
   id: 0, createdAt: 0, creator: '', versionNo: 0, operatorName: '', versionCreatedAt: 0, changeSummary: '',
-  spliceMode: 'battery-segments',
+  spliceMode: 'project-cycles',
   selections: selections.value, ...savedReport.value, ...matrix.value,
   name: reportName.value, description: experimentName.value, settings: statisticSettings.value,
 } : null)
@@ -77,6 +81,7 @@
   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))]
@@ -101,6 +106,7 @@
       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()
@@ -153,7 +159,6 @@
   if (!item) return
   selectedTests.value.splice(index, 1); selectedTests.value.splice(index + direction, 0, item); reordered = true
 }
-function updateSeriesColor(index: number, color: string | null) { if (color) seriesColors.value[index] = color }
 async function printReport() {
   if (!savedReport.value) { ElMessage.warning('请先保存报表后再打印'); return }
   if (formContent() !== savedContent.value) { ElMessage.warning('有未保存修改,请先保存新版本后再打印'); return }
@@ -201,42 +206,30 @@
 
     <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></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"><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>
+          <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" class="panel-actions"><button class="primary-button compact-button" type="button" @click="openAddTest"><el-icon><Plus /></el-icon>添加拼接</button>
+      <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>
 
-    <ReportTestDetail v-model="sourceDetailOverrides" :columns="reportDetailColumns" :disabled="saving || previewing || !!previewError" />
-    <div class="detail-meta"><label><span>报表说明</span><el-input v-model="experimentName" /></label></div>
+    <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 v-if="segmentMode" class="chart-legend-bar"><div class="chart-legend-item"><el-color-picker :model-value="seriesColors[0]" aria-label="修改接续曲线颜色" @change="updateSeriesColor(0, $event)" /><button type="button" :aria-pressed="!hiddenSeries" @click="hiddenSeries = !hiddenSeries">接续曲线{{ hiddenSeries ? '(已隐藏)' : '' }}</button></div></div>
-      <div v-else 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" :charts-only="segmentMode" />
-    </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="暂无循环统计预览" />
-    <section v-if="matrix" class="detail-panel">
-      <header class="panel-heading"><strong>循环统计表</strong></header>
-      <div class="test-table-wrap"><table class="statistics-editor"><thead><tr><th>来源测试</th><th>电池号</th><th>原循环号</th><th>拼接循环号</th><th v-for="metric in statisticMetrics" :key="metric.key">{{ metric.title }} ({{ metric.unit }})</th></tr></thead><tbody>
-        <template v-for="segment in matrix.segments" :key="segment.source.selection.testBatteryId"><tr v-for="cycle in segment.originalCycleNos" :key="cycle"><td>{{ segment.source.name }}</td><td>{{ segment.source.batteryNo }}</td><td>{{ cycle }}</td><td>{{ cycle + segment.offset }}</td>
-          <td v-for="metric in statisticMetrics" :key="metric.key"><el-input :aria-label="`${segment.source.name} ${segment.source.batteryNo} 循环${cycle} ${metric.title}`" :model-value="statisticValues[spliceStatisticKey(segment, cycle, metric.key)] ?? String(segment.statistics[String(cycle)]?.[metric.key] ?? '')" placeholder="缺失" @update:model-value="statisticValues[spliceStatisticKey(segment, cycle, metric.key)] = $event" /><small>原值:{{ segment.statistics[String(cycle)]?.[metric.key] ?? '缺失' }}</small><el-button v-if="spliceStatisticKey(segment, cycle, metric.key) in statisticValues" link @click="delete statisticValues[spliceStatisticKey(segment, cycle, metric.key)]">还原</el-button></td>
-        </tr></template>
-      </tbody></table></div>
-    </section>
+
 
     </template>
     <footer class="form-actions">
@@ -262,7 +255,7 @@
 </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}
diff --git a/src/views/TodoList.vue b/src/views/TodoList.vue
index 0e62c91..aff66a4 100644
--- a/src/views/TodoList.vue
+++ b/src/views/TodoList.vue
@@ -1,121 +1,86 @@
 <script setup lang="ts">
 import { computed, onMounted, reactive, ref } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { getCurrentUser } from '@/utils/auth'
+import { getUserInfo } from '@/api/auth'
+import { getTodoOwners, type TodoOwner } from '@/api/todos'
 import { useTodos, type TodoRecord, type TodoStatus } from '@/stores/todos'
-import { getEnabledUserOptions } from '@/api/users'
 
-interface FilterState {
-  owner: string
-  status: string
-  dateRange: [string, string] | null
-}
-
-const owners = ref<{ id:number; name:string }[]>([])
+interface FilterState { owner: number | ''; status: string; dateRange: [string, string] | null }
+const owners = ref<TodoOwner[]>([])
 const statuses: TodoStatus[] = ['待处理', '已处理']
 const { records, createTodo, handleTodo, removeTodo, load } = useTodos()
-const currentOwner = computed(() => getCurrentUser()?.displayName || getCurrentUser()?.username || 'Admin')
 const filters = reactive<FilterState>({ owner: '', status: '', dateRange: null })
 const appliedFilters = ref<FilterState>({ owner: '', status: '', dateRange: null })
-const currentPage = ref(1)
-const pageSize = ref(10)
-const createDialogVisible = ref(false)
-const createForm = reactive({ content: '', owner: '' })
-onMounted(async () => { await load(); owners.value = await getEnabledUserOptions() })
-
-const filteredRecords = computed(() => {
-  const query = appliedFilters.value
-  return records.value
-    .filter(item => item.owner === currentOwner.value)
-    .filter(item => !query.owner || query.owner === '全部' || item.owner === query.owner)
-    .filter(item => !query.status || query.status === '全部' || item.status === query.status)
-    .filter(item => !query.dateRange || (item.operatedAt.slice(0, 10) >= query.dateRange[0] && item.operatedAt.slice(0, 10) <= query.dateRange[1]))
-    .sort((a, b) => b.operatedAt.localeCompare(a.operatedAt))
-})
-
-const pagedRecords = computed(() => {
-  const start = (currentPage.value - 1) * pageSize.value
-  return filteredRecords.value.slice(start, start + pageSize.value)
-})
-
-function applySearch() {
-  appliedFilters.value = { ...filters, dateRange: filters.dateRange ? [...filters.dateRange] as [string, string] : null }
-  currentPage.value = 1
-  ElMessage.success(`已找到 ${filteredRecords.value.length} 条待办`)
+const currentPage = ref(1); const pageSize = ref(10)
+const createDialogVisible = ref(false); const createForm = reactive({ content: '', ownerId: null as number | null })
+const permissions = ref<string[]>([]); const busy = ref(false); const loading = ref(false); const loadError = ref('')
+const has = (action: string) => permissions.value.includes('*:*:*') || permissions.value.includes('todo:list:' + action)
+const filterOwners = computed(() => [...new Map(records.value.map(item => [item.ownerId, { id: item.ownerId, name: item.owner }])).values()])
+async function loadPage() {
+  loading.value = true; loadError.value = ''
+  try { permissions.value = (await getUserInfo()).permissions || []; await load() }
+  catch (error) { loadError.value = error instanceof Error ? error.message : '待办加载失败' }
+  finally { loading.value = false }
 }
-
-function resetSearch() {
-  Object.assign(filters, { owner: '', status: '', dateRange: null })
-  appliedFilters.value = { owner: '', status: '', dateRange: null }
-  currentPage.value = 1
+onMounted(loadPage)
+const filteredRecords = computed(() => records.value.filter(item => !appliedFilters.value.owner || item.ownerId === appliedFilters.value.owner)
+  .filter(item => !appliedFilters.value.status || item.status === appliedFilters.value.status)
+  .filter(item => !appliedFilters.value.dateRange || (item.operatedAt.slice(0, 10) >= appliedFilters.value.dateRange[0] && item.operatedAt.slice(0, 10) <= appliedFilters.value.dateRange[1]))
+  .slice().sort((a, b) => b.operatedAt.localeCompare(a.operatedAt) || b.id - a.id))
+const pagedRecords = computed(() => filteredRecords.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value))
+function applySearch() { appliedFilters.value = { ...filters, dateRange: filters.dateRange ? [...filters.dateRange] : null }; currentPage.value = 1 }
+function resetSearch() { Object.assign(filters, { owner: '', status: '', dateRange: null }); appliedFilters.value = { ...filters }; currentPage.value = 1 }
+async function openCreate() {
+  try { owners.value = await getTodoOwners(); Object.assign(createForm, { content: '', ownerId: null }); createDialogVisible.value = true }
+  catch (error) { ElMessage.error(error instanceof Error ? error.message : '负责人加载失败') }
 }
-
-function openCreate() {
-  Object.assign(createForm, { content: '', owner: '' })
-  createDialogVisible.value = true
+async function submitCreate() {
+  if (busy.value) return
+  if (!createForm.content.trim() || !createForm.ownerId) { ElMessage.warning('请填写内容并选择负责人'); return }
+  busy.value = true
+  try { await createTodo({ content: createForm.content.trim(), ownerId: createForm.ownerId }); createDialogVisible.value = false; currentPage.value = 1; ElMessage.success('待办已新增') }
+  catch (error) { ElMessage.error(error instanceof Error ? error.message : '新增失败') }
+  finally { busy.value = false }
 }
-
-function submitCreate() {
-  const content = createForm.content.trim()
-  if (!content) { ElMessage.warning('请输入待办内容'); return }
-  if (!createForm.owner) { ElMessage.warning('请选择负责人'); return }
-  const owner = owners.value.find(item => item.name === createForm.owner)
-  if (!owner) { ElMessage.warning('负责人不存在'); return }
-  createTodo({ content, ownerId: owner.id, owner: owner.name })
-  createDialogVisible.value = false
-  currentPage.value = 1
-  ElMessage.success('待办已新增')
-}
-
 async function processTodo(item: TodoRecord) {
-  if (item.status === '已处理' || item.owner !== currentOwner.value) return
+  if (busy.value || item.status === '已处理') return
   try {
-    const result = await ElMessageBox.prompt(`请填写“${item.content}”的处理说明。`, '处理待办', {
-      confirmButtonText: '完成处理', cancelButtonText: '取消', inputType: 'textarea', inputPlaceholder: '请输入处理说明',
-      inputValidator: value => value.trim() ? true : '请输入处理说明',
-    })
-    handleTodo(item.id, result.value.trim())
-    ElMessage.success('待办已处理')
-  } catch {
-    // 用户取消处理。
-  }
+    const { value } = await ElMessageBox.prompt('请填写“' + item.content + '”的处理说明。', '处理待办', { confirmButtonText: '完成处理', cancelButtonText: '取消', inputType: 'textarea', inputValidator: value => Boolean(value?.trim()) && value.trim().length <= 500 || '请输入不超过500字的处理说明' })
+    busy.value = true; await handleTodo(item.id, value.trim()); ElMessage.success('待办已处理')
+  } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '处理失败') }
+  finally { busy.value = false }
 }
-
 async function deleteTodo(item: TodoRecord) {
+  if (busy.value) return
   try {
-    await ElMessageBox.confirm(`删除待办“${item.content}”后无法恢复,是否继续?`, '确认删除待办', {
-      confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'danger-confirm',
-    })
-    if (item.owner !== currentOwner.value) return
-    removeTodo(item.id)
-    currentPage.value = Math.min(currentPage.value, Math.max(1, Math.ceil(filteredRecords.value.length / pageSize.value)))
-    ElMessage.success('待办已删除')
-  } catch {
-    // 用户取消删除。
-  }
+    await ElMessageBox.confirm('删除待办“' + item.content + '”后无法恢复,是否继续?', '确认删除待办', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
+    busy.value = true; await removeTodo(item.id); currentPage.value = Math.min(currentPage.value, Math.max(1, Math.ceil(filteredRecords.value.length / pageSize.value))); ElMessage.success('待办已删除')
+  } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '删除失败') }
+  finally { busy.value = false }
 }
 </script>
 
 <template>
   <div class="todo-page">
+    <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" /><el-button v-if="loadError" @click="loadPage">重试</el-button>
     <section class="filter-panel" aria-label="待办查询条件">
       <header class="section-heading"><div><i></i><strong>查询条件</strong><span>TODO FILTER CONDITIONS</span></div><small>共 {{ records.length }} 条待办</small></header>
       <div class="filter-grid">
-        <label><span>负责人</span><el-select v-model="filters.owner" clearable placeholder="全部"><el-option label="全部" value="全部" /><el-option v-for="item in owners" :key="item.id" :label="item.name" :value="item.name" /></el-select></label>
-        <label><span>状态</span><el-select v-model="filters.status" clearable placeholder="全部"><el-option label="全部" value="全部" /><el-option v-for="item in statuses" :key="item" :label="item" :value="item" /></el-select></label>
+        <label><span>负责人</span><el-select v-model="filters.owner" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="item in filterOwners" :key="item.id" :label="item.name" :value="item.id" /></el-select></label>
+        <label><span>状态</span><el-select v-model="filters.status" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="item in statuses" :key="item" :label="item" :value="item" /></el-select></label>
         <label class="date-filter"><span>操作时间</span><el-date-picker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" unlink-panels /></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><RefreshLeft /></el-icon>重置</button></div>
       </div>
     </section>
 
     <section class="result-panel">
-      <div class="result-toolbar"><div><i></i><strong>待办列表</strong><span>{{ filteredRecords.length }} RESULTS · 按操作时间倒序</span></div><button class="primary-button" type="button" @click="openCreate"><el-icon><Plus /></el-icon>新增待办</button></div>
+      <div class="result-toolbar"><div><i></i><strong>待办列表</strong><span>{{ filteredRecords.length }} RESULTS · 按操作时间倒序</span></div><button v-if="has('add')" class="primary-button" type="button" @click="openCreate"><el-icon><Plus /></el-icon>新增待办</button></div>
       <div class="table-wrap">
         <table>
           <thead><tr><th>待办ID</th><th>待办内容</th><th>负责人</th><th>状态</th><th class="action-column">操作</th></tr></thead>
           <tbody>
-            <tr v-for="item in pagedRecords" :key="item.id"><td class="id-cell">#{{ item.id }}</td><td :title="item.content"><strong>{{ item.content }}</strong></td><td>{{ item.owner }}</td><td><span class="status-tag" :class="item.status === '待处理' ? 'pending' : 'handled'"><i></i>{{ item.status }}</span></td><td class="action-column"><div class="row-actions"><button v-if="item.status === '待处理'" type="button" @click="processTodo(item)">处理待办</button><button class="delete-action" type="button" @click="deleteTodo(item)">删除</button></div></td></tr>
-            <tr v-if="!pagedRecords.length"><td colspan="5" class="empty-cell">暂无符合条件的待办</td></tr>
+            <tr v-for="item in pagedRecords" :key="item.id"><td class="id-cell">#{{ item.id }}</td><td :title="item.content"><strong>{{ item.content }}</strong></td><td>{{ item.owner }}</td><td><span class="status-tag" :class="item.status === '待处理' ? 'pending' : 'handled'"><i></i>{{ item.status }}</span></td><td class="action-column"><div class="row-actions"><button v-if="has('handle') && item.status === '待处理'" :disabled="busy" type="button" @click="processTodo(item)">处理待办</button><button v-if="has('remove')" :disabled="busy" class="delete-action" type="button" @click="deleteTodo(item)">删除</button></div></td></tr>
+            <tr v-if="!pagedRecords.length"><td colspan="5" class="empty-cell">{{ loading ? '正在加载待办' : loadError ? '待办加载失败' : '暂无符合条件的待办' }}</td></tr>
           </tbody>
         </table>
       </div>
@@ -123,8 +88,8 @@
     </section>
 
     <el-dialog v-model="createDialogVisible" title="新增待办" width="600px" align-center destroy-on-close>
-      <div class="create-form"><label><span>待办内容</span><el-input v-model="createForm.content" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="请输入待办内容" /></label><label><span>负责人</span><el-select v-model="createForm.owner" filterable placeholder="请选择负责人"><el-option v-for="item in owners" :key="item.id" :label="item.name" :value="item.name" /></el-select></label></div>
-      <template #footer><button class="secondary-button" type="button" @click="createDialogVisible = false">取消</button><button class="primary-button" type="button" @click="submitCreate"><el-icon><Check /></el-icon>确定新增</button></template>
+      <div class="create-form"><label><span>待办内容</span><el-input v-model="createForm.content" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="请输入待办内容" /></label><label><span>负责人</span><el-select v-model="createForm.ownerId" filterable placeholder="请选择负责人"><el-option v-for="item in owners" :key="item.id" :label="item.name" :value="item.id" /></el-select></label></div>
+      <template #footer><button class="secondary-button" type="button" @click="createDialogVisible = false">取消</button><button class="primary-button" type="button" :disabled="busy" @click="submitCreate"><el-icon><Check /></el-icon>确定新增</button></template>
     </el-dialog>
   </div>
 </template>
diff --git a/tests/load-typescript.cjs b/tests/load-typescript.cjs
new file mode 100644
index 0000000..bfea412
--- /dev/null
+++ b/tests/load-typescript.cjs
@@ -0,0 +1,12 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const ts = require('typescript');
+// 无服务的公共绘图函数测试;加载器只负责转译本地 TypeScript 模块。
+module.exports = function load(file) {
+  if (file instanceof URL) file = require('node:url').fileURLToPath(file);
+  const source = fs.readFileSync(file, 'utf8');
+  const script = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText;
+  const exports = {};
+  new Function('exports', 'require', script)(exports, id => id.startsWith('.') ? load(path.resolve(path.dirname(file), id + '.ts')) : require(id));
+  return exports;
+};
diff --git a/tests/refinement.ui.test.mjs b/tests/refinement.ui.test.mjs
new file mode 100644
index 0000000..30285f7
--- /dev/null
+++ b/tests/refinement.ui.test.mjs
@@ -0,0 +1,115 @@
+import assert from 'node:assert/strict'
+import { readFile, mkdir } from 'node:fs/promises'
+import { extname, resolve } from 'node:path'
+import { chromium, expect } from '@playwright/test'
+
+// 构建产物浏览器验收:请求接缝用夹具覆盖成功和失败,不启动开发或业务服务。
+const dist = resolve(import.meta.dirname, '../dist')
+const artifacts = resolve(import.meta.dirname, 'e2e/.artifacts/refinement')
+await mkdir(artifacts, { recursive: true })
+const browser = await chromium.launch({ channel: 'chrome', headless: true })
+const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
+await context.addInitScript(() => {
+  if (location.protocol !== 'http:') return
+  localStorage.setItem('battery_detection_auth_token', 'fixture-owner')
+  localStorage.setItem('battery_detection_auth_user', JSON.stringify({username:'owner',displayName:'负责人'}))
+  window.print = () => {}
+})
+const now = Date.UTC(2026,8,8,16)
+let todos = [{ id:1,content:'本人待办',ownerId:2,owner:'负责人',status:'待处理',creator:'分派人',createdAt:now,operatedAt:now,handleRemark:'' }]
+const summary = { testCount:1001,batteryGroupCount:3,todayCount:2,monthCount:5 }
+const dashboard = { summary, trends: { week:{labels:['9/8','9/9'],values:[0,2]},month:{labels:['9/1','9/9'],values:[3,2]},year:{labels:['9月'],values:[5]} }, tests:[{id:1,name:'真实聚合测试',type:'循环',status:'RUNNING',receptionStatus:'PAUSED',step:'',warning:0}], cabinets:[{id:1,name:'检测柜甲',capacity:50,channelCount:8,usedChannels:1,testCount:1,batteryCount:1,onlineStatus:'在线'}], warnings:[] }
+const source = (id, groupId=9) => ({ selection:{testId:id,testBatteryId:id,cycleNo:1,logicalNo:1},name:`测试${id}`,type:'循环',groupId,group:`组${groupId}`,batteryGroup:`组${groupId}`,batteryNo:'1号',process:'合并工步',startedAt:1,consoleDetail:{batteryFields:[],specialFields:[],fieldValues:{}} })
+const segments = [source(1),source(2),source(3,10)].map((source,index) => ({source,originalCycleNos:index===0?[1,3]:index===1?[10,11]:[1],cyclePositions:index===0?{'1':1,'3':2}:index===1?{'10':3,'11':4}:{'1':5},capacityFactor:2,offset:0,statistics:Object.fromEntries((index===0?[1,3]:index===1?[10,11]:[1]).map(no=>[no,{finalVoltage1:4-no/100,dischargeCapacityF:2}])),consoleDetail:{batteryFields:[],specialFields:[],fieldValues:{}} }))
+const splice = {id:7,name:'循环拼接验收',description:'按实际循环接续',versionNo:1,creator:'负责人',operatorName:'负责人',createdAt:now,versionCreatedAt:now,changeSummary:'新建',spliceMode:'project-cycles',segments,cycleNos:[1,2,3,4,5],selections:segments.map(item=>item.source.selection),settings:{metrics:['finalVoltage1','finalVoltage2','dischargeCapacityF'],seriesColors:['#3978f6','#7d42d8'],sourceDetailOverrides:{}}}
+const compare = {id:7,name:'工步对比验收',description:'跨测试叠加',versionNo:1,creator:'负责人',operatorName:'负责人',createdAt:now,versionCreatedAt:now,changeSummary:'新建',items:[source(1),source(2)].map(source=>({source,zeroTime:1000,records:[{deviceTime:1000,collectorDeviceTime:1000,voltage:3,current:0.1,temperature:25},{deviceTime:61000,collectorDeviceTime:61000,voltage:4,current:0.2,temperature:26}]})),settings:{metrics:['voltage','current'],seriesColors:['#3978f6','#7d42d8'],sourceDetailOverrides:{}}}
+let dashboardFails = false, todoFails = false
+const requests = [], errors = []
+context.on('page', page => page.on('pageerror', error => errors.push(error.message)))
+await context.route('http://refinement.test/**', async route => {
+  const url = new URL(route.request().url()), path = url.pathname, method = route.request().method()
+  if (path.startsWith('/dev-api')) {
+    requests.push({path,method})
+    let response = {code:200,msg:'成功',rows:[],total:0}
+    if (path.endsWith('/getInfo')) Object.assign(response,{user:{userId:2,userName:'owner',nickName:'负责人'},roles:['admin'],permissions:['*:*:*']})
+    else if (path.endsWith('/system/dashboard')) response = dashboardFails ? {code:500,msg:'统计查询失败'} : {...response,data:dashboard}
+    else if (path.endsWith('/system/todo/list')) response = todoFails ? {code:500,msg:'待办读取失败'} : {...response,rows:todos}
+    else if (path.endsWith('/system/todo/owners')) response.data = [{id:2,name:'负责人'}]
+    else if (path.endsWith('/system/todo') && method==='POST') { const input=route.request().postDataJSON();todos.push({...todos[0],...input,id:2});response.data=2 }
+    else if (path.endsWith('/system/todo/1/handle')) { todos[0]={...todos[0],status:'已处理',handleRemark:route.request().postDataJSON().remark}; }
+    else if (path.endsWith('/system/todo/2') && method==='DELETE') todos=todos.filter(item=>item.id!==2)
+    else if (path.includes('/system/splice-report/7')) response.data=splice
+    else if (path.includes('/system/compare-report/7')) response.data=compare
+    else if (path.endsWith('/monitor/operlog/list')) Object.assign(response,{rows:[{operId:4,operName:'负责人',title:'测试详情',businessType:2,status:0,operTime:'2026-09-09 08:00:00'}],total:1})
+    await route.fulfill({json:response});return
+  }
+  const file=path.startsWith('/assets/')?resolve(dist,'.'+path):resolve(dist,'index.html')
+  assert.ok(file.startsWith(dist))
+  await route.fulfill({body:await readFile(file),contentType:{'.html':'text/html','.js':'text/javascript','.css':'text/css'}[extname(file)]||'application/octet-stream'})
+})
+try {
+  const page=await context.newPage()
+  await page.goto('http://refinement.test/dashboard')
+  await expect(page.locator('.metrics')).toContainText('1001')
+  await expect(page.locator('.todo-panel')).toContainText('本人待办')
+  await expect(page.locator('.cabinet-panel')).toContainText('在线')
+  await expect(page.locator('.dashboard')).not.toContainText('P1')
+  await page.screenshot({path:resolve(artifacts,'dashboard-desktop.png'),fullPage:true})
+  dashboardFails=true
+  await page.getByRole('button',{name:'刷新',exact:true}).click()
+  await expect(page.getByText('统计查询失败')).toBeVisible()
+  await expect(page.locator('.metrics')).not.toContainText('1001')
+  dashboardFails=false
+  await page.goto('http://refinement.test/todo-list')
+  await expect(page.locator('.todo-page tbody')).toContainText('本人待办')
+  await page.getByRole('button',{name:'新增待办',exact:true}).click()
+  await page.getByPlaceholder('请输入待办内容').fill('新增真实接口事项')
+  await page.locator('.el-dialog .el-select').click()
+  await page.getByRole('option',{name:'负责人',exact:true}).click()
+  await page.getByRole('button',{name:'确定新增'}).click()
+  await expect(page.locator('.todo-page tbody')).toContainText('新增真实接口事项')
+  await page.locator('tr').filter({hasText:'本人待办'}).getByRole('button',{name:'处理待办'}).click()
+  await page.locator('.el-message-box textarea').fill('已核对完成')
+  await page.getByRole('button',{name:'完成处理',exact:true}).click()
+  await expect(page.locator('tr').filter({hasText:'本人待办'})).toContainText('已处理')
+  await page.reload()
+  await expect(page.locator('tr').filter({hasText:'本人待办'})).toContainText('已处理')
+  await page.locator('tr').filter({hasText:'新增真实接口事项'}).getByRole('button',{name:'删除',exact:true}).click()
+  await page.locator('.el-message-box').getByRole('button',{name:'删除',exact:true}).click()
+  await expect(page.locator('.todo-page tbody')).not.toContainText('新增真实接口事项')
+  todoFails=true;await page.reload();await expect(page.locator('.todo-page .el-alert')).toContainText('待办读取失败');todoFails=false
+  await page.goto('http://refinement.test/report-splice/7/edit?mode=edit')
+  await expect(page.locator('.project-chart-card')).toHaveCount(3)
+  await expect(page.locator('.statistics-editor')).toHaveCount(0)
+  await expect(page.locator('.report-test-detail')).toHaveCount(1)
+  await expect(page.locator('.splice-form-page')).not.toContainText('能量效率')
+  await page.screenshot({path:resolve(artifacts,'splice-desktop.png'),fullPage:true})
+  const popupPromise=page.waitForEvent('popup')
+  await page.getByRole('button',{name:'打印 / 另存 PDF'}).click()
+  const popup=await popupPromise
+  await expect(popup.locator('body')).toContainText('2:原循环3')
+  await expect(popup.locator('body')).toContainText('3:原循环10')
+  await expect(popup.locator('svg')).toHaveCount(3)
+  await popup.close()
+  await page.setViewportSize({width:390,height:844})
+  await expect.poll(() => page.locator('.app-main').evaluate(element => element.getBoundingClientRect().left)).toBe(0)
+  await page.screenshot({path:resolve(artifacts,'splice-mobile.png'),fullPage:true,animations:'disabled'})
+  await page.setViewportSize({width:1440,height:1000})
+  await page.goto('http://refinement.test/report-compare/7/edit?mode=edit')
+  await expect(page.locator('.chart-card')).toHaveCount(2)
+  await expect(page.locator('.report-test-detail')).not.toContainText('循环')
+  await expect(page.locator('.report-test-detail')).not.toContainText('工步')
+  await expect(page.locator('.chart-legend-bar')).toContainText('合并工步')
+  await expect(page.getByText('数据选择',{exact:true})).toHaveCount(0)
+  await page.screenshot({path:resolve(artifacts,'compare-desktop.png'),fullPage:true})
+  await page.goto('http://refinement.test/operation-log')
+  await expect(page.locator('.operation-log-page tbody')).toContainText('测试详情:修改(成功)')
+  const formationStart=requests.length
+  await page.goto('http://refinement.test/report-formation')
+  await expect(page.getByText('SP2化成过程分析报表')).toBeVisible()
+  await page.getByRole('button',{name:'新建报表'}).click()
+  await expect(page.locator('.formation-form-page')).toBeVisible()
+  assert.ok(!requests.slice(formationStart).some(item=>item.path.includes('/formation-report')))
+  assert.deepEqual(errors,[])
+  console.log('首页、待办增删处理/重开/失败、拼接图表/打印、对比详情、日志及化成演示隔离:通过')
+} finally { await context.close();await browser.close() }
diff --git a/tests/report-chart-behavior.test.mjs b/tests/report-chart-behavior.test.mjs
new file mode 100644
index 0000000..971b623
--- /dev/null
+++ b/tests/report-chart-behavior.test.mjs
@@ -0,0 +1,41 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import load from './load-typescript.cjs'
+const { projectCycleChartOption, projectMetrics } = load(new URL('../src/utils/projectCycleChart.ts', import.meta.url))
+const { spliceProjectSeries } = load(new URL('../src/utils/spliceProjectChart.ts', import.meta.url))
+const { measurementChartOption } = load(new URL('../src/utils/reportMeasurementChart.ts', import.meta.url))
+
+test('缺循环连续接续,同组同电池合线,跨组同名电池独立且提示原循环', () => {
+  const segment = (testId, groupId, originalCycleNos, cyclePositions) => ({ source: { name: `测试${testId}`, group: `组${groupId}`, groupId, batteryNo: '1号', selection: { testId, testBatteryId: testId } }, originalCycleNos, cyclePositions, statistics: Object.fromEntries(originalCycleNos.map(no => [no, { finalVoltage1: no === 3 ? null : 4, dischargeCapacityF: 2 }])) })
+  const matrix = { cycleNos: [1, 2, 3, 4, 5], segments: [segment(1, 9, [1,3], {1:1,3:2}), segment(2, 9, [10,11], {10:3,11:4}), segment(3, 10, [1], {1:5})] }
+  const before = structuredClone(matrix)
+  const series = spliceProjectSeries(matrix)
+  assert.equal(series.length, 2)
+  assert.deepEqual(series[0].points.map(point => point.position), [1,2,3,4])
+  assert.equal(series[0].points[1].source, '测试1 (#1) · 1号 · 原循环3')
+  assert.equal(series[0].points[2].source, '测试2 (#2) · 1号 · 原循环10')
+  assert.deepEqual(series[1].points.map(point => point.position), [5])
+  const option = projectCycleChartOption(projectMetrics[0], series, matrix.cycleNos, ['#123456', '#abcdef'], [series[1].key], true)
+  assert.deepEqual(option.series[0].data.map(point => point.value), [[1,4],[2,null],[3,4],[4,4]])
+  assert.equal(option.legend.selected[series[1].name], false)
+  assert.equal(option.series[0].connectNulls, false)
+  assert.equal(option.xAxis.name, '累计位置')
+  assert.equal(projectCycleChartOption(projectMetrics[1], series, matrix.cycleNos, []).series.length, 0)
+  assert.deepEqual(matrix, before)
+})
+
+test('对比来源独立归零并重叠,采集器时间和毫单位保持事实', () => {
+  const items = [
+    { name: '甲', zeroTime: 1000, records: [{ deviceTime: 1000, current: -0.125, collectorDeviceTime: 9000, temperature: 0 }, { deviceTime: 181000, current: null }] },
+    { name: '乙', zeroTime: 3000, records: [{ deviceTime: 3000, current: 0.25 }, { deviceTime: 7203000, current: 0 }] },
+  ]
+  const option = measurementChartOption({key:'current', unit:'mA'}, items, ['#123456','#abcdef'])
+  assert.deepEqual(option.series[0].data, [[0,-125],[180,null]])
+  assert.deepEqual(option.series[1].data, [[0,250],[7200,0]])
+  const hidden = measurementChartOption({key:'current', unit:'mA'}, [{...items[0], hidden:true}, items[1]], ['#123456','#abcdef'])
+  assert.equal(hidden.legend.selected['甲'], false)
+  assert.equal(hidden.xAxis.max, option.xAxis.max)
+  assert.deepEqual(hidden.yAxis.map(axis => [axis.min, axis.max]), option.yAxis.map(axis => [axis.min, axis.max]))
+  const temperature = measurementChartOption({key:'temperature', unit:'℃'}, items, [])
+  assert.deepEqual(temperature.series[0].data, [[8,0]])
+})
diff --git a/tests/report-test-detail.ui.test.mjs b/tests/report-test-detail.ui.test.mjs
index d7adc0c..f6c5cee 100644
--- a/tests/report-test-detail.ui.test.mjs
+++ b/tests/report-test-detail.ui.test.mjs
@@ -26,7 +26,7 @@
     settings, items, segments, cycleNos: [], curveMode: 'actual-steps', spliceMode: 'battery-segments', units: {}, displayUnits: {} }
 }
 try {
-  for (const type of ['compare', 'formation', 'splice']) {
+  for (const type of ['compare', 'splice']) {
     const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
     await context.addInitScript(() => { localStorage.setItem('battery_detection_auth_token', 'ui-fixture'); window.print = () => {} })
     let saved = fixture(type), submitted, failSave = true, failFieldLoad = true
diff --git a/tests/test-chart-display-units.test.mjs b/tests/test-chart-display-units.test.mjs
index c0f1987..0a40e2e 100644
--- a/tests/test-chart-display-units.test.mjs
+++ b/tests/test-chart-display-units.test.mjs
@@ -2,6 +2,9 @@
 import { readFile } from 'node:fs/promises'
 import { test } from 'node:test'
 import ts from 'typescript'
+import load from './load-typescript.cjs'
+const { stepCurveChartOption } = load(new URL('../src/utils/stepCurveChart.ts', import.meta.url))
+const { projectCycleChartOption, projectMetrics } = load(new URL('../src/utils/projectCycleChart.ts', import.meta.url))
 import { toTestDisplayValue } from '../src/utils/testDataUnits.ts'
 
 async function componentFunctions(path, names, bindings) {
@@ -12,26 +15,15 @@
   return new Function(...Object.keys(bindings), 'toTestDisplayValue', script)(...Object.values(bindings), toTestDisplayValue)
 }
 
-async function measurementModule() {
-  const name = 'reportMeasurementChart'
-  const source = await readFile(new URL(`../src/utils/${name}.ts`, import.meta.url), 'utf8')
-  const script = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
-  const exports = {}
-  new Function('exports', 'require', script)(exports, id => {
-    assert.equal(id, './testDataUnits')
-    return { toTestDisplayValue }
-  })
-  return exports
-}
-const { measurementChartOption } = await measurementModule()
+const { measurementChartOption } = load(new URL('../src/utils/reportMeasurementChart.ts', import.meta.url))
 
 test('工步图电流序列和纵轴同步为 mA,保留负数与零', async () => {
   const records = [-8.1651, 0, -8.1674].map((current, index) => ({ batteryNo: 'BAT-1', current, deviceTime: index * 60000, sourceType: 'SNAPSHOT' }))
   const { metricValue, chartOption } = await componentFunctions('components/ConsoleChartPanel.vue', ['metricValue', 'recordTime', 'chartOption'], {
-    props: { cells: [{ no: 'BAT-1' }] }, selectedRecords: () => records, selectedCells: { value: [{ no: 'BAT-1' }] }, metricMatches: () => true, seriesColors: { value: ['#3978f6'] },
+    stepCurveChartOption, props: { cells: [{ no: 'BAT-1' }] }, selectedRecords: () => records, selectedCells: { value: [{ no: 'BAT-1' }] }, metricMatches: () => true, seriesColors: { value: ['#3978f6'] },
   })
   const option = chartOption({ key: 'current', unit: 'mA' })
-  assert.deepEqual(option.series[0].data, [-8165.1, 0, -8167.4])
+  assert.deepEqual(option.series[0].data, [[0,-8165.1], [60,0], [120,-8167.4]])
   assert.equal(option.yAxis[0].name, 'mA')
   assert.ok(option.yAxis[0].min < -8167.4)
   assert.ok(option.yAxis[0].max > 0)
@@ -40,12 +32,12 @@
 
 test('项目图表以 mAh 除以 F,缺失和无效 F 不造点,电压不缩放', async () => {
   const capacityFactor = { value: 2 }
-  const funcs = await componentFunctions('components/ConsoleProjectChartPanel.vue', ['numericValue', 'lastStepVoltage', 'dischargeCapacityPerFactor', 'seriesValues', 'chartOption'], {
+  const funcs = await componentFunctions('components/ConsoleProjectChartPanel.vue', ['numericValue', 'lastStepVoltage', 'dischargeCapacityPerFactor'], {
     props: { cells: [{ no: 'BAT-1' }], cycles: [{ batteryNo: 'BAT-1', cycleNo: 1, dischargeCapacity: 8.5122 }, { batteryNo: 'BAT-1', cycleNo: 2, dischargeCapacity: 0 }], steps: [{ batteryNo: 'BAT-1', cycleNo: 1, stepNo: 1, endVoltage: 0.8999 }] },
     capacityFactor, cycleLabels: { value: ['1', '2', '3'] }, seriesColors: { value: ['#3978f6'] },
   })
-  const option = funcs.chartOption({ key: 'dischargeCapacityF', unit: 'mAh / F' })
-  assert.deepEqual(option.series[0].data, [4256.1, 0, null])
+  const option = projectCycleChartOption(projectMetrics[2], [{key:'BAT-1',name:'BAT-1',points:[1,2,3].map(position=>({position,values:{dischargeCapacityF:funcs.dischargeCapacityPerFactor('BAT-1',position)}}))}], [1,2,3], [])
+  assert.deepEqual(option.series[0].data.map(point=>point.value[1]), [4256.1, 0, null])
   assert.equal(option.yAxis.name, 'mAh / F')
   assert.equal(option.yAxis.axisLabel.formatter(7366.332499999995), '7366.33')
   assert.equal(funcs.lastStepVoltage('BAT-1', 1), 0.8999)
@@ -69,10 +61,10 @@
   assert.equal(option.series[0].connectNulls, false)
   assert.equal(option.xAxis.max, 7200)
   assert.equal(option.xAxis.axisLabel.formatter(1800), '0.5')
-  assert.deepEqual(option.yAxis.map(axis => [axis.name, axis.min, axis.max]), [['mA', -143.75, 250], ['mA', -143.75, 250]])
+  assert.deepEqual(option.yAxis.map(axis => [axis.name, axis.min, axis.max]), [['mA', -205, 330], ['mA', -205, 330]])
   const hidden = measurementChartOption(metric, [{ ...items[0], hidden: true }, items[1]], ['#123456', '#abcdef'])
-  assert.equal(hidden.series.length, 1)
-  assert.equal(hidden.series[0].lineStyle.color, '#abcdef')
+  assert.equal(hidden.legend.selected['测试甲 · 1号'], false)
+  assert.equal(hidden.series[1].itemStyle.color, '#abcdef')
   assert.equal(hidden.xAxis.max, option.xAxis.max)
   assert.equal(hidden.yAxis[0].min, option.yAxis[0].min)
   assert.deepEqual(items, before)
@@ -80,4 +72,4 @@
   assert.deepEqual(voltage.series[0].data, [[0, 3.2], [17, null], [180, 0]])
   const temperature = measurementChartOption({ key: 'temperature', unit: '℃' }, [items[0]], ['#123456'])
   assert.deepEqual(temperature.series[0].data, [[8, 0], [21, null], [181, -5]])
-})
\ No newline at end of file
+})
diff --git a/tests/test-console-project-chart.contract.test.mjs b/tests/test-console-project-chart.contract.test.mjs
index 1de6a1b..494cc83 100644
--- a/tests/test-console-project-chart.contract.test.mjs
+++ b/tests/test-console-project-chart.contract.test.mjs
@@ -2,6 +2,7 @@
 import { readFile } from 'node:fs/promises'
 
 const chart = await readFile(new URL('../src/components/ConsoleProjectChartPanel.vue', import.meta.url), 'utf8')
+const plot = await readFile(new URL('../src/utils/projectCycleChart.ts', import.meta.url), 'utf8')
 const consoleView = await readFile(new URL('../src/views/TestConsole.vue', import.meta.url), 'utf8')
 
 assert.match(chart, /PersistedTestStep/)
@@ -10,9 +11,9 @@
 assert.match(chart, /endVoltage/)
 assert.match(chart, /dischargeCapacityPerFactor/)
 assert.match(chart, /capacityFactor/)
-assert.match(chart, /finalVoltage2/)
-assert.match(chart, /metric\.key !== 'finalVoltage2'/)
-assert.match(chart, /connectNulls: false/)
+assert.match(plot, /finalVoltage2/)
+assert.match(plot, /metric\.key === 'finalVoltage2' \? \[\]/)
+assert.match(plot, /connectNulls: false/)
 assert.match(chart, /value === null \|\| value === undefined/)
 assert.match(consoleView, /getPersistedTestSteps/)
 assert.match(consoleView, /getPersistedTestCycles/)
diff --git a/tests/test-console-step-chart.contract.test.mjs b/tests/test-console-step-chart.contract.test.mjs
index 88f16c1..4e067e7 100644
--- a/tests/test-console-step-chart.contract.test.mjs
+++ b/tests/test-console-step-chart.contract.test.mjs
@@ -16,7 +16,7 @@
 assert.match(chart, /保存详情/)
 assert.doesNotMatch(chart, /另存特殊字段模板/)
 assert.match(chart, /Number\(selectedCycle\.value\)/)
-assert.match(chart, /smooth: false/)
+assert.match(chart, /stepCurveChartOption/)
 assert.match(chart, /暂无当前测试实时数据/)
 assert.doesNotMatch(chart, /const stepOptions = \['5-21 恒流充电'/)
 assert.doesNotMatch(chart, /function seriesValues\(/)

--
Gitblit v1.7.1