接入化成矩阵报表保存与字段模板
沿用化成列表和分组表格,移除当前路径的演示业务数据。接入真实循环、独立版本、人工项、模板和权限,补充隔离正式接口与浏览器验收。
| | |
| | | # 化成报表页面 |
| | | |
| | | - 路由:`/report-formation` |
| | | - 列表实现:`src/views/FormationReport.vue` |
| | | - 新增/编辑实现:`src/views/FormationReportForm.vue` |
| | | - 数据:`src/stores/formationReports.ts` |
| | | - 状态:演示页面已开发;数据仍由前端种子数据和 `localStorage` 提供,后端报表事实未接入 |
| | | - 路由:`/report-formation`、`/report-formation/new`、`/report-formation/:id/edit` |
| | | - 页面:`src/views/FormationReport.vue`、`src/views/FormationReportForm.vue` |
| | | - 正式接口:`src/api/formationReports.ts` |
| | | - 实现范围:数据分析 Ticket08,待用户验收;真实充电终止电压属于 Ticket09,五类曲线与打印属于 Ticket10。 |
| | | |
| | | ## 页面目标 |
| | | ## 来源与矩阵 |
| | | |
| | | 汇总化成过程数据、工步结果、容量与一致性指标,支持质量判断和打印导出。 |
| | | 候选来自测试类型名称精确为“化成”的检测完成测试,后端验证测试和电池归属。横向按真实测试、电池组和电池号分组,纵向使用三类持久化记录实际出现的循环号并集。无该循环显示“无该循环”,已有循环但指标为空显示“指标缺失”;不复用前一循环。 |
| | | |
| | | ## 计划功能 |
| | | Z/H/F/A/B 从电池组当前字段读取,随版本冻结。充放电容量和能量按 mAh/mWh 显示;设备充放电效率与能量效率分别展示。能量效率仅在充电能量大于零且两项能量都存在时计算,不截断超过 100% 的值。同一电池循环有多条统计记录而无确定合并口径时,保留来源记录、指标保持缺失。 |
| | | |
| | | 支持报表名称、创建人和创建时间筛选;列表支持分页、编辑、查看、下载PDF和删除。完整报表页支持选择测试、电池组、电池号,展示全部化成循环明细,区分原始数据、自动计算值和人工填写项,并展示电压、电流、正电位、负电位、温度五类曲线。 |
| | | C、F/N、每克量、1.0V/拐点等未知公式仅供人工填写,单位默认空白。人工调整与冻结测量分开保存,不反写测试事实。 |
| | | |
| | | ## 页面路由 |
| | | ## 保存与版本 |
| | | |
| | | - `/report-formation`:化成报表查询列表。 |
| | | - `/report-formation/new`:新增化成报表。 |
| | | - `/report-formation/:id/edit`:编辑已保存化成报表,使用与新增页一致的完整页面。 |
| | | - `/report-formation/:id/edit?mode=view`:只读查看完整化成报表。 |
| | | 首次保存从后端当前有效数据冻结所选范围;列表支持名称、创建人和创建日期筛选、分页和重开。普通编辑仅使用已有快照,修改说明必填,保存追加新版本。历史版本只读;编辑入口始终打开最新基准。 |
| | | |
| | | ## 交互说明 |
| | | “按最新来源生成新版本”是独立确认动作,未保存修改需先保存。来源仍存在时重新冻结并保留当前范围内人工值,已消失循环的人工值保留在旧版;来源测试已删除时更新失败,查看、普通编辑及历史版本仍可用。陈旧页面不能覆盖新版本。 |
| | | |
| | | - 报表列表查询条件固定四列布局,操作列在横向滚动时固定最右侧。 |
| | | - 列表“查看报表”进入只读模式,隐藏字段模板、添加项、指标开关、选色和保存操作,保留循环切换、返回与导出 PDF。 |
| | | - 化成循环由测试柜数据返回,页面不提供新增、删除或分页切换;所有循环按顺序纵向连续展示,每个循环按电池号横向生成指标列。 |
| | | - 循环表格不展示单位列或循环合格汇总块;每个循环只保留一行“测试方法”输入框及工步指标数据。 |
| | | - “报表数据”沿用对比报表的测试详情结构:测试分组横向展开,电池组和电池号由测试记录固定带出,只读展示且不可在报表页新增或切换;固定字段 `Z/H/F/A/B/F/N/C` 始终保留,不受字段模板切换影响。 |
| | | - 报表数据表格不展示单位列;`F/N`、`C` 只显示自动计算状态。 |
| | | - 报表数据支持添加特殊字段、应用电池组字段模板、应用特殊字段模板和另存特殊字段模板。 |
| | | - `测试方法2`、`数字`为可填写字段;原始数据展示数值;自动计算字段显示“自动计算”,右侧展示单位。 |
| | | - 曲线横坐标按经过时间使用 `h:mm` 格式,当前演示数据每个点间隔30分钟。 |
| | | - 化成曲线视觉和交互参考对比报表:支持指标显隐、顶部样品图例、色块颜色修改、双纵坐标和两列图表布局;仅增加按循环切换的页签。 |
| | | - 列表“下载PDF”与编辑页“导出PDF”共用同一打印模板,内容同步报表数据、循环明细、字段填写值、当前启用曲线、系列颜色和报表说明。 |
| | | - PDF 不使用循环页签;每个循环独立展示自己的循环明细表和五类曲线,循环之间按纵向分组排列。 |
| | | - 循环矩阵中的人工填写值按“循环 + 字段 + 电池号”保存,编辑报表时会恢复。 |
| | | - 电池组模板字段、特殊字段和曲线显隐状态随报表保存,确保从列表下载的 PDF 与编辑页内容一致。 |
| | | - 保存和删除均提供中文反馈,删除前需要二次确认。 |
| | | ## 字段模板 |
| | | |
| | | ## 待开发 |
| | | 字段设置沿用小弹窗,选择详情/循环字段,调整顺序、展示名称和单位;测量单位不可改成不匹配的标签。可新增人工项或选取当前来源已包含的共享字段。 |
| | | |
| | | 接入检测完成测试查询、后端版本快照、字段/公式计算、修订审计、正式 PDF 模板及报表审核流程。 |
| | | 化成模板由后端独立保存,只包含结构,不能带测试、电池、测量或人工值;与对比模板隔离。同名更新需要明确确认及编辑权限。应用仅影响当前草稿,既有报表不随模板更新;普通编辑新增未冻结共享字段时保持缺失。同名换共享身份时不会沿用原字段人工调整。 |
| | | |
| | | ## 权限与删除 |
| | | |
| | | 后端分别校验查看、新增、编辑、删除权限,允许有对应权限者跨创建人操作。模板读取沿用查看/新增/编辑权限,创建和更新分别沿用新增与编辑权限。 |
| | | |
| | | 整体删除报表时清理全部版本,与最小操作日志同一事务提交,不删除来源测试;日志仅保留报表身份、操作人和时间。来源测试删除不删除独立报表。 |
| | | |
| | | 当前正式列表和矩阵不读取本地种子报表,不展示旧假终止电压、演示曲线或打印入口。旧打印工具仍被未接入的拼接演示页面引用,不用于 Ticket08 正式版本。 |
| | | |
| | | ## 验证 |
| | | |
| | | `tests/e2e/formation-matrix.spec.ts` 使用隔离正式后端、MySQL 8、Redis 和 Chrome 验证矩阵、保存、模板、权限、历史和两个删除方向。普通服务由用户手动启动,不使用共享开发库进行自动验收。 |
| New file |
| | |
| | | import { request, type ApiResponse } from './request' |
| | | |
| | | export interface FormationSelection { testId: number; testBatteryId: number } |
| | | export interface FormationCandidate { selection: FormationSelection; name: string; type: string; groupId: number; batteryGroup: string; batteryNo: string } |
| | | export interface FormationField { key: string; scope: 'detail' | 'cycle'; kind: 'battery' | 'metric' | 'manual'; displayName: string; unit: string; fieldDefinitionId?: number | null } |
| | | export interface FormationItem { |
| | | source: FormationCandidate |
| | | cycleNos: number[] |
| | | cycles: Array<Record<string, unknown>> |
| | | metrics: Record<string, Record<string, number | null>> |
| | | businessFields: Array<{ fieldDefinitionId: number; fieldName: string; fieldValue: string | null }> |
| | | } |
| | | export interface FormationMatrix { items: FormationItem[]; cycleNos: number[] } |
| | | export interface FormationSettings { fields: FormationField[]; values: Record<string, string> } |
| | | export interface FormationSummary { id: number; name: string; creator: string; createdAt: number; testName?: string; batteryGroup?: string } |
| | | export interface FormationVersion { versionNo: number; operatorName: string; createdAt: number; changeSummary: string } |
| | | export interface SavedFormationReport extends FormationSummary, FormationMatrix { |
| | | description: string; settings: FormationSettings; versionNo: number; operatorName: string; versionCreatedAt: number; changeSummary: string |
| | | units: Record<string, string>; displayUnits: Record<string, string> |
| | | } |
| | | export interface FormationTemplate { id: number; name: string; type: 'formation'; fields: FormationField[]; creator: string; updatedAt: number } |
| | | export interface CreateFormationReport { name: string; description: string; selections: FormationSelection[]; settings: FormationSettings } |
| | | |
| | | const root = '/system/formation-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 async function listFormationReports() { return (await request<ApiResponse & { rows: FormationSummary[] }>(`${root}/list`)).rows } |
| | | export function getFormationSources() { return data<FormationCandidate[]>('/sources') } |
| | | export function previewFormationReport(selections: FormationSelection[]) { return data<FormationMatrix>('/preview', 'POST', selections) } |
| | | /** 首次保存发送身份与展示值,测量由后端冻结。 */ |
| | | export function createFormationReport(body: CreateFormationReport) { return data<SavedFormationReport>('', 'POST', body) } |
| | | export function getFormationReport(id: number, version?: number, editing = false) { |
| | | return data<SavedFormationReport>(`/${id}${editing ? '/editing' : version === undefined ? '' : `/versions/${version}`}`) |
| | | } |
| | | export function editFormationReport(id: number, body: Omit<CreateFormationReport, 'selections'> & { baseVersionNo: number; changeSummary: string }) { |
| | | return data<SavedFormationReport>(`/${id}/versions`, 'POST', body) |
| | | } |
| | | export function refreshFormationSource(id: number, baseVersionNo: number, changeSummary: string) { |
| | | return data<SavedFormationReport>(`/${id}/refresh-source`, 'POST', { baseVersionNo, changeSummary }) |
| | | } |
| | | export function listFormationVersions(id: number) { return data<FormationVersion[]>(`/${id}/versions`) } |
| | | export function deleteFormationReport(id: number) { return data<void>(`/${id}`, 'DELETE') } |
| | | export function listFormationTemplates() { return data<FormationTemplate[]>('/templates') } |
| | | export function createFormationTemplate(name: string, fields: FormationField[]) { return data<number>('/templates', 'POST', { name, fields }) } |
| | | export function updateFormationTemplate(id: number, name: string, fields: FormationField[]) { return data<void>(`/templates/${id}`, 'PUT', { name, fields }) } |
| | | |
| | | /** 相同电池号跨测试仍为独立列,详情字段使用循环零占位而非虚构循环。 */ |
| | | export function formationValueKey(item: FormationItem, cycle: number, field: FormationField) { |
| | | return `${item.source.selection.testId}|${item.source.selection.testBatteryId}|${cycle}|${field.key}` |
| | | } |
| New file |
| | |
| | | <script setup lang="ts"> |
| | | import { ref } from 'vue' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { defaultFormationFields } from '@/utils/formationReportFields' |
| | | import { listFormationTemplates, createFormationTemplate, updateFormationTemplate, |
| | | type FormationField, type FormationItem, type FormationTemplate } from '@/api/formationReports' |
| | | |
| | | const props = defineProps<{ fields: FormationField[]; items: FormationItem[]; canCreate: boolean; canEdit: boolean }>() |
| | | const emit = defineEmits<{ apply: [fields: FormationField[]] }>() |
| | | const settingsVisible = ref(false) |
| | | const draft = ref<Array<FormationField & { enabled: boolean }>>([]) |
| | | const templatesVisible = ref(false) |
| | | const templates = ref<FormationTemplate[]>([]) |
| | | const selectedTemplate = ref<number | null>(null) |
| | | const loading = ref(false) |
| | | const saving = ref(false) |
| | | const error = ref('') |
| | | const identity = (field: FormationField) => `${field.scope}|${field.key}` |
| | | function openSettings() { |
| | | const available: FormationField[] = props.items.flatMap(item => item.businessFields.map(field => ({ key: field.fieldName, displayName: field.fieldName, |
| | | unit: '', kind: 'battery' as const, scope: 'detail' as const, fieldDefinitionId: field.fieldDefinitionId }))) |
| | | const combined = new Map<string, FormationField>() |
| | | for (const field of [...props.fields, ...defaultFormationFields, ...available]) if (!combined.has(identity(field))) combined.set(identity(field), field) |
| | | draft.value = [...combined.values()].map(field => ({ ...field, enabled: props.fields.some(selected => identity(selected) === identity(field)) })) |
| | | settingsVisible.value = true |
| | | } |
| | | function move(index: number, direction: number) { |
| | | const row = draft.value[index] |
| | | if (!row || index + direction < 0 || index + direction >= draft.value.length) return |
| | | draft.value.splice(index, 1); draft.value.splice(index + direction, 0, row) |
| | | } |
| | | function applySettings() { |
| | | const selected = draft.value.filter(field => field.enabled).map(({ enabled: _enabled, ...field }) => field) |
| | | if (!selected.length || selected.some(field => !field.displayName.trim())) { ElMessage.warning('至少选择一项并填写展示名称'); return } |
| | | emit('apply', selected); settingsVisible.value = false |
| | | } |
| | | async function addField() { |
| | | try { |
| | | const { value } = await ElMessageBox.prompt('请输入字段名称', '添加人工项', { confirmButtonText: '添加', cancelButtonText: '取消', inputValidator: value => Boolean(value?.trim()) || '请输入字段名称' }) |
| | | const key = value.trim() |
| | | if (key.includes('|') || key.length > 100 || draft.value.some(field => field.key === key)) { ElMessage.warning('字段名重复或无效'); return } |
| | | draft.value.push({ key, displayName: key, unit: '', kind: 'manual', scope: 'detail', enabled: true }) |
| | | } catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error('添加字段失败') } |
| | | } |
| | | async function openTemplates() { |
| | | templatesVisible.value = true; loading.value = true; error.value = ''; selectedTemplate.value = null |
| | | try { templates.value = await listFormationTemplates() } |
| | | catch (cause) { error.value = cause instanceof Error ? cause.message : '字段模板加载失败' } |
| | | finally { loading.value = false } |
| | | } |
| | | function applyTemplate() { |
| | | const template = templates.value.find(item => item.id === selectedTemplate.value) |
| | | if (!template) { ElMessage.warning('请选择字段模板'); return } |
| | | emit('apply', template.fields.map(field => ({ ...field }))) |
| | | templatesVisible.value = false; ElMessage.success('字段模板已应用') |
| | | } |
| | | async function saveTemplate() { |
| | | try { |
| | | const selected = templates.value.find(item => item.id === selectedTemplate.value) |
| | | const { value } = await ElMessageBox.prompt('请输入模板名称', '保存化成字段模板', { inputValue: selected?.name || '', confirmButtonText: '保存', cancelButtonText: '取消', inputValidator: value => Boolean(value?.trim()) || '请输入模板名称' }) |
| | | const name = value.trim() |
| | | const existing = templates.value.find(item => item.name === name) |
| | | if (existing) { |
| | | if (!props.canEdit) { ElMessage.warning('无编辑化成模板权限'); return } |
| | | await ElMessageBox.confirm(`更新“${name}”的字段结构?`, '更新字段模板', { confirmButtonText: '更新', cancelButtonText: '取消' }) |
| | | } else if (!props.canCreate) { ElMessage.warning('无新增化成模板权限'); return } |
| | | saving.value = true |
| | | if (existing) await updateFormationTemplate(existing.id, name, props.fields) |
| | | else await createFormationTemplate(name, props.fields) |
| | | templates.value = await listFormationTemplates() |
| | | selectedTemplate.value = templates.value.find(item => item.name === name)?.id ?? null |
| | | ElMessage.success('字段模板已保存') |
| | | } catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error(cause instanceof Error ? cause.message : '模板保存失败') } |
| | | finally { saving.value = false } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="field-controls"><el-button @click="openSettings"><el-icon><Setting /></el-icon>字段设置</el-button><el-button @click="openTemplates"><el-icon><DocumentCopy /></el-icon>选择字段模板</el-button></div> |
| | | <el-dialog v-model="settingsVisible" title="字段设置" width="min(850px, calc(100vw - 28px))"> |
| | | <div class="field-settings-table"><table><thead><tr><th>选择</th><th>字段</th><th>范围</th><th>展示名称</th><th>单位</th><th>顺序</th></tr></thead><tbody> |
| | | <tr v-for="(field, index) in draft" :key="identity(field)"><td><el-checkbox v-model="field.enabled" :aria-label="`选择${field.key}`" /></td><td>{{ field.key }}</td> |
| | | <td><el-select v-if="field.kind === 'manual'" v-model="field.scope" aria-label="字段范围"><el-option label="详情" value="detail" /><el-option label="循环" value="cycle" /></el-select><span v-else>{{ field.scope === 'detail' ? '详情' : '循环' }}</span></td> |
| | | <td><el-input v-model="field.displayName" aria-label="展示名称" maxlength="100" /></td><td><el-input v-model="field.unit" aria-label="单位" maxlength="30" :disabled="field.kind === 'metric'" /></td> |
| | | <td class="move-buttons"><el-tooltip content="上移"><el-button aria-label="上移" :disabled="index === 0" @click="move(index, -1)"><el-icon><ArrowUp /></el-icon></el-button></el-tooltip><el-tooltip content="下移"><el-button aria-label="下移" :disabled="index === draft.length - 1" @click="move(index, 1)"><el-icon><ArrowDown /></el-icon></el-button></el-tooltip></td> |
| | | </tr></tbody></table></div> |
| | | <template #footer><el-button @click="addField"><el-icon><Plus /></el-icon>添加人工项</el-button><el-button @click="settingsVisible = false">取消</el-button><el-button type="primary" @click="applySettings">确认</el-button></template> |
| | | </el-dialog> |
| | | <el-dialog v-model="templatesVisible" title="选择化成字段模板" width="min(600px, calc(100vw - 28px))"> |
| | | <div v-loading="loading"><el-alert v-if="error" :title="error" type="error" :closable="false" /><el-empty v-else-if="!templates.length && !loading" description="暂无已保存模板" :image-size="60" /> |
| | | <el-radio-group v-model="selectedTemplate" class="template-options"><el-radio v-for="template in templates" :key="template.id" :value="template.id">{{ template.name }}</el-radio></el-radio-group> |
| | | </div> |
| | | <template #footer><el-button v-if="canCreate || canEdit" :disabled="saving || loading || !!error" @click="saveTemplate">保存为模板</el-button><el-button @click="templatesVisible = false">取消</el-button><el-button type="primary" :disabled="loading || !selectedTemplate" @click="applyTemplate">应用模板</el-button></template> |
| | | </el-dialog> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .field-controls{display:flex;flex-wrap:wrap;gap:8px;padding:16px 24px}.field-controls .el-button{margin:0;gap:5px}.field-settings-table{max-height:55vh;overflow:auto}table{border-collapse:collapse;min-width:730px;width:100%;table-layout:fixed;font-size:12px}th,td{padding:8px;border-bottom:1px solid #d8e1e9;text-align:left}th{background:#f2f7fb}th:first-child{width:48px}th:nth-child(2){width:150px}th:nth-child(3){width:90px}th:nth-child(4){width:150px}th:nth-child(5){width:85px}td{overflow-wrap:anywhere}.move-buttons{white-space:nowrap}.move-buttons .el-button{width:28px;height:28px;padding:0;margin-left:4px}.template-options{display:flex;align-items:flex-start;flex-direction:column;gap:8px}.template-options :deep(.el-radio__label){white-space:normal;overflow-wrap:anywhere} |
| | | </style> |
| | |
| | | import { useTests } from '@/stores/tests' |
| | | import { listCompareReports, type CompareReportSummary } from '@/api/compareReports' |
| | | import { getUserInfo } from '@/api/auth' |
| | | import { useFormationReports } from '@/stores/formationReports' |
| | | import { listFormationReports, type FormationSummary } from '@/api/formationReports' |
| | | import { useSpliceReports } from '@/stores/spliceReports' |
| | | |
| | | interface SearchResult { |
| | |
| | | const keyword = ref('') |
| | | const { records: tests } = useTests() |
| | | const compareReports = ref<CompareReportSummary[]>([]) |
| | | const formationReports = ref<FormationSummary[]>([]) |
| | | const compareError = ref('') |
| | | watch(visible, async open => { |
| | | compareReports.value = [] |
| | | formationReports.value = [] |
| | | compareError.value = '' |
| | | if (!open) return |
| | | try { |
| | | const info = await getUserInfo() |
| | | if (info.permissions?.some(item => item === '*:*:*' || item === 'system:compare-report:query')) |
| | | compareReports.value = await listCompareReports() |
| | | } catch (error) { compareError.value = error instanceof Error ? error.message : '对比报表加载失败' } |
| | | if (info.permissions?.some(item => item === '*:*:*' || item === 'system:formation-report:query')) |
| | | formationReports.value = await listFormationReports() |
| | | } catch (error) { compareError.value = error instanceof Error ? error.message : '报表加载失败' } |
| | | }) |
| | | const { records: formationReports } = useFormationReports() |
| | | const { records: spliceReports } = useSpliceReports() |
| | | |
| | | const pages: SearchResult[] = [ |
| | |
| | | category: '报表', path: `/report-compare/${item.id}/edit?mode=view`, icon: 'TrendCharts', keywords: `${item.id} 对比 报告`, |
| | | })), |
| | | ...formationReports.value.map(item => ({ |
| | | key: `formation-${item.id}`, title: item.name, description: `化成报表 · ${item.creator} · ${item.createdAt}`, |
| | | key: `formation-${item.id}`, title: item.name, description: `化成报表 · ${item.creator} · ${new Date(item.createdAt).toLocaleString()}`, |
| | | category: '报表', path: `/report-formation/${item.id}/edit?mode=view`, icon: 'DataLine', keywords: `${item.id} 化成 报告`, |
| | | })), |
| | | ...spliceReports.value.map(item => ({ |
| | |
| | | import { ref } from 'vue' |
| | | import { listFormationReports, deleteFormationReport, type FormationSummary } from '@/api/formationReports' |
| | | |
| | | export interface FormationReportCycle { |
| | | cycle: number |
| | |
| | | description: string |
| | | } |
| | | |
| | | export type FormationReportPayload = Pick<FormationReportRecord, 'name' | 'creator' | 'testName' | 'batteryGroup' | 'samples' | 'cycles' | 'cycleFieldValues' | 'reportFieldValues' | 'batteryFields' | 'specialFields' | 'metrics' | 'seriesColors' | 'description'> |
| | | const records = ref<FormationSummary[]>([]) |
| | | |
| | | 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 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) { |
| | | async function refreshReports() { records.value = await listFormationReports() } |
| | | async function removeReport(id: number) { |
| | | await deleteFormationReport(id) |
| | | records.value = records.value.filter(item => item.id !== id) |
| | | persist() |
| | | } |
| | | return { records, createReport, updateReport, removeReport } |
| | | return { records, refreshReports, removeReport } |
| | | } |
| New file |
| | |
| | | import type { FormationField, FormationItem } from '@/api/formationReports' |
| | | import { formatTestDisplayValue } from './testDataUnits' |
| | | |
| | | export const defaultFormationFields: FormationField[] = [ |
| | | ...['Z', 'H', 'F', 'A', 'B'].map(key => ({ key, scope: 'detail' as const, kind: 'battery' as const, displayName: key, unit: '' })), |
| | | ...[['FN', 'F/N'], ['C', 'C']].map(([key, displayName]) => ({ key: key!, scope: 'detail' as const, kind: 'manual' as const, displayName: displayName!, unit: '' })), |
| | | ...[ |
| | | ['chargeCapacity', '充电容量', 'mAh'], ['dischargeCapacity', '放电容量', 'mAh'], |
| | | ['chargeEnergy', '充电能量', 'mWh'], ['dischargeEnergy', '放电能量', 'mWh'], |
| | | ['efficiency', '充放电效率', '%'], ['energyEfficiency', '能量效率', '%'], |
| | | ].map(([key, displayName, unit]) => ({ key: key!, scope: 'cycle' as const, kind: 'metric' as const, displayName: displayName!, unit: unit! })), |
| | | ...[ |
| | | ['method2', '测试方法2'], ['manual', '数字'], ['chargePerGram', '每克瓦充电量'], ['chargePerGramTotal', '每克瓦完整充电量'], |
| | | ['capacity1v', '1.0V容量'], ['capacity1vf', '1.0VF完整容量'], ['dischargeInflection', '拐点容量'], ['dischargeInflectionTotal', '拐点完整容量'], |
| | | ].map(([key, displayName]) => ({ key: key!, scope: 'cycle' as const, kind: 'manual' as const, displayName: displayName!, unit: '' })), |
| | | ] |
| | | |
| | | export function formationFieldLabel(field: FormationField) { return `${field.displayName}${field.unit ? ` (${field.unit})` : ''}` } |
| | | |
| | | /** 默认值从当前预览或冻结快照读取;共享身份优先,缺失不能回退其他循环。 */ |
| | | export function formationSourceValue(item: FormationItem, cycle: number, field: FormationField): string { |
| | | if (field.kind === 'battery') return item.businessFields.find(value => field.fieldDefinitionId != null |
| | | ? value.fieldDefinitionId === field.fieldDefinitionId : value.fieldName === field.key)?.fieldValue ?? '' |
| | | if (field.kind === 'metric') { |
| | | const value = item.metrics[String(cycle)]?.[field.key] |
| | | return value == null ? '' : formatTestDisplayValue(field.key, value) |
| | | } |
| | | return '' |
| | | } |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, reactive, ref } from 'vue' |
| | | import { computed, onMounted, reactive, ref } from 'vue' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { useRouter } from 'vue-router' |
| | | import { useFormationReports, type FormationReportRecord } from '@/stores/formationReports' |
| | | import { createFormationReportPrintData, openFormationReportPrint } from '@/utils/formationReportPrint' |
| | | import { useFormationReports } from '@/stores/formationReports' |
| | | import type { FormationSummary } from '@/api/formationReports' |
| | | import { getUserInfo } from '@/api/auth' |
| | | |
| | | interface FilterState { reportName: string; creator: string; dateRange: [string, string] | null } |
| | | const router = useRouter() |
| | | const { records, removeReport } = useFormationReports() |
| | | 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 filters = reactive<FilterState>({ reportName: '', creator: '', dateRange: null }) |
| | | const appliedFilters = ref<FilterState>({ reportName: '', creator: '', dateRange: null }) |
| | | const currentPage = ref(1) |
| | |
| | | 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 || (item.createdAt.slice(0, 10) >= query.dateRange[0] && item.createdAt.slice(0, 10) <= query.dateRange[1])) |
| | | .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) |
| | | .filter(item => !query.dateRange || (dateKey(item.createdAt) >= query.dateRange[0] && dateKey(item.createdAt) <= query.dateRange[1])) |
| | | .sort((a, b) => b.createdAt - 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: 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 { /* 用户取消 */ } |
| | | 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 } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="formation-page"> |
| | | <div class="formation-page" v-loading="loading"> |
| | | <el-alert v-if="loadError" :title="loadError" type="error" :closable="false" /> |
| | | <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 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> |
| | | <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> |
| | | </div> |
| | | </template> |
| | | |
| | |
| | | <script setup lang="ts"> |
| | | import { toTestDisplayValue } from '@/utils/testDataUnits' |
| | | import { computed, reactive, ref, watch } from 'vue' |
| | | import { computed, ref, watch } from 'vue' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { use } from 'echarts/core' |
| | | import { LineChart } from 'echarts/charts' |
| | | import { CanvasRenderer } from 'echarts/renderers' |
| | | import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components' |
| | | import VChart from 'vue-echarts' |
| | | 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' |
| | | import { getUserInfo } from '@/api/auth' |
| | | import { getFormationSources, previewFormationReport, formationValueKey, type FormationCandidate, type FormationItem, type FormationField } from '@/api/formationReports' |
| | | import { defaultFormationFields, formationFieldLabel, formationSourceValue } from '@/utils/formationReportFields' |
| | | import FormationFieldControls from '@/components/FormationFieldControls.vue' |
| | | import { createFormationReport, getFormationReport, editFormationReport, refreshFormationSource, listFormationVersions, |
| | | type SavedFormationReport, type FormationVersion } from '@/api/formationReports' |
| | | |
| | | use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]) |
| | | 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[]> = { |
| | | '标准容量样品': ['额定容量', '电流', '内阻'], |
| | | '标准循环样品': ['额定容量', '循环温度', '内阻'], |
| | | '低温性能样品': ['目标温度', '静置时间', '环境备注'], |
| | | 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 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)) |
| | | const values = ref<Record<string, string>>({}) |
| | | const detailFields = computed(() => fields.value.filter(field => field.scope === 'detail')) |
| | | 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 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) |
| | | } |
| | | 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) |
| | | function setValue(item: FormationItem, cycle: number, field: FormationField, value: string) { |
| | | values.value[formationValueKey(item, cycle, field)] = value |
| | | } |
| | | |
| | | 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 formContent() { return JSON.stringify({ name: reportName.value, description: description.value, fields: fields.value, values: values.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; values.value = record.settings.values |
| | | changeSummary.value = ''; savedContent.value = formContent() |
| | | } |
| | | 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 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))) |
| | | } |
| | | 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 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 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 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 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 => toTestDisplayValue(metric.key, 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 (!testName.value || !batteryGroup.value) { ElMessage.warning('请选择测试和电池组'); return } |
| | | if (!items.value.length || previewLoading.value || previewError.value) { ElMessage.warning('请先选择有效化成来源'); return } |
| | | if (editingId.value && !changeSummary.value.trim()) { ElMessage.warning('请输入修改说明'); return } |
| | | saving.value = true |
| | | 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') |
| | | try { |
| | | pruneValues() |
| | | const content = { name: reportName.value.trim(), description: description.value, settings: { fields: fields.value, values: values.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 } |
| | | } |
| | | 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 } }) |
| | | } |
| | | 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 = {}; selectedKeys.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') |
| | | if (!editingId.value) { |
| | | if (!canCreate.value) throw new Error('无新增化成报表权限') |
| | | reportName.value = ''; description.value = ''; fields.value = structuredClone(defaultFormationFields) |
| | | 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(() => route.fullPath, () => { void load() }, { immediate: true }) |
| | | </script> |
| | | |
| | | <template> |
| | | <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 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"> |
| | | <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="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><tr v-for="field in detailFields" :key="field.key"><td>{{ formationFieldLabel(field) }}</td><td v-for="item in reportItems" :key="selectionKey(item.source)"> |
| | | <el-input :model-value="inputValue(item, 0, field)" @update:model-value="setValue(item, 0, field, $event)" :disabled="readonly" :aria-label="`${item.source.name} ${item.source.batteryNo} ${field.displayName}`" :placeholder="field.kind === 'manual' ? '人工填写' : '指标缺失'" size="small" /> |
| | | </td></tr></tbody> |
| | | </table></div> |
| | | <el-empty v-if="!reportItems.length && !previewLoading" description="暂无所选测试数据" :image-size="60" /> |
| | | <FormationFieldControls v-if="!readonly" :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><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> |
| | | </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 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> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .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}} |
| | |
| | | @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> |
| New file |
| | |
| | | import { expect, test, type Page } from '@playwright/test' |
| | | import { Ticket04Stack } from './support/ticket04-stack' |
| | | |
| | | const stack = new Ticket04Stack() |
| | | test.use({ actionTimeout: 20_000 }) |
| | | test.beforeAll(async () => { await stack.start(); seed() }) |
| | | test.afterAll(async () => stack.stop()) |
| | | |
| | | async function login(page: Page, username = 'admin', password = 'admin123') { |
| | | if (page.url().startsWith('http')) await page.evaluate(() => { localStorage.removeItem('battery_detection_auth_token'); localStorage.removeItem('battery_detection_auth_user') }) |
| | | await page.goto('/login') |
| | | await page.locator('input[autocomplete="username"]').fill(username) |
| | | await page.locator('input[autocomplete="current-password"]').fill(password) |
| | | await page.getByRole('button', { name: '登录', exact: true }).click() |
| | | await expect(page).toHaveURL(/\/dashboard$/) |
| | | } |
| | | |
| | | async function api(page: Page, path: string, method = 'GET', data?: unknown) { |
| | | const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token')) |
| | | return (await page.request.fetch(`${stack.backendUrl}${path.startsWith('/') ? path : `/system/${path}`}`, { method, data, headers: { Authorization: `Bearer ${token}` } })).json() |
| | | } |
| | | |
| | | function seed() { |
| | | stack.executeIsolatedSql(` |
| | | INSERT INTO t_battery_group(group_id,group_name,model) VALUES(8801,'化成组甲','甲'),(8802,'化成组乙','乙'); |
| | | INSERT INTO t_battery_group_relation_field(group_id,battery_group_field_id,field_value) |
| | | SELECT 8801,field_id,'12.5' FROM t_battery_group_field WHERE field_name='F'; |
| | | INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(8801,'化成'),(8802,'其他'); |
| | | INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status) |
| | | VALUES(8801,'化成测试甲',1,1,8801,'验收','COMPLETED','ENDED'),(8802,'化成测试乙',1,1,8801,'验收','COMPLETED','ENDED'), |
| | | (8803,'非化成',1,1,8802,'验收','COMPLETED','ENDED'),(8804,'未完成化成',1,1,8801,'验收','WAITING','NOT_STARTED'); |
| | | INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id) |
| | | VALUES(8801,8801,8801,'同名电池',8801),(8802,8802,8802,'同名电池',8802),(8803,8803,8801,'其他',8803),(8804,8804,8801,'未完成',8804); |
| | | INSERT INTO t_test_cycle_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,dedupe_key,cycle_no,device_time_utc,server_received_at_utc,adapter_timezone_id,device_time_raw,charge_capacity_ah,discharge_capacity_ah,charge_energy_wh,discharge_energy_wh,content_sha256) |
| | | VALUES ${[8801, 8802].flatMap(id => Array.from({ length: id === 8801 ? 3 : 5 }, (_, index) => `(${id},${id},${id},'formation-fixture','formation:${id}','${index + 1}','${index + 1}',${index + 1},'2026-09-01 00:00:00.123','2026-09-01 00:00:01.123','Asia/Hong_Kong','2026-09-01 08:00:00.123',${index === 1 ? 'NULL' : '8.5122'},0,0.1361,0.1694,UNHEX(SHA2('${id}:${index}',256)))`)).join(',')}; |
| | | `) |
| | | } |
| | | |
| | | test('真实化成候选校验归属,循环并集区分缺失并冻结当前字段', async ({ page }) => { |
| | | await login(page) |
| | | const sources = await api(page, 'formation-report/sources') |
| | | expect(sources.code).toBe(200) |
| | | expect(sources.data.map((item: any) => item.selection.testId)).toEqual([8802, 8801]) |
| | | for (const selection of [{ testId: 8803, testBatteryId: 8803 }, { testId: 8804, testBatteryId: 8804 }, { testId: 8801, testBatteryId: 8802 }]) { |
| | | expect((await api(page, 'formation-report/preview', 'POST', [selection])).code).not.toBe(200) |
| | | } |
| | | const preview = await api(page, 'formation-report/preview', 'POST', [8801, 8802].map(id => ({ testId: id, testBatteryId: id }))) |
| | | expect(preview.code).toBe(200) |
| | | expect(preview.data.items.map((item: any) => item.cycleNos)).toEqual([[1, 2, 3], [1, 2, 3, 4, 5]]) |
| | | expect(preview.data.cycleNos).toEqual([1, 2, 3, 4, 5]) |
| | | expect(preview.data.items[0].cycles[0].chargeCapacity).toBe(8.5122) |
| | | expect(preview.data.items[0].cycles[1].chargeCapacity ?? null).toBeNull() |
| | | expect(preview.data.items[0].businessFields.find((field: any) => field.fieldName === 'F').fieldValue).toBe('12.5') |
| | | await page.goto('/report-formation/new') |
| | | await expect(page.getByRole('combobox', { name: '化成来源', exact: true })).toBeEnabled() |
| | | await page.locator('.source-panel .el-select').click() |
| | | await page.getByRole('option', { name: '化成测试甲 / 化成组甲 / 同名电池', exact: true }).click() |
| | | await page.getByRole('option', { name: '化成测试乙 / 化成组乙 / 同名电池', exact: true }).click() |
| | | await page.getByRole('combobox', { name: '化成来源', exact: true }).press('Escape') |
| | | await expect(page.locator('.cycle-card')).toHaveCount(5) |
| | | await expect(page.getByRole('textbox', { name: '化成测试甲 同名电池 循环1 充电容量', exact: true })).toHaveValue('8512.2000') |
| | | await expect(page.getByRole('textbox', { name: '化成测试甲 同名电池 循环2 充电容量', exact: true })).toHaveAttribute('placeholder', '指标缺失') |
| | | await expect(page.getByRole('textbox', { name: '化成测试甲 同名电池 循环1 放电容量', exact: true })).toHaveValue('0.0000') |
| | | await expect(page.locator('[data-cycle="4"] .missing-cycle')).toHaveCount(14) |
| | | await expect(page.getByRole('textbox', { name: '化成测试甲 同名电池 F/N', exact: true })).toHaveValue('') |
| | | await expect(page.getByRole('textbox', { name: '化成测试甲 同名电池 F', exact: true })).toHaveValue('12.5') |
| | | await expect(page.getByText('自动计算', { exact: true })).toHaveCount(0) |
| | | await expect(page.locator('canvas')).toHaveCount(0) |
| | | for (const width of [1440, 390]) { |
| | | await page.setViewportSize({ width, height: 900 }) |
| | | await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) |
| | | await page.screenshot({ path: `tests/e2e/.artifacts/formation-matrix-${width}.png`, animations: 'disabled' }) |
| | | } |
| | | }) |
| | | |
| | | const reportFields = [ |
| | | { key: 'F', scope: 'detail', kind: 'battery', displayName: 'F', unit: '', fieldDefinitionId: null }, |
| | | { key: 'FN', scope: 'detail', kind: 'manual', displayName: 'F/N', unit: '', fieldDefinitionId: null }, |
| | | { key: 'chargeCapacity', scope: 'cycle', kind: 'metric', displayName: '充电容量', unit: 'mAh', fieldDefinitionId: null }, |
| | | { key: 'energyEfficiency', scope: 'cycle', kind: 'metric', displayName: '能量效率', unit: '%', fieldDefinitionId: null }, |
| | | { key: 'capacity1v', scope: 'cycle', kind: 'manual', displayName: '1.0V容量', unit: '', fieldDefinitionId: null }, |
| | | ] |
| | | |
| | | test('化成字段模板只存结构、类型隔离,版本独立于模板与来源', async ({ page }) => { |
| | | await login(page) |
| | | const templates = 'formation-report/templates' |
| | | expect((await api(page, templates)).data).toEqual([]) |
| | | for (const fields of [[{ ...reportFields[0], value: 42 }], [null], [reportFields[0], reportFields[0]], [{ ...reportFields[2], unit: 'Ah' }], [{ ...reportFields[0], key: 'C' }]]) { |
| | | expect((await api(page, templates, 'POST', { name: '非法结构', fields })).code).not.toBe(200) |
| | | } |
| | | const template = await api(page, templates, 'POST', { name: '化成结构', fields: reportFields }) |
| | | expect(template.code).toBe(200) |
| | | const fields = (await api(page, templates)).data[0].fields |
| | | expect(fields[0].fieldDefinitionId).toBeGreaterThan(0) |
| | | expect((await api(page, 'compare-report/templates')).data).toEqual([]) |
| | | expect((await api(page, `compare-report/templates/${template.data}`, 'PUT', { name: '越界', fields: [] })).code).not.toBe(200) |
| | | const created = await api(page, 'formation-report', 'POST', { |
| | | name: '化成独立版本', description: '人工项与测量分开', selections: [8801, 8802].map(id => ({ testId: id, testBatteryId: id })), |
| | | settings: { fields, values: { '8801|8801|0|FN': '人工甲', '8802|8802|5|capacity1v': '9.2' } }, |
| | | }) |
| | | expect(created.code).toBe(200) |
| | | const first = created.data |
| | | expect(first.items[0].metrics['1'].energyEfficiency).toBeCloseTo(124.467303453, 6) |
| | | expect(first.items[0].cycles[0].chargeCapacity).toBe(8.5122) |
| | | expect(first.items[0].cycles[0].dischargeCapacity).toBeUndefined() |
| | | stack.executeIsolatedSql("UPDATE t_test_cycle_record SET charge_capacity_ah=99 WHERE test_id=8801 AND cycle_no=1; UPDATE t_battery_group_relation_field SET field_value='25' WHERE group_id=8801") |
| | | const edit = { baseVersionNo: 1, name: '化成独立版本二', description: '', changeSummary: '仅表格调整', settings: { fields, values: { ...first.settings.values, '8801|8801|1|chargeCapacity': '123' } } } |
| | | expect((await api(page, `formation-report/${first.id}/versions`, 'POST', { ...edit, items: [{ cycles: [{ chargeCapacity: 999 }] }] })).code).not.toBe(200) |
| | | const second = await api(page, `formation-report/${first.id}/versions`, 'POST', edit) |
| | | expect(second.code).toBe(200) |
| | | expect(second.data.items).toEqual(first.items) |
| | | expect((await api(page, `formation-report/${first.id}/versions`, 'POST', edit)).code).not.toBe(200) |
| | | const updatedFields = fields.map((field: any) => ({ ...field, displayName: `${field.displayName}改名` })) |
| | | expect((await api(page, `${templates}/${template.data}`, 'PUT', { name: '化成结构', fields: updatedFields })).code).toBe(200) |
| | | expect((await api(page, `formation-report/${first.id}/versions/1`)).data).toEqual(first) |
| | | const refreshed = await api(page, `formation-report/${first.id}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '更新当前来源' }) |
| | | expect(refreshed.code).toBe(200) |
| | | expect(refreshed.data.items[0].cycles[0].chargeCapacity).toBe(99) |
| | | expect(refreshed.data.items[0].businessFields[0].fieldValue).toBe('25') |
| | | expect(refreshed.data.settings.values['8801|8801|1|chargeCapacity']).toBe('123') |
| | | expect(refreshed.data.settings.fields).toEqual(fields) |
| | | expect((await api(page, `formation-report/${first.id}/versions`)).data).toHaveLength(3) |
| | | await stack.restartBackend() |
| | | expect((await api(page, `formation-report/${first.id}`)).data).toEqual(refreshed.data) |
| | | }) |
| | | |
| | | test('现有化成页面保存人工值、刷新重开、普通编辑、历史和模板草稿应用', async ({ page }) => { |
| | | const errors: string[] = [] |
| | | page.on('pageerror', error => errors.push(error.message)) |
| | | await login(page) |
| | | await page.goto('/report-formation/new') |
| | | await expect(page.getByRole('combobox', { name: '化成来源', exact: true })).toBeEnabled() |
| | | await page.locator('.source-panel .el-select').click() |
| | | await page.getByRole('option', { name: '化成测试甲 / 化成组甲 / 同名电池', exact: true }).click() |
| | | await page.getByRole('combobox', { name: '化成来源', exact: true }).press('Escape') |
| | | await expect(page.locator('.cycle-card')).toHaveCount(3) |
| | | await page.getByPlaceholder('请输入报表名称').fill('页面化成报表') |
| | | await page.getByRole('textbox', { name: '化成测试甲 同名电池 F/N', exact: true }).fill('页面人工值') |
| | | await page.getByRole('button', { name: '保存报表', exact: true }).click() |
| | | await expect(page).toHaveURL(/\/report-formation\/\d+\/edit\?mode=view$/) |
| | | const id = Number(page.url().match(/formation\/(\d+)/)![1]) |
| | | await page.reload() |
| | | await expect(page.getByRole('textbox', { name: '化成测试甲 同名电池 F/N', exact: true })).toHaveValue('页面人工值') |
| | | await expect(page.getByRole('textbox', { name: '化成测试甲 同名电池 F/N', exact: true })).toBeDisabled() |
| | | await page.getByRole('button', { name: '编辑', exact: true }).click() |
| | | await page.getByRole('button', { name: '字段设置', exact: true }).click() |
| | | const fieldDialog = page.getByRole('dialog', { name: '字段设置', exact: true }) |
| | | await fieldDialog.getByRole('textbox', { name: '展示名称', exact: true }).first().fill('Z展示名') |
| | | await fieldDialog.getByRole('button', { name: '确认', exact: true }).click() |
| | | await page.getByRole('button', { name: '选择字段模板', exact: true }).click() |
| | | const templateDialog = page.getByRole('dialog', { name: '选择化成字段模板', exact: true }) |
| | | await templateDialog.getByRole('button', { name: '保存为模板', exact: true }).click() |
| | | await page.getByRole('dialog', { name: '保存化成字段模板', exact: true }).getByRole('textbox').fill('页面结构') |
| | | await page.getByRole('dialog', { name: '保存化成字段模板', exact: true }).getByRole('button', { name: '保存', exact: true }).click() |
| | | await expect(page.getByText('字段模板已保存', { exact: true })).toBeVisible() |
| | | await templateDialog.getByRole('button', { name: '取消', exact: true }).click() |
| | | await page.getByPlaceholder('请输入修改说明').fill('修改展示结构') |
| | | await page.getByRole('button', { name: '保存新版本', exact: true }).click() |
| | | await expect(page).toHaveURL(/mode=view/) |
| | | await page.getByRole('button', { name: '历史版本', exact: true }).click() |
| | | const history = page.getByRole('dialog', { name: '化成报表历史版本', exact: true }) |
| | | await expect(history.locator('tbody tr')).toHaveCount(2) |
| | | await history.locator('tbody tr').last().getByRole('button', { name: '查看', exact: true }).click() |
| | | await expect(page).toHaveURL(/version=1/) |
| | | await expect(page.getByRole('textbox', { name: '化成测试甲 同名电池 Z', exact: true })).toBeVisible() |
| | | expect((await api(page, `formation-report/${id}`)).data.settings.fields[0].displayName).toBe('Z展示名') |
| | | await page.goto('/report-formation') |
| | | await page.getByPlaceholder('输入报表名称或ID').fill('页面化成报表') |
| | | await page.getByRole('button', { name: '查询', exact: true }).click() |
| | | await expect(page.locator('.formation-page tbody tr')).toHaveCount(1) |
| | | await expect(page.locator('.formation-page tbody tr')).toContainText('化成测试甲') |
| | | await page.reload() |
| | | await expect(page.getByText('页面化成报表', { exact: true })).toBeVisible() |
| | | await page.goto('/report-formation/new') |
| | | await expect(page.getByRole('combobox', { name: '化成来源', exact: true })).toBeEnabled() |
| | | await page.locator('.source-panel .el-select').click() |
| | | await page.getByRole('option', { name: '化成测试乙 / 化成组乙 / 同名电池', exact: true }).click() |
| | | await page.getByRole('combobox', { name: '化成来源', exact: true }).press('Escape') |
| | | await expect(page.locator('.cycle-card')).toHaveCount(5) |
| | | await page.getByRole('button', { name: '选择字段模板', exact: true }).click() |
| | | await templateDialog.getByRole('radio', { name: '页面结构', exact: true }).check() |
| | | await templateDialog.getByRole('button', { name: '应用模板', exact: true }).click() |
| | | await expect(page.getByRole('textbox', { name: '化成测试乙 同名电池 F/N', exact: true })).toHaveValue('') |
| | | await expect(page.getByRole('textbox', { name: '化成测试乙 同名电池 Z展示名', exact: true })).toBeVisible() |
| | | await expect(page.locator('.cycle-card')).toHaveCount(5) |
| | | for (const width of [1440, 390]) { |
| | | await page.setViewportSize({ width, height: 900 }) |
| | | await page.getByRole('button', { name: '字段设置', exact: true }).click() |
| | | await expect(fieldDialog).toBeVisible() |
| | | await page.screenshot({ path: `tests/e2e/.artifacts/formation-fields-${width}.png`, animations: 'disabled' }) |
| | | await fieldDialog.getByRole('button', { name: '取消', exact: true }).click() |
| | | } |
| | | expect(errors).toEqual([]) |
| | | }) |
| | | |
| | | test('化成动作权限独立且允许跨创建人操作,失败事务与两种删除方向正确', async ({ page }) => { |
| | | await login(page) |
| | | const payload = { name: '权限化成', description: '', selections: [{ testId: 8802, testBatteryId: 8802 }], settings: { fields: reportFields, values: {} } } |
| | | const before = (await api(page, 'formation-report/list')).rows |
| | | stack.executeIsolatedSql("CREATE TRIGGER reject_formation_version BEFORE INSERT ON t_formation_report_version FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='版本写入失败'") |
| | | try { expect((await api(page, 'formation-report', 'POST', payload)).code).not.toBe(200) } |
| | | finally { stack.executeIsolatedSql('DROP TRIGGER reject_formation_version') } |
| | | expect((await api(page, 'formation-report/list')).rows).toEqual(before) |
| | | const saved = (await api(page, 'formation-report', 'POST', payload)).data |
| | | const menus = (await api(page, 'menu/permissionList')).data |
| | | const permissions = menus.flatMap((module: any) => module.pages).find((item: any) => item.pageName === '化成报表').permissions |
| | | for (const [name, action] of [['formation_add', '新增'], ['formation_edit', '编辑'], ['formation_read', '查看'], ['formation_delete', '删除'], ['formation_denied', '']]) { |
| | | expect((await api(page, 'role', 'POST', { roleName: name, roleKey: 'common', roleSort: 1, status: '0', menuIds: permissions.filter((item: any) => item.menuName === action).map((item: any) => item.menuId) })).code).toBe(200) |
| | | const role = (await api(page, `role/list?roleName=${name}`)).rows[0] |
| | | expect((await api(page, 'user', 'POST', { userName: name, nickName: name, password: 'Report123!', status: '0', roleIds: [role.roleId] })).code).toBe(200) |
| | | } |
| | | let version = 1 |
| | | for (const [name, query, add, edit, remove] of [ |
| | | ['formation_add', 403, 200, 403, 403], ['formation_edit', 403, 403, 200, 403], |
| | | ['formation_read', 200, 403, 403, 403], ['formation_denied', 403, 403, 403, 403], ['formation_delete', 403, 403, 403, 200], |
| | | ] as const) { |
| | | await login(page, name, 'Report123!') |
| | | expect((await api(page, 'formation-report/list')).code).toBe(query) |
| | | expect((await api(page, `formation-report/${saved.id}`)).code).toBe(query) |
| | | expect((await api(page, `formation-report/${saved.id}/versions/1`)).code).toBe(query) |
| | | expect((await api(page, 'formation-report/sources')).code).toBe(add) |
| | | expect((await api(page, 'formation-report/preview', 'POST', payload.selections)).code).toBe(add) |
| | | expect((await api(page, 'formation-report', 'POST', payload)).code).toBe(add) |
| | | expect((await api(page, `formation-report/${saved.id}/editing`)).code).toBe(edit) |
| | | const updated = await api(page, `formation-report/${saved.id}/versions`, 'POST', { baseVersionNo: version, name: payload.name, description: '', changeSummary: '跨创建人编辑', settings: payload.settings }) |
| | | expect(updated.code).toBe(edit) |
| | | if (edit === 200) version++ |
| | | expect((await api(page, 'formation-report/templates')).code).toBe(Math.min(query, add, edit)) |
| | | expect((await api(page, 'formation-report/templates', 'POST', { name, fields: reportFields })).code).toBe(add) |
| | | expect((await api(page, `formation-report/${saved.id}/refresh-source`, 'POST', { baseVersionNo: version, changeSummary: '权限更新' })).code).toBe(edit) |
| | | if (edit === 200) version++ |
| | | expect((await api(page, `formation-report/${saved.id}`, 'DELETE')).code).toBe(remove) |
| | | } |
| | | await login(page) |
| | | expect((await api(page, `formation-report/${saved.id}`)).code).not.toBe(200) |
| | | expect((await api(page, 'test/8802')).code).toBe(200) |
| | | const survivor = (await api(page, 'formation-report', 'POST', payload)).data |
| | | expect((await api(page, 'test/8802', 'DELETE')).code).toBe(200) |
| | | expect((await api(page, `formation-report/${survivor.id}`)).data).toEqual(survivor) |
| | | expect((await api(page, `formation-report/${survivor.id}/refresh-source`, 'POST', { baseVersionNo: 1, changeSummary: '已删除来源' })).code).not.toBe(200) |
| | | const edited = await api(page, `formation-report/${survivor.id}/versions`, 'POST', { baseVersionNo: 1, name: '来源已删除仍可编辑', description: '', settings: survivor.settings, changeSummary: '编辑独立快照' }) |
| | | expect(edited.code).toBe(200) |
| | | stack.executeIsolatedSql("CREATE TRIGGER reject_formation_log BEFORE INSERT ON sys_oper_log FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='日志失败'") |
| | | try { expect((await api(page, `formation-report/${survivor.id}`, 'DELETE')).code).not.toBe(200) } |
| | | finally { stack.executeIsolatedSql('DROP TRIGGER reject_formation_log') } |
| | | expect((await api(page, `formation-report/${survivor.id}`)).data).toEqual(edited.data) |
| | | await page.goto('/report-formation') |
| | | const row = page.locator('.formation-page tbody tr').filter({ hasText: '来源已删除仍可编辑' }) |
| | | await row.getByRole('button', { name: '删除', exact: true }).click() |
| | | await page.getByRole('dialog', { name: '确认删除化成报表', exact: true }).getByRole('button', { name: '删除', exact: true }).click() |
| | | await expect(row).toHaveCount(0) |
| | | expect((await api(page, `formation-report/${survivor.id}/versions/1`)).code).not.toBe(200) |
| | | const logs = (await api(page, '/monitor/operlog/list?title=' + encodeURIComponent('化成报表删除'))).rows |
| | | const log = logs.find((item: any) => JSON.parse(item.operParam).reportId === survivor.id) |
| | | expect(Object.keys(JSON.parse(log.operParam)).sort()).toEqual(['operatorId', 'reportId', 'reportName']) |
| | | expect(log.operName).toBe('admin') |
| | | expect((await api(page, '/monitor/operlog/clean', 'DELETE')).code).toBe(200) |
| | | expect((await api(page, '/monitor/operlog/list?title=' + encodeURIComponent('化成报表删除'))).rows.some((item: any) => item.operId === log.operId)).toBe(true) |
| | | expect(stack.queryDatabase("SELECT COUNT(*) FROM sys_oper_log WHERE oper_url='/system/test/8802'")).toBe('0') |
| | | }) |