refactor(steady): 重构稳态校验功能的合同验证逻辑

- 优化测量点对话框路径解析格式化
- 添加稳态校验重启API端点验证功能
- 更新创建参数类型定义支持可选lineId和lineIds数组
- 修复任务表格组件模板缩进格式问题
- 扩展任务表格列配置验证数组格式
- 添加任务表格仅对失败行显示重启操作功能
- 集成共享台账树组件发送选中叶节点监测点功能
- 添加多监测点创建参数构建支持
- 实现指标选择为空时全指标校验功能
- 添加预期项目数量计算功能
- 优化创建对话框指标选择标签防止输入框重置
- 添加任务表格树形选择滚动下拉菜单样式
- 实现页面创建流程调用创建API并轮询任务状态
- 添加创建结果面板加载状态和进度显示
- 实现页面重启流程确认调用重启API功能
- 优化汇总表格异常字段持久化支持
- 添加汇总表格监测点名称显示功能
- 优化详情面板按需加载项目详情
- 更新接口类型支持分页字段定义
- 优化ICD路径检查对话框标准映射参数传递
- 添加ICD记录导出SQL和JSON功能
- 扩展ICD路径API端点和类型定义
- 添加ICD路径参考选项和映射详情功能
- 重构ICD表格列显示移除激活和创建时间列
- 添加ICD映射详情对话框三个标签页功能
- 优化ICD映射详情JSON树形视图显示
- 更新ICD类型选项覆盖手动和上游标准状态
This commit is contained in:
2026-06-18 16:35:06 +08:00
parent e7519e5524
commit 92b7d3cd73
38 changed files with 3486 additions and 758 deletions

View File

@@ -35,6 +35,16 @@ export const deleteSteadyChecksquareTasks = (taskIds: SteadyDataView.SteadyCheck
})
}
export const restartSteadyChecksquareTask = (taskId: string) => {
return http.post<SteadyDataView.SteadyChecksquareTask>(
`/steady/checksquare/restart?taskId=${encodeURIComponent(taskId)}`,
{},
{
loading: false
}
)
}
export const getSteadyChecksquareDetail = (taskId: string) => {
return http.get<SteadyDataView.SteadyChecksquareQueryResult>('/steady/checksquare/detail', { taskId }, { loading: false })
}

View File

@@ -97,7 +97,8 @@ export namespace SteadyDataView {
}
export interface SteadyChecksquareCreateParams {
lineId: string
lineId?: string
lineIds?: string[]
indicatorCodes: string[]
timeStart: string
timeEnd: string
@@ -149,6 +150,8 @@ export namespace SteadyDataView {
export interface SteadyChecksquareItem {
itemId?: string
itemKey: string
lineId?: string
lineName?: string
indicatorCode: string
indicatorName?: string
harmonicOrder?: number | null

View File

@@ -45,6 +45,10 @@ export const listIcdPathsApi = (params: MmsMapping.IcdPathListRequest) => {
return http.post<MmsMapping.IcdPathRecord[]>('/api/mms-mapping/icd-paths/list', params)
}
export const listIcdPathReferencesApi = () => {
return http.post<MmsMapping.IcdPathReferenceOption[]>('/api/mms-mapping/icd-paths/reference-list')
}
export const createIcdPathApi = (params: MmsMapping.CreateIcdPathRequest) => {
return http.post<boolean>('/api/mms-mapping/icd-paths/add', params)
}
@@ -73,6 +77,14 @@ export const saveIcdPathCheckResultApi = (id: string, params: MmsMapping.SaveIcd
return http.post<boolean>(`/api/mms-mapping/icd-paths/${id}/icd-check-result`, params)
}
export const getIcdPathCheckMsgApi = (id: string) => {
return http.post<MmsMapping.IcdPathCheckMsgResponse>(`/api/mms-mapping/icd-paths/${id}/icd-check-msg`)
}
export const getIcdPathMappingDetailApi = (id: string) => {
return http.post<MmsMapping.IcdPathMappingDetailResponse>(`/api/mms-mapping/icd-paths/${id}/mapping-detail`)
}
export const checkIcdJsonConsistencyApi = (params: MmsMapping.IcdJsonConsistencyCheckRequest) => {
return http.post<MmsMapping.IcdJsonConsistencyCheckResponse>('/api/mms-mapping/check-icd-json-consistency', params)
}

View File

@@ -150,7 +150,7 @@ export namespace MmsMapping {
export interface IcdCheckMsg {
summary?: string
details?: string[]
details?: unknown[]
issuesJson?: string
correctedJson?: string
[key: string]: unknown
@@ -183,7 +183,6 @@ export namespace MmsMapping {
export interface IcdPathRecord {
id?: string
name?: string
path?: string
angle?: number
usePhaseIndex?: number
state?: number
@@ -199,6 +198,19 @@ export namespace MmsMapping {
updateTime?: string
}
export interface IcdPathReferenceOption {
id?: string
name?: string
}
export interface IcdPathMappingDetailResponse {
id?: string
name?: string
jsonStr?: string
xmlStr?: string
icdText?: string
}
export interface IcdPathListRequest {
keyword?: string
type?: number
@@ -207,10 +219,10 @@ export namespace MmsMapping {
export interface CreateIcdPathRequest {
name: string
path: string
angle?: number
usePhaseIndex?: number
type?: number
referenceIcdId?: string
}
export interface CreateIcdPathWithFileRequest {
@@ -236,6 +248,8 @@ export namespace MmsMapping {
msg?: IcdCheckMsg
}
export type IcdPathCheckMsgResponse = IcdCheckMsg | null
export interface IcdJsonConsistencyCheckRequest {
checkedJson: string
standardJson: string

View File

@@ -0,0 +1,13 @@
import http from '@/api'
import type { ParsePqdif } from './interface'
export const parsePqdifApi = (pqdifFile: File) => {
const formData = new FormData()
// 关键业务节点:后端 @RequestPart 固定读取 pqdifFile字段名不能与页面变量名脱钩。
formData.append('pqdifFile', pqdifFile)
return http.post<ParsePqdif.ParseResponse>('/api/parse-pqdif/parse', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}

View File

@@ -0,0 +1,56 @@
export namespace ParsePqdif {
export type ParseStatus = 'SUCCESS' | 'FAILED' | (string & {})
export type SeriesDataStatus = 'DATA_SUCCESS' | 'DATA_FAILED' | (string & {})
export interface RecordItem {
recordIndex?: number
typeGuid?: string
typeName?: string
observation?: boolean
}
export interface SeriesItem {
seriesIndex?: number
quantityUnitsId?: number
quantityCharacteristicGuid?: string
valueTypeGuid?: string
seriesBaseType?: number
scale?: number
offset?: number
dataStatus?: SeriesDataStatus
dataMessage?: string | null
valueCount?: number
firstValues?: number[]
}
export interface ChannelItem {
channelIndex?: number
name?: string
seriesCount?: number
phaseId?: number
quantityTypeGuid?: string
quantityMeasuredId?: number
series?: SeriesItem[]
}
export interface ObservationItem {
recordIndex?: number
name?: string
timeStartExcelDays?: number
timeStartText?: string
channelCount?: number
channels?: ChannelItem[]
}
export interface ParseResponse {
status?: ParseStatus
message?: string
fileName?: string | null
nativeVersion?: string | null
recordCount?: number
observationCount?: number
sampleValueCount?: number
records?: RecordItem[]
observations?: ObservationItem[]
}
}

View File

@@ -43,6 +43,7 @@ const STATIC_ROUTE_NAMES = new Set([
'tools',
'toolWaveform',
'toolMmsMapping',
'toolParsePqdif',
'deviceTypes',
'toolAddData',
'toolAddLedger',

View File

@@ -63,6 +63,15 @@ export const staticRouter: RouteRecordRaw[] = [
title: '模型映射管理'
}
},
{
path: '/tools/parsePqdif',
name: 'toolParsePqdif',
component: () => import('@/views/tools/parsePqdif/index.vue'),
meta: {
cacheName: 'ParsePqdifView',
title: 'PQDIF解析'
}
},
{
path: '/tools/deviceTypes',
name: 'deviceTypes',

View File

@@ -1,417 +0,0 @@
# check-square API 调试文档
## 1. 模块说明
- 模块路径:`steady/check-square`
- 接口基础路径:`/steady/checksquare`
- 返回包装:接口统一返回 `HttpResult<T>`,调试时重点查看响应体中的业务数据字段 `data`
- 时间格式:`yyyy-MM-dd HH:mm:ss`
本模块用于按监测点、时间范围和指标执行稳态数据校验,并提供任务列表、任务详情、检测项明细查询和任务删除能力。
## 2. 通用约定
### 2.1 请求头
| 名称 | 示例 | 说明 |
| --- | --- | --- |
| `Content-Type` | `application/json` | `POST` 请求使用 JSON 请求体 |
| `Authorization` | 登录态 Token | 如当前环境开启认证,需携带现有登录接口返回的认证信息 |
### 2.2 任务状态
| 值 | 说明 |
| --- | --- |
| `RUNNING` | 执行中 |
| `SUCCESS` | 执行成功 |
| `FAIL` | 执行失败 |
### 2.3 明细类型
| 值 | 说明 |
| --- | --- |
| `SEGMENT` | 缺失区间 |
| `VALUE_ORDER` | 指标值大小关系异常明细 |
| `HARMONIC_PARITY` | 谐波奇偶关系异常明细 |
## 3. 调试顺序建议
1. 调用 `POST /steady/checksquare/create`:按监测点和时间范围创建或获取任务。
2. 读取 `/create` 返回的 `data.taskId`:该返回值是任务列表中的行信息。
3. 调用 `GET /steady/checksquare/detail`:用 `taskId` 查询任务下的检测项。
4. 读取 `/detail` 返回的 `items[].itemId`:按检测项继续查询缺失区间或异常明细。
5. 调用 `GET /steady/checksquare/item-detail`:按 `itemId + detailType` 查询具体明细。
6. 调用 `POST /steady/checksquare/query`:按条件分页查询历史任务列表。
7. 需要清理任务时调用 `POST /steady/checksquare/delete`
注意:旧的获取或创建兼容接口已移除。创建或复用任务的逻辑已经合并到 `/create` 中。
## 4. 创建或获取任务
### 4.1 接口
`POST /steady/checksquare/create`
### 4.2 行为说明
接口开始执行前,会先按 `lineId + timeStart + timeEnd` 查询是否存在未删除的任务:
- 已存在:直接返回该任务的任务列表行信息。
- 不存在:创建任务,执行校验,任务执行完成后返回任务列表行信息。
返回对象为 `SteadyChecksquareTaskVO`,用于页面任务列表展示。若要查看检测项明细,需要再调用 `GET /steady/checksquare/detail`
### 4.3 请求字段
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `lineId` | `String` | 是 | 监测点 ID |
| `indicatorCodes` | `Array<String>` | 是 | 指标编码列表 |
| `timeStart` | `String` | 是 | 开始时间,格式 `yyyy-MM-dd HH:mm:ss` |
| `timeEnd` | `String` | 是 | 结束时间,格式 `yyyy-MM-dd HH:mm:ss` |
`indicatorCodes` 是数组;不要传成单个字符串。
### 4.4 请求示例
```http
POST /steady/checksquare/create
Content-Type: application/json
```
```json
{
"lineId": "LINE_001",
"indicatorCodes": ["VOLTAGE_A", "CURRENT_A"],
"timeStart": "2026-06-13 00:00:00",
"timeEnd": "2026-06-13 01:00:00"
}
```
### 4.5 响应字段 `data`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `taskId` | `String` | 任务 ID |
| `taskNo` | `String` | 任务编号 |
| `lineId` | `String` | 监测点 ID |
| `lineName` | `String` | 监测点名称 |
| `timeStart` | `String` | 开始时间 |
| `timeEnd` | `String` | 结束时间 |
| `intervalMinutes` | `Integer` | 统计间隔,单位分钟 |
| `taskStatus` | `String` | 任务状态:`RUNNING``SUCCESS``FAIL` |
| `itemCount` | `Integer` | 检测项数量 |
| `abnormalItemCount` | `Integer` | 异常检测项数量 |
| `minDataIntegrity` | `BigDecimal` | 最低数据完整率 |
| `createTime` | `String` | 创建时间 |
### 4.6 响应示例
```json
{
"code": 200,
"msg": "success",
"data": {
"taskId": "1812345678901234567",
"taskNo": "CS202606130001",
"lineId": "LINE_001",
"lineName": "1号监测点",
"timeStart": "2026-06-13 00:00:00",
"timeEnd": "2026-06-13 01:00:00",
"intervalMinutes": 1,
"taskStatus": "SUCCESS",
"itemCount": 2,
"abnormalItemCount": 1,
"minDataIntegrity": 98.50,
"createTime": "2026-06-13 09:30:00"
}
}
```
## 5. 查询任务列表
### 5.1 接口
`POST /steady/checksquare/query`
### 5.2 请求字段
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `lineId` | `String` | 否 | 监测点 ID |
| `indicatorCode` | `String` | 否 | 指标编码 |
| `timeStart` | `String` | 否 | 检测开始时间,格式 `yyyy-MM-dd HH:mm:ss` |
| `timeEnd` | `String` | 否 | 检测结束时间,格式 `yyyy-MM-dd HH:mm:ss` |
| `hasAbnormal` | `Boolean` | 否 | 是否存在异常 |
| `pageNum` | `Integer` | 否 | 页码 |
| `pageSize` | `Integer` | 否 | 每页条数 |
`indicatorCode` 是单个字符串,用于历史任务筛选;和 `/create``indicatorCodes` 数组不同。
### 5.3 请求示例
```http
POST /steady/checksquare/query
Content-Type: application/json
```
```json
{
"lineId": "LINE_001",
"indicatorCode": "VOLTAGE_A",
"timeStart": "2026-06-13 00:00:00",
"timeEnd": "2026-06-13 23:59:59",
"hasAbnormal": true,
"pageNum": 1,
"pageSize": 10
}
```
### 5.4 响应说明
`data` 为 MyBatis-Plus `Page<SteadyChecksquareTaskVO>` 分页对象,常用字段如下:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `records` | `Array<Object>` | 任务列表,每项字段同 `/create` 返回的 `SteadyChecksquareTaskVO` |
| `total` | `Long` | 总记录数 |
| `size` | `Long` | 每页条数 |
| `current` | `Long` | 当前页码 |
| `pages` | `Long` | 总页数 |
### 5.5 响应示例
```json
{
"code": 200,
"msg": "success",
"data": {
"records": [
{
"taskId": "1812345678901234567",
"taskNo": "CS202606130001",
"lineId": "LINE_001",
"lineName": "1号监测点",
"timeStart": "2026-06-13 00:00:00",
"timeEnd": "2026-06-13 01:00:00",
"intervalMinutes": 1,
"taskStatus": "SUCCESS",
"itemCount": 2,
"abnormalItemCount": 1,
"minDataIntegrity": 98.50,
"createTime": "2026-06-13 09:30:00"
}
],
"total": 1,
"size": 10,
"current": 1,
"pages": 1
}
}
```
## 6. 查询任务详情
### 6.1 接口
`GET /steady/checksquare/detail?taskId={taskId}`
### 6.2 请求参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `taskId` | `String` | 是 | 任务 ID |
### 6.3 请求示例
```http
GET /steady/checksquare/detail?taskId=1812345678901234567
```
### 6.4 响应字段 `data`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `taskId` | `String` | 任务 ID |
| `taskNo` | `String` | 任务编号 |
| `lineId` | `String` | 监测点 ID |
| `lineName` | `String` | 监测点名称 |
| `timeStart` | `String` | 开始时间 |
| `timeEnd` | `String` | 结束时间 |
| `intervalMinutes` | `Integer` | 统计间隔,单位分钟 |
| `items` | `Array<Object>` | 检测项列表 |
### 6.5 检测项字段 `items[]`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `itemId` | `String` | 检测项 ID用于查询明细 |
| `itemKey` | `String` | 检测项唯一键 |
| `indicatorCode` | `String` | 指标编码 |
| `indicatorName` | `String` | 指标名称 |
| `harmonicOrder` | `Integer` | 谐波次数 |
| `intervalMinutes` | `Integer` | 当前检测项统计间隔,单位分钟 |
| `hasData` | `Boolean` | 时间范围内是否存在任意数据 |
| `expectedPointCount` | `Integer` | 期望点数 |
| `actualPointCount` | `Integer` | 实际点数 |
| `missingPointCount` | `Integer` | 缺失点数 |
| `dataIntegrity` | `BigDecimal` | 数据完整率 |
| `dataIntegrityText` | `String` | 数据完整率文本 |
| `abnormal` | `Boolean` | 指标值大小关系是否异常 |
| `abnormalPointCount` | `Integer` | 指标值大小关系异常点数 |
| `abnormalDetails` | `Array<Object>` | 指标值大小关系异常明细摘要 |
| `harmonicParityAbnormal` | `Boolean` | 谐波奇偶关系是否异常 |
| `harmonicParityAbnormalPointCount` | `Integer` | 谐波奇偶关系异常点数 |
| `harmonicParityAbnormalDetails` | `Array<Object>` | 谐波奇偶关系异常明细摘要 |
| `statSummaries` | `Array<Object>` | 统计类型摘要 |
| `statDetails` | `Array<Object>` | 统计类型明细 |
### 6.6 响应示例
```json
{
"code": 200,
"msg": "success",
"data": {
"taskId": "1812345678901234567",
"taskNo": "CS202606130001",
"lineId": "LINE_001",
"lineName": "1号监测点",
"timeStart": "2026-06-13 00:00:00",
"timeEnd": "2026-06-13 01:00:00",
"intervalMinutes": 1,
"items": [
{
"itemId": "1812345678901234568",
"itemKey": "LINE_001:VOLTAGE_A",
"indicatorCode": "VOLTAGE_A",
"indicatorName": "A相电压",
"harmonicOrder": null,
"intervalMinutes": 1,
"hasData": true,
"expectedPointCount": 60,
"actualPointCount": 59,
"missingPointCount": 1,
"dataIntegrity": 98.33,
"dataIntegrityText": "98.33%",
"abnormal": true,
"abnormalPointCount": 1,
"abnormalDetails": [],
"harmonicParityAbnormal": false,
"harmonicParityAbnormalPointCount": 0,
"harmonicParityAbnormalDetails": [],
"statSummaries": [],
"statDetails": []
}
]
}
}
```
## 7. 查询检测项明细
### 7.1 接口
`GET /steady/checksquare/item-detail`
### 7.2 请求参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `itemId` | `String` | 是 | 检测项 ID |
| `detailType` | `String` | 是 | 明细类型:`SEGMENT``VALUE_ORDER``HARMONIC_PARITY` |
| `statType` | `String` | 否 | 统计类型;查询统计明细时使用 |
| `pageNum` | `Integer` | 否 | 页码 |
| `pageSize` | `Integer` | 否 | 每页条数 |
### 7.3 请求示例
```http
GET /steady/checksquare/item-detail?itemId=1812345678901234568&detailType=SEGMENT&pageNum=1&pageSize=10
```
### 7.4 响应字段 `data`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `itemId` | `String` | 检测项 ID |
| `detailType` | `String` | 明细类型 |
| `statType` | `String` | 统计类型 |
| `pageNum` | `Integer` | 当前页码;未分页查询时为空 |
| `pageSize` | `Integer` | 每页条数;未分页查询时为空 |
| `total` | `Long` | 总记录数;未分页查询时为空 |
| `segments` | `Array<Object>` | 缺失区间,`detailType=SEGMENT` 时查看 |
| `valueOrderDetails` | `Array<Object>` | 指标值大小关系异常明细,`detailType=VALUE_ORDER` 时查看 |
| `harmonicParityDetails` | `Array<Object>` | 谐波奇偶关系异常明细,`detailType=HARMONIC_PARITY` 时查看 |
### 7.5 响应示例
```json
{
"code": 200,
"msg": "success",
"data": {
"itemId": "1812345678901234568",
"detailType": "SEGMENT",
"statType": null,
"pageNum": 1,
"pageSize": 10,
"total": 1,
"segments": [
{
"segmentStart": "2026-06-13 00:10:00",
"segmentEnd": "2026-06-13 00:10:00",
"pointCount": 1
}
],
"valueOrderDetails": [],
"harmonicParityDetails": []
}
}
```
## 8. 删除任务
### 8.1 接口
`POST /steady/checksquare/delete`
### 8.2 请求字段
请求体直接传任务 ID 数组。
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `taskIds` | `Array<String>` | 是 | 任务 ID 数组 |
### 8.3 请求示例
```http
POST /steady/checksquare/delete
Content-Type: application/json
```
```json
[
"1812345678901234567"
]
```
### 8.4 响应示例
```json
{
"code": 200,
"msg": "success",
"data": true
}
```
## 9. 常见调试注意事项
- `/create` 返回的是任务列表行信息,不是详情页完整数据。
- `/create` 如果命中已存在任务,会直接返回该任务;不会生成重复任务。
- `/detail` 需要使用 `/create``/query` 返回的 `taskId`
- `/item-detail` 需要使用 `/detail` 返回的 `items[].itemId`
- `/query` 使用 `indicatorCode` 单值筛选;`/create` 使用 `indicatorCodes` 数组创建检测项。
- 当前文档只覆盖现有有效接口,不包含旧的任务获取或创建兼容接口。

View File

@@ -0,0 +1,621 @@
# check-square API 调试文档
## 1. 模块说明
- 模块路径:`steady/check-square`
- 接口基础路径:`/steady/checksquare`
- 返回包装:接口统一返回 `HttpResult<T>`,调试时重点查看响应体中的业务数据字段 `data`
- 时间格式:`yyyy-MM-dd HH:mm:ss`
本模块用于按监测点、时间范围和指标执行稳态数据校验,并提供任务列表、任务详情、检测项明细查询、失败任务重启和任务删除能力。当前标准支持单监测点和多监测点任务:单监测点可继续传 `lineId`,多监测点推荐传 `lineIds`
## 2. 通用约定
### 2.1 请求头
| 名称 | 示例 | 说明 |
| --- | --- | --- |
| `Content-Type` | `application/json` | `POST` 请求使用 JSON 请求体 |
| `Authorization` | 登录态 Token | 如当前环境开启认证,需携带现有登录接口返回的认证信息 |
### 2.2 任务状态
| 值 | 说明 |
| --- | --- |
| `RUNNING` | 执行中 |
| `SUCCESS` | 执行成功 |
| `FAIL` | 执行失败 |
### 2.3 明细类型
| 值 | 说明 |
| --- | --- |
| `SEGMENT` | 连续性区间,包含正常区间和缺失区间 |
| `VALUE_ORDER` | 指标值大小关系异常明细 |
| `HARMONIC_PARITY` | 谐波奇偶关系异常明细 |
### 2.4 统计类型
| 值 | 说明 |
| --- | --- |
| `AVG` | 平均值 |
| `MAX` | 最大值 |
| `MIN` | 最小值 |
| `CP95` | CP95 值 |
## 3. 调试顺序建议
1. 调用 `POST /steady/checksquare/create`:按监测点列表、时间范围和指标列表创建或复用任务。
2. 读取 `/create` 返回的 `data.taskId`:该返回值是任务列表中的行信息。
3. 如果 `taskStatus=RUNNING`,轮询 `POST /steady/checksquare/query`,或稍后调用 `GET /steady/checksquare/detail` 查看任务详情。
4. 调用 `GET /steady/checksquare/detail`:用 `taskId` 查询任务下的检测项。
5. 读取 `/detail` 返回的 `items[].itemId`:按检测项继续查询连续性区间或异常明细。
6. 调用 `GET /steady/checksquare/item-detail`:按 `itemId + detailType` 查询具体明细。
7. 任务失败后如需重新执行,调用 `POST /steady/checksquare/restart`
8. 需要清理任务时调用 `POST /steady/checksquare/delete`
注意:旧的获取或创建兼容接口已移除。创建或复用任务的逻辑已经合并到 `/create` 中。
## 4. 创建或复用任务
### 4.1 接口
`POST /steady/checksquare/create`
### 4.2 行为说明
接口开始执行前,会先按 `lineIds + timeStart + timeEnd` 查询是否存在未删除任务:
- 已存在:直接返回该任务的任务列表行信息。
- 不存在:创建 `RUNNING` 任务并立即返回任务列表行信息,后台继续执行校验;任务完成后状态更新为 `SUCCESS`,失败时更新为 `FAIL`
`lineIds` 会去重并清理空字符串。若未传 `lineIds`,后端会兼容读取 `lineId` 并转换为单元素监测点列表。返回对象为 `SteadyChecksquareTaskVO`
### 4.3 请求字段
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `lineId` | `String` | 条件必填 | 单监测点 ID`lineIds` 为空时使用 |
| `lineIds` | `Array<String>` | 条件必填 | 监测点 ID 列表;多监测点推荐使用该字段 |
| `indicatorCodes` | `Array<String>` | 是 | 指标编码列表 |
| `timeStart` | `String` | 是 | 开始时间,格式 `yyyy-MM-dd HH:mm:ss` |
| `timeEnd` | `String` | 是 | 结束时间,格式 `yyyy-MM-dd HH:mm:ss` |
`lineId``lineIds` 至少传一个;`indicatorCodes` 是数组,不要传成单个字符串。
### 4.4 请求示例
单监测点兼容写法:
```http
POST /steady/checksquare/create
Content-Type: application/json
```
```json
{
"lineId": "LINE_001",
"indicatorCodes": ["VOLTAGE_A", "CURRENT_A"],
"timeStart": "2026-06-18 00:00:00",
"timeEnd": "2026-06-18 01:00:00"
}
```
多监测点标准写法:
```json
{
"lineIds": ["LINE_001", "LINE_002"],
"indicatorCodes": ["VOLTAGE_A", "CURRENT_A"],
"timeStart": "2026-06-18 00:00:00",
"timeEnd": "2026-06-18 01:00:00"
}
```
### 4.5 响应字段 `data`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `taskId` | `String` | 任务 ID |
| `taskNo` | `String` | 任务编号 |
| `lineId` | `String` | 任务首个监测点 ID兼容旧页面字段 |
| `lineIds` | `Array<String>` | 本次任务的监测点 ID 列表 |
| `lineName` | `String` | 监测点名称;多监测点时为多个名称拼接结果 |
| `timeStart` | `String` | 开始时间 |
| `timeEnd` | `String` | 结束时间 |
| `intervalMinutes` | `Integer` | 统计间隔,单位分钟 |
| `taskStatus` | `String` | 任务状态:`RUNNING``SUCCESS``FAIL` |
| `itemCount` | `Integer` | 检测项数量 |
| `abnormalItemCount` | `Integer` | 异常检测项数量 |
| `minDataIntegrity` | `BigDecimal` | 最低数据完整性0 到 1 的小数 |
| `createTime` | `String` | 创建时间 |
### 4.6 响应示例
```json
{
"code": 200,
"msg": "success",
"data": {
"taskId": "1812345678901234567",
"taskNo": "CS202606180001",
"lineId": "LINE_001",
"lineIds": ["LINE_001", "LINE_002"],
"lineName": "1号监测点,2号监测点",
"timeStart": "2026-06-18 00:00:00",
"timeEnd": "2026-06-18 01:00:00",
"intervalMinutes": 1,
"taskStatus": "RUNNING",
"itemCount": 0,
"abnormalItemCount": 0,
"minDataIntegrity": 0.000000,
"createTime": "2026-06-18 09:30:00"
}
}
```
## 5. 查询任务列表
### 5.1 接口
`POST /steady/checksquare/query`
### 5.2 请求字段
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `lineId` | `String` | 否 | 监测点 ID后端按任务的 `lineIds` 检索文本匹配 |
| `indicatorCode` | `String` | 否 | 指标编码;后端按任务的指标编码检索文本匹配 |
| `timeStart` | `String` | 否 | 检测开始时间,格式 `yyyy-MM-dd HH:mm:ss` |
| `timeEnd` | `String` | 否 | 检测结束时间,格式 `yyyy-MM-dd HH:mm:ss` |
| `hasAbnormal` | `Boolean` | 否 | 是否存在异常;传 `true` 时仅查询异常检测项数量大于 0 的任务 |
| `pageNum` | `Integer` | 否 | 页码 |
| `pageSize` | `Integer` | 否 | 每页条数 |
`indicatorCode` 是单个字符串,用于历史任务筛选;和 `/create``indicatorCodes` 数组不同。
### 5.3 请求示例
```http
POST /steady/checksquare/query
Content-Type: application/json
```
```json
{
"lineId": "LINE_001",
"indicatorCode": "VOLTAGE_A",
"timeStart": "2026-06-18 00:00:00",
"timeEnd": "2026-06-18 23:59:59",
"hasAbnormal": true,
"pageNum": 1,
"pageSize": 10
}
```
### 5.4 响应说明
`data` 为 MyBatis-Plus `Page<SteadyChecksquareTaskVO>` 分页对象,常用字段如下:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `records` | `Array<Object>` | 任务列表,每项字段同 `/create` 返回的 `SteadyChecksquareTaskVO` |
| `total` | `Long` | 总记录数 |
| `size` | `Long` | 每页条数 |
| `current` | `Long` | 当前页码 |
| `pages` | `Long` | 总页数 |
### 5.5 响应示例
```json
{
"code": 200,
"msg": "success",
"data": {
"records": [
{
"taskId": "1812345678901234567",
"taskNo": "CS202606180001",
"lineId": "LINE_001",
"lineIds": ["LINE_001", "LINE_002"],
"lineName": "1号监测点,2号监测点",
"timeStart": "2026-06-18 00:00:00",
"timeEnd": "2026-06-18 01:00:00",
"intervalMinutes": 1,
"taskStatus": "SUCCESS",
"itemCount": 4,
"abnormalItemCount": 1,
"minDataIntegrity": 0.985000,
"createTime": "2026-06-18 09:30:00"
}
],
"total": 1,
"size": 10,
"current": 1,
"pages": 1
}
}
```
## 6. 查询任务详情
### 6.1 接口
`GET /steady/checksquare/detail?taskId={taskId}`
### 6.2 请求参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `taskId` | `String` | 是 | 任务 ID |
### 6.3 请求示例
```http
GET /steady/checksquare/detail?taskId=1812345678901234567
```
### 6.4 响应字段 `data`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `taskId` | `String` | 任务 ID |
| `taskNo` | `String` | 任务编号 |
| `lineId` | `String` | 任务首个监测点 ID兼容旧页面字段 |
| `lineIds` | `Array<String>` | 本次任务的监测点 ID 列表 |
| `lineName` | `String` | 监测点名称 |
| `timeStart` | `String` | 开始时间 |
| `timeEnd` | `String` | 结束时间 |
| `intervalMinutes` | `Integer` | 统计间隔,单位分钟 |
| `items` | `Array<Object>` | 检测项列表 |
### 6.5 检测项字段 `items[]`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `itemId` | `String` | 检测项 ID用于查询明细 |
| `itemKey` | `String` | 检测项唯一键 |
| `lineId` | `String` | 当前检测项所属监测点 ID |
| `lineName` | `String` | 当前检测项所属监测点名称 |
| `indicatorCode` | `String` | 指标编码 |
| `indicatorName` | `String` | 指标名称 |
| `harmonicOrder` | `Integer` | 谐波次数;非谐波或聚合项可为空 |
| `intervalMinutes` | `Integer` | 当前检测项统计间隔,单位分钟 |
| `hasData` | `Boolean` | 时间范围内是否存在任意数据 |
| `expectedPointCount` | `Integer` | 期望点数 |
| `actualPointCount` | `Integer` | 实际点数 |
| `missingPointCount` | `Integer` | 缺失点数 |
| `dataIntegrity` | `BigDecimal` | 数据完整性0 到 1 的小数 |
| `dataIntegrityText` | `String` | 数据完整性文本 |
| `abnormal` | `Boolean` | 指标值大小关系是否异常 |
| `abnormalPointCount` | `Integer` | 指标值大小关系异常点数 |
| `abnormalDetails` | `Array<Object>` | 指标值大小关系异常明细摘要 |
| `harmonicParityAbnormal` | `Boolean` | 谐波奇偶关系是否异常 |
| `harmonicParityAbnormalPointCount` | `Integer` | 谐波奇偶关系异常点数 |
| `harmonicParityAbnormalDetails` | `Array<Object>` | 谐波奇偶关系异常明细摘要 |
| `statSummaries` | `Array<Object>` | 统计类型摘要 |
| `statDetails` | `Array<Object>` | 统计类型明细 |
### 6.6 统计摘要字段 `statSummaries[]`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `statType` | `String` | 统计类型:`AVG``MAX``MIN``CP95` |
| `supported` | `Boolean` | 是否支持该统计类型 |
| `hasData` | `Boolean` | 是否存在数据 |
| `expectedPointCount` | `Integer` | 期望点数 |
| `actualPointCount` | `Integer` | 实际点数 |
| `missingPointCount` | `Integer` | 缺失点数 |
| `dataIntegrity` | `BigDecimal` | 数据完整性 |
| `dataIntegrityText` | `String` | 数据完整性文本 |
### 6.7 响应示例
```json
{
"code": 200,
"msg": "success",
"data": {
"taskId": "1812345678901234567",
"taskNo": "CS202606180001",
"lineId": "LINE_001",
"lineIds": ["LINE_001", "LINE_002"],
"lineName": "1号监测点,2号监测点",
"timeStart": "2026-06-18 00:00:00",
"timeEnd": "2026-06-18 01:00:00",
"intervalMinutes": 1,
"items": [
{
"itemId": "1812345678901234568",
"itemKey": "LINE_001:VOLTAGE_A",
"lineId": "LINE_001",
"lineName": "1号监测点",
"indicatorCode": "VOLTAGE_A",
"indicatorName": "A相电压",
"harmonicOrder": null,
"intervalMinutes": 1,
"hasData": true,
"expectedPointCount": 60,
"actualPointCount": 59,
"missingPointCount": 1,
"dataIntegrity": 0.983333,
"dataIntegrityText": "98.33%",
"abnormal": true,
"abnormalPointCount": 1,
"abnormalDetails": [],
"harmonicParityAbnormal": false,
"harmonicParityAbnormalPointCount": 0,
"harmonicParityAbnormalDetails": [],
"statSummaries": [
{
"statType": "AVG",
"supported": true,
"hasData": true,
"expectedPointCount": 60,
"actualPointCount": 59,
"missingPointCount": 1,
"dataIntegrity": 0.983333,
"dataIntegrityText": "98.33%"
}
],
"statDetails": []
}
]
}
}
```
## 7. 查询检测项明细
### 7.1 接口
`GET /steady/checksquare/item-detail`
### 7.2 请求参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `itemId` | `String` | 是 | 检测项 ID |
| `detailType` | `String` | 是 | 明细类型:`SEGMENT``VALUE_ORDER``HARMONIC_PARITY` |
| `statType` | `String` | 否 | 统计类型;查询统计类型连续性或谐波奇偶明细时使用 |
| `pageNum` | `Integer` | 否 | 页码;和 `pageSize` 同时为正数时启用分页 |
| `pageSize` | `Integer` | 否 | 每页条数;和 `pageNum` 同时为正数时启用分页 |
### 7.3 请求示例
查询连续性区间:
```http
GET /steady/checksquare/item-detail?itemId=1812345678901234568&detailType=SEGMENT&pageNum=1&pageSize=10
```
查询大小关系异常:
```http
GET /steady/checksquare/item-detail?itemId=1812345678901234568&detailType=VALUE_ORDER&pageNum=1&pageSize=10
```
查询谐波奇偶关系异常:
```http
GET /steady/checksquare/item-detail?itemId=1812345678901234568&detailType=HARMONIC_PARITY&statType=AVG&pageNum=1&pageSize=10
```
### 7.4 响应字段 `data`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `itemId` | `String` | 检测项 ID |
| `lineId` | `String` | 当前检测项所属监测点 ID |
| `lineName` | `String` | 当前检测项所属监测点名称 |
| `detailType` | `String` | 明细类型 |
| `statType` | `String` | 统计类型 |
| `pageNum` | `Integer` | 当前页码;未分页查询时为空 |
| `pageSize` | `Integer` | 每页条数;未分页查询时为空 |
| `total` | `Long` | 总记录数;未分页查询时为空 |
| `segments` | `Array<Object>` | 连续性区间,`detailType=SEGMENT` 时查看 |
| `valueOrderDetails` | `Array<Object>` | 指标值大小关系异常明细,`detailType=VALUE_ORDER` 时查看 |
| `harmonicParityDetails` | `Array<Object>` | 谐波奇偶关系异常明细,`detailType=HARMONIC_PARITY` 时查看 |
### 7.5 连续性区间字段 `segments[]`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `startTime` | `String` | 区间开始时间 |
| `endTime` | `String` | 区间结束时间 |
| `status` | `String` | 区间状态:`NORMAL``MISSING` |
| `harmonicOrder` | `Integer` | 谐波次数 |
| `missingPointCount` | `Integer` | 缺失点数 |
| `durationMinutes` | `Integer` | 持续时长,单位分钟 |
### 7.6 大小关系异常字段 `valueOrderDetails[]`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `time` | `String` | 异常点时间 |
| `phase` | `String` | 相别 |
| `harmonicOrder` | `Integer` | 谐波次数 |
| `maxValue` | `BigDecimal` | 最大值 |
| `minValue` | `BigDecimal` | 最小值 |
| `avgValue` | `BigDecimal` | 平均值 |
| `cp95Value` | `BigDecimal` | CP95 值 |
### 7.7 谐波奇偶关系异常字段 `harmonicParityDetails[]`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `time` | `String` | 异常点时间 |
| `phase` | `String` | 相别 |
| `statType` | `String` | 统计类型 |
| `evenHarmonicOrder` | `Integer` | 偶次谐波次数 |
| `evenValue` | `BigDecimal` | 偶次谐波值 |
| `oddHarmonicOrders` | `Array<Integer>` | 参与比较的奇次谐波次数 |
| `oddValues` | `Array<BigDecimal>` | 参与比较的奇次谐波值 |
| `oddMedianValue` | `BigDecimal` | 奇次谐波中位数 |
| `thresholdMultiplier` | `BigDecimal` | 异常阈值倍数 |
### 7.8 响应示例
连续性区间:
```json
{
"code": 200,
"msg": "success",
"data": {
"itemId": "1812345678901234568",
"lineId": "LINE_001",
"lineName": "1号监测点",
"detailType": "SEGMENT",
"statType": null,
"pageNum": 1,
"pageSize": 10,
"total": 1,
"segments": [
{
"startTime": "2026-06-18 00:10:00",
"endTime": "2026-06-18 00:10:00",
"status": "MISSING",
"harmonicOrder": null,
"missingPointCount": 1,
"durationMinutes": 1
}
],
"valueOrderDetails": [],
"harmonicParityDetails": []
}
}
```
大小关系异常:
```json
{
"code": 200,
"msg": "success",
"data": {
"itemId": "1812345678901234568",
"lineId": "LINE_001",
"lineName": "1号监测点",
"detailType": "VALUE_ORDER",
"statType": null,
"pageNum": 1,
"pageSize": 10,
"total": 1,
"segments": [],
"valueOrderDetails": [
{
"time": "2026-06-18 00:20:00",
"phase": "A",
"harmonicOrder": null,
"maxValue": 231.12000000,
"minValue": 228.45000000,
"avgValue": 229.64000000,
"cp95Value": 230.98000000
}
],
"harmonicParityDetails": []
}
}
```
## 8. 重启失败任务
### 8.1 接口
`POST /steady/checksquare/restart?taskId={taskId}`
### 8.2 行为说明
- 仅允许重启 `taskStatus=FAIL` 的任务。
- 重启复用原任务记录,`taskId` 不变。
- 重启前会清理该任务旧的检测项、统计摘要和明细数据,避免重复结果键。
- 接口返回时任务状态已更新为 `RUNNING`;后台执行结束后更新为 `SUCCESS``FAIL`
### 8.3 请求参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `taskId` | `String` | 是 | 失败任务 ID |
### 8.4 请求示例
```http
POST /steady/checksquare/restart?taskId=1812345678901234567
```
### 8.5 响应说明
响应 `data``/create``SteadyChecksquareTaskVO` 字段一致。
```json
{
"code": 200,
"msg": "success",
"data": {
"taskId": "1812345678901234567",
"taskNo": "CS202606180001",
"lineId": "LINE_001",
"lineIds": ["LINE_001", "LINE_002"],
"lineName": "1号监测点,2号监测点",
"timeStart": "2026-06-18 00:00:00",
"timeEnd": "2026-06-18 01:00:00",
"intervalMinutes": 1,
"taskStatus": "RUNNING",
"itemCount": 0,
"abnormalItemCount": 0,
"minDataIntegrity": 0.000000,
"createTime": "2026-06-18 09:30:00"
}
}
```
## 9. 删除任务
### 9.1 接口
`POST /steady/checksquare/delete`
### 9.2 请求字段
请求体直接传任务 ID 数组。
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `taskIds` | `Array<String>` | 是 | 任务 ID 数组 |
### 9.3 请求示例
```http
POST /steady/checksquare/delete
Content-Type: application/json
```
```json
[
"1812345678901234567"
]
```
### 9.4 响应示例
```json
{
"code": 200,
"msg": "success",
"data": true
}
```
## 10. 常见调试注意事项
- `/create` 返回的是任务列表行信息,不是详情页完整数据。
- `/create` 如果命中已存在任务,会直接返回该任务,不会生成重复任务。
- 新标准推荐传 `lineIds``lineId` 仅作为单监测点兼容字段保留。
- `/query``lineId` 会匹配任务内的 `lineIds`,可用于筛选多监测点任务。
- `/detail` 需要使用 `/create``/restart``/query` 返回的 `taskId`
- `/detail` 的任务级 `lineId` 是首个监测点,检测项级 `items[].lineId` 才是该检测项实际所属监测点。
- `/item-detail` 需要使用 `/detail` 返回的 `items[].itemId`
- `/item-detail` 只有 `pageNum``pageSize` 同时为正数时才分页;否则返回全部匹配明细,分页字段为空。
- `/restart` 只允许重启失败任务,成功或执行中的任务调用会返回业务失败。
- `dataIntegrity` 是 0 到 1 的小数,展示百分比优先使用 `dataIntegrityText`

View File

@@ -6,9 +6,16 @@
</div>
</div>
<el-empty v-if="!task" class="empty-result" description="新增后在此查看任务摘要" />
<div v-if="loading" class="create-loading">
<el-icon class="create-loading__icon"><Loading /></el-icon>
<span class="create-loading__title">正在创建校验任务</span>
<span class="create-loading__desc">任务提交后将在此显示摘要</span>
</div>
<el-empty v-else-if="!task" class="empty-result" description="新增后在此查看任务摘要" />
<template v-else>
<div class="result-scroll">
<div class="result-body">
<div class="result-overview">
<div class="task-card">
@@ -31,7 +38,7 @@
</div>
<div class="metric-item">
<span class="metric-label">检测项</span>
<span class="metric-value">{{ task.itemCount ?? '-' }}</span>
<span class="metric-value">{{ displayItemCount }}</span>
</div>
<div class="metric-item">
<span class="metric-label">异常项</span>
@@ -39,7 +46,9 @@
</div>
<div class="metric-item">
<span class="metric-label">最低完整率</span>
<span class="metric-value">{{ formatChecksquareIntegrity(task.minDataIntegrity) }}</span>
<span class="metric-value">
{{ formatChecksquareIntegrity(task.minDataIntegrity) }}
</span>
</div>
<div class="metric-item">
<span class="metric-label">统计间隔</span>
@@ -59,11 +68,14 @@
</div>
</div>
</div>
</div>
</template>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import {
formatChecksquareIntegrity,
@@ -75,9 +87,18 @@ defineOptions({
name: 'ChecksquareCreateResultPanel'
})
defineProps<{
const props = defineProps<{
task: SteadyDataView.SteadyChecksquareTask | null
expectedItemCount: number
loading?: boolean
}>()
const displayItemCount = computed(() => {
if (Number(props.task?.itemCount || 0) > 0) return props.task?.itemCount
if (props.expectedItemCount > 0) return props.expectedItemCount
return props.task?.itemCount ?? '-'
})
</script>
<style scoped lang="scss">
@@ -99,6 +120,43 @@ defineProps<{
flex: 1;
}
.create-loading {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--el-text-color-secondary);
}
.create-loading__icon {
color: var(--el-color-primary);
font-size: 28px;
animation: rotate-loading 1s linear infinite;
}
.create-loading__title {
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 600;
}
.create-loading__desc {
font-size: 13px;
}
@keyframes rotate-loading {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.result-body {
display: flex;
flex: 1;
@@ -107,6 +165,13 @@ defineProps<{
gap: 12px;
}
.result-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
}
.result-overview {
display: grid;
flex: none;

View File

@@ -31,6 +31,13 @@
highlight-current-row
empty-text="暂无校验结果"
>
<el-table-column label="监测点名称" width="150">
<template #default="{ row }">
<span class="line-name" :title="resolveChecksquareLineName(row)">
{{ resolveChecksquareLineName(row) }}
</span>
</template>
</el-table-column>
<el-table-column prop="indicatorName" label="指标名称" width="160">
<template #default="{ row }">
<span class="indicator-name" :title="resolveChecksquareRowName(row)">
@@ -105,21 +112,25 @@ defineOptions({
name: 'ChecksquareSummaryTable'
})
defineProps<{
result: SteadyDataView.SteadyChecksquareQueryResult | null
items: SteadyDataView.SteadyChecksquareItem[]
loading: boolean
}>()
const emit = defineEmits<{
refresh: []
detail: [row: SteadyDataView.SteadyChecksquareItem]
}>()
const props = defineProps<{
result: SteadyDataView.SteadyChecksquareQueryResult | null
items: SteadyDataView.SteadyChecksquareItem[]
loading: boolean
}>()
const hasAbnormalCount = (value?: number | null) => Number(value || 0) > 0
const stripPercentUnit = (value: string) => value.replace(/%$/, '')
const resolveChecksquareLineName = (row: SteadyDataView.SteadyChecksquareItem) => {
return row.lineName || row.lineId || props.result?.lineName || props.result?.lineId || '-'
}
const formatSummaryDataIntegrity = (row: SteadyDataView.SteadyChecksquareItem) => {
return stripPercentUnit(formatDataIntegrity(row.dataIntegrity, row.dataIntegrityText))
}
@@ -157,6 +168,7 @@ const formatSummaryStatIntegrity = (
vertical-align: middle;
}
.line-name,
.indicator-name {
display: inline-block;
max-width: 100%;

View File

@@ -11,6 +11,15 @@
</template>
<template #operation="{ row }">
<el-button
v-if="row.taskStatus === 'FAIL'"
type="primary"
link
:icon="RefreshRight"
@click="emit('restart', row)"
>
重启
</el-button>
<el-button type="danger" link :icon="Delete" @click="emit('delete', row)">删除</el-button>
</template>
</ProTable>
@@ -19,7 +28,7 @@
<script setup lang="ts">
import { computed, h, reactive, ref } from 'vue'
import { ElButton, ElDatePicker, ElTag, ElTreeSelect } from 'element-plus'
import { Delete, Plus } from '@element-plus/icons-vue'
import { Delete, Plus, RefreshRight } from '@element-plus/icons-vue'
import ProTable from '@/components/ProTable/index.vue'
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
@@ -45,6 +54,7 @@ const props = defineProps<{
const emit = defineEmits<{
createTask: []
detail: [row: SteadyDataView.SteadyChecksquareTask]
restart: [row: SteadyDataView.SteadyChecksquareTask]
delete: [row: SteadyDataView.SteadyChecksquareTask]
viewMeasurementPoint: [row: SteadyDataView.SteadyChecksquareTask]
}>()
@@ -135,6 +145,7 @@ const renderLineSearch = ({ searchParam }: { searchParam: ChecksquareTaskSearchP
collapseTags: true,
collapseTagsTooltip: true,
maxCollapseTags: 1,
popperClass: 'checksquare-search-tree-popper',
filterable: true,
clearable: true,
defaultExpandAll: true,
@@ -159,6 +170,7 @@ const renderIndicatorSearch = ({ searchParam }: { searchParam: ChecksquareTaskSe
collapseTags: true,
collapseTagsTooltip: true,
maxCollapseTags: 1,
popperClass: 'checksquare-search-tree-popper',
filterable: true,
clearable: true,
defaultExpandAll: true,
@@ -292,7 +304,7 @@ const columns = reactive<ColumnProps<SteadyDataView.SteadyChecksquareTask>[]>([
minWidth: 170,
render: ({ row }) => resolveChecksquareText(row.createTime)
},
{ prop: 'operation', label: '操作', fixed: 'right', width: 150 }
{ prop: 'operation', label: '操作', fixed: 'right', width: 180 }
])
const getTableList = (params: ChecksquareTaskSearchParams) => {
@@ -329,4 +341,8 @@ defineExpose({
vertical-align: bottom;
white-space: nowrap;
}
:global(.checksquare-search-tree-popper .el-select-dropdown__wrap) {
max-height: 280px;
}
</style>

View File

@@ -33,10 +33,10 @@
/>
</div>
<div class="query-actions">
<el-button type="primary" :icon="Plus" :loading="loading.query" @click="emit('create')">
<el-button type="primary" :icon="Plus" :loading="loading.query" :disabled="loading.query" @click="emit('create')">
新增
</el-button>
<el-button type="primary" plain :icon="RefreshLeft" @click="emit('reset')">重置</el-button>
<el-button type="primary" plain :icon="RefreshLeft" :disabled="loading.query" @click="emit('reset')">重置</el-button>
</div>
<div class="toolbar-field indicator-form-item">
<span class="toolbar-field__label">稳态指标</span>
@@ -52,6 +52,7 @@
filterable
clearable
default-expand-all
:disabled="loading.query"
node-key="treeKey"
value-key="treeKey"
:props="{ label: 'name', children: 'children' }"
@@ -65,7 +66,7 @@
</div>
</template>
</el-tree-select>
<el-button type="primary" plain @click="handleSelectAllIndicators">全选</el-button>
<el-button type="primary" plain :disabled="loading.query" @click="handleSelectAllIndicators">全选</el-button>
</div>
</div>
</section>
@@ -331,7 +332,65 @@ watch(
.indicator-tree-select {
flex: 1;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.indicator-tree-select :deep(.el-select__wrapper) {
width: 100%;
min-width: 0;
max-width: 100%;
min-height: 32px;
height: 32px;
align-items: center;
overflow: hidden;
}
.indicator-tree-select :deep(.el-select__selection) {
flex: 1;
min-width: 0;
width: 0;
max-width: 100%;
min-height: 24px;
height: 24px;
max-height: 24px;
overflow: hidden;
}
.indicator-tree-select :deep(.el-select__selected-item) {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
height: 24px;
overflow: hidden;
}
.indicator-tree-select :deep(.el-select__tags) {
min-width: 0;
width: 100%;
max-width: 100%;
min-height: 24px;
height: 24px;
max-height: 24px;
overflow: hidden;
flex-wrap: nowrap;
}
.indicator-tree-select :deep(.el-tag) {
min-width: 0;
max-width: 100%;
height: 22px;
flex: 0 1 auto;
overflow: hidden;
}
.indicator-tree-select :deep(.el-select__tags-text) {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.indicator-select-node {

View File

@@ -15,7 +15,10 @@ const files = {
summaryTable: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareSummaryTable.vue'),
detailPanel: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareDetailPanel.vue'),
createResultPanel: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareCreateResultPanel.vue'),
measurementPointDialog: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareMeasurementPointDialog.vue'),
measurementPointDialog: path.resolve(
rootDir,
'views/steady/checksquare/components/ChecksquareMeasurementPointDialog.vue'
),
payload: path.resolve(rootDir, 'views/steady/checksquare/utils/checksquarePayload.ts'),
ledgerUtils: path.resolve(rootDir, 'views/steady/checksquare/utils/checksquareLedger.ts'),
taskTableUtils: path.resolve(rootDir, 'views/steady/checksquare/utils/checksquareTaskTable.ts'),
@@ -39,11 +42,20 @@ const checks = [
/\/steady\/checksquare\/create/.test(api) &&
!/\/steady\/checksquare\/get-or-create/.test(api) &&
/\/steady\/checksquare\/delete/.test(api) &&
/\/steady\/checksquare\/restart\?taskId=/.test(api) &&
/\/steady\/checksquare\/detail/.test(api) &&
/\/steady\/checksquare\/item-detail/.test(api)
)
}
],
[
'checksquare restart api uses documented task id query endpoint',
() =>
/export const restartSteadyChecksquareTask = \(taskId: string\)/.test(read(files.api)) &&
/http\.post<SteadyDataView\.SteadyChecksquareTask>\([\s\S]*`\/steady\/checksquare\/restart\?taskId=\$\{encodeURIComponent\(taskId\)\}`/.test(
read(files.api)
)
],
[
'checksquare delete api accepts documented task id array body',
() =>
@@ -66,7 +78,8 @@ const checks = [
const typeBlock =
read(files.apiTypes).match(/interface SteadyChecksquareCreateParams\s*\{[\s\S]*?\n {4}\}/)?.[0] || ''
return (
/lineId: string/.test(typeBlock) &&
/lineId\?: string/.test(typeBlock) &&
/lineIds\?: string\[\]/.test(typeBlock) &&
/indicatorCodes: string\[\]/.test(typeBlock) &&
/timeStart: string/.test(typeBlock) &&
/timeEnd: string/.test(typeBlock) &&
@@ -75,7 +88,10 @@ const checks = [
}
],
['task table component exists', () => exists(files.taskTable)],
['task table uses ProTable like event list', () => /<ProTable[\s\S]*row-key="taskId"[\s\S]*:columns="columns"/.test(read(files.taskTable))],
[
'task table uses ProTable like event list',
() => /<ProTable[\s\S]*row-key="taskId"[\s\S]*:columns="columns"/.test(read(files.taskTable))
],
[
'task table exposes create task header action',
() =>
@@ -87,9 +103,17 @@ const checks = [
'task table has documented task columns',
() => {
const source = read(files.taskTable)
return ['taskNo', 'lineName', 'timeStart', 'timeEnd', 'taskStatus', 'itemCount', 'abnormalItemCount', 'minDataIntegrity', 'createTime'].every(
prop => new RegExp(`prop:\\s*'${prop}'`).test(source)
)
return [
'taskNo',
'lineName',
'timeStart',
'timeEnd',
'taskStatus',
'itemCount',
'abnormalItemCount',
'minDataIntegrity',
'createTime'
].every(prop => new RegExp(`prop:\\s*'${prop}'`).test(source))
}
],
[
@@ -105,6 +129,19 @@ const checks = [
)
}
],
[
'task table exposes restart only for failed rows',
() => {
const taskTable = read(files.taskTable)
const operationSlot = taskTable.match(/<template #operation="\{ row \}">[\s\S]*?<\/template>/)?.[0] || ''
return (
/v-if="row\.taskStatus === 'FAIL'"/.test(operationSlot) &&
/emit\('restart', row\)/.test(operationSlot) &&
/restart: \[row: SteadyDataView\.SteadyChecksquareTask\]/.test(taskTable) &&
/RefreshRight/.test(taskTable)
)
}
],
[
'task detail opens from abnormal item count value',
() => {
@@ -112,8 +149,7 @@ const checks = [
return (
/prop:\s*'abnormalItemCount'[\s\S]*ElButton[\s\S]*type:\s*'primary'[\s\S]*link:\s*true[\s\S]*emit\('detail', row\)/.test(
taskTable
) &&
/resolveChecksquareText\(row\.abnormalItemCount\)/.test(taskTable)
) && /resolveChecksquareText\(row\.abnormalItemCount\)/.test(taskTable)
)
}
],
@@ -124,8 +160,18 @@ const checks = [
/taskTimeRange/.test(read(files.taskTableUtils)) &&
/hasAbnormal/.test(read(files.taskTable))
],
['workbench remains create dialog selector body', () => /SteadyLedgerTree/.test(read(files.workbench)) && /TimePeriodSearch/.test(read(files.workbench))],
['workbench emits create instead of old query action', () => /create: \[\]/.test(read(files.workbench)) && !/query: \[\]/.test(read(files.workbench))],
[
'workbench remains create dialog selector body',
() => /SteadyLedgerTree/.test(read(files.workbench)) && /TimePeriodSearch/.test(read(files.workbench))
],
[
'shared ledger tree emits checked leaf monitor points for checksquare create payload',
() => /getCheckedNodes\(\s*true\s*,\s*false\s*\)/.test(read(path.resolve(rootDir, 'views/steady/steadyDataView/components/SteadyLedgerTree.vue')))
],
[
'workbench emits create instead of old query action',
() => /create: \[\]/.test(read(files.workbench)) && !/query: \[\]/.test(read(files.workbench))
],
['workbench no longer renders result table', () => !/ChecksquareSummaryTable/.test(read(files.workbench))],
[
'workbench create action uses short add label',
@@ -148,7 +194,51 @@ const checks = [
],
[
'payload builds create params without harmonic orders',
() => /buildSteadyChecksquareCreatePayload/.test(read(files.payload)) && !/harmonicOrder/.test(read(files.payload))
() =>
/buildSteadyChecksquareCreatePayload/.test(read(files.payload)) &&
!/harmonicOrder/.test(read(files.payload))
],
[
'payload supports multi monitor point create params',
() => {
const payload = read(files.payload)
const page = read(files.page)
return (
/buildSteadyChecksquareCreatePayload\s*=\s*\(\s*lineIds:\s*string\[\]/.test(payload) &&
/lineIds,/.test(payload) &&
/lineId:\s*lineIds\[0\]/.test(payload) &&
!/lineIds\.length > 1/.test(payload) &&
/buildSteadyChecksquareCreatePayload\(lineIds\.value,\s*selectedIndicators\.value,\s*formState\.value\)/.test(
page
) &&
!/buildSteadyChecksquareCreatePayload\(lineIds\.value\[0\]/.test(page)
)
}
],
[
'payload allows empty indicator selection for full indicator checks',
() => {
const payload = read(files.payload)
const page = read(files.page)
return (
!/if\s*\(!indicators\.length\)\s*return\s*'请选择指标'/.test(payload) &&
/indicatorCodes:\s*collectChecksquareIndicatorCodes\(indicators\)/.test(payload) &&
/indicators:\s*selectedIndicators\.value/.test(page)
)
}
],
[
'payload exposes expected item count calculation for selected or full indicators',
() => {
const payload = read(files.payload)
return (
/calculateChecksquareExpectedItemCount/.test(payload) &&
/selectedIndicatorCount/.test(payload) &&
/totalIndicatorCount/.test(payload) &&
/lineCount/.test(payload) &&
/selectedIndicatorCount > 0 \? selectedIndicatorCount : totalIndicatorCount/.test(payload)
)
}
],
[
'page renders task table as first screen',
@@ -211,6 +301,38 @@ const checks = [
)
}
],
[
'create dialog indicator select keeps selected tags from resizing input',
() => {
const workbench = read(files.workbench)
return (
/class="indicator-tree-select"/.test(workbench) &&
/collapse-tags/.test(workbench) &&
/\.indicator-tree-select\s*:deep\(\.el-select__wrapper\)[\s\S]*height:\s*32px/.test(workbench) &&
/\.indicator-tree-select\s*:deep\(\.el-select__selection\)[\s\S]*width:\s*0[\s\S]*height:\s*24px[\s\S]*overflow:\s*hidden/.test(
workbench
) &&
/\.indicator-tree-select\s*:deep\(\.el-select__tags\)[\s\S]*height:\s*24px[\s\S]*overflow:\s*hidden[\s\S]*flex-wrap:\s*nowrap/.test(
workbench
) &&
/\.indicator-tree-select\s*:deep\(\.el-tag\)[\s\S]*height:\s*22px[\s\S]*overflow:\s*hidden/.test(
workbench
)
)
}
],
[
'task table tree select filters use scrollable dropdowns',
() => {
const taskTable = read(files.taskTable)
return (
/popperClass:\s*'checksquare-search-tree-popper'/.test(taskTable) &&
/\.checksquare-search-tree-popper[\s\S]*\.el-select-dropdown__wrap[\s\S]*max-height:\s*280px/.test(
taskTable
)
)
}
],
[
'task table monitor point name opens measurement point dialog like event list',
() => {
@@ -292,20 +414,23 @@ const checks = [
[
'page wraps old workbench in create dialog',
() =>
/<el-dialog[\s\S]*新增校验任务[\s\S]*width="960px"[\s\S]*<ChecksquareWorkbench/.test(
read(files.page)
) &&
/<el-dialog[\s\S]*新增校验任务[\s\S]*width="960px"[\s\S]*<ChecksquareWorkbench/.test(read(files.page)) &&
/\.checksquare-create-dialog\s*\{[\s\S]*height:\s*560px/.test(read(files.page))
],
[
'page create flow calls create api, keeps summary only and refreshes task table',
() =>
/createSteadyChecksquareTask/.test(read(files.page)) &&
!/getOrCreateSteadyChecksquareTask/.test(read(files.page)) &&
!/refreshCreateTaskDetail/.test(read(files.page)) &&
!/startCreateTaskPolling/.test(read(files.page)) &&
/taskTableRef\.value\?\.refresh\(\)/.test(read(files.page)) &&
/activeCreateTask\.value/.test(read(files.page))
'page create flow calls create api, then polls task list status and refreshes task table',
() => {
const page = read(files.page)
return (
/createSteadyChecksquareTask/.test(page) &&
/querySteadyChecksquareTasks\(buildCreateTaskStatusQuery\(activeCreateTask\.value\)\)/.test(page) &&
!/getSteadyChecksquareDetail\(createdTask\.taskId\)/.test(page) &&
!/getOrCreateSteadyChecksquareTask/.test(page) &&
/startCreateTaskPolling\(createdTask\.taskId\)/.test(page) &&
/taskTableRef\.value\?\.refresh\(\)/.test(page) &&
/activeCreateTask\.value/.test(page)
)
}
],
[
'create dialog shows create task summary without detail table',
@@ -315,54 +440,75 @@ const checks = [
const panel = read(files.createResultPanel)
return (
exists(files.createResultPanel) &&
/<template #result>[\s\S]*<ChecksquareCreateResultPanel[\s\S]*:task="activeCreateTask"[\s\S]*<\/template>/.test(
/<template #result>[\s\S]*<ChecksquareCreateResultPanel[\s\S]*:task="activeCreateTask"[\s\S]*:loading="loading\.query"[\s\S]*<\/template>/.test(
page
) &&
/activeCreateTask\.value\s*=\s*null[\s\S]*loading\.query\s*=\s*true/.test(page) &&
/:disabled="loading\.query"/.test(workbench) &&
/\.checksquare-create-dialog\s*\{[\s\S]*display:\s*block/.test(page) &&
/<slot name="result" \/>/.test(workbench) &&
/\.checksquare-main\s*\{[\s\S]*display:\s*flex[\s\S]*flex-direction:\s*column/.test(workbench) &&
/\.checksquare-layout\s*\{[\s\S]*grid-template-columns:\s*240px\s+minmax\(0,\s*1fr\)/.test(
workbench
) &&
/\.checksquare-layout\s*\{[\s\S]*grid-template-columns:\s*240px\s+minmax\(0,\s*1fr\)/.test(workbench) &&
/\.checksquare-result-slot\s*\{[\s\S]*width:\s*100%/.test(workbench) &&
/\.checksquare-result-slot\s*\{[\s\S]*min-width:\s*0/.test(workbench) &&
/\.checksquare-result-slot\s*:deep\(\.checksquare-result-panel\)\s*\{[\s\S]*height:\s*100%/.test(
workbench
) &&
/name:\s*'ChecksquareCreateResultPanel'/.test(panel) &&
/loading\?:\s*boolean/.test(panel) &&
!/detail:\s*SteadyDataView\.SteadyChecksquareQueryResult \| null/.test(panel) &&
/expectedItemCount:\s*number/.test(panel) &&
/:expected-item-count="expectedCreateItemCount"/.test(page) &&
/v-if="loading"/.test(panel) &&
/正在创建校验任务/.test(panel) &&
/class="result-scroll"/.test(panel) &&
/class="result-overview"/.test(panel) &&
/class="result-body"/.test(panel) &&
!/class="result-items"/.test(panel) &&
!/detailItems/.test(panel) &&
/class="result-detail-grid"/.test(panel) &&
!/detail-block--wide/.test(panel) &&
/\.result-overview\s*\{[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\)/.test(panel) &&
/\.result-metrics\s*\{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/.test(panel) &&
/\.result-detail-grid\s*\{[^}]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/.test(panel) &&
/\.result-detail-grid\s*\{[^}]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/.test(
panel
) &&
!/class="result-tips"/.test(panel) &&
!/class="tips-title"/.test(panel) &&
!/class="tips-list"/.test(panel) &&
/class="result-metrics"[\s\S]*lineName[\s\S]*task\.itemCount/.test(panel) &&
/class="result-metrics"[\s\S]*lineName[\s\S]*displayItemCount/.test(panel) &&
/校验任务摘要/.test(panel) &&
/task\.itemCount/.test(panel) &&
/displayItemCount/.test(panel) &&
/props\.expectedItemCount/.test(panel) &&
/task\.abnormalItemCount/.test(panel) &&
/task\.minDataIntegrity/.test(panel) &&
!/class="detail-label">任务编号/.test(panel) &&
/\.result-body\s*\{[\s\S]*flex:\s*1/.test(panel) &&
/\.result-scroll\s*\{[\s\S]*overflow-y:\s*auto/.test(panel) &&
!/class="result-detail-table"/.test(panel) &&
!/resultItems/.test(panel) &&
!/emit\('detail', row\)/.test(panel)
)
}
],
[
'create dialog does not poll create task detail',
'create dialog polls running task status and clears polling lifecycle',
() => {
const page = read(files.page)
return (
!/createTaskPollingTimer/.test(page) &&
!/startCreateTaskPolling/.test(page) &&
!/stopCreateTaskPolling/.test(page) &&
!/setInterval/.test(page) &&
!/onBeforeUnmount/.test(page)
/createTaskPollingTimer/.test(page) &&
/startCreateTaskPolling/.test(page) &&
/stopCreateTaskPolling/.test(page) &&
/window\.setTimeout/.test(page) &&
/window\.clearTimeout/.test(page) &&
/CREATE_TASK_POLLING_INTERVALS/.test(page) &&
/getCreateTaskPollingDelay/.test(page) &&
/buildCreateTaskStatusQuery/.test(page) &&
/isChecksquareTaskFinished/.test(page) &&
/onBeforeUnmount\(stopCreateTaskPolling\)/.test(page) &&
/watch\(createDialogVisible/.test(page) &&
/refreshCreateTaskStatus/.test(page) &&
!/refreshCreateTaskDetail/.test(page) &&
/activeCreateTask\.value\s*=\s*\{\s*\.\.\.activeCreateTask\.value/.test(page)
)
}
],
@@ -374,9 +520,20 @@ const checks = [
/deleteSteadyChecksquareTasks\(\[row\.taskId\]\)/.test(read(files.page)) &&
/taskTableRef\.value\?\.refresh\(\)/.test(read(files.page))
],
[
'page restart flow confirms, calls restart api and refreshes task table',
() =>
/@restart="handleRestartTask"/.test(read(files.page)) &&
/restartSteadyChecksquareTask/.test(read(files.page)) &&
/ElMessageBox\.confirm/.test(read(files.page)) &&
/restartSteadyChecksquareTask\(row\.taskId\)/.test(read(files.page)) &&
/taskTableRef\.value\?\.refresh\(\)/.test(read(files.page))
],
[
'page detail flow calls detail api',
() => /getSteadyChecksquareDetail/.test(read(files.page)) && /detailDialogVisible\.value = true/.test(read(files.page))
() =>
/getSteadyChecksquareDetail/.test(read(files.page)) &&
/detailDialogVisible\.value = true/.test(read(files.page))
],
[
'task detail and item detail dialogs use the same size',
@@ -393,7 +550,9 @@ const checks = [
],
[
'summary table supports persisted abnormal fields',
() => /abnormalPointCount/.test(read(files.summaryTable)) && /harmonicParityAbnormalPointCount/.test(read(files.summaryTable))
() =>
/abnormalPointCount/.test(read(files.summaryTable)) &&
/harmonicParityAbnormalPointCount/.test(read(files.summaryTable))
],
[
'summary table renders positive abnormal counts in danger color',
@@ -401,7 +560,9 @@ const checks = [
const summaryTable = read(files.summaryTable)
return (
/hasAbnormalCount/.test(summaryTable) &&
/:class="\{\s*'is-abnormal-count': hasAbnormalCount\(row\.abnormalPointCount\)\s*\}"/.test(summaryTable) &&
/:class="\{\s*'is-abnormal-count': hasAbnormalCount\(row\.abnormalPointCount\)\s*\}"/.test(
summaryTable
) &&
/:class="\{\s*'is-abnormal-count': hasAbnormalCount\(row\.harmonicParityAbnormalPointCount\)\s*\}"/.test(
summaryTable
) &&
@@ -414,8 +575,9 @@ const checks = [
() => {
const summaryTable = read(files.summaryTable)
const dataIntegrityGroup =
summaryTable.match(/<el-table-column label="数据完整性"[\s\S]*?<\/el-table-column>\s*<el-table-column label="操作"/)?.[0] ||
''
summaryTable.match(
/<el-table-column label="数据完整性"[\s\S]*?<\/el-table-column>\s*<el-table-column label="操作"/
)?.[0] || ''
return (
/label="数据完整性"/.test(dataIntegrityGroup) &&
@@ -459,6 +621,24 @@ const checks = [
'summary table keeps indicator name column at configured width',
() => /prop="indicatorName" label="指标名称" width="160"/.test(read(files.summaryTable))
],
[
'summary table displays monitor point name before indicator name',
() => {
const summaryTable = read(files.summaryTable)
const lineNameIndex = summaryTable.indexOf('label="监测点名称"')
const indicatorIndex = summaryTable.indexOf('prop="indicatorName"')
return (
/resolveChecksquareLineName/.test(summaryTable) &&
/:title="resolveChecksquareLineName\(row\)"/.test(summaryTable) &&
/row\.lineName\s*\|\|\s*row\.lineId\s*\|\|\s*props\.result\?\.lineName\s*\|\|\s*props\.result\?\.lineId/.test(
summaryTable
) &&
lineNameIndex >= 0 &&
indicatorIndex > lineNameIndex
)
}
],
[
'summary table places abnormal fields after indicator name and hides max continuous missing column',
() => {
@@ -534,14 +714,17 @@ const checks = [
],
[
'detail panel loads item details on demand',
() => /getSteadyChecksquareItemDetail/.test(read(files.detailPanel)) && /detailType/.test(read(files.detailPanel))
() =>
/getSteadyChecksquareItemDetail/.test(read(files.detailPanel)) && /detailType/.test(read(files.detailPanel))
],
[
'item detail api types support documented pagination fields',
() => {
const types = read(files.apiTypes)
return (
/interface SteadyChecksquareItemDetailParams[\s\S]*pageNum\?: number[\s\S]*pageSize\?: number/.test(types) &&
/interface SteadyChecksquareItemDetailParams[\s\S]*pageNum\?: number[\s\S]*pageSize\?: number/.test(
types
) &&
/interface SteadyChecksquareItemDetail[\s\S]*pageNum\?: number \| null[\s\S]*pageSize\?: number \| null[\s\S]*total\?: number \| null/.test(
types
)

View File

@@ -7,22 +7,14 @@
:request-api="querySteadyChecksquareTasks"
@create-task="openCreateDialog"
@detail="openTaskDetail"
@restart="handleRestartTask"
@delete="handleDeleteTask"
@view-measurement-point="openMeasurementPointDialog"
/>
<ChecksquareMeasurementPointDialog
v-model:visible="measurementPointDialogVisible"
:data="measurementPointData"
/>
<ChecksquareMeasurementPointDialog v-model:visible="measurementPointDialogVisible" :data="measurementPointData" />
<el-dialog
v-model="createDialogVisible"
title="新增校验任务"
width="960px"
append-to-body
destroy-on-close
>
<el-dialog v-model="createDialogVisible" title="新增校验任务" width="960px" append-to-body destroy-on-close>
<div class="checksquare-create-dialog">
<ChecksquareWorkbench
v-model:form="formState"
@@ -44,6 +36,8 @@
<template #result>
<ChecksquareCreateResultPanel
:task="activeCreateTask"
:loading="loading.query"
:expected-item-count="expectedCreateItemCount"
/>
</template>
</ChecksquareWorkbench>
@@ -71,7 +65,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createSteadyChecksquareTask,
@@ -79,7 +73,8 @@ import {
getSteadyChecksquareDetail,
getSteadyTrendIndicatorTree,
getSteadyTrendLedgerTree,
querySteadyChecksquareTasks
querySteadyChecksquareTasks,
restartSteadyChecksquareTask
} from '@/api/steady/steadyDataView'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import {
@@ -102,6 +97,7 @@ import {
} from './utils/checksquareLedger'
import {
buildSteadyChecksquareCreatePayload,
calculateChecksquareExpectedItemCount,
defaultChecksquareFormState,
validateChecksquareSelection
} from './utils/checksquarePayload'
@@ -137,8 +133,20 @@ const loading = reactive({
detail: false
})
let ledgerSearchTimer: ReturnType<typeof setTimeout> | null = null
let createTaskPollingTimer: number | null = null
let createTaskPollingRunning = false
let createTaskPollingCount = 0
const CREATE_TASK_POLLING_INTERVALS = [3000, 5000, 10000]
const lineIds = computed(() => collectSelectedLineIds(selectedLedgerNodes.value))
const totalIndicatorCount = computed(() => collectLeafIndicators(indicatorTree.value).length)
const expectedCreateItemCount = computed(() =>
calculateChecksquareExpectedItemCount({
lineCount: lineIds.value.length,
selectedIndicatorCount: selectedIndicators.value.length,
totalIndicatorCount: totalIndicatorCount.value
})
)
const unwrapData = <T,>(response: { data: T } | T): T => {
if (response && typeof response === 'object' && 'data' in response) {
@@ -148,6 +156,13 @@ const unwrapData = <T,>(response: { data: T } | T): T => {
return response as T
}
const isChecksquareTaskFinished = (status?: string) => status === 'SUCCESS' || status === 'FAIL'
const getCreateTaskPollingDelay = () => {
const index = Math.min(createTaskPollingCount, CREATE_TASK_POLLING_INTERVALS.length - 1)
return CREATE_TASK_POLLING_INTERVALS[index]
}
const loadLedgerTree = async (keyword = ledgerKeyword.value) => {
loading.ledger = true
try {
@@ -177,6 +192,7 @@ const loadIndicatorTree = async () => {
}
const openCreateDialog = () => {
stopCreateTaskPolling()
activeCreateTask.value = null
createDialogVisible.value = true
}
@@ -196,6 +212,7 @@ const handleIndicatorChange = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
}
const handleReset = () => {
stopCreateTaskPolling()
formState.value = defaultChecksquareFormState()
selectedLedgerNodes.value = []
selectedIndicators.value = []
@@ -205,7 +222,82 @@ const handleReset = () => {
selectorResetKey.value += 1
}
const normalizeCreateTask = (result: SteadyDataView.SteadyChecksquareTask): SteadyDataView.SteadyChecksquareTask | null => {
const buildCreateTaskStatusQuery = (
task: SteadyDataView.SteadyChecksquareTask | null
): SteadyDataView.SteadyChecksquareTaskQueryParams => ({
pageNum: 1,
pageSize: 10,
lineId: task?.lineId,
timeStart: task?.timeStart,
timeEnd: task?.timeEnd
})
const refreshCreateTaskStatus = async (taskId: string) => {
if (createTaskPollingRunning) return
createTaskPollingRunning = true
try {
const taskResponse = await querySteadyChecksquareTasks(buildCreateTaskStatusQuery(activeCreateTask.value))
const taskPage = unwrapData(taskResponse)
const latestTask = taskPage.records?.find(task => task.taskId === taskId)
if (activeCreateTask.value && latestTask) {
activeCreateTask.value = {
...activeCreateTask.value,
taskNo: latestTask.taskNo || activeCreateTask.value.taskNo,
lineId: latestTask.lineId || activeCreateTask.value.lineId,
lineName: latestTask.lineName || activeCreateTask.value.lineName,
timeStart: latestTask.timeStart || activeCreateTask.value.timeStart,
timeEnd: latestTask.timeEnd || activeCreateTask.value.timeEnd,
intervalMinutes: latestTask.intervalMinutes ?? activeCreateTask.value.intervalMinutes,
taskStatus: latestTask.taskStatus || activeCreateTask.value.taskStatus,
itemCount: latestTask.itemCount ?? activeCreateTask.value.itemCount,
abnormalItemCount: latestTask.abnormalItemCount ?? activeCreateTask.value.abnormalItemCount,
minDataIntegrity: latestTask.minDataIntegrity ?? activeCreateTask.value.minDataIntegrity,
createTime: latestTask.createTime || activeCreateTask.value.createTime
}
}
if (isChecksquareTaskFinished(latestTask?.taskStatus || activeCreateTask.value?.taskStatus)) {
stopCreateTaskPolling()
taskTableRef.value?.refresh()
return
}
scheduleCreateTaskPolling(taskId)
} finally {
createTaskPollingRunning = false
}
}
const stopCreateTaskPolling = () => {
if (createTaskPollingTimer !== null) window.clearTimeout(createTaskPollingTimer)
createTaskPollingTimer = null
createTaskPollingRunning = false
createTaskPollingCount = 0
}
const scheduleCreateTaskPolling = (taskId: string) => {
if (createTaskPollingTimer !== null) window.clearTimeout(createTaskPollingTimer)
createTaskPollingTimer = window.setTimeout(() => {
createTaskPollingTimer = null
createTaskPollingCount += 1
void refreshCreateTaskStatus(taskId).catch(() => scheduleCreateTaskPolling(taskId))
}, getCreateTaskPollingDelay())
}
const startCreateTaskPolling = (taskId: string) => {
stopCreateTaskPolling()
// 校验任务只需要监控执行状态,轮询任务列表行信息,避免反复拉取检测项明细。
scheduleCreateTaskPolling(taskId)
}
const normalizeCreateTask = (
result: SteadyDataView.SteadyChecksquareTask
): SteadyDataView.SteadyChecksquareTask | null => {
const taskId = result.taskId || result.taskNo
if (!taskId) return null
@@ -236,14 +328,19 @@ const handleCreateTask = async () => {
return
}
activeCreateTask.value = null
loading.query = true
try {
// /create 返回任务行信息,检测项明细统一通过 /detail 拉取,避免把列表行误当成详情数据
// /create 返回任务行信息,新增弹窗只监控任务状态,不拉取检测项详情
const response = await createSteadyChecksquareTask(
buildSteadyChecksquareCreatePayload(lineIds.value[0], selectedIndicators.value, formState.value)
buildSteadyChecksquareCreatePayload(lineIds.value, selectedIndicators.value, formState.value)
)
const result = unwrapData(response)
activeCreateTask.value = normalizeCreateTask(result)
const createdTask = normalizeCreateTask(result)
activeCreateTask.value = createdTask
if (createdTask?.taskId && !isChecksquareTaskFinished(createdTask.taskStatus)) {
startCreateTaskPolling(createdTask.taskId)
}
ElMessage.success('校验任务已获取')
taskTableRef.value?.refresh()
} finally {
@@ -283,12 +380,31 @@ const handleDeleteTask = async (row: SteadyDataView.SteadyChecksquareTask) => {
return
}
// 删除接口按任务 ID 数组批量处理;列表行操作只传当前任务 ID。
// 删除接口按任务 ID 数组批量处理行操作只传当前任务 ID。
await deleteSteadyChecksquareTasks([row.taskId])
ElMessage.success('删除校验任务成功')
taskTableRef.value?.refresh()
}
const handleRestartTask = async (row: SteadyDataView.SteadyChecksquareTask) => {
if (!row.taskId) return
try {
await ElMessageBox.confirm('确认重启该失败的校验任务吗?重启后会清理旧结果并重新执行。', '重启确认', {
confirmButtonText: '重启',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
// 重启接口仅支持失败任务,成功后复用原任务 ID 并刷新列表展示最新运行状态。
await restartSteadyChecksquareTask(row.taskId)
ElMessage.success('校验任务已重启')
taskTableRef.value?.refresh()
}
const openItemDetail = (item: SteadyDataView.SteadyChecksquareItem) => {
selectedItem.value = item
itemDetailDialogVisible.value = true
@@ -303,6 +419,12 @@ onMounted(() => {
loadLedgerTree()
loadIndicatorTree()
})
watch(createDialogVisible, visible => {
if (!visible) stopCreateTaskPolling()
})
onBeforeUnmount(stopCreateTaskPolling)
</script>
<style scoped lang="scss">

View File

@@ -29,13 +29,25 @@ export const collectChecksquareIndicatorCodes = (indicators: SteadyDataView.Stea
return Array.from(new Set(indicators.map(item => item.indicatorCode).filter(Boolean))) as string[]
}
export const calculateChecksquareExpectedItemCount = (params: {
lineCount: number
selectedIndicatorCount: number
totalIndicatorCount: number
}) => {
const { lineCount, selectedIndicatorCount, totalIndicatorCount } = params
const indicatorCount = selectedIndicatorCount > 0 ? selectedIndicatorCount : totalIndicatorCount
return lineCount * indicatorCount
}
export const buildSteadyChecksquareCreatePayload = (
lineId: string,
lineIds: string[],
indicators: SteadyDataView.SteadyIndicatorNode[],
formState: ChecksquareFormState
): SteadyDataView.SteadyChecksquareCreateParams => {
return {
lineId,
lineId: lineIds[0],
lineIds,
indicatorCodes: collectChecksquareIndicatorCodes(indicators),
timeStart: (formState.timeRange[0] || '').replace(/\.[^.]+$/, ''),
timeEnd: (formState.timeRange[1] || '').replace(/\.[^.]+$/, '')
@@ -47,11 +59,9 @@ export const validateChecksquareSelection = (params: {
indicators: SteadyDataView.SteadyIndicatorNode[]
timeRange: string[]
}) => {
const { lineIds, indicators, timeRange } = params
const { lineIds, timeRange } = params
if (!lineIds.length) return '请选择监测点'
if (lineIds.length > 1) return '数据校验一次只能选择一个监测点'
if (!indicators.length) return '请选择指标'
if (!timeRange[0]) return '请选择开始时间'
if (!timeRange[1]) return '请选择结束时间'
if (Date.parse(timeRange[0].replace(' ', 'T')) > Date.parse(timeRange[1].replace(' ', 'T'))) {

View File

@@ -123,7 +123,7 @@ const handleKeywordChange = (value: string) => {
}
const handleCheck = () => {
emit('change', (treeRef.value?.getCheckedNodes(false, false) || []) as SteadyDataView.SteadyLedgerNode[])
emit('change', (treeRef.value?.getCheckedNodes(true, false) || []) as SteadyDataView.SteadyLedgerNode[])
}
const applyDefaultCheckedKeys = async () => {

View File

@@ -12,8 +12,8 @@ const selectionRulesSource = fs.readFileSync(path.join(currentDir, '..', 'utils'
const expectations = [
[
'ledger tree excludes half-checked parents when collecting checked nodes',
/getCheckedNodes\(\s*false\s*,\s*false\s*\)/,
'ledger tree emits leaf monitor points when collecting checked nodes',
/getCheckedNodes\(\s*true\s*,\s*false\s*\)/,
read('SteadyLedgerTree.vue')
],
[

View File

@@ -123,7 +123,7 @@ const handleKeywordChange = (value: string) => {
}
const handleCheck = () => {
emit('change', (treeRef.value?.getCheckedNodes(false, false) || []) as SteadyTrend.SteadyLedgerNode[])
emit('change', (treeRef.value?.getCheckedNodes(true, false) || []) as SteadyTrend.SteadyLedgerNode[])
}
const applyDefaultCheckedKeys = async () => {

View File

@@ -12,8 +12,8 @@ const selectionRulesSource = fs.readFileSync(path.join(currentDir, '..', 'utils'
const expectations = [
[
'ledger tree excludes half-checked parents when collecting checked nodes',
/getCheckedNodes\(\s*false\s*,\s*false\s*\)/,
'ledger tree emits leaf monitor points when collecting checked nodes',
/getCheckedNodes\(\s*true\s*,\s*false\s*\)/,
read('SteadyLedgerTree.vue')
],
[

View File

@@ -19,6 +19,11 @@
<div class="tool-text">进入 MMS 映射页面后续可继续补充映射配置和预览能力</div>
</button>
<button class="tool-item" type="button" @click="handleNavigate('/tools/parsePqdif')">
<div class="tool-name">PQDIF解析</div>
<div class="tool-text">进入 PQDIF 解析页面后续可接入文件上传解析进度和结果展示能力</div>
</button>
<button class="tool-item" type="button" @click="handleNavigate('/tools/deviceTypes')">
<div class="tool-name">设备类型维护</div>
<div class="tool-text">维护设备类型并从列表进入 ICD 一致性校验和 PQDIF 预留校验</div>

View File

@@ -83,11 +83,14 @@
:is-saving-icd-check-result="isSavingIcdCheckResult"
:save-icd-check-result-text="saveIcdCheckResultText"
:icd-consistency-status="icdConsistencyStatus"
:should-open-icd-consistency-problems="shouldOpenIcdConsistencyProblems"
:show-description="false"
@export-mapping="handleExportMapping"
@export-mapping="handleExportMappingEvent"
@generate-xml-mapping="handleGenerateXmlMapping"
@icd-check="handleIcdConsistencyCheck"
@update-mapping-json="handleUpdateMappingJson"
@confirm-icd-consistency-problems="handleConfirmIcdConsistencyProblemsEvent"
@remove-icd-consistency-problem="handleRemoveIcdConsistencyProblemEvent"
@update-mapping-json="handleUpdateMappingJsonEvent"
@update:sequence-dialog-visible="sequenceDialogVisible = $event"
@sequence-config-complete="handleSequenceConfigComplete"
@save-icd-check-result="handleSaveIcdCheckResult"
@@ -125,7 +128,8 @@ const props = defineProps<{
icdPathId: string
icdPathName: string
icdPathType: number
activeIcdPathRecord: MmsMapping.IcdPathRecord | null
standardMappingJson: string
standardMappingName: string
}>()
const emit = defineEmits<{
@@ -134,7 +138,7 @@ const emit = defineEmits<{
}>()
const dialogTitle = computed(() => (props.icdPathName ? `ICD校验${props.icdPathName}` : 'ICD校验'))
const showConsistencyCheck = computed(() => props.icdPathType === 2 || props.icdPathType === 3)
const showConsistencyCheck = computed(() => props.icdPathType === 2 || props.icdPathType === 4)
const saveIcdCheckResult = (params: MmsMapping.SaveIcdCheckResultRequest): Promise<ResultData<boolean> | boolean> => {
return saveIcdPathCheckResultApi(props.icdPathId, params)
@@ -184,6 +188,7 @@ const {
saveIcdCheckResultText,
hasIcdConsistencyCheckResult,
icdConsistencyStatus,
shouldOpenIcdConsistencyProblems,
confirmDialogVisible,
isConfirmingSelection,
handleIcdFileChange,
@@ -193,6 +198,8 @@ const {
handleExportMapping,
handleGenerateXmlMapping,
handleIcdConsistencyCheck,
handleConfirmIcdConsistencyProblems,
handleRemoveIcdConsistencyProblem,
handleUpdateMappingJson,
handleSequenceConfigComplete,
handleSaveIcdCheckResult,
@@ -200,9 +207,9 @@ const {
} = useMmsMappingFlow({
deviceTypeCheckId: toRef(props, 'icdPathId'),
deviceTypeCheckName: toRef(props, 'icdPathName'),
// 关键业务节点:只有需要一致性校验的 ICD 类型才注入标准 JSON避免隐藏校验按钮后保存仍被校验前置条件卡住
standardMappingJson: computed(() => (showConsistencyCheck.value ? props.activeIcdPathRecord?.jsonStr?.trim() || '' : '')),
standardMappingName: computed(() => (showConsistencyCheck.value ? props.activeIcdPathRecord?.name?.trim() || '' : '')),
// 关键业务节点:列表接口不再返回大字段,标准 JSON 由宿主页通过 mapping-detail 详情接口加载后传入
standardMappingJson: computed(() => (showConsistencyCheck.value ? props.standardMappingJson?.trim() || '' : '')),
standardMappingName: computed(() => (showConsistencyCheck.value ? props.standardMappingName?.trim() || '' : '')),
saveIcdCheckResult,
onIcdCheckSaved: () => {
emit('saved')
@@ -210,6 +217,31 @@ const {
}
})
const handleExportMappingEvent = (...args: unknown[]) => {
const [type] = args
if (type !== 'json' && type !== 'xml') return
handleExportMapping(type)
}
const handleConfirmIcdConsistencyProblemsEvent = () => {
handleConfirmIcdConsistencyProblems()
}
const handleRemoveIcdConsistencyProblemEvent = (...args: unknown[]) => {
const [index] = args
if (typeof index !== 'number') return
handleRemoveIcdConsistencyProblem(index)
}
const handleUpdateMappingJsonEvent = (...args: unknown[]) => {
const [mappingJson] = args
if (typeof mappingJson !== 'string') return
handleUpdateMappingJson(mappingJson)
}
type StepStatus = 'success' | 'wait' | 'process' | 'finish' | 'error'
interface IcdCheckFlowStep {
@@ -348,10 +380,16 @@ const handleClose = () => {
:deep(.icd-check-flow .el-step__description) {
max-width: 132px;
min-height: 36px;
margin-top: 6px;
display: -webkit-box;
overflow: hidden;
font-size: 12px;
line-height: 1.5;
text-overflow: ellipsis;
word-break: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
:deep(.icd-check-flow .el-step__head.is-process),

View File

@@ -10,8 +10,6 @@
<div class="icd-check-dialog__body">
<div class="icd-path-form-dialog__body">
<el-form ref="formRef" :model="formModel" :rules="formRules" label-width="120px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="ICD名称" prop="name">
<el-input
v-model="formModel.name"
@@ -21,8 +19,6 @@
placeholder="请输入 ICD 名称"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="ICD类型">
<el-select v-model="formModel.type" clearable placeholder="请选择 ICD 类型">
<el-option
@@ -33,33 +29,23 @@
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="ICD路径" prop="path">
<el-input
v-model="formModel.path"
maxlength="260"
show-word-limit
<el-form-item label="标准ICD引用" prop="referenceIcdId">
<el-select
v-model="formModel.referenceIcdId"
clearable
placeholder="请输入 ICD 存储路径"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="ICD文件">
<el-upload
accept=".icd,.cid,.scd,.xml"
:auto-upload="false"
:limit="1"
:show-file-list="true"
:on-change="handleIcdFileChange"
:on-remove="handleIcdFileRemove"
filterable
:loading="referenceLoading"
placeholder="请选择标准 ICD 引用"
>
<el-button type="primary" plain>选择文件</el-button>
</el-upload>
<el-option
v-for="option in referenceOptions"
:key="option.id"
:label="option.name"
:value="option.id"
:disabled="!option.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="角度">
<el-input-number
v-model="formModel.angle"
@@ -68,8 +54,6 @@
controls-position="right"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="相位索引">
<el-switch
v-model="formModel.usePhaseIndex"
@@ -79,8 +63,6 @@
inactive-text="关闭"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
</div>
@@ -94,9 +76,9 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import type { ResultData } from '@/api/interface'
import { createIcdPathApi, createIcdPathWithFileApi, updateIcdPathApi, updateIcdPathWithFileApi } from '@/api/tools/mmsmapping'
import { createIcdPathApi, listIcdPathReferencesApi, updateIcdPathApi } from '@/api/tools/mmsmapping'
import type { MmsMapping } from '@/api/tools/mmsmapping/interface'
defineOptions({
@@ -117,32 +99,38 @@ const emit = defineEmits<{
interface IcdPathFormModel {
id: string
name: string
path: string
angle?: number
usePhaseIndex: number
type?: number
referenceIcdId: string
}
interface IcdPathReferenceSelectOption {
id: string
name: string
}
const formRef = ref<FormInstance>()
const saving = ref(false)
const selectedIcdFile = ref<File | null>(null)
const referenceLoading = ref(false)
const referenceOptions = ref<IcdPathReferenceSelectOption[]>([])
const formModel = reactive<IcdPathFormModel>({
id: '',
name: '',
path: '',
angle: 0,
usePhaseIndex: 0,
type: 3
type: 4,
referenceIcdId: ''
})
const icdTypeOptions = [
{ label: '手动录入的标准', value: 1 },
{ label: '手动录入的非标准', value: 2 },
{ label: '上游解析传递', value: 3 }
{ label: '手动录入的标准 ICD', value: 1 },
{ label: '手动录入的非标准 ICD', value: 2 },
{ label: '上游解析传递的标准 ICD', value: 3 },
{ label: '上游解析传递的非标准 ICD', value: 4 }
]
const pathRequired = computed(() => !selectedIcdFile.value)
const formRules = computed<FormRules<IcdPathFormModel>>(() => ({
name: [{ required: true, message: '请输入 ICD 名称', trigger: 'blur' }],
path: pathRequired.value ? [{ required: true, message: '请输入 ICD 存储路径', trigger: 'blur' }] : []
referenceIcdId: [{ required: true, message: '请选择标准 ICD 引用', trigger: 'change' }]
}))
const dialogTitle = computed(() => (props.mode === 'create' ? '新增ICD记录' : '编辑ICD记录'))
@@ -162,11 +150,10 @@ const getErrorMessage = (error: unknown) => {
const resetForm = () => {
formModel.id = ''
formModel.name = ''
formModel.path = ''
formModel.angle = 0
formModel.usePhaseIndex = 0
formModel.type = 3
selectedIcdFile.value = null
formModel.type = 4
formModel.referenceIcdId = ''
formRef.value?.clearValidate()
}
@@ -176,38 +163,57 @@ const fillForm = (record: MmsMapping.IcdPathRecord | null) => {
formModel.id = record.id || ''
formModel.name = record.name || ''
formModel.path = record.path || ''
formModel.angle = record.angle ?? 0
formModel.usePhaseIndex = record.usePhaseIndex ?? 0
formModel.type = record.type
formModel.referenceIcdId = record.referenceIcdId || ''
}
watch(
() => props.visible,
visible => {
if (visible) fillForm(props.record)
if (!visible) return
fillForm(props.record)
loadReferenceOptions()
}
)
const buildSavePayload = (): MmsMapping.CreateIcdPathRequest => ({
name: formModel.name.trim(),
path: formModel.path.trim() || selectedIcdFile.value?.name || '',
angle: formModel.angle,
usePhaseIndex: formModel.usePhaseIndex,
type: formModel.type
type: formModel.type,
referenceIcdId: formModel.referenceIcdId
})
const handleClose = () => {
emit('update:visible', false)
}
const handleIcdFileChange = (uploadFile: UploadFile) => {
selectedIcdFile.value = uploadFile.raw || null
formRef.value?.clearValidate('path')
}
const loadReferenceOptions = async () => {
referenceLoading.value = true
const handleIcdFileRemove = () => {
selectedIcdFile.value = null
try {
const response = await listIcdPathReferencesApi()
const records = unwrapApiPayload<MmsMapping.IcdPathReferenceOption[]>(response) || []
referenceOptions.value = records.reduce<IcdPathReferenceSelectOption[]>((result, option) => {
if (option.id && option.name) {
result.push({
id: option.id,
name: option.name
})
}
return result
}, [])
} catch (error) {
ElMessage.error(getErrorMessage(error))
referenceOptions.value = []
} finally {
referenceLoading.value = false
}
}
const handleSave = async () => {
@@ -225,22 +231,6 @@ const handleSave = async () => {
const payload = buildSavePayload()
let response: ResultData<boolean> | boolean
if (selectedIcdFile.value) {
// 关键业务节点:选择 ICD 文件时按接口文档提交 icdFile + request后端会同步保存原始文件内容。
response =
props.mode === 'create'
? await createIcdPathWithFileApi({
icdFile: selectedIcdFile.value,
request: payload
})
: await updateIcdPathWithFileApi({
icdFile: selectedIcdFile.value,
request: {
...payload,
id: formModel.id
}
})
} else {
response =
props.mode === 'create'
? await createIcdPathApi(payload)
@@ -248,7 +238,6 @@ const handleSave = async () => {
...payload,
id: formModel.id
})
}
const saved = unwrapApiPayload<boolean>(response)
if (saved === false) {

View File

@@ -33,12 +33,17 @@ const checks = [
[
'ICD path check dialog only requires standard mapping when consistency check is visible',
() =>
/standardMappingJson:\s*computed\(\(\)\s*=>\s*\(?showConsistencyCheck\.value\s*\?\s*props\.activeIcdPathRecord\?\.jsonStr\?\.trim\(\)\s*\|\|\s*''\s*:\s*''\)?\)/.test(
/:standard-mapping-json="activeIcdPathMappingJson"/.test(pageSource) &&
/:standard-mapping-name="activeIcdPathMappingName"/.test(pageSource) &&
/standardMappingJson:\s*string/.test(checkDialogSource) &&
/standardMappingName:\s*string/.test(checkDialogSource) &&
/standardMappingJson:\s*computed\(\(\)\s*=>\s*\(?showConsistencyCheck\.value\s*\?\s*props\.standardMappingJson\?\.trim\(\)\s*\|\|\s*''\s*:\s*''\)?\)/.test(
checkDialogSource
) &&
/standardMappingName:\s*computed\(\(\)\s*=>\s*\(?showConsistencyCheck\.value\s*\?\s*props\.activeIcdPathRecord\?\.name\?\.trim\(\)\s*\|\|\s*''\s*:\s*''\)?\)/.test(
/standardMappingName:\s*computed\(\(\)\s*=>\s*\(?showConsistencyCheck\.value\s*\?\s*props\.standardMappingName\?\.trim\(\)\s*\|\|\s*''\s*:\s*''\)?\)/.test(
checkDialogSource
)
) &&
!/props\.activeIcdPathRecord\?\.jsonStr/.test(checkDialogSource)
]
]

View File

@@ -0,0 +1,86 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
const rootDir = process.cwd()
const pageDir = path.resolve(rootDir, 'frontend/src/views/tools/mmsMapping')
const resultPanelPath = path.join(pageDir, 'components/MappingResultPanel.vue')
const checkDialogPath = path.join(pageDir, 'components/IcdPathCheckDialog.vue')
const flowPath = path.join(pageDir, 'utils/useMmsMappingFlow.ts')
const resultPanelSource = fs.readFileSync(resultPanelPath, 'utf8')
const checkDialogSource = fs.readFileSync(checkDialogPath, 'utf8')
const flowSource = fs.readFileSync(flowPath, 'utf8')
const checks = [
[
'ICD consistency issue dialog reviews the current issue list without clearing it on confirm',
() =>
!/reviewingIcdConsistencyProblemList/.test(resultPanelSource) &&
/v-for="\([^"]+in\s+icdConsistencyProblemList"/.test(resultPanelSource)
],
[
'ICD consistency issue confirm emits an acknowledgement only',
() =>
/confirmIcdConsistencyProblems/.test(resultPanelSource) &&
/emit\('confirm-icd-consistency-problems'\)/.test(resultPanelSource) &&
!/emit\('confirm-icd-consistency-problems',/.test(resultPanelSource)
],
[
'ICD consistency issue dialog has explicit confirm action',
() => /confirmIcdConsistencyProblems/.test(resultPanelSource) && /event: 'confirm-icd-consistency-problems'/.test(resultPanelSource)
],
[
'ICD consistency indicator shows remaining issue count on the warning icon',
() => /el-badge[\s\S]*icdConsistencyProblemCount/.test(resultPanelSource)
],
[
'ICD consistency issue dialog supports removing a single reviewed issue',
() =>
/@click="emit\('remove-icd-consistency-problem',\s*index\)"/.test(resultPanelSource) &&
/event:\s*'remove-icd-consistency-problem',\s*index:\s*number/.test(resultPanelSource) &&
/handleRemoveIcdConsistencyProblem/.test(flowSource)
],
[
'ICD consistency issue removal is wired from dialog host to flow state',
() =>
/@remove-icd-consistency-problem="handleRemoveIcdConsistencyProblemEvent"/.test(checkDialogSource) &&
/handleRemoveIcdConsistencyProblemEvent/.test(checkDialogSource) &&
/handleRemoveIcdConsistencyProblem/.test(flowSource)
],
[
'ICD consistency check opens the issue dialog when failed',
() =>
/shouldOpenIcdConsistencyProblems/.test(resultPanelSource) &&
/shouldOpenIcdConsistencyProblems\.value \+= 1/.test(flowSource)
],
[
'save is gated until failed ICD consistency issues are confirmed',
() => /hasConfirmedIcdConsistencyProblems/.test(flowSource) && /!hasConfirmedIcdConsistencyProblems\.value/.test(flowSource)
],
[
'reviewed ICD consistency result follows the remaining reviewed issue list',
() =>
/reviewedIcdConsistencyResult/.test(flowSource) &&
/if\s*\(!lastIcdConsistencyCheckResult\.value\)\s*return\s+1/.test(flowSource) &&
/return\s+reviewedIcdConsistencyProblemList\.value\.length\s*\?\s*0\s*:\s*1/.test(flowSource)
],
[
'save payload preserves the remaining reviewed issue list after manual removal',
() =>
/buildReviewedIcdCheckMsg/.test(flowSource) &&
/const\s+reviewedIssues\s*=\s*reviewedIcdConsistencyProblemList\.value/.test(flowSource) &&
/details:\s*reviewedIssues/.test(flowSource) &&
/issuesJson:\s*reviewedIssues\.length/.test(flowSource)
]
]
const failedChecks = checks.filter(([, check]) => !check()).map(([name]) => name)
if (failedChecks.length) {
console.error('ICD consistency issue review contract failed:')
failedChecks.forEach(name => console.error(`- ${name}`))
process.exit(1)
}
console.log('ICD consistency issue review contract passed.')

View File

@@ -55,6 +55,33 @@ const checks = [
['mmsMapping page uses ProTable request API', () => /<ProTable[\s\S]*ref="proTable"[\s\S]*:request-api="getTableList"/.test(pageSource)],
['mmsMapping table keeps default refresh and column setting tools', () => /:tool-button="\['refresh', 'setting'\]"/.test(pageSource)],
['mmsMapping table header exposes create and batch delete', () => /<template\s+#tableHeader="scope">[\s\S]*openCreateDialog[\s\S]*handleBatchDelete\(scope\.selectedListIds\)/.test(pageSource)],
['mmsMapping table header exports selected ICD records as SQL', () => /<template\s+#tableHeader="scope">[\s\S]*handleExportSelectedSql\(scope\.selectedList\)/.test(pageSource)],
['mmsMapping table header exports selected ICD records as JSON', () => /<template\s+#tableHeader="scope">[\s\S]*handleExportSelectedJson\(scope\.selectedList\)/.test(pageSource)],
['ICD SQL export targets icd_path table', () => /INSERT INTO\s+\\?`icd_path\\?`/.test(pageSource)],
['ICD SQL export escapes single quotes', () => /replace\(/.test(pageSource) && /''/.test(pageSource)],
['ICD SQL export downloads sql file', () => /downloadTextFile[\s\S]*\.sql[\s\S]*application\/sql;charset=utf-8/.test(pageSource)],
[
'ICD JSON export includes all database fields and detail payloads',
() =>
/buildIcdPathJsonRecord/.test(pageSource) &&
/id:\s*row\.id/.test(pageSource) &&
/name:\s*row\.name/.test(pageSource) &&
/angle:\s*row\.angle/.test(pageSource) &&
/usePhaseIndex:\s*row\.usePhaseIndex/.test(pageSource) &&
/state:\s*row\.state/.test(pageSource) &&
/jsonStr:\s*detail\?\.jsonStr/.test(pageSource) &&
/xmlStr:\s*detail\?\.xmlStr/.test(pageSource) &&
/result:\s*row\.result/.test(pageSource) &&
/msg:\s*row\.msg/.test(pageSource) &&
/type:\s*row\.type/.test(pageSource) &&
/referenceIcdId:\s*row\.referenceIcdId/.test(pageSource) &&
/createBy:\s*row\.createBy/.test(pageSource) &&
/createTime:\s*row\.createTime/.test(pageSource) &&
/updateBy:\s*row\.updateBy/.test(pageSource) &&
/updateTime:\s*row\.updateTime/.test(pageSource)
],
['ICD JSON export encodes ICD text as Base64', () => /encodeTextToBase64/.test(pageSource) && /TextEncoder/.test(pageSource) && /detail\?\.icdText/.test(pageSource)],
['ICD JSON export downloads json file', () => /handleExportSelectedJson[\s\S]*\.json[\s\S]*application\/json;charset=utf-8/.test(pageSource)],
['mmsMapping row actions expose edit check and delete', () => /<template\s+#operation="\{\s*row\s*\}">[\s\S]*openEditDialog\(row\)[\s\S]*handleIcdCheck\(row\)[\s\S]*handleDeleteIcdPath\(row\)/.test(pageSource)],
['mmsMapping uses ICD path form dialog', () => /import\s+IcdPathFormDialog\s+from\s+'\.\/components\/IcdPathFormDialog\.vue'/.test(pageSource) && /<IcdPathFormDialog[\s\S]*@saved="refreshIcdPaths"/.test(pageSource)],
['mmsMapping uses ICD path check dialog', () => /import\s+IcdPathCheckDialog\s+from\s+'\.\/components\/IcdPathCheckDialog\.vue'/.test(pageSource) && /<IcdPathCheckDialog[\s\S]*@saved="refreshIcdPaths"/.test(pageSource)],
@@ -67,17 +94,47 @@ const checks = [
/createIcdPathWithFileApi/.test(apiSource) &&
/updateIcdPathWithFileApi/.test(apiSource) &&
/deleteIcdPathsApi/.test(apiSource) &&
/saveIcdPathCheckResultApi/.test(apiSource)
/saveIcdPathCheckResultApi/.test(apiSource) &&
/getIcdPathCheckMsgApi/.test(apiSource) &&
/getIcdPathMappingDetailApi/.test(apiSource) &&
/listIcdPathReferencesApi/.test(apiSource)
],
['ICD path APIs use documented mms-mapping endpoints', () => /\/api\/mms-mapping\/icd-paths\/list/.test(apiSource) && /\/api\/mms-mapping\/icd-paths\/add/.test(apiSource) && /\/api\/mms-mapping\/icd-paths\/update/.test(apiSource) && /\/api\/mms-mapping\/icd-paths\/delete/.test(apiSource) && /\/api\/mms-mapping\/icd-paths\/\$\{id\}\/icd-check-result/.test(apiSource)],
['ICD path APIs use documented mms-mapping endpoints', () => /\/api\/mms-mapping\/icd-paths\/list/.test(apiSource) && /\/api\/mms-mapping\/icd-paths\/add/.test(apiSource) && /\/api\/mms-mapping\/icd-paths\/update/.test(apiSource) && /\/api\/mms-mapping\/icd-paths\/delete/.test(apiSource) && /\/api\/mms-mapping\/icd-paths\/\$\{id\}\/icd-check-result/.test(apiSource) && /\/api\/mms-mapping\/icd-paths\/\$\{id\}\/icd-check-msg/.test(apiSource) && /\/api\/mms-mapping\/icd-paths\/\$\{id\}\/mapping-detail/.test(apiSource) && /\/api\/mms-mapping\/icd-paths\/reference-list/.test(apiSource)],
['ICD path file save APIs submit icdFile and JSON request parts', () => /formData\.append\('icdFile',\s*icdFile\)/.test(apiSource) && /formData\.append\('request',\s*new Blob\(\[JSON\.stringify\(request\)\],\s*\{\s*type:\s*'application\/json'\s*\}\)\)/.test(apiSource)],
['ICD JSON consistency API uses backend comparison endpoint', () => /checkIcdJsonConsistencyApi/.test(apiSource) && /\/api\/mms-mapping\/check-icd-json-consistency/.test(apiSource) && /interface\s+IcdJsonConsistencyCheckRequest/.test(typeSource) && /interface\s+IcdJsonConsistencyCheckResponse/.test(typeSource)],
['ICD path types are defined', () => /interface\s+IcdPathRecord/.test(typeSource) && /interface\s+CreateIcdPathRequest/.test(typeSource) && /interface\s+UpdateIcdPathRequest/.test(typeSource)],
['ICD path type options are fixed business enum values', () => /icdTypeOptions\s*=\s*\[[\s\S]*手动录入的标准[\s\S]*value:\s*1[\s\S]*手动录入的非标准[\s\S]*value:\s*2[\s\S]*上游解析传递[\s\S]*value:\s*3/.test(pageSource) && /icdTypeOptions\s*=\s*\[[\s\S]*手动录入的标准[\s\S]*value:\s*1[\s\S]*手动录入的非标准[\s\S]*value:\s*2[\s\S]*上游解析传递[\s\S]*value:\s*3/.test(formDialogSource)],
['ICD path reference option type is defined', () => /interface\s+IcdPathReferenceOption[\s\S]*id\?:\s*string[\s\S]*name\?:\s*string/.test(typeSource)],
['ICD path save requests include standard reference ID', () => /CreateIcdPathRequest[\s\S]*referenceIcdId\?:\s*string/.test(typeSource) && /buildSavePayload[\s\S]*referenceIcdId:\s*formModel\.referenceIcdId/.test(formDialogSource)],
['ICD path mapping detail response type is defined', () => /interface\s+IcdPathMappingDetailResponse[\s\S]*id\?:\s*string[\s\S]*name\?:\s*string[\s\S]*jsonStr\?:\s*string[\s\S]*xmlStr\?:\s*string[\s\S]*icdText\?:\s*string/.test(typeSource)],
[
'ICD path type options cover manual and upstream standard states',
() =>
/icdTypeOptions\s*=\s*\[[\s\S]*手动录入的标准 ICD[\s\S]*value:\s*1[\s\S]*手动录入的非标准 ICD[\s\S]*value:\s*2[\s\S]*上游解析传递的标准 ICD[\s\S]*value:\s*3[\s\S]*上游解析传递的非标准 ICD[\s\S]*value:\s*4/.test(
pageSource
) &&
/icdTypeOptions\s*=\s*\[[\s\S]*手动录入的标准 ICD[\s\S]*value:\s*1[\s\S]*手动录入的非标准 ICD[\s\S]*value:\s*2[\s\S]*上游解析传递的标准 ICD[\s\S]*value:\s*3[\s\S]*上游解析传递的非标准 ICD[\s\S]*value:\s*4/.test(
formDialogSource
)
],
['ICD path type renders readable text in table', () => /getIcdTypeText\(scope\.row\.type\)/.test(pageSource)],
['ICD path type search uses select options', () => /prop:\s*'type'[\s\S]*enum:\s*icdTypeOptions[\s\S]*search:\s*\{[\s\S]*el:\s*'select'/.test(pageSource)],
['ICD path form uses select for type', () => /<el-select\s+v-model="formModel\.type"[\s\S]*<el-option[\s\S]*v-for="option in icdTypeOptions"/.test(formDialogSource)],
['new ICD path defaults to upstream parsed type', () => /type:\s*3/.test(formDialogSource) && /formModel\.type\s*=\s*3/.test(formDialogSource)],
['ICD path form requires standard reference select after ICD type', () => /<el-form-item label="ICD类型"[\s\S]*<\/el-form-item>\s*<el-form-item label="标准ICD引用" prop="referenceIcdId">[\s\S]*<el-select\s+v-model="formModel\.referenceIcdId"[\s\S]*v-for="option in referenceOptions"[\s\S]*:label="option\.name"[\s\S]*:value="option\.id"/.test(formDialogSource) && /referenceIcdId:\s*\[\{\s*required:\s*true,\s*message:\s*'请选择标准 ICD 引用'/.test(formDialogSource)],
['ICD path form loads and backfills standard reference ID', () => /listIcdPathReferencesApi/.test(formDialogSource) && /loadReferenceOptions/.test(formDialogSource) && /formModel\.referenceIcdId\s*=\s*record\.referenceIcdId\s*\|\|\s*''/.test(formDialogSource)],
['ICD path create and edit form uses single column layout', () => !/<el-col\s+:span="12"/.test(formDialogSource)],
['new ICD path defaults to upstream parsed non-standard type', () => /type:\s*4/.test(formDialogSource) && /formModel\.type\s*=\s*4/.test(formDialogSource)],
[
'ICD path field is removed from page display form and request payloads',
() =>
!/prop:\s*'path'/.test(pageSource) &&
!/label:\s*'ICD璺緞'/.test(pageSource) &&
!/ICD 瀛樺偍璺緞/.test(formDialogSource) &&
!/formModel\.path/.test(formDialogSource) &&
!/path:\s*formModel/.test(formDialogSource) &&
!/path:\s*row\.path/.test(pageSource) &&
!/IcdPathRecord[\s\S]*path\?:/.test(typeSource) &&
!/CreateIcdPathRequest[\s\S]*path:/.test(typeSource)
],
[
'ICD dialog action buttons match parse ICD primary style',
() =>
@@ -86,25 +143,89 @@ const checks = [
isPrimarySolidButton(resultPanelSource, 'saveIcdCheckResultText')
],
[
'ICD table activation column calls activation handler before operation',
'ICD table removes activation and create time display columns',
() =>
/prop:\s*'activation'[\s\S]*label:\s*'激活'[\s\S]*render:\s*scope\s*=>\s*renderActivationStatus\(scope\.row\)[\s\S]*prop:\s*'operation'/.test(
pageSource
) && /renderActivationStatus[\s\S]*handleActivateIcdPath\(row\)/.test(pageSource)
!/prop:\s*'activation'[\s\S]*label:\s*'激活'/.test(pageSource) &&
!/renderActivationStatus/.test(pageSource) &&
!/prop:\s*'createTime'[\s\S]*label:\s*'创建时间'/.test(pageSource)
],
['ICD activation updates current record to standard type', () => /handleActivateIcdPath/.test(pageSource) && /type:\s*1/.test(pageSource) && /updateIcdPathApi/.test(pageSource)],
['ICD activation requires mapping JSON before setting standard type', () => /handleActivateIcdPath[\s\S]*row\.jsonStr\?\.trim\(\)[\s\S]*不能激活/.test(pageSource)],
['ICD page fetches active standard record independent of current table filters', () => /refreshActiveIcdPathRecord/.test(pageSource) && /listIcdPathsApi\(\{\s*type:\s*1\s*\}\)/.test(pageSource)],
['ICD path form supports multipart file save', () => /<el-upload[\s\S]*:auto-upload="false"[\s\S]*handleIcdFileChange/.test(formDialogSource) && /createIcdPathWithFileApi/.test(formDialogSource) && /updateIcdPathWithFileApi/.test(formDialogSource)],
['ICD path form allows uploaded file name to fill missing path', () => /pathRequired/.test(formDialogSource) && /selectedIcdFile\.value\?\.name/.test(formDialogSource)],
[
'ICD table shows mapping detail, reference standard ICD, then check result',
() =>
/listIcdPathReferencesApi/.test(pageSource) &&
/referenceIcdNameMap/.test(pageSource) &&
/loadReferenceIcdNameMap/.test(pageSource) &&
/renderReferenceIcdName/.test(pageSource) &&
/prop:\s*'mappingDetail'[\s\S]*width:\s*150[\s\S]*prop:\s*'referenceIcdId'[\s\S]*width:\s*150[\s\S]*prop:\s*'result'[\s\S]*width:\s*150/.test(
pageSource
) &&
/renderReferenceIcdName[\s\S]*openReferenceIcdDetail\(row\)/.test(pageSource) &&
/openReferenceIcdDetail[\s\S]*openMappingDetail\(\{[\s\S]*id:\s*row\.referenceIcdId[\s\S]*name:\s*getReferenceIcdName\(row\.referenceIcdId\)/.test(
pageSource
)
],
[
'ICD mapping detail column appears before operation',
() =>
/prop:\s*'mappingDetail'[\s\S]*label:\s*'映射文件详情'[\s\S]*render:\s*scope\s*=>\s*renderMappingDetailAction\(scope\.row\)[\s\S]*prop:\s*'operation'/.test(
pageSource
)
],
[
'ICD mapping detail dialog uses three documented tabs',
() =>
(/v-model="mappingDetailDialogVisible"/.test(pageSource) &&
/<el-tab-pane\s+[^>]*name="json"/.test(pageSource) &&
/<el-tab-pane\s+[^>]*name="xml"/.test(pageSource) &&
/<el-tab-pane\s+[^>]*name="icd"/.test(pageSource)) ||
/v-model="mappingDetailDialogVisible"/.test(pageSource) &&
/<el-tab-pane\s+label="JSON映射"\s+name="json"/.test(pageSource) &&
/<el-tab-pane\s+label="XML映射"\s+name="xml"/.test(pageSource) &&
/<el-tab-pane\s+label="ICD源文件"\s+name="icd"/.test(pageSource) &&
/currentMappingDetail\?\.jsonStr/.test(pageSource) &&
/currentMappingDetail\?\.xmlStr/.test(pageSource) &&
/currentMappingDetail\?\.icdText/.test(pageSource)
],
[
'ICD mapping detail JSON tab uses standard JSON viewer',
() =>
/import JsonMappingTree from '\.\/components\/JsonMappingTree\.vue'/.test(pageSource) &&
/formatMappingDetailJsonSource/.test(pageSource) &&
/mappingDetailJsonSource/.test(pageSource) &&
/<JsonMappingTree\s+:source="mappingDetailJsonSource"/.test(pageSource) &&
!/<pre class="mapping-detail-dialog__content">\{\{ currentMappingDetail\?\.jsonStr/.test(pageSource)
],
[
'ICD mapping detail source tab uses raw text viewer without JSON tree parsing',
() =>
!/mappingDetailIcdSource/.test(pageSource) &&
!/<JsonMappingTree\s+:source="mappingDetailIcdSource"/.test(pageSource) &&
/<pre class="mapping-detail-dialog__content">\{\{ currentMappingDetail\?\.icdText/.test(pageSource)
],
[
'ICD mapping detail tabs keep a single scroll owner',
() =>
!/<el-scrollbar\s+max-height="420px">[\s\S]*?<JsonMappingTree/.test(pageSource) &&
!/<el-scrollbar\s+max-height="420px">[\s\S]*?mapping-detail-dialog__content/.test(pageSource) &&
/class="mapping-detail-dialog__json"/.test(pageSource) &&
/\.mapping-detail-dialog__content\s*\{[\s\S]*max-height:\s*420px[\s\S]*overflow:\s*auto/.test(
pageSource
)
],
['ICD standard type helper treats type 1 and 3 as standard', () => /isStandardIcdType\s*=\s*\(type\?:\s*number\)\s*=>\s*type\s*===\s*1\s*\|\|\s*type\s*===\s*3/.test(pageSource)],
['ICD non-standard type helper treats type 2 and 4 as activatable', () => /isNonStandardIcdType\s*=\s*\(type\?:\s*number\)\s*=>\s*type\s*===\s*2\s*\|\|\s*type\s*===\s*4/.test(pageSource)],
['ICD page fetches active standard records independent of current table filters', () => /refreshActiveIcdPathRecord/.test(pageSource) && /listIcdPathsApi\(\{\s*type:\s*1\s*\}\)/.test(pageSource) && /listIcdPathsApi\(\{\s*type:\s*3\s*\}\)/.test(pageSource)],
['ICD path form does not expose file selection', () => !/<el-upload[\s\S]*ICD/.test(formDialogSource) && !/handleIcdFileChange/.test(formDialogSource) && !/selectedIcdFile/.test(formDialogSource)],
['ICD path form saves JSON request only', () => /createIcdPathApi/.test(formDialogSource) && /updateIcdPathApi/.test(formDialogSource) && !/createIcdPathWithFileApi/.test(formDialogSource) && !/updateIcdPathWithFileApi/.test(formDialogSource)],
['ICD check dialog receives ICD type', () => /:icd-path-type="currentIcdCheckRecord\.type"/.test(pageSource) && /icdPathType:\s*number/.test(checkDialogSource)],
['ICD check dialog receives active record JSON as standard mapping', () => /:active-icd-path-record="activeIcdPathRecord"/.test(pageSource) && /activeIcdPathRecord:\s*MmsMapping\.IcdPathRecord\s*\|\s*null/.test(checkDialogSource) && /standardMappingJson/.test(flowSource)],
['ICD check dialog receives detail-loaded standard mapping JSON', () => /:standard-mapping-json="activeIcdPathMappingJson"/.test(pageSource) && /standardMappingJson:\s*string/.test(checkDialogSource) && !/activeIcdPathRecord:\s*MmsMapping\.IcdPathRecord\s*\|\s*null/.test(checkDialogSource) && /standardMappingJson/.test(flowSource)],
[
'non-standard and upstream ICD types show consistency check action',
() =>
/showConsistencyCheck/.test(checkDialogSource) &&
/props\.icdPathType\s*===\s*2[\s\S]*props\.icdPathType\s*===\s*3/.test(checkDialogSource) &&
/props\.icdPathType\s*===\s*2[\s\S]*props\.icdPathType\s*===\s*4/.test(checkDialogSource) &&
!/props\.icdPathType\s*===\s*1/.test(checkDialogSource) &&
!/props\.icdPathType\s*===\s*3/.test(checkDialogSource) &&
/:show-icd-check-action="showConsistencyCheck"/.test(checkDialogSource)
],
['ICD check action calls backend consistency check before save', () => /@icd-check="handleIcdConsistencyCheck"/.test(checkDialogSource) && /handleIcdConsistencyCheck/.test(flowSource) && /checkIcdJsonConsistencyApi/.test(flowSource) && /lastIcdConsistencyCheckResult/.test(flowSource)],
@@ -114,13 +235,14 @@ const checks = [
/icdConsistencyStatus/.test(flowSource) &&
/:icd-consistency-status="icdConsistencyStatus"/.test(checkDialogSource) &&
/icd-consistency-indicator/.test(resultPanelSource) &&
/icdConsistencyProblemDialogVisible\s*=\s*true/.test(resultPanelSource)
/openIcdConsistencyProblems/.test(resultPanelSource)
],
[
'ICD check issues are separated from JSON mapping problems',
() =>
/const\s+jsonMappingProblemList\s*=\s*computed\(\(\)\s*=>\s*\[/.test(flowSource) &&
/const\s+icdConsistencyProblemList\s*=\s*computed\(\(\)\s*=>\s*lastIcdConsistencyCheckResult\.value\?\.issues/.test(
/const\s+icdConsistencyProblemList\s*=\s*computed\(\(\)\s*=>/.test(flowSource) &&
/reviewedIcdConsistencyProblemList\.value/.test(
flowSource
) &&
!/const\s+problemList\s*=\s*computed\(\(\)\s*=>\s*\[[\s\S]*lastIcdConsistencyCheckResult\.value\?\.issues/.test(
@@ -150,7 +272,7 @@ const checks = [
[
'ICD path check msg uses JSON object payload and readable table render',
() =>
/interface\s+IcdCheckMsg[\s\S]*summary\?:\s*string[\s\S]*details\?:\s*string\[\][\s\S]*\[key:\s*string\]:\s*unknown/.test(
/interface\s+IcdCheckMsg[\s\S]*summary\?:\s*string[\s\S]*details\?:\s*unknown\[\][\s\S]*\[key:\s*string\]:\s*unknown/.test(
typeSource
) &&
/SaveIcdPathCheckResultRequest[\s\S]*msg\?:\s*IcdCheckMsg/.test(typeSource) &&
@@ -163,6 +285,24 @@ const checks = [
/renderIcdCheckMsg/.test(pageSource) &&
/JSON\.stringify\(value\)/.test(pageSource)
],
[
'ICD inconsistent result opens saved check detail dialog',
() =>
/renderIcdResult[\s\S]*row\.result\s*===\s*0[\s\S]*handleOpenIcdCheckMsg\(row\)/.test(pageSource) &&
/getIcdPathCheckMsgApi\(row\.id\)/.test(pageSource) &&
/v-model="icdCheckMsgDialogVisible"/.test(pageSource) &&
/currentCheckMsgDetail/.test(pageSource)
],
[
'ICD check msg dialog shows formatted raw JSON without diff list',
() =>
!/icdCheckMsgDetails/.test(pageSource) &&
!/String\(item\)/.test(pageSource) &&
/normalizeIcdCheckMsgJsonValue/.test(pageSource) &&
/formatIcdCheckMsgJson/.test(pageSource) &&
/JSON\.parse\(trimmedValue\)/.test(pageSource) &&
/JSON\.stringify\(normalizeIcdCheckMsgJsonValue\(detail\),\s*null,\s*4\)/.test(pageSource)
],
[
'ICD path check save writes parsed ICD document back to path record',
() =>

View File

@@ -0,0 +1,60 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(currentDir, '../../../..')
const pageFile = path.resolve(rootDir, 'views/tools/mmsMapping/index.vue')
const source = fs.readFileSync(pageFile, 'utf8')
const columnsStart = source.indexOf('const columns = reactive')
const columnsEnd = source.indexOf('const buildListParams', columnsStart)
const columnsSource = columnsStart >= 0 && columnsEnd > columnsStart ? source.slice(columnsStart, columnsEnd) : ''
const getColumnIndex = prop => columnsSource.indexOf(`prop: '${prop}'`)
const checks = [
[
'ICD type column is widened to avoid text clipping',
() => /prop: 'type'[\s\S]*?label: 'ICD类型'[\s\S]*?width: 220/.test(columnsSource)
],
[
'Mapping detail column uses width 150',
() => /prop: 'mappingDetail'[\s\S]*?label: '映射文件详情'[\s\S]*?width: 150/.test(columnsSource)
],
[
'Reference ICD column uses width 150',
() => /prop: 'referenceIcdId'[\s\S]*?label: '参照标准ICD'[\s\S]*?width: 150/.test(columnsSource)
],
[
'ICD result column uses width 150',
() => /prop: 'result'[\s\S]*?label: '校验结论'[\s\S]*?width: 150/.test(columnsSource)
],
[
'Detail, reference ICD, and result columns keep requested order',
() => {
const mappingDetailIndex = getColumnIndex('mappingDetail')
const referenceIcdIndex = getColumnIndex('referenceIcdId')
const resultIndex = getColumnIndex('result')
return (
mappingDetailIndex >= 0 &&
referenceIcdIndex > mappingDetailIndex &&
resultIndex > referenceIcdIndex
)
}
]
]
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
if (failures.length) {
console.error('mmsMapping ICD path table columns contract failed:')
for (const failure of failures) {
console.error(`- ${failure}`)
}
process.exit(1)
}
console.log('mmsMapping ICD path table columns contract passed')

View File

@@ -20,6 +20,24 @@
>
批量删除
</el-button>
<el-button
type="primary"
plain
:icon="Download"
:disabled="!scope.isSelected"
@click="handleExportSelectedSql(scope.selectedList)"
>
导出SQL
</el-button>
<el-button
type="primary"
plain
:icon="Download"
:disabled="!scope.isSelected"
@click="handleExportSelectedJson(scope.selectedList)"
>
导出JSON
</el-button>
</template>
<template #operation="{ row }">
<el-button link type="primary" :icon="Edit" @click="openEditDialog(row)">编辑</el-button>
@@ -42,23 +60,91 @@
:icd-path-id="currentIcdCheckRecord.id"
:icd-path-name="currentIcdCheckRecord.name"
:icd-path-type="currentIcdCheckRecord.type"
:active-icd-path-record="activeIcdPathRecord"
:standard-mapping-json="activeIcdPathMappingJson"
:standard-mapping-name="activeIcdPathMappingName"
@saved="refreshIcdPaths"
/>
<el-dialog
v-model="mappingDetailDialogVisible"
title="映射文件详情"
width="860px"
append-to-body
destroy-on-close
>
<div v-loading="mappingDetailLoading" class="mapping-detail-dialog">
<el-descriptions :column="1" border>
<el-descriptions-item label="ICD名称">
{{ currentMappingDetail?.name || currentMappingDetailRecordName || '-' }}
</el-descriptions-item>
</el-descriptions>
<el-tabs v-model="mappingDetailActiveTab" class="mapping-detail-dialog__tabs">
<el-tab-pane label="JSON映射" name="json">
<div class="mapping-detail-dialog__json">
<JsonMappingTree :source="mappingDetailJsonSource" />
</div>
</el-tab-pane>
<el-tab-pane label="XML映射" name="xml">
<pre class="mapping-detail-dialog__content">{{ currentMappingDetail?.xmlStr || '暂无 XML 映射内容' }}</pre>
</el-tab-pane>
<el-tab-pane label="ICD源文件" name="icd">
<pre class="mapping-detail-dialog__content">{{ currentMappingDetail?.icdText || '暂无 ICD 源文件内容' }}</pre>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<el-button type="primary" @click="mappingDetailDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog
v-model="icdCheckMsgDialogVisible"
title="校验结论详情"
width="720px"
append-to-body
destroy-on-close
>
<div v-loading="icdCheckMsgLoading" class="icd-check-msg-detail">
<el-descriptions :column="1" border>
<el-descriptions-item label="ICD名称">{{ currentCheckMsgRecordName || '-' }}</el-descriptions-item>
<el-descriptions-item label="结论摘要">
{{ icdCheckMsgSummary || '暂无校验结论详情' }}
</el-descriptions-item>
</el-descriptions>
<div v-if="icdCheckMsgExtraJson" class="icd-check-msg-detail__section">
<div class="icd-check-msg-detail__title">原始详情</div>
<el-scrollbar max-height="260px">
<pre class="icd-check-msg-detail__json">{{ icdCheckMsgExtraJson }}</pre>
</el-scrollbar>
</div>
</div>
<template #footer>
<el-button type="primary" @click="icdCheckMsgDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="tsx">
import { Connection, Delete, Edit, Plus } from '@element-plus/icons-vue'
import { Connection, Delete, Document, Download, Edit, Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, type TagProps } from 'element-plus'
import { nextTick, reactive, ref } from 'vue'
import { computed, nextTick, reactive, ref } from 'vue'
import type { ResultData } from '@/api/interface'
import ProTable from '@/components/ProTable/index.vue'
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
import { deleteIcdPathsApi, listIcdPathsApi, updateIcdPathApi } from '@/api/tools/mmsmapping'
import {
deleteIcdPathsApi,
getIcdPathCheckMsgApi,
getIcdPathMappingDetailApi,
listIcdPathReferencesApi,
listIcdPathsApi
} from '@/api/tools/mmsmapping'
import type { MmsMapping } from '@/api/tools/mmsmapping/interface'
import IcdPathFormDialog from './components/IcdPathFormDialog.vue'
import IcdPathCheckDialog from './components/IcdPathCheckDialog.vue'
import JsonMappingTree from './components/JsonMappingTree.vue'
defineOptions({
name: 'MmsMappingView'
@@ -77,15 +163,56 @@ interface IcdPathTableParams {
const proTable = ref<ProTableInstance>()
const formDialogVisible = ref(false)
const icdCheckDialogVisible = ref(false)
const icdCheckMsgDialogVisible = ref(false)
const icdCheckMsgLoading = ref(false)
const mappingDetailDialogVisible = ref(false)
const mappingDetailLoading = ref(false)
const mappingDetailActiveTab = ref('json')
const formMode = ref<'create' | 'edit'>('create')
const currentFormRecord = ref<MmsMapping.IcdPathRecord | null>(null)
const currentIcdCheckRecord = reactive({
id: '',
name: '',
type: 3
type: 4
})
const activeIcdPathRecord = ref<MmsMapping.IcdPathRecord | null>(null)
const activatingIcdPathId = ref('')
const activeIcdPathMappingJson = ref('')
const activeIcdPathMappingName = ref('')
const referenceIcdNameMap = ref<Record<string, string>>({})
const currentCheckMsgRecordName = ref('')
const currentCheckMsgDetail = ref<MmsMapping.IcdPathCheckMsgResponse>(null)
const currentMappingDetailRecordName = ref('')
const currentMappingDetail = ref<MmsMapping.IcdPathMappingDetailResponse | null>(null)
const formatMappingDetailJsonSource = (source?: string | null, emptyText = '') => {
const text = source?.trim()
if (!text) return emptyText
try {
return JSON.stringify(JSON.parse(text), null, 4)
} catch {
return text
}
}
const mappingDetailJsonSource = computed(() =>
formatMappingDetailJsonSource(currentMappingDetail.value?.jsonStr, '暂无 JSON 映射内容')
)
const ICD_PATH_SQL_COLUMNS = [
'id',
'name',
'angle',
'use_phase_index',
'state',
'json_str',
'xml_str',
'result',
'msg',
'type',
'reference_icd_id',
'create_by',
'create_time',
'update_by',
'update_time'
] as const
function unwrapApiPayload<T>(response: ResultData<T> | T): T {
if (response && typeof response === 'object' && 'data' in response) {
@@ -129,12 +256,16 @@ const resultOptions = [
]
const icdTypeOptions = [
{ label: '手动录入的标准', value: 1 },
{ label: '手动录入的非标准', value: 2 },
{ label: '上游解析传递', value: 3 }
{ label: '手动录入的标准 ICD', value: 1 },
{ label: '手动录入的非标准 ICD', value: 2 },
{ label: '上游解析传递的标准 ICD', value: 3 },
{ label: '上游解析传递的非标准 ICD', value: 4 }
]
const getIcdTypeText = (value?: number) => icdTypeOptions.find(option => option.value === value)?.label || '未知类型'
const isStandardIcdType = (type?: number) => type === 1 || type === 3
const isNonStandardIcdType = (type?: number) => type === 2 || type === 4
const getReferenceIcdName = (id?: string) => (id ? referenceIcdNameMap.value[id] || '' : '')
const renderIcdCheckMsg = (value: MmsMapping.IcdPathRecord['msg']) => {
if (!value) return ''
@@ -148,36 +279,189 @@ const renderIcdCheckMsg = (value: MmsMapping.IcdPathRecord['msg']) => {
}
}
const renderActivationStatus = (row: MmsMapping.IcdPathRecord) => {
if (row.type === 1) {
const icdCheckMsgSummary = computed(() => {
const detail = currentCheckMsgDetail.value
if (!detail) return ''
if (typeof detail.summary === 'string' && detail.summary.trim()) return detail.summary.trim()
return ''
})
const normalizeIcdCheckMsgJsonValue = (value: unknown): unknown => {
if (typeof value === 'string') {
const trimmedValue = value.trim()
if (!trimmedValue) return value
try {
return normalizeIcdCheckMsgJsonValue(JSON.parse(trimmedValue))
} catch {
return value
}
}
if (Array.isArray(value)) return value.map(item => normalizeIcdCheckMsgJsonValue(item))
if (value && typeof value === 'object') {
return Object.entries(value).reduce<Record<string, unknown>>((result, [key, item]) => {
result[key] = normalizeIcdCheckMsgJsonValue(item)
return result
}, {})
}
return value
}
const formatIcdCheckMsgJson = (detail: MmsMapping.IcdPathCheckMsgResponse) => {
if (!detail) return ''
return JSON.stringify(normalizeIcdCheckMsgJsonValue(detail), null, 4)
}
const icdCheckMsgExtraJson = computed(() => {
const detail = currentCheckMsgDetail.value
if (!detail) return ''
return formatIcdCheckMsgJson(detail)
})
const renderIcdResult = (row: MmsMapping.IcdPathRecord) => {
if (row.result === 0) {
return (
<el-tag type="success" effect="light">
已激活
<el-tag
class="icd-result-tag--clickable"
type="danger"
effect="light"
onClick={() => handleOpenIcdCheckMsg(row)}
>
{getIcdResultText(row.result)}
</el-tag>
)
}
if (row.type === 2) {
return (
<el-button
type="primary"
size="small"
loading={activatingIcdPathId.value === row.id}
disabled={!row.id}
onClick={() => handleActivateIcdPath(row)}
>
激活
</el-button>
<el-tag type={getIcdResultTagType(row.result)} effect="light">
{getIcdResultText(row.result)}
</el-tag>
)
}
}
const renderReferenceIcdName = (row: MmsMapping.IcdPathRecord) => {
const referenceName = getReferenceIcdName(row.referenceIcdId)
if (!row.referenceIcdId) return '-'
return (
<el-button type="primary" size="small" disabled>
激活
<el-button link type="primary" disabled={!referenceName} onClick={() => openReferenceIcdDetail(row)}>
{referenceName || row.referenceIcdId}
</el-button>
)
}
const renderMappingDetailAction = (row: MmsMapping.IcdPathRecord) => (
<el-button link type="primary" icon={Document} disabled={!row.id} onClick={() => openMappingDetail(row)}>
查看详情
</el-button>
)
const stringifySqlValue = (value: unknown) => {
if (value === undefined || value === null || value === '') return 'NULL'
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL'
if (typeof value === 'boolean') return value ? '1' : '0'
const text = typeof value === 'string' ? value : JSON.stringify(value)
return `'${text.replace(/'/g, "''")}'`
}
const buildIcdPathSqlValues = (
row: MmsMapping.IcdPathRecord,
detail: MmsMapping.IcdPathMappingDetailResponse | null = null
) => [
row.id,
row.name,
row.angle,
row.usePhaseIndex,
row.state,
detail?.jsonStr,
detail?.xmlStr,
row.result,
row.msg,
row.type,
row.referenceIcdId,
row.createBy,
row.createTime,
row.updateBy,
row.updateTime
]
const buildIcdPathInsertSql = (
records: Array<{
row: MmsMapping.IcdPathRecord
detail: MmsMapping.IcdPathMappingDetailResponse | null
}>
) => {
const columns = ICD_PATH_SQL_COLUMNS.map(column => `\`${column}\``).join(', ')
return records
.map(({ row, detail }) => {
const values = buildIcdPathSqlValues(row, detail).map(stringifySqlValue).join(', ')
return `INSERT INTO \`icd_path\` (${columns}) VALUES (${values});`
})
.join('\n')
}
const encodeTextToBase64 = (value?: string) => {
if (!value) return ''
const bytes = new TextEncoder().encode(value)
let binaryText = ''
bytes.forEach(byte => {
binaryText += String.fromCharCode(byte)
})
return window.btoa(binaryText)
}
const buildIcdPathJsonRecord = (
row: MmsMapping.IcdPathRecord,
detail: MmsMapping.IcdPathMappingDetailResponse | null = null
) => ({
id: row.id,
name: row.name,
angle: row.angle,
usePhaseIndex: row.usePhaseIndex,
state: row.state,
icd: encodeTextToBase64(detail?.icdText),
jsonStr: detail?.jsonStr,
xmlStr: detail?.xmlStr,
result: row.result,
msg: row.msg,
type: row.type,
referenceIcdId: row.referenceIcdId,
createBy: row.createBy,
createTime: row.createTime,
updateBy: row.updateBy,
updateTime: row.updateTime
})
const downloadTextFile = (content: string, fileName: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType })
const objectUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = objectUrl
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(objectUrl)
}
const columns = reactive<ColumnProps<MmsMapping.IcdPathRecord>[]>([
{ type: 'selection', fixed: 'left', width: 70 },
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
@@ -192,15 +476,14 @@ const columns = reactive<ColumnProps<MmsMapping.IcdPathRecord>[]>([
label: '关键字',
order: 1,
props: {
placeholder: '请输入 ICD 名称或路径'
placeholder: '请输入 ICD 名称'
}
}
},
{ prop: 'path', label: 'ICD路径', minWidth: 260 },
{
prop: 'type',
label: 'ICD类型',
width: 170,
width: 220,
render: scope => getIcdTypeText(scope.row.type),
enum: icdTypeOptions,
isFilterEnum: false,
@@ -220,15 +503,19 @@ const columns = reactive<ColumnProps<MmsMapping.IcdPathRecord>[]>([
width: 110,
render: scope => getUsePhaseIndexText(scope.row.usePhaseIndex)
},
{
prop: 'mappingDetail',
label: '映射文件详情',
width: 150,
render: scope => renderMappingDetailAction(scope.row)
},
{ prop: 'msg', label: '结论描述', minWidth: 220, isShow: false, render: scope => renderIcdCheckMsg(scope.row.msg) },
{ prop: 'referenceIcdId', label: '参照标准ICD', width: 150, render: scope => renderReferenceIcdName(scope.row) },
{
prop: 'result',
label: '校验结论',
minWidth: 140,
render: scope => (
<el-tag type={getIcdResultTagType(scope.row.result)} effect="light">
{getIcdResultText(scope.row.result)}
</el-tag>
),
width: 150,
render: scope => renderIcdResult(scope.row),
enum: resultOptions,
isFilterEnum: false,
search: {
@@ -237,16 +524,7 @@ const columns = reactive<ColumnProps<MmsMapping.IcdPathRecord>[]>([
order: 3
}
},
{ prop: 'msg', label: '结论描述', minWidth: 220, isShow: false, render: scope => renderIcdCheckMsg(scope.row.msg) },
{ prop: 'referenceIcdId', label: '标准ICD引用', minWidth: 170, isShow: false },
{ prop: 'createTime', label: '创建时间', minWidth: 170 },
{ prop: 'updateTime', label: '更新时间', minWidth: 170, isShow: false },
{
prop: 'activation',
label: '激活',
width: 110,
render: scope => renderActivationStatus(scope.row)
},
{ prop: 'operation', label: '操作', fixed: 'right', width: 240 }
])
@@ -261,13 +539,15 @@ const buildListParams = (params: IcdPathTableParams): MmsMapping.IcdPathListRequ
}
const getTableList = async (params: IcdPathTableParams = {}) => {
await loadReferenceIcdNameMap()
const response = await listIcdPathsApi(buildListParams(params))
const records = unwrapApiPayload<MmsMapping.IcdPathRecord[]>(response) || []
const pageNum = params.pageNum || 1
const pageSize = params.pageSize || 10
const startIndex = (pageNum - 1) * pageSize
activeIcdPathRecord.value = records.find(record => record.type === 1 && record.state !== 0) || activeIcdPathRecord.value
activeIcdPathRecord.value = records.find(record => isStandardIcdType(record.type) && record.state !== 0) || activeIcdPathRecord.value
// ICD 路径接口当前返回列表数据;这里统一包装为 ProTable 分页结构,保持和设备类型管理页一致。
return {
@@ -281,11 +561,30 @@ const getTableList = async (params: IcdPathTableParams = {}) => {
}
const refreshActiveIcdPathRecord = async () => {
// 关键业务节点:标准 ICD 不能依赖当前表格筛选结果,否则校验弹窗可能拿不到已激活记录的 JSON
const response = await listIcdPathsApi({ type: 1 })
const records = unwrapApiPayload<MmsMapping.IcdPathRecord[]>(response) || []
// 关键业务节点:标准 ICD 不能依赖当前表格筛选结果,否则校验弹窗可能拿不到已激活记录。
const [manualResponse, upstreamResponse] = await Promise.all([listIcdPathsApi({ type: 1 }), listIcdPathsApi({ type: 3 })])
const records = [
...(unwrapApiPayload<MmsMapping.IcdPathRecord[]>(manualResponse) || []),
...(unwrapApiPayload<MmsMapping.IcdPathRecord[]>(upstreamResponse) || [])
]
activeIcdPathRecord.value = records.find(record => record.type === 1 && record.state !== 0) || null
activeIcdPathRecord.value = records.find(record => isStandardIcdType(record.type) && record.state !== 0) || null
if (!activeIcdPathRecord.value?.id) {
activeIcdPathMappingJson.value = ''
activeIcdPathMappingName.value = ''
return
}
try {
const detail = await getIcdPathMappingDetail(activeIcdPathRecord.value)
activeIcdPathMappingJson.value = detail.jsonStr?.trim() || ''
activeIcdPathMappingName.value = detail.name?.trim() || activeIcdPathRecord.value.name?.trim() || ''
} catch {
activeIcdPathMappingJson.value = ''
activeIcdPathMappingName.value = activeIcdPathRecord.value.name?.trim() || ''
}
}
const refreshIcdPaths = () => {
@@ -294,6 +593,24 @@ const refreshIcdPaths = () => {
refreshActiveIcdPathRecord()
}
const loadReferenceIcdNameMap = async () => {
try {
const response = await listIcdPathReferencesApi()
const records = unwrapApiPayload<MmsMapping.IcdPathReferenceOption[]>(response) || []
referenceIcdNameMap.value = records.reduce<Record<string, string>>((result, record) => {
if (record.id && record.name) {
result[record.id] = record.name
}
return result
}, {})
} catch (error) {
ElMessage.error(getErrorMessage(error))
referenceIcdNameMap.value = {}
}
}
const handleTableRequestError = (error: unknown) => {
ElMessage.error(getErrorMessage(error))
}
@@ -310,6 +627,50 @@ const openEditDialog = (row: MmsMapping.IcdPathRecord) => {
formDialogVisible.value = true
}
const getIcdPathMappingDetail = async (row: MmsMapping.IcdPathRecord) => {
if (!row.id) {
throw new Error('当前 ICD 记录缺少 ID不能获取映射文件详情')
}
const response = await getIcdPathMappingDetailApi(row.id)
return unwrapApiPayload<MmsMapping.IcdPathMappingDetailResponse>(response) || {}
}
const openMappingDetail = async (row: MmsMapping.IcdPathRecord) => {
if (!row.id) {
ElMessage.warning('当前 ICD 记录缺少 ID不能查看映射文件详情')
return
}
currentMappingDetailRecordName.value = row.name || ''
currentMappingDetail.value = null
mappingDetailActiveTab.value = 'json'
mappingDetailDialogVisible.value = true
mappingDetailLoading.value = true
try {
// 关键业务节点:列表接口不再返回映射大字段,详情弹窗必须按记录 ID 单独获取。
currentMappingDetail.value = await getIcdPathMappingDetail(row)
} catch (error) {
ElMessage.error(getErrorMessage(error))
} finally {
mappingDetailLoading.value = false
}
}
const openReferenceIcdDetail = async (row: MmsMapping.IcdPathRecord) => {
if (!row.referenceIcdId) {
ElMessage.warning('当前 ICD 记录缺少参照标准 ICD不能查看详情')
return
}
await openMappingDetail({
id: row.referenceIcdId,
name: getReferenceIcdName(row.referenceIcdId)
})
}
const deleteIcdPaths = async (ids: string[], successMessage: string) => {
if (!ids.length) {
ElMessage.warning('请选择要删除的 ICD 记录')
@@ -348,56 +709,57 @@ const handleBatchDelete = async (ids: string[]) => {
await deleteIcdPaths(ids, 'ICD 记录批量删除成功')
}
const handleActivateIcdPath = async (row: MmsMapping.IcdPathRecord) => {
if (!row.id) {
ElMessage.warning('当前 ICD 记录缺少 ID不能激活')
return
}
if (!row.jsonStr?.trim()) {
ElMessage.warning('当前 ICD 记录缺少 JSON 映射结果,不能激活,请先完成 ICD 校验并保存')
const handleExportSelectedSql = async (records: MmsMapping.IcdPathRecord[]) => {
if (!records.length) {
ElMessage.warning('请选择要导出的 ICD 记录')
return
}
try {
await ElMessageBox.confirm('激活后该 ICD 将作为标准 ICD 参与一致性校验,是否继续?', '激活ICD记录', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
const exportRecords = await Promise.all(
records.map(async row => ({
row,
detail: row.id ? await getIcdPathMappingDetail(row) : null
}))
)
const sql = buildIcdPathInsertSql(exportRecords)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
activatingIcdPathId.value = row.id
try {
// 关键业务节点:当前后端文档未提供独立激活接口,前端通过更新 ICD 类型为标准记录完成激活。
const response = await updateIcdPathApi({
id: row.id,
name: row.name || '',
path: row.path || '',
angle: row.angle,
usePhaseIndex: row.usePhaseIndex,
type: 1
})
const activated = unwrapApiPayload<boolean>(response)
if (activated === false) {
ElMessage.warning('ICD 记录激活未返回成功')
return
}
ElMessage.success('ICD 记录激活成功')
refreshIcdPaths()
downloadTextFile(sql, `icd-path-${timestamp}.sql`, 'application/sql;charset=utf-8')
ElMessage.success('SQL 已导出')
} catch (error) {
ElMessage.error(getErrorMessage(error))
} finally {
activatingIcdPathId.value = ''
}
}
const handleIcdCheck = (row: MmsMapping.IcdPathRecord) => {
const handleExportSelectedJson = async (records: MmsMapping.IcdPathRecord[]) => {
if (!records.length) {
ElMessage.warning('请选择要导出的 ICD 记录')
return
}
try {
const exportRecords = await Promise.all(
records.map(async row => ({
row,
detail: row.id ? await getIcdPathMappingDetail(row) : null
}))
)
const json = JSON.stringify(
exportRecords.map(({ row, detail }) => buildIcdPathJsonRecord(row, detail)),
null,
4
)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
downloadTextFile(json, `icd-path-${timestamp}.json`, 'application/json;charset=utf-8')
ElMessage.success('JSON 已导出')
} catch (error) {
ElMessage.error(getErrorMessage(error))
}
}
const handleIcdCheck = async (row: MmsMapping.IcdPathRecord) => {
if (!row.id) {
ElMessage.warning('当前 ICD 记录缺少 ID不能执行 ICD 校验')
return
@@ -406,11 +768,38 @@ const handleIcdCheck = (row: MmsMapping.IcdPathRecord) => {
// 关键业务节点MMS 映射页承载 ICD 存储记录校验,生成的 JSON/XML 回写当前 ICD 记录。
currentIcdCheckRecord.id = row.id
currentIcdCheckRecord.name = row.name || ''
currentIcdCheckRecord.type = row.type ?? 3
refreshActiveIcdPathRecord()
currentIcdCheckRecord.type = row.type ?? 4
if (isNonStandardIcdType(currentIcdCheckRecord.type)) {
await refreshActiveIcdPathRecord()
}
icdCheckDialogVisible.value = true
}
const handleOpenIcdCheckMsg = async (row: MmsMapping.IcdPathRecord) => {
if (!row.id) {
ElMessage.warning('当前 ICD 记录缺少 ID不能查看校验结论详情')
return
}
currentCheckMsgRecordName.value = row.name || ''
currentCheckMsgDetail.value = null
icdCheckMsgDialogVisible.value = true
icdCheckMsgLoading.value = true
try {
// 关键业务节点:不一致详情以服务端保存的 Msg 为准,避免列表摘要和详情内容不同步。
const response = await getIcdPathCheckMsgApi(row.id)
currentCheckMsgDetail.value = unwrapApiPayload<MmsMapping.IcdPathCheckMsgResponse>(response)
} catch (error) {
ElMessage.error(getErrorMessage(error))
} finally {
icdCheckMsgLoading.value = false
}
}
nextTick(refreshActiveIcdPathRecord)
</script>
@@ -420,4 +809,71 @@ nextTick(refreshActiveIcdPathRecord)
padding: 0;
overflow: hidden;
}
.icd-result-tag--clickable {
cursor: pointer;
}
.icd-check-msg-detail {
min-height: 120px;
}
.icd-check-msg-detail__section {
margin-top: 16px;
}
.icd-check-msg-detail__title {
margin-bottom: 8px;
color: #303133;
font-size: 14px;
font-weight: 600;
}
.icd-check-msg-detail__json {
margin: 0;
padding: 12px;
overflow: auto;
color: #303133;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
background: #f5f7fa;
border-radius: 4px;
}
.mapping-detail-dialog {
min-height: 180px;
}
.mapping-detail-dialog__tabs {
margin-top: 14px;
}
.mapping-detail-dialog__json {
display: flex;
max-height: 420px;
min-height: 260px;
overflow: auto;
}
.mapping-detail-dialog__json :deep(.json-tree-body),
.mapping-detail-dialog__json :deep(.mapping-json-text) {
overflow: visible;
}
.mapping-detail-dialog__content {
max-height: 420px;
min-height: 260px;
margin: 0;
padding: 12px;
overflow: auto;
color: #303133;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
background: #f5f7fa;
border-radius: 4px;
}
</style>

View File

@@ -48,6 +48,9 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
const isGeneratingXml = ref(false)
const isSavingIcdCheckResult = ref(false)
const lastIcdConsistencyCheckResult = ref<MmsMapping.IcdJsonConsistencyCheckResponse | null>(null)
const reviewedIcdConsistencyProblemList = ref<string[]>([])
const hasConfirmedIcdConsistencyProblems = ref(false)
const shouldOpenIcdConsistencyProblems = ref(0)
const parsedIcdDocument = ref<MmsMapping.IcdDocument | null>(null)
const hasSequenceConfigured = ref(false)
const sequenceDialogVisible = ref(false)
@@ -271,13 +274,20 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
const canSaveIcdCheckResult = computed(() => {
if (!deviceTypeCheckId.value || !mappingJsonPreview.value || !xmlContentForExport.value || isSubmitting.value) return false
if (standardMappingJson.value && !lastIcdConsistencyCheckResult.value) return false
if (icdConsistencyStatus.value === 'failed' && !hasConfirmedIcdConsistencyProblems.value) return false
return true
})
const hasIcdConsistencyCheckResult = computed(() => Boolean(lastIcdConsistencyCheckResult.value))
const reviewedIcdConsistencyResult = computed(() => {
if (!lastIcdConsistencyCheckResult.value) return 1
if (lastIcdConsistencyCheckResult.value.result === 1) return 1
return reviewedIcdConsistencyProblemList.value.length ? 0 : 1
})
const icdConsistencyStatus = computed<IcdConsistencyStatus>(() => {
if (!lastIcdConsistencyCheckResult.value) return ''
return lastIcdConsistencyCheckResult.value.result === 1 ? 'success' : 'failed'
return reviewedIcdConsistencyResult.value === 1 ? 'success' : 'failed'
})
const saveIcdCheckResultText = computed(() => '保存')
@@ -324,7 +334,11 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
...(xmlResponsePayload.value?.problems?.filter(Boolean) || [])
])
const problemList = jsonMappingProblemList
const icdConsistencyProblemList = computed(() => lastIcdConsistencyCheckResult.value?.issues?.filter(Boolean) || [])
const icdConsistencyProblemList = computed(() => {
if (!lastIcdConsistencyCheckResult.value) return []
if (lastIcdConsistencyCheckResult.value.result === 1) return []
return reviewedIcdConsistencyProblemList.value
})
const icdConsistencyProblemEmptyText = '当前 ICD 校验未返回 issues'
const methodDescribe = computed(() => xmlResponsePayload.value?.methodDescribe?.trim() || '')
@@ -397,6 +411,9 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
responsePayload.value = null
xmlResponsePayload.value = null
lastIcdConsistencyCheckResult.value = null
reviewedIcdConsistencyProblemList.value = []
hasConfirmedIcdConsistencyProblems.value = false
shouldOpenIcdConsistencyProblems.value = 0
parsedIcdDocument.value = null
hasSequenceConfigured.value = false
sequenceDialogVisible.value = false
@@ -646,6 +663,8 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
const payload = unwrapApiPayload<MmsMapping.IcdJsonConsistencyCheckResponse>(response)
lastIcdConsistencyCheckResult.value = payload
reviewedIcdConsistencyProblemList.value = payload.issues?.filter(Boolean) || []
hasConfirmedIcdConsistencyProblems.value = payload.result === 1
if (payload.result === 1) {
ElMessage.success(payload.message || `${standardMappingName.value}一致`)
@@ -653,6 +672,7 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
}
activeResultTab.value = 'problem'
shouldOpenIcdConsistencyProblems.value += 1
ElMessage.warning(payload.message || `${standardMappingName.value}不一致`)
} catch (error) {
lastIcdConsistencyCheckResult.value = {
@@ -660,6 +680,9 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
message: getErrorMessage(error),
issues: [getErrorMessage(error)]
}
reviewedIcdConsistencyProblemList.value = [getErrorMessage(error)]
hasConfirmedIcdConsistencyProblems.value = false
shouldOpenIcdConsistencyProblems.value += 1
activeResultTab.value = 'problem'
ElMessage.error(getErrorMessage(error))
} finally {
@@ -677,6 +700,43 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
correctedJson: checkResult?.correctedJson || undefined
})
const handleConfirmIcdConsistencyProblems = () => {
// 关键业务节点:确认只表示用户已审核当前问题列表,剩余问题需要继续带入保存结果,不能把确认动作当成清空问题。
hasConfirmedIcdConsistencyProblems.value = true
if (!reviewedIcdConsistencyProblemList.value.length) {
ElMessage.success('ICD校验问题已确认当前没有剩余问题')
return
}
ElMessage.warning(`ICD校验问题已确认问题列表仍保留 ${reviewedIcdConsistencyProblemList.value.length} 条问题`)
}
const handleRemoveIcdConsistencyProblem = (index: number) => {
if (index < 0 || index >= reviewedIcdConsistencyProblemList.value.length) return
reviewedIcdConsistencyProblemList.value = reviewedIcdConsistencyProblemList.value.filter(
(_, itemIndex) => itemIndex !== index
)
if (!reviewedIcdConsistencyProblemList.value.length) {
hasConfirmedIcdConsistencyProblems.value = true
}
}
const buildReviewedIcdCheckMsg = (
checkResult: MmsMapping.IcdJsonConsistencyCheckResponse | null,
summaryMessage: string
): MmsMapping.IcdCheckMsg => {
const reviewedIssues = reviewedIcdConsistencyProblemList.value
return {
...buildIcdCheckMsg(checkResult, summaryMessage),
details: reviewedIssues,
issuesJson: reviewedIssues.length ? checkResult?.issuesJson || undefined : undefined
}
}
const handleSaveIcdCheckResult = async () => {
if (!deviceTypeCheckId.value) {
ElMessage.warning('当前缺少设备类型 ID不能入库校验结果')
@@ -700,20 +760,28 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
return
}
if (icdConsistencyStatus.value === 'failed' && !hasConfirmedIcdConsistencyProblems.value) {
ElMessage.warning('请先确认 ICD 校验问题列表')
return
}
isSavingIcdCheckResult.value = true
try {
const checkResult = lastIcdConsistencyCheckResult.value
const result = checkResult?.result ?? 1
const result = reviewedIcdConsistencyResult.value
const summaryMessage =
checkResult?.message ||
(xmlContentForExport.value ? 'ICD一致性校验通过JSON/XML已生成' : 'ICD一致性校验通过JSON已生成')
result === 1
? xmlContentForExport.value
? 'ICD一致性校验通过JSON/XML已生成'
: 'ICD一致性校验通过JSON已生成'
: checkResult?.message || 'ICD一致性校验不通过'
const savePayload = {
icdDocument: parsedIcdDocument.value || responsePayload.value?.icdDocument,
mappingJson,
xml: xmlContentForExport.value,
result,
msg: buildIcdCheckMsg(checkResult, summaryMessage)
msg: buildReviewedIcdCheckMsg(checkResult, summaryMessage)
}
// 关键业务节点ICD 校验流程可由设备类型或 ICD 存储记录承载,保存接口由宿主页注入以避免串写业务表。
const response = options.saveIcdCheckResult
@@ -745,6 +813,9 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
}
xmlResponsePayload.value = null
lastIcdConsistencyCheckResult.value = null
reviewedIcdConsistencyProblemList.value = []
hasConfirmedIcdConsistencyProblems.value = false
shouldOpenIcdConsistencyProblems.value = 0
hasSequenceConfigured.value = false
activeResultTab.value = 'json'
}
@@ -809,6 +880,7 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
saveIcdCheckResultText,
hasIcdConsistencyCheckResult,
icdConsistencyStatus,
shouldOpenIcdConsistencyProblems,
confirmDialogVisible,
isConfirmingSelection,
handleIcdFileChange,
@@ -818,6 +890,8 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
handleExportMapping,
handleGenerateXmlMapping,
handleIcdConsistencyCheck,
handleConfirmIcdConsistencyProblems,
handleRemoveIcdConsistencyProblem,
handleUpdateMappingJson,
handleSequenceConfigComplete,
handleSaveIcdCheckResult,

View File

@@ -54,7 +54,11 @@
<div class="panel-section result-card grow-card preview-tab-section">
<div v-if="icdConsistencyStatus" class="icd-consistency-status">
<el-tooltip
:content="icdConsistencyStatus === 'success' ? 'ICD校验通过' : 'ICD校验不通过点击查看JSON映射问题列表'"
:content="
icdConsistencyStatus === 'success'
? 'ICD校验通过'
: `ICD校验不通过点击查看JSON映射问题列表${icdConsistencyProblemCount}条)`
"
placement="top"
>
<button
@@ -62,8 +66,14 @@
:class="['icd-consistency-indicator', `is-${icdConsistencyStatus}`]"
:disabled="icdConsistencyStatus !== 'failed'"
@click="openIcdConsistencyProblems"
>
<el-badge
:value="icdConsistencyProblemCount"
:hidden="icdConsistencyProblemCount === 0"
class="icd-consistency-indicator__badge"
>
<el-icon><WarningFilled /></el-icon>
</el-badge>
</button>
</el-tooltip>
</div>
@@ -108,7 +118,7 @@
plain
size="small"
:icon="WarningFilled"
@click="icdConsistencyProblemDialogVisible = true"
@click="openIcdConsistencyProblems"
>
{{ icdConsistencyProblemButtonText }}
</el-button>
@@ -199,9 +209,22 @@
>
<span class="problem-index">{{ index + 1 }}</span>
<span class="problem-text">{{ problem }}</span>
<el-button
link
type="danger"
:icon="Delete"
class="problem-remove"
@click="emit('remove-icd-consistency-problem', index)"
>
删除
</el-button>
</div>
</div>
<el-empty v-else :description="icdConsistencyProblemEmptyText" />
<template #footer>
<el-button @click="icdConsistencyProblemDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmIcdConsistencyProblems">确认</el-button>
</template>
</el-dialog>
<el-dialog
@@ -301,6 +324,7 @@
import {
CircleCheck,
Connection,
Delete,
Document,
Download,
Finished,
@@ -382,10 +406,12 @@ const props = withDefaults(defineProps<{
isSavingIcdCheckResult: boolean
saveIcdCheckResultText: string
icdConsistencyStatus?: IcdConsistencyStatus
shouldOpenIcdConsistencyProblems?: number
}>(), {
showDescription: true,
showIcdCheckAction: true,
icdConsistencyStatus: ''
icdConsistencyStatus: '',
shouldOpenIcdConsistencyProblems: 0
})
const emit = defineEmits<{
@@ -397,6 +423,8 @@ const emit = defineEmits<{
(event: 'sequence-config-complete'): void
(event: 'icd-check'): void
(event: 'save-icd-check-result'): void
(event: 'confirm-icd-consistency-problems'): void
(event: 'remove-icd-consistency-problem', index: number): void
}>()
const activeTabProxy = computed({
@@ -414,6 +442,7 @@ const icdConsistencyProblemButtonText = computed(() =>
? `${ICD_CONSISTENCY_PROBLEM_LABEL} (${props.icdConsistencyProblemList.length})`
: ICD_CONSISTENCY_PROBLEM_LABEL
)
const icdConsistencyProblemCount = computed(() => props.icdConsistencyProblemList.length)
const problemDialogVisible = ref(false)
const icdConsistencyProblemDialogVisible = ref(false)
const matchResultDialogVisible = ref(false)
@@ -480,6 +509,19 @@ const openIcdConsistencyProblems = () => {
icdConsistencyProblemDialogVisible.value = true
}
const confirmIcdConsistencyProblems = () => {
emit('confirm-icd-consistency-problems')
icdConsistencyProblemDialogVisible.value = false
}
watch(
() => props.shouldOpenIcdConsistencyProblems,
value => {
if (!value) return
openIcdConsistencyProblems()
}
)
const isRecord = (value: unknown): value is JsonObject =>
Boolean(value && typeof value === 'object' && !Array.isArray(value))
@@ -940,6 +982,11 @@ const confirmSequenceConfig = () => {
word-break: break-word;
}
.problem-remove {
flex: 0 0 auto;
margin-top: 1px;
}
.match-result-detail {
max-height: 58vh;
padding: 14px 16px;

View File

@@ -0,0 +1,217 @@
<template>
<section class="mapping-panel request-panel">
<div class="panel-header">
<div>
<div class="panel-title-tabs">
<span class="panel-title-tab">PQDIF解析</span>
</div>
<p class="panel-description">选择 .pqd .pqdif 文件后调用后端解析接口读取结构和采样摘要</p>
</div>
<el-tag :type="requestStatusTagType" effect="light">{{ requestStatusText }}</el-tag>
</div>
<div class="panel-content">
<div class="panel-section file-action-row">
<div class="file-select-row">
<el-input
:model-value="selectedFileName"
readonly
placeholder="请选择 .pqd 或 .pqdif 文件"
class="file-input"
/>
<el-button type="primary" :icon="FolderOpened" :disabled="isParsing" @click="openFilePicker">
选择文件
</el-button>
<input
ref="fileInputRef"
class="hidden-file-input"
type="file"
accept=".pqd,.pqdif"
@change="event => emit('file-change', event)"
/>
</div>
<el-button type="primary" :icon="Search" :loading="isParsing" :disabled="!canParse" @click="emit('parse')">
开始解析
</el-button>
<el-button type="danger" plain :icon="Delete" :disabled="!canReset" @click="emit('reset')">清空</el-button>
</div>
<div v-if="selectedFileName" class="file-meta">
<el-icon><Document /></el-icon>
<span class="file-meta__name">{{ selectedFileName }}</span>
<span v-if="selectedFileSizeText" class="file-meta__size">{{ selectedFileSizeText }}</span>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { Delete, Document, FolderOpened, Search } from '@element-plus/icons-vue'
import { ref } from 'vue'
defineOptions({
name: 'PqdifRequestPanel'
})
type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
defineProps<{
selectedFileName: string
selectedFileSizeText: string
isParsing: boolean
canParse: boolean
canReset: boolean
requestStatusText: string
requestStatusTagType: TagType
}>()
const emit = defineEmits<{
(event: 'file-change', value: Event): void
(event: 'parse'): void
(event: 'reset'): void
}>()
const fileInputRef = ref<HTMLInputElement | null>(null)
const openFilePicker = () => {
fileInputRef.value?.click()
}
</script>
<style scoped lang="scss">
.mapping-panel {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
min-height: 0;
padding: 24px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
overflow: hidden;
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.panel-content {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.panel-section {
border: 1px solid #e5e7eb;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.panel-title-tabs {
display: inline-flex;
align-items: center;
border-bottom: 1px solid var(--el-border-color-light);
}
.panel-title-tab {
position: relative;
height: 36px;
padding: 0 2px;
color: var(--el-color-primary);
font-size: 13px;
line-height: 36px;
white-space: nowrap;
}
.panel-title-tab::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 2px;
background-color: var(--el-color-primary);
content: '';
}
.panel-description {
margin: 8px 0 0;
color: #4b5563;
font-size: 14px;
line-height: 1.7;
}
.file-action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 16px;
}
.file-select-row {
display: flex;
flex: 1;
align-items: center;
gap: 12px;
min-width: 0;
}
.file-input {
width: 420px;
max-width: 100%;
}
.hidden-file-input {
display: none;
}
.file-meta {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
color: #475569;
font-size: 13px;
}
.file-meta__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta__size {
flex: 0 0 auto;
color: #909399;
}
@media (max-width: 768px) {
.mapping-panel {
padding: 20px;
}
.panel-header {
flex-direction: column;
align-items: flex-start;
}
.file-select-row {
width: 100%;
}
.file-input {
width: 100%;
}
.file-action-row {
align-items: stretch;
}
}
</style>

View File

@@ -0,0 +1,333 @@
<template>
<section class="mapping-panel result-panel">
<div class="panel-header">
<div class="result-view-tabs">
<button
type="button"
:class="['panel-title-tab', { 'is-active': activeTabProxy === 'records' }]"
@click="activeTabProxy = 'records'"
>
Records
</button>
<button
type="button"
:class="['panel-title-tab', { 'is-active': activeTabProxy === 'observations' }]"
@click="activeTabProxy = 'observations'"
>
Observations
</button>
</div>
<div class="panel-actions">
<el-tag type="info" effect="light">Record {{ records.length }}</el-tag>
<el-tag type="info" effect="light">Observation {{ observations.length }}</el-tag>
</div>
</div>
<div class="panel-content panel-content--fixed">
<div class="panel-section result-card">
<el-tabs v-model="activeTabProxy" class="result-tabs">
<el-tab-pane label="Records" name="records">
<el-table v-if="records.length" :data="records" border height="100%">
<el-table-column prop="recordIndex" label="下标" width="90" />
<el-table-column prop="typeName" label="类型名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="typeGuid" label="类型GUID" min-width="260" show-overflow-tooltip />
<el-table-column label="Observation" width="130">
<template #default="{ row }">
<el-tag :type="row.observation ? 'success' : 'info'" effect="light">
{{ row.observation ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无 Record 数据" />
</el-tab-pane>
<el-tab-pane label="Observations" name="observations">
<el-scrollbar v-if="observations.length" class="observation-scroll">
<el-collapse class="observation-collapse">
<el-collapse-item
v-for="(observation, observationIndex) in observations"
:key="`${observation.recordIndex}-${observationIndex}`"
:name="String(observationIndex)"
>
<template #title>
<span class="observation-title">
{{ observation.name || `Observation ${observationIndex + 1}` }}
</span>
<span class="observation-meta">
Record {{ observation.recordIndex ?? '-' }} / Channel {{ observation.channelCount ?? 0 }}
</span>
</template>
<el-descriptions :column="2" border class="observation-desc">
<el-descriptions-item label="开始时间">
{{ observation.timeStartText || '-' }}
</el-descriptions-item>
<el-descriptions-item label="Excel days">
{{ observation.timeStartExcelDays ?? '-' }}
</el-descriptions-item>
</el-descriptions>
<div
v-for="(channel, channelIndex) in observation.channels || []"
:key="`${channel.channelIndex}-${channelIndex}`"
class="channel-card"
>
<div class="channel-header">
<div class="channel-title">
{{ channel.name || `Channel ${channelIndex + 1}` }}
</div>
<div class="channel-meta">
Series {{ channel.seriesCount ?? 0 }} / Phase {{ channel.phaseId ?? '-' }}
</div>
</div>
<el-descriptions :column="2" border class="channel-desc">
<el-descriptions-item label="量类型GUID">
{{ channel.quantityTypeGuid || '-' }}
</el-descriptions-item>
<el-descriptions-item label="测量量ID">
{{ channel.quantityMeasuredId ?? '-' }}
</el-descriptions-item>
</el-descriptions>
<el-table :data="channel.series || []" border row-key="seriesIndex">
<el-table-column prop="seriesIndex" label="Series" width="90" />
<el-table-column prop="dataStatus" label="数据状态" width="140">
<template #default="{ row }">
<el-tag
:type="row.dataStatus === 'DATA_FAILED' ? 'danger' : 'success'"
effect="light"
>
{{ row.dataStatus || '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="valueCount" label="点数" width="100" />
<el-table-column prop="quantityUnitsId" label="单位ID" width="100" />
<el-table-column prop="seriesBaseType" label="基础类型" width="110" />
<el-table-column label="scale/offset" width="140">
<template #default="{ row }">
{{ row.scale ?? '-' }} / {{ row.offset ?? '-' }}
</template>
</el-table-column>
<el-table-column label="前5个采样值" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ formatFirstValues(row.firstValues) }}</template>
</el-table-column>
<el-table-column prop="dataMessage" label="数据消息" min-width="180" show-overflow-tooltip />
</el-table>
</div>
</el-collapse-item>
</el-collapse>
</el-scrollbar>
<el-empty v-else description="暂无 Observation 数据" />
</el-tab-pane>
</el-tabs>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ParsePqdif } from '@/api/tools/parsePqdif/interface'
defineOptions({
name: 'PqdifResultPanel'
})
const props = defineProps<{
activeTab: 'records' | 'observations'
records: ParsePqdif.RecordItem[]
observations: ParsePqdif.ObservationItem[]
}>()
const emit = defineEmits<{
(event: 'update:activeTab', value: 'records' | 'observations'): void
}>()
const activeTabProxy = computed({
get: () => props.activeTab,
set: value => emit('update:activeTab', value)
})
const formatFirstValues = (values?: number[]) => {
if (!values?.length) return '-'
return values.map(value => String(value)).join(', ')
}
</script>
<style scoped lang="scss">
.mapping-panel {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
padding: 24px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
overflow: hidden;
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.panel-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.result-view-tabs {
display: inline-flex;
align-items: center;
gap: 16px;
border-bottom: 1px solid var(--el-border-color-light);
}
.panel-title-tab {
position: relative;
height: 36px;
padding: 0 2px;
border: 0;
background: transparent;
color: #64748b;
font-size: 13px;
line-height: 36px;
white-space: nowrap;
cursor: pointer;
}
.panel-title-tab.is-active {
color: var(--el-color-primary);
}
.panel-title-tab.is-active::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 2px;
background-color: var(--el-color-primary);
content: '';
}
.result-panel {
min-height: 0;
}
.panel-content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: auto;
}
.panel-content--fixed {
overflow: hidden;
}
.panel-section {
border: 1px solid #e5e7eb;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.result-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 16px;
overflow: hidden;
}
.result-tabs {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
:deep(.el-tabs__header) {
display: none;
}
:deep(.el-tabs__content) {
flex: 1;
min-height: 0;
}
:deep(.el-tab-pane) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
}
.observation-collapse {
border-top: 0;
}
.observation-title {
margin-right: 12px;
color: #1f2937;
font-weight: 600;
}
.observation-meta,
.channel-meta {
color: #64748b;
font-size: 12px;
}
.observation-scroll {
flex: 1;
min-height: 0;
}
.observation-desc,
.channel-desc {
margin-bottom: 12px;
}
.channel-card {
margin-top: 12px;
padding: 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
}
.channel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.channel-title {
color: #1f2937;
font-weight: 600;
}
@media (max-width: 768px) {
.mapping-panel {
padding: 20px;
}
.panel-header,
.panel-actions {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,148 @@
<template>
<section class="mapping-panel summary-panel">
<div class="panel-header">
<div class="panel-title-tabs">
<span class="panel-title-tab">解析摘要</span>
</div>
<el-tag v-if="result" :type="statusTagType" effect="light">{{ statusText }}</el-tag>
</div>
<template v-if="result">
<el-alert
v-if="result.message"
:title="result.message"
:type="isBusinessFailed ? 'error' : 'success'"
show-icon
:closable="false"
/>
<div class="summary-metric-list">
<div v-for="metric in summaryMetrics" :key="metric.label" class="summary-metric-item">
<span class="summary-metric-item__label">{{ metric.label }}</span>
<span class="summary-metric-item__value">{{ metric.value }}</span>
</div>
</div>
<el-descriptions :column="1" border>
<el-descriptions-item label="文件名">{{ result.fileName || '-' }}</el-descriptions-item>
<el-descriptions-item label="native版本">{{ result.nativeVersion || '-' }}</el-descriptions-item>
</el-descriptions>
</template>
<el-empty v-else description="暂无解析摘要" />
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ParsePqdif } from '@/api/tools/parsePqdif/interface'
defineOptions({
name: 'PqdifSummaryPanel'
})
const props = defineProps<{
result: ParsePqdif.ParseResponse | null
isBusinessFailed: boolean
}>()
const statusText = computed(() => {
if (props.result?.status === 'SUCCESS') return '成功'
if (props.result?.status === 'FAILED') return '失败'
return props.result?.status || '-'
})
const statusTagType = computed(() => {
if (props.result?.status === 'SUCCESS') return 'success'
if (props.result?.status === 'FAILED') return 'danger'
return 'info'
})
const summaryMetrics = computed(() => [
{ label: 'Record', value: props.result?.recordCount ?? 0 },
{ label: 'Observation', value: props.result?.observationCount ?? 0 },
{ label: '采样上限', value: props.result?.sampleValueCount ?? 0 },
{ label: '业务状态', value: props.result?.status || '-' }
])
</script>
<style scoped lang="scss">
.mapping-panel {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
padding: 24px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.panel-title-tabs {
display: inline-flex;
align-items: center;
border-bottom: 1px solid var(--el-border-color-light);
}
.panel-title-tab {
position: relative;
height: 36px;
padding: 0 2px;
color: var(--el-color-primary);
font-size: 13px;
line-height: 36px;
white-space: nowrap;
}
.panel-title-tab::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 2px;
background-color: var(--el-color-primary);
content: '';
}
.summary-panel {
height: 100%;
}
.summary-metric-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.summary-metric-item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
}
.summary-metric-item__label {
color: #64748b;
font-size: 12px;
line-height: 1.4;
}
.summary-metric-item__value {
overflow: hidden;
color: #1f2937;
font-size: 18px;
font-weight: 600;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,67 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(currentDir, '../../../..')
const files = {
page: path.resolve(rootDir, 'views/tools/parsePqdif/index.vue'),
flow: path.resolve(rootDir, 'views/tools/parsePqdif/utils/useParsePqdifFlow.ts'),
requestPanel: path.resolve(rootDir, 'views/tools/parsePqdif/components/PqdifRequestPanel.vue'),
summaryPanel: path.resolve(rootDir, 'views/tools/parsePqdif/components/PqdifSummaryPanel.vue'),
resultPanel: path.resolve(rootDir, 'views/tools/parsePqdif/components/PqdifResultPanel.vue'),
api: path.resolve(rootDir, 'api/tools/parsePqdif/index.ts'),
apiTypes: path.resolve(rootDir, 'api/tools/parsePqdif/interface/index.ts')
}
const exists = file => fs.existsSync(file)
const read = file => (exists(file) ? fs.readFileSync(file, 'utf8') : '')
const pageSource = read(files.page)
const flowSource = read(files.flow)
const apiSource = read(files.api)
const apiTypesSource = read(files.apiTypes)
const checks = [
['parsePqdif API file exists', () => exists(files.api)],
['parsePqdif API type file exists', () => exists(files.apiTypes)],
['parsePqdif API uses documented endpoint', () => /\/api\/parse-pqdif\/parse/.test(apiSource)],
['parsePqdif API appends documented pqdifFile field', () => /append\('pqdifFile',\s*pqdifFile\)/.test(apiSource)],
['parsePqdif API sends multipart form data', () => /multipart\/form-data/.test(apiSource)],
['parsePqdif types include response status', () => /export\s+type\s+ParseStatus/.test(apiTypesSource)],
['parsePqdif types include observations', () => /interface\s+ObservationItem/.test(apiTypesSource)],
['parsePqdif flow exists', () => exists(files.flow)],
['parsePqdif flow imports API', () => /parsePqdifApi/.test(flowSource)],
['parsePqdif flow validates .pqd and .pqdif suffixes', () => /SUPPORTED_PQDIF_EXTENSIONS/.test(flowSource)],
['request panel exists', () => exists(files.requestPanel)],
['summary panel exists', () => exists(files.summaryPanel)],
['result panel exists', () => exists(files.resultPanel)],
['page uses mmsMapping-style parse layout', () => /parse-pqdif-grid/.test(pageSource)],
['request panel reuses mapping panel class', () => /class="mapping-panel/.test(read(files.requestPanel))],
['request panel keeps compact action section', () => /panel-section file-action-row/.test(read(files.requestPanel))],
['summary panel exposes metric cards', () => /summary-metric-list/.test(read(files.summaryPanel))],
['result panel exposes records and observations tabs', () => /result-view-tabs/.test(read(files.resultPanel))],
['result panel highlights failed series rows', () => /DATA_FAILED/.test(read(files.resultPanel))],
[
'page imports parsePqdif components',
() =>
/PqdifRequestPanel/.test(pageSource) &&
/PqdifSummaryPanel/.test(pageSource) &&
/PqdifResultPanel/.test(pageSource)
],
['page keeps route component name', () => /name:\s*'ParsePqdifView'/.test(pageSource)],
['page uses parsePqdif flow composable', () => /useParsePqdifFlow/.test(pageSource)]
]
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
if (failures.length) {
console.error('parsePqdif page contract failed:')
for (const failure of failures) {
console.error(`- ${failure}`)
}
process.exit(1)
}
console.log('parsePqdif page contract passed')

View File

@@ -0,0 +1,48 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(currentDir, '../../../..')
const files = {
page: path.resolve(rootDir, 'views/tools/parsePqdif/index.vue'),
toolsHome: path.resolve(rootDir, 'views/tools/index.vue'),
staticRouter: path.resolve(rootDir, 'routers/modules/staticRouter.ts'),
dynamicRouter: path.resolve(rootDir, 'routers/modules/dynamicRouter.ts')
}
const read = file => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '')
const exists = file => fs.existsSync(file)
const checks = [
['parsePqdif page exists', () => exists(files.page)],
['parsePqdif page declares route component name', () => /name:\s*'ParsePqdifView'/.test(read(files.page))],
['static router registers /tools/parsePqdif', () => /path:\s*'\/tools\/parsePqdif'/.test(read(files.staticRouter))],
['static route name is toolParsePqdif', () => /name:\s*'toolParsePqdif'/.test(read(files.staticRouter))],
[
'static router imports parsePqdif page',
() => /@\/views\/tools\/parsePqdif\/index\.vue/.test(read(files.staticRouter))
],
[
'dynamic router keeps parsePqdif static route from being overwritten',
() => /STATIC_ROUTE_NAMES[\s\S]*'toolParsePqdif'/.test(read(files.dynamicRouter))
],
[
'tools center links to parsePqdif',
() => /handleNavigate\('\/tools\/parsePqdif'\)/.test(read(files.toolsHome)) && /PQDIF解析/.test(read(files.toolsHome))
]
]
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
if (failures.length) {
console.error('parsePqdif route contract failed:')
for (const failure of failures) {
console.error(`- ${failure}`)
}
process.exit(1)
}
console.log('parsePqdif route contract passed')

View File

@@ -0,0 +1,84 @@
<template>
<div class="table-box parse-pqdif-page">
<section class="parse-pqdif-grid">
<PqdifRequestPanel
:selected-file-name="selectedFileName"
:selected-file-size-text="selectedFileSizeText"
:is-parsing="isParsing"
:can-parse="canParse"
:can-reset="canReset"
:request-status-text="requestStatusText"
:request-status-tag-type="requestStatusTagType"
@file-change="handleFileChange"
@parse="parseSelectedFile"
@reset="resetPage"
/>
<PqdifSummaryPanel :result="parseResult" :is-business-failed="isBusinessFailed" />
<PqdifResultPanel
v-model:active-tab="activeResultTab"
:records="records"
:observations="observations"
class="parse-pqdif-result"
/>
</section>
</div>
</template>
<script setup lang="ts">
import PqdifRequestPanel from './components/PqdifRequestPanel.vue'
import PqdifResultPanel from './components/PqdifResultPanel.vue'
import PqdifSummaryPanel from './components/PqdifSummaryPanel.vue'
import { useParsePqdifFlow } from './utils/useParsePqdifFlow'
defineOptions({
name: 'ParsePqdifView'
})
const {
selectedFileName,
selectedFileSizeText,
parseResult,
records,
observations,
isParsing,
isBusinessFailed,
activeResultTab,
requestStatusText,
requestStatusTagType,
canParse,
canReset,
handleFileChange,
parseSelectedFile,
resetPage
} = useParsePqdifFlow()
</script>
<style scoped lang="scss">
.parse-pqdif-page {
min-height: 0;
padding: 0;
overflow: hidden;
}
.parse-pqdif-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 420px;
gap: 12px;
height: 100%;
min-height: 0;
}
.parse-pqdif-result {
grid-column: 1 / -1;
min-height: 0;
}
@media (max-width: 1200px) {
.parse-pqdif-grid {
grid-template-columns: 1fr;
overflow-y: auto;
}
}
</style>

View File

@@ -0,0 +1,142 @@
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { ResultData } from '@/api/interface'
import { parsePqdifApi } from '@/api/tools/parsePqdif'
import type { ParsePqdif } from '@/api/tools/parsePqdif/interface'
type ResultTab = 'records' | 'observations'
type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
const SUPPORTED_PQDIF_EXTENSIONS = ['pqd', 'pqdif']
const unwrapApiPayload = <T>(response: ResultData<T> | T): T => {
if (response && typeof response === 'object' && 'data' in response) {
return (response as ResultData<T>).data
}
return response as T
}
const getFileExtension = (fileName: string) => fileName.split('.').pop()?.toLowerCase() || ''
const formatFileSize = (size: number) => {
if (!Number.isFinite(size) || size <= 0) return '0 B'
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
return `${(size / 1024 / 1024).toFixed(2)} MB`
}
const getErrorMessage = (error: unknown) => {
if (error instanceof Error && error.message) return error.message
if (error && typeof error === 'object' && 'message' in error) return String((error as { message?: unknown }).message || '')
return 'PQDIF解析接口调用失败请检查后端服务和文件内容'
}
export const useParsePqdifFlow = () => {
const selectedFile = ref<File | null>(null)
const parseResult = ref<ParsePqdif.ParseResponse | null>(null)
const isParsing = ref(false)
const activeResultTab = ref<ResultTab>('records')
const selectedFileName = computed(() => selectedFile.value?.name || '')
const selectedFileSizeText = computed(() => (selectedFile.value ? formatFileSize(selectedFile.value.size) : ''))
const hasResult = computed(() => Boolean(parseResult.value))
const isBusinessFailed = computed(() => parseResult.value?.status === 'FAILED')
const records = computed(() => parseResult.value?.records || [])
const observations = computed(() => parseResult.value?.observations || [])
const canParse = computed(() => Boolean(selectedFile.value && !isParsing.value))
const canReset = computed(() => Boolean(selectedFile.value || parseResult.value) && !isParsing.value)
const requestStatusText = computed(() => {
if (isParsing.value) return '解析中'
if (parseResult.value?.status === 'SUCCESS') return '解析成功'
if (parseResult.value?.status === 'FAILED') return '解析失败'
if (selectedFile.value) return '待解析'
return '未选择文件'
})
const requestStatusTagType = computed<TagType>(() => {
if (isParsing.value) return 'warning'
if (parseResult.value?.status === 'SUCCESS') return 'success'
if (parseResult.value?.status === 'FAILED') return 'danger'
if (selectedFile.value) return 'primary'
return 'info'
})
const isSupportedPqdifFile = (fileName: string) => SUPPORTED_PQDIF_EXTENSIONS.includes(getFileExtension(fileName))
const setSelectedFile = (file: File | null) => {
if (!file) return
if (!isSupportedPqdifFile(file.name)) {
ElMessage.warning('仅支持 .pqd 或 .pqdif 格式文件')
return
}
selectedFile.value = file
parseResult.value = null
activeResultTab.value = 'records'
}
const handleFileChange = (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0] || null
setSelectedFile(file)
input.value = ''
}
const parseSelectedFile = async () => {
if (!selectedFile.value) {
ElMessage.warning('请先选择 PQDIF 文件')
return
}
isParsing.value = true
try {
// 关键业务节点HTTP code 成功不等于解析成功,业务失败需要读取 data.status 和 data.message。
const response = await parsePqdifApi(selectedFile.value)
const payload = unwrapApiPayload(response)
parseResult.value = payload
activeResultTab.value = payload.observations?.length ? 'observations' : 'records'
if (payload.status === 'FAILED') {
ElMessage.error(payload.message || 'PQDIF解析失败')
return
}
ElMessage.success(payload.message || 'PQDIF解析完成')
} catch (error) {
ElMessage.error(getErrorMessage(error))
} finally {
isParsing.value = false
}
}
const resetPage = () => {
selectedFile.value = null
parseResult.value = null
activeResultTab.value = 'records'
}
return {
selectedFile,
selectedFileName,
selectedFileSizeText,
parseResult,
records,
observations,
isParsing,
isBusinessFailed,
hasResult,
activeResultTab,
requestStatusText,
requestStatusTagType,
canParse,
canReset,
handleFileChange,
parseSelectedFile,
resetPage
}
}