修正化成报表跨循环显隐与保存权限流程
保留其他循环来源的图例状态,逐电池显示所属电池组,按动作权限处理保存和导出。补充保存版打印与显隐回归测试。
| | |
| | | export interface FormationMatrix { items: FormationItem[]; cycleNos: number[] } |
| | | export interface FormationStepSelection { charge: number[]; discharge: number[] } |
| | | export interface FormationSettings { fields: FormationField[]; values: Record<string, string>; chargeStepSelections: Record<string, number>; metrics?: ReportMetricKey[]; seriesColors?: Record<string, string>; manualChargeSteps?: Record<string, number[]>; curveStepSelections?: Record<string, FormationStepSelection>; hiddenSeriesKeys?: string[]; activeCycle?: number | null; sourceDetailOverrides?: ReportTestDetails } |
| | | export interface FormationSummary { id: number; name: string; creator: string; createdAt: number; testName?: string; batteryGroup?: string } |
| | | export interface FormationSummary { id: number; name: string; creator: string; createdAt: number; testName?: string; batteryGroup?: string; versionNo: number } |
| | | 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 |
| | |
| | | import { init, use } from 'echarts/core' |
| | | import { LineChart } from 'echarts/charts' |
| | | import { GridComponent, LegendComponent } from 'echarts/components' |
| | | import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components' |
| | | import { SVGRenderer } from 'echarts/renderers' |
| | | import { getFormationPrintVersion, type FormationItem, type FormationField, type SavedFormationReport } from '@/api/formationReports' |
| | | import { formationFieldLabel, formationSourceValue } from './formationReportFields' |
| | |
| | | import { formationChargeSteps, formationStepGroups, formationStepSeries, formationStepChartOption } from './formationStepChart' |
| | | import { openSavedReportPrint } from './savedReportPrint' |
| | | |
| | | use([LineChart, GridComponent, LegendComponent, SVGRenderer]) |
| | | use([LineChart, GridComponent, LegendComponent, TooltipComponent, SVGRenderer]) |
| | | function escapeHtml(value: unknown) { |
| | | const entities: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } |
| | | return String(value ?? '').replace(/[&<>"']/g, character => entities[character]!) |
| | |
| | | export type FormationStepGroup = typeof formationStepGroups[number]['key'] |
| | | export function formationCycleKey(item: FormationItem, cycle: number) { return formationSeriesKey(item) + '|' + cycle } |
| | | |
| | | /** 当前循环图例事件只更新其中出现的来源,保留其他循环独有电池的显示设置。 */ |
| | | export function formationHiddenSeries(hidden: string[], selected: Record<string, boolean>) { |
| | | const next = new Set(hidden) |
| | | for (const [key, shown] of Object.entries(selected)) shown ? next.delete(key) : next.add(key) |
| | | return [...next] |
| | | } |
| | | |
| | | /** 化成只使用可靠识别的实际工步,逻辑合并与人工范围均不参与。 */ |
| | | export function formationChargeSteps(item: FormationItem, cycle: number, _manual?: Record<string, number[]>) { |
| | | return (item.chargeSteps?.[cycle] ?? []).filter(step => step.direction === 'CHARGE').sort((a,b)=>a.stepNo-b.stepNo) |
| | |
| | | import { computed, onMounted, reactive, ref } from 'vue' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { useRouter } from 'vue-router' |
| | | import { listFormationReports, deleteFormationReport, getFormationReport, type FormationSummary } from '@/api/formationReports' |
| | | import { listFormationReports, deleteFormationReport, type FormationSummary } from '@/api/formationReports' |
| | | import { getUserInfo } from '@/api/auth' |
| | | import { openFormationSavedReportPrint } from '@/utils/formationSavedReportPrint' |
| | | |
| | |
| | | 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' } }) } |
| | | async function downloadPdf(item: FormationReportRecord) { |
| | | try{const record=await getFormationReport(item.id);await openFormationSavedReportPrint(item.id,record.versionNo)} |
| | | // 列表提供版本身份,导出只走独立导出权限,不额外请求查看接口。 |
| | | try{await openFormationSavedReportPrint(item.id,item.versionNo)} |
| | | catch(cause){ElMessage.error(cause instanceof Error?cause.message:'打印版加载失败')} |
| | | } |
| | | async function deleteReport(item: FormationReportRecord) { |
| | |
| | | import { defaultFormationFields, formationSourceValue } from '@/utils/formationReportFields' |
| | | import { formationCellKey, formationInputValue, formationCalculatedValue, formationNumberText } from '@/utils/formationReportCalculations' |
| | | import { formationCurveMetrics, formationSeriesKey, formationSeriesColor } from '@/utils/formationCycleChart' |
| | | import { formationChargeSteps, formationStepChartOption, formationStepGroups } from '@/utils/formationStepChart' |
| | | import { formationChargeSteps, formationStepChartOption, formationStepGroups, formationHiddenSeries } from '@/utils/formationStepChart' |
| | | import { openFormationSavedReportPrint } from '@/utils/formationSavedReportPrint' |
| | | |
| | | use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent]) |
| | | const route = useRoute(), router = useRouter() |
| | | const editingId = computed(() => Number(route.params.id) || null) |
| | | const readonly = computed(() => route.query.mode === 'view') |
| | | const readonly = computed(() => route.query.mode === 'view' || (saved.value !== null && !can('edit'))) |
| | | const reportName = ref(''), description = ref(''), error = ref('') |
| | | const loading = ref(false), saving = ref(false), previewLoading = ref(false) |
| | | const permissions = ref<string[]>([]) |
| | |
| | | function setMetric(key:typeof formationCurveMetrics[number]['key'],show:boolean){settings.value.metrics=formationCurveMetrics.filter(metric=>metric.key===key?show:settings.value.metrics?.includes(metric.key)).map(metric=>metric.key);if(!readonly.value)dirty.value=true} |
| | | function setColor(item:FormationItem,color:string|null){if(color){settings.value.seriesColors={...settings.value.seriesColors,[formationSeriesKey(item)]:color};dirty.value=true}} |
| | | function toggleSeries(item:FormationItem){const key=formationSeriesKey(item),hidden=new Set(settings.value.hiddenSeriesKeys??[]);hidden.has(key)?hidden.delete(key):hidden.add(key);settings.value.hiddenSeriesKeys=[...hidden];if(!readonly.value)dirty.value=true} |
| | | function legendChanged(event:{selected:Record<string,boolean>}){settings.value.hiddenSeriesKeys=Object.entries(event.selected).filter(([,shown])=>!shown).map(([key])=>key);if(!readonly.value)dirty.value=true} |
| | | function legendChanged(event:{selected:Record<string,boolean>}){settings.value.hiddenSeriesKeys=formationHiddenSeries(settings.value.hiddenSeriesKeys??[],event.selected);if(!readonly.value)dirty.value=true} |
| | | |
| | | async function saveReport(){ |
| | | if(!reportName.value.trim()){ElMessage.warning('请输入报表名称');return} |
| | |
| | | const payload={name:reportName.value.trim(),description:description.value,settings:JSON.parse(JSON.stringify(settings.value)) as FormationSettings} |
| | | const record=saved.value?await editFormationReport(saved.value.id,{...payload,baseVersionNo:saved.value.versionNo,changeSummary:reason}):await createFormationReport({...payload,selections:matrix.value.items.map(item=>item.source.selection)}) |
| | | loadSaved(record);ElMessage.success('化成报表已保存') |
| | | await router.replace({path:`/report-formation/${record.id}/edit`,query:{mode:'view'}}) |
| | | // 动作权限独立;无查询权限时不因保存成功而跳入未授权的查看请求。 |
| | | if(can('query'))await router.replace({path:`/report-formation/${record.id}/edit`,query:{mode:'view'}}) |
| | | else if(can('edit'))await router.replace({path:`/report-formation/${record.id}/edit`}) |
| | | }catch(cause){ElMessage.error(message(cause))}finally{saving.value=false} |
| | | } |
| | | async function refreshSource(){ |
| | |
| | | <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> |
| | | <el-alert v-if="error" :title="error" type="error" :closable="false" show-icon /> |
| | | <fieldset :disabled="busy || !!error" class="report-fieldset"> |
| | | <section class="report-name-panel"><label><span>报表名称</span><el-input v-model="reportName" :disabled="readonly" clearable maxlength="100" placeholder="请输入报表名称" @input="dirty=true" /></label><label><span>选择测试</span><el-select v-model="selectedTestIds" multiple filterable collapse-tags collapse-tags-tooltip :disabled="!!editingId || busy" placeholder="选择已完成的化成测试" @change="selectTests"><el-option v-for="test in testOptions" :key="test.id" :label="test.name" :value="test.id" /></el-select></label></section> |
| | | <section class="report-name-panel"><label><span>报表名称</span><el-input v-model="reportName" :disabled="readonly" clearable maxlength="100" placeholder="请输入报表名称" @input="dirty=true" /></label><label><span>选择测试</span><el-select v-model="selectedTestIds" multiple filterable collapse-tags collapse-tags-tooltip :disabled="!!editingId || !!saved || busy" placeholder="选择已完成的化成测试" @change="selectTests"><el-option v-for="test in testOptions" :key="test.id" :label="test.name" :value="test.id" /></el-select></label></section> |
| | | <el-empty v-if="!matrix.items.length && !previewLoading" :description="editingId ? '该版本暂无来源数据' : '请选择化成测试'" /> |
| | | <div v-loading="previewLoading" v-if="matrix.items.length"> |
| | | <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,matrix.items.length*150+160)}px`}"><thead><tr><th>测试 / 电池组</th><th v-for="column in reportColumns" :key="column.id" :colspan="column.items.length">{{ column.name }} / {{ column.items[0]?.source.batteryGroup }}</th></tr><tr><th>电池号</th><th v-for="item in matrix.items" :key="formationSeriesKey(item)">{{ item.source.batteryNo }}</th></tr></thead><tbody> |
| | | <div class="report-data-table-wrap"><table class="report-data-table" :style="{minWidth:`${Math.max(980,matrix.items.length*150+160)}px`}"><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 matrix.items" :key="formationSeriesKey(item)">{{ item.source.batteryGroup }} / {{ item.source.batteryNo }}</th></tr></thead><tbody> |
| | | <tr v-for="field in detailFields" :key="field.key"><td>{{ field.displayName }}</td><td v-for="item in matrix.items" :key="formationSeriesKey(item)"><span v-if="field.kind==='calculated'" class="auto-value" :title="calculation(item,0,field).reason">{{ displayValue(item,0,field) }}</span><el-input v-else :model-value="inputValue(item,0,field)" :disabled="readonly" size="small" maxlength="500" placeholder="请输入" @update:model-value="updateValue(item,0,field,$event)" /></td></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')">电池组字段模板</button><button class="secondary-button" type="button" @click="openFieldTemplate('special')">选择特殊字段模板</button><button class="secondary-button" type="button" @click="openFieldTemplate('special')">另存特殊字段模板</button></div> |
| | |
| | | import { test } from 'node:test' |
| | | import load from './load-typescript.cjs' |
| | | const { formationCalculatedValue } = load(new URL('../src/utils/formationReportCalculations.ts', import.meta.url)) |
| | | const { formationStepChartOption, formationStepSeries } = load(new URL('../src/utils/formationStepChart.ts', import.meta.url)) |
| | | const { formationStepChartOption, formationStepSeries, formationHiddenSeries } = load(new URL('../src/utils/formationStepChart.ts', import.meta.url)) |
| | | const { formationSavedReportHtml } = load(new URL('../src/utils/formationSavedReportPrint.ts', import.meta.url), { |
| | | '@/api/formationReports': {getFormationPrintVersion:()=>{throw new Error('打印内容不应重新请求来源')}} |
| | | }) |
| | | const source = { selection: { testId: 1, testBatteryId: 11 }, name: '化成甲', batteryNo: '电池1', batteryGroup: '组1' } |
| | | const item = { source, cycleNos: [1], metrics: {}, businessFields: Object.entries({H:'2',Z:'3',A:'4',F:'5',B:'6'}).map(([fieldName,fieldValue])=>({fieldName,fieldValue})), |
| | | chargeSteps: {1:[{stepNo:1,direction:'CHARGE',endVoltage:4.1},{stepNo:2,direction:'UNKNOWN'}, {stepNo:3,direction:'CHARGE',endVoltage:4.2},{stepNo:4,direction:'DISCHARGE',capacity:0.25}]}, |
| | |
| | | assert.deepEqual(formationStepChartOption({key:'current',unit:'mA'},[item],1,'discharge',settings).series[0].data,[[0,-100]]) |
| | | assert.deepEqual(formationStepChartOption({key:'temperature',unit:'℃'},[item],1,'charge',settings).series[0].data.filter(point=>point[1]!==null),[[0,25],[1,26],[4,27]]) |
| | | }) |
| | | test('当前循环的图例操作不清除其他循环独有来源的隐藏设置',()=>{ |
| | | assert.deepEqual(formationHiddenSeries(['2|22'],{'1|11':false}),['2|22','1|11']) |
| | | assert.deepEqual(formationHiddenSeries(['2|22','1|11'],{'1|11':true}),['2|22']) |
| | | }) |
| | | test('保存版打印完整输出跨组电池和循环,使用冻结结果且转义人工文本',()=>{ |
| | | const items=Array.from({length:5},(_,index)=>({...item,source:{...source,selection:{testId:index+1,testBatteryId:11},name:`测试${index+1}`}})) |
| | | const settings={fields:[{key:'F',scope:'detail',kind:'battery',displayName:'F',unit:''},{key:'C',scope:'detail',kind:'calculated',displayName:'C',unit:''},{key:'method',scope:'cycle',kind:'manual',displayName:'测试方法',unit:''},{key:'capacity1v',scope:'cycle',kind:'calculated',displayName:'1.0V容量',unit:'mAh'}],values:{'1|11|0|F':'<自定义>','1|11|1|method':'<script>不能执行</script>'},metrics:['voltage'],chargeStepSelections:{}} |
| | | const html=formationSavedReportHtml({id:1,name:'已保存化成',items,cycleNos:[1],settings,calculatedValues:{'1|11|0|C':777,'1|11|1|capacity1v':222},versionNo:2,createdAt:0,versionCreatedAt:0,creator:'甲',operatorName:'甲',changeSummary:'测试',description:''}) |
| | | assert.ok(html.includes('测试5')) |
| | | assert.ok(html.includes('777')) |
| | | assert.ok(html.includes('222')) |
| | | assert.ok(html.includes('<自定义>')) |
| | | assert.ok(html.includes('<script>不能执行</script>')) |
| | | assert.ok(!html.includes('<script>不能执行')) |
| | | assert.ok(html.includes('data-group="charge"')&&html.includes('data-group="discharge"')) |
| | | assert.equal((html.match(/<td>终止电压<\/td>/g)??[]).length,4) |
| | | }) |
| | |
| | | const path = require('node:path'); |
| | | const ts = require('typescript'); |
| | | // 无服务的公共绘图函数测试;加载器只负责转译本地 TypeScript 模块。 |
| | | module.exports = function load(file) { |
| | | module.exports = function load(file, external = {}) { |
| | | if (file instanceof URL) file = require('node:url').fileURLToPath(file); |
| | | const source = fs.readFileSync(file, 'utf8'); |
| | | const script = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText; |
| | | const exports = {}; |
| | | new Function('exports', 'require', script)(exports, id => id.startsWith('.') ? load(path.resolve(path.dirname(file), id.endsWith('.ts') ? id : id + '.ts')) : require(id)); |
| | | new Function('exports', 'require', script)(exports, id => Object.hasOwn(external, id) ? external[id] : id.startsWith('.') ? load(path.resolve(path.dirname(file), id.endsWith('.ts') ? id : id + '.ts'), external) : require(id)); |
| | | return exports; |
| | | }; |