Compare commits

...

10 Commits

Author SHA1 Message Date
a1e1fb124a feat(mmsmapping): 添加 XML 映射生成功能和波形标记功能
- 新增 getXmlFromJsonApi 接口用于从 JSON 生成 XML 映射
- 添加 XML 映射相关的数据结构定义和响应处理
- 实现 XML 映射生成功能,支持 JSON 到 XML 的转换
- 添加波形图表点击事件处理和标记功能
- 实现趋势图表的标记点显示和标签功能
- 更新界面以支持 XML 映射预览和导出
- 优化图表交互体验,添加标记工具模式
- 重构部分界面组件以支持新的映射功能
2026-05-08 09:54:52 +08:00
fe3ab1f679 refactor(waveform): 优化波形趋势图表交互功能
- 添加了 canResetTrendChart 计算属性用于判断是否可以重置图表
- 更新了工具栏状态控制逻辑,禁用状态下禁止重置操作
- 调整了图表边距配置,右侧留白从 18px 增加到 36px
- 时间轴底部留白从 30px 调整为 34px
- 时间轴标签间距从 6 调整为 8
- 集成了 ECharts 工具箱的数据缩放功能
- 替换了图标组件,使用箭头图标替代缩放图标
- 调整了工具栏项目顺序,将平移工具移到最后
2026-05-07 11:47:44 +08:00
32f324909d feat(waveform): 添加趋势图动态线宽和平移功能
- 实现趋势图按可见点数动态计算线宽,避免大数据量线条过粗
- 新增平移工具支持图表区域拖拽浏览
- 优化数据缩放事件处理,提升图表交互体验
- 添加线宽分档规则配置,支持不同数据量级的显示优化
- 移除原有的光标测量和峰值定位功能
- 更新图表点击事件为数据缩放事件处理
2026-05-07 11:16:51 +08:00
2babe9d99d feat(waveform): 添加波形图趋势工具和坐标轴规则
- 实现波形图纵坐标显示规则,包括最大最小值显示、刻度均分、对称边界等功能
- 添加多图趋势图对齐规则,确保绘图区左边界一致和标签宽度统一
- 集成图表点击事件发射器,支持时间标签、数值和系列名称的数据传递
- 实现波形图缩放、平移、测量、峰值标记等交互功能
- 添加坐标轴刻度精度控制和可读步长归一化处理
- 实现多图对齐的网格配置和纵坐标标签防重叠机制
- 添加波形图全屏展示和图片导出功能
- 实现趋势图峰值标记点和测量游标功能
2026-05-07 09:38:06 +08:00
407ab0a7f6 feat(waveform): 添加全通道波形数据显示功能
- 实现全通道模式下的波形数据展示
- 添加通道选择器支持全部/单个通道切换
- 新增全通道趋势分组数据结构
- 重构波形数据获取逻辑支持多通道模式
- 更新图表配置支持动态图例显示控制
- 完善波形数据导出功能支持全通道数据
- 优化工具栏界面适配新的通道选择功能
2026-05-06 16:35:48 +08:00
bf9f3719a4 feat(mmsMapping): 添加映射确认对话框的诊断日志和键值优化
- 实现 logConfirmDataDiagnostics 函数用于诊断间谐波相关数据
- 添加控制台日志输出用于调试构建索引确认数据的结果
- 为标签项目和目标项目添加唯一键值以优化列表渲染性能
- 在准备确认对话框草稿数据时生成默认实例编号
- 修复确认对话框中的默认状态映射逻辑
- 为间谐波相关的 lnInst 缺失情况添加警告日志
2026-05-06 11:41:20 +08:00
483b3d7ae4 feat(mmsmapping): 添加ICD索引配置人工确认功能
- 新增IndexConfirmTarget、IndexConfirmLabelItem、IndexConfirmGroup等接口定义
- 添加buildIndexConfirmDataApi和buildIndexSelectionApi两个API方法
- 实现MappingConfirmDialog组件用于人工确认索引配置
- 将解析ICD流程分为候选数据获取和人工确认两个步骤
- 添加确认弹窗的验证逻辑和状态管理
- 更新页面重置逻辑以清除确认相关状态
- 修改请求配置面板显示确认按钮和相应操作
- 移除原有的自动生成默认索引选择的工具函数
2026-05-06 08:47:39 +08:00
297f89ef52 docs(api): 删除MMS映射API调试文档
- 移除generateAndSubmitIndexSelection标准API调试文档
- 移除getIcdMmsJson标准API调试文档
- 清理相关API接口说明和调试示例内容
- 删除Postman和cURL调试方式说明
- 移除请求响应结构定义和常见问题解答
2026-04-30 11:30:59 +08:00
0dc0e4ecdc feat(tools): 新增数据补数功能模块
- 实现补数任务面板组件,支持监测点ID输入、时间范围选择和时间步长设置
- 添加任务状态卡片组件,实时展示任务执行进度和结果统计
- 集成参数规则表格组件,显示后端配置的模板规则信息
- 实现补数API接口服务,包括预估写入量、创建任务和查询状态功能
- 添加磁盘监控策略对话框组件,支持全局监控配置管理
- 完成补数功能页面布局设计,集成左右双栏界面结构
- 实现任务轮询机制,自动更新任务执行状态直到完成
- 添加表单验证逻辑,确保输入参数符合业务规则要求
2026-04-30 09:04:52 +08:00
398a2cf1dc style(diskMonitor): 统一磁盘监控页面样式规范
- 添加页面边距、表格样式和按钮样式约定到 AGENTS.md 文档
- 为任务详情抽屉组件添加卡片样式和表格结构优化
- 重构任务表格组件,增加搜索功能和列设置选项
- 移除独立的策略表单组件,整合到汇总页面
- 优化监控摘要组件的网格布局和样式
- 重新设计目标对话框的表单分组和禁用状态处理
- 统一所有组件使用 card、table-main 等标准类名
- 添加文件编码规范要求确保 UTF-8 编码一致性
2026-04-30 09:02:57 +08:00
42 changed files with 6506 additions and 1677 deletions

View File

@@ -17,6 +17,9 @@
- 外科手术式修改:只改与任务直接相关的文件和代码行,不重构无关模块,不调整无关格式或注释。
- 保持现有风格:遵循仓库已有包结构、分层方式、命名和写法,不按个人偏好重写。
- 只清理自己造成的问题:可以删除因本次修改而产生的未使用 `import`、变量或方法;不要删除仓库中原本就存在的死代码,除非用户明确要求。
- 页面边距约定:业务页面根节点默认跟随布局主内容区 `el-main``15px` 边距,不再额外叠加页面级外边距;如需特殊边距,必须先有明确的视觉参照页面或业务原因。
- 表格样式约定:业务表格优先复用仓库现有 `table-main``card``table-header` 结构,参照 `dictdata` 页面;表格卡片内部默认不再额外堆叠页面标题、说明文案或自定义装饰区,表头左侧用于主操作、次操作和危险批量操作,右侧用于刷新、列设置、搜索等工具按钮;不要在单页里重复自定义表格卡片边框、表头按钮布局和表头配色,除非有明确视觉参照或业务原因。
- 按钮样式约定:业务页面按钮参照 `dictdata` 页面;表头主操作使用 `type="primary"`,表头次操作使用 `type="primary" plain`,危险批量操作使用 `type="danger" plain`,表格行内操作统一使用 `link` 风格并优先保持 `primary` 语义与图标一致性;弹窗底部保持“取消”默认按钮、“主确认”使用 `primary`,同级辅助执行按钮使用 `primary plain`
- 先定义验证方式:执行方案里要写清楚“改哪里、怎么判断改对了”;默认通过代码路径、配置一致性、界面影响范围和启动链路检查进行验证。
- 除非用户明确要求,否则不执行 `npm run build``npm run build-w``electron-builder`、加密打包或其他重型构建命令;通常只做静态检查、必要的代码阅读和轻量验证。
@@ -35,6 +38,8 @@
## 代码风格与命名规范
前端格式化规则定义在 `frontend/.prettierrc`4 空格缩进、单引号、不写分号、单行 120 字符、LF 换行。Lint 规则基于 Vue 3 与 TypeScript。
文件编码规范:所有新增或修改的源码、脚本、配置、文档统一使用 UTF-8 编码(无 BOM和 LF 换行,不要保存为 GBK、ANSI 或其他本地编码,避免再次出现乱码。
请遵循现有命名方式:
- 页面或路由目录通常使用 `index.vue`,例如 `views/home/`
- 通用组件使用 PascalCase例如 `HomeToolCard.vue`
@@ -59,4 +64,32 @@ PR 应包含:
## 安全与配置提示
`public/ssl/``build/extraResources/``electron/config/` 包含敏感运行资源。不要硬编码新的密钥或口令;凡是影响打包或启动的本地 `.env`、端口或运行配置调整,都应同步记录到 `doc/`
## 趋势图纵坐标显示规则
涉及 waveform 或其他趋势图纵坐标时,统一遵循以下规则:
- 必须显示纵坐标最大值和最小值,图表配置中应显式保留 `showMaxLabel``showMinLabel`
- 纵坐标刻度值采用均分方式生成,不再使用会改变刻度间隔的“友好刻度”取整逻辑。
- 纵坐标最大值和最小值基于图形内真实最大值、最小值按 `1.2` 倍扩展;正数下边界使用 `0.8` 倍向下留白,负数上边界使用 `0.8` 倍向上留白,避免正数最小值或负数最大值被扩展到数据内侧。
- 当数据同时包含正负值且正负幅值接近时,纵坐标最大值和最小值应尽量对称,按较大绝对值向外取整后取 `±同一边界`,例如最大值 `178`、最小值 `-177` 时显示为 `180``-180`
- 当最大值、最小值相同或数据接近 `0` 时,需要补充兜底范围,避免坐标轴退化为一条线;小于 `1` 的小数范围按实际小数精度保留,不强制取整。
- 当纵坐标区间较小且均分后出现冗长小数时,应优先使用 `1``2``2.5``5` 等可读步长归一化刻度;必要时可少量增加分段,但必须继续保证刻度均分、最大最小值显示、真实数据完整落在坐标范围内。
- 纵坐标标签不能重叠。小高度趋势图应减少均分段数,优先保证最大值、最小值和必要中间值可读;高度足够时再增加分段。
## 多图趋势图对齐规则
涉及 waveform 或其他上下堆叠的多张趋势图时,统一遵循以下规则:
- 同一组多图必须保证绘图区左边界一致,纵向观察时各图的 y 轴线、x 轴 `0` 起点和曲线起始位置应上下对齐。
- 多图不得让 ECharts 按各自纵坐标标签宽度自动改变绘图区起点;应使用统一的 `grid.left`,并显式配置 `grid.containLabel: false` 或等效方案,避免 `150``2``-100` 等标签宽度差异导致曲线区域错位。
- 纵坐标标签宽度预留应按同组图中最长标签统一评估,必要时增加统一的左侧 `grid.left`,不能为单张图单独调整左边距。
- 横坐标首尾标签、单位文字或底部留白只能影响底部显示空间,不应改变绘图区左边界;调整 `grid.bottom``axisLabel.margin``nameGap` 时,需要同步检查多图 x=0 起点是否仍然对齐。
- 验证多图趋势图时,至少检查单通道拆分图和全部通道列表图两种场景;判断标准是多张图左侧坐标轴竖线形成同一条垂直线,底部横坐标标签不遮挡、不贴线。
## 趋势图线宽显示规则
涉及 waveform 或其他线形趋势图时,线宽应按当前可见点数动态计算,避免初始化大数据量时线条糊成一片,也避免放大后线条过细。
- 当前可见点数按横向缩放范围计算:`ceil(seriesDataLength * ((dataZoom.end - dataZoom.start) / 100))`
- 初始全量展示时,点数越多线越细;横向放大后可见点数减少,线宽可逐步变粗;横向缩小或重置后线宽恢复到对应细线档位。
- Y 轴缩放、测量模式、峰值显示不改变主线线宽,避免状态切换造成额外视觉跳动。
- 主线最大线宽不得超过 `1.6`
- 线宽分档统一为:`>= 200000` 使用 `0.35``100000 - 199999` 使用 `0.45``50000 - 99999` 使用 `0.55``20000 - 49999` 使用 `0.65``10000 - 19999` 使用 `0.75``5000 - 9999` 使用 `0.9``2000 - 4999` 使用 `1``800 - 1999` 使用 `1.2``200 - 799` 使用 `1.4``< 200` 使用 `1.6`

View File

@@ -0,0 +1,98 @@
import http from '@/api'
import type { ResultData } from '@/api/interface'
import type { AddData } from './interface'
type AddDataRequestMethod = 'get' | 'post'
const ADD_DATA_ROUTE_PATHS = ['/addData', '/api/addData'] as const
const ADD_DATA_BASE_URL = String(import.meta.env.VITE_API_URL || '').trim()
const resolveDevProxyTarget = () => {
const proxyConfig = import.meta.env.VITE_PROXY
if (!Array.isArray(proxyConfig)) return ''
const matchedProxy = proxyConfig.find(item => Array.isArray(item) && item[0] === '/api')
if (!matchedProxy?.[1]) return ''
return String(matchedProxy[1]).replace(/\/+$/, '')
}
const buildAddDataRequestPaths = (path: string) => {
const requestPaths = new Set<string>()
const devProxyTarget = resolveDevProxyTarget()
for (const routePath of ADD_DATA_ROUTE_PATHS) {
if (ADD_DATA_BASE_URL === '/api' && routePath.startsWith('/api/')) {
if (devProxyTarget) {
requestPaths.add(`${devProxyTarget}${routePath}${path}`)
}
requestPaths.add(`${window.location.origin}${routePath}${path}`)
continue
}
requestPaths.add(`${routePath}${path}`)
}
return Array.from(requestPaths)
}
const isFallbackableAddDataError = (error: unknown) => {
const responseCode = typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : ''
const responseMessage = typeof error === 'object' && error !== null && 'message' in error ? String(error.message) : ''
const normalizedMessage = responseMessage.toLowerCase()
// 部分部署环境会把未命中的 addData 路由转到旧的操作分发入口,
// 前端在识别到“unknown operate”或典型路由错误时回退到备用前缀重试一次。
return (
responseCode === '404' ||
normalizedMessage.includes('unknown operate') ||
normalizedMessage.includes('not found') ||
normalizedMessage.includes('no handler found')
)
}
const requestAddData = async <T>(
method: AddDataRequestMethod,
path: string,
params?: object
): Promise<ResultData<T>> => {
let lastError: unknown
const requestPaths = buildAddDataRequestPaths(path)
for (let index = 0; index < requestPaths.length; index += 1) {
const requestPath = requestPaths[index]
try {
if (method === 'get') {
return await http.get<T>(requestPath)
}
return await http.post<T>(requestPath, params)
} catch (error) {
lastError = error
if (index === requestPaths.length - 1 || !isFallbackableAddDataError(error)) {
throw error
}
}
}
throw lastError
}
export const getAddDataPreview = (params: AddData.TaskRequestParams) => {
return requestAddData<AddData.PreviewResponse>('post', '/task/preview', params)
}
export const createAddDataTask = (params: AddData.TaskRequestParams) => {
return requestAddData<AddData.CreateTaskResponse>('post', '/task/create', params)
}
export const getAddDataTaskStatus = (taskId: string | number) => {
return requestAddData<AddData.TaskStatusResponse>('get', `/task/status/${taskId}`)
}
export const getAddDataTemplateList = () => {
return requestAddData<AddData.TemplateItem[]>('get', '/template/list')
}

View File

@@ -0,0 +1,109 @@
export namespace AddData {
export type LineMode = 'single' | 'multiple'
export type IntervalMinutes = 1 | 3 | 5 | 10
export type TaskStatus = 'WAITING' | 'RUNNING' | 'SUCCESS' | 'FAILED' | (string & {})
export interface TaskRequestParams {
lineIds: string[]
startTime: string
endTime: string
intervalMinutes: IntervalMinutes
}
export interface TaskFormModel {
lineMode: LineMode
lineIds: string[]
startTime: string
endTime: string
intervalMinutes: IntervalMinutes
}
export interface PreviewTableStat {
tableName?: string
timePointCount?: number | string
phaseCount?: number | string
rowCount?: number | string
}
export interface PreviewResponse {
lineCount?: number | string
intervalMinutes?: number | string
totalRowCount?: number | string
tableStats?: PreviewTableStat[]
}
export interface CreateTaskResponse {
taskId?: string | number
status?: TaskStatus
}
export interface TaskStatusResponse {
taskId?: string
status?: TaskStatus
currentTableName?: string
currentBatchInfo?: string
insertedCount?: number | string
skippedCount?: number | string
failedCount?: number | string
failureReason?: string
startTime?: string
endTime?: string
hourlyTimeResults?: string[]
}
export interface TemplateItem {
parameterName?: string
tableName?: string
phaseDisplay?: string
phaseCodes?: string[]
display?: boolean
showQualified?: boolean
maxValueRule?: string
minValueRule?: string
averageValueRule?: string
cp95ValueRule?: string
decimalScale?: number | string
}
export interface PreviewTableSummary {
tableName: string
timePointCount: number
phaseCount: number
rowCount: number
}
export interface NormalizedPreview {
lineCount: number
intervalMinutes: number
totalRowCount: number
tableStats: PreviewTableSummary[]
}
export interface NormalizedTaskStatus {
taskId: string
status: TaskStatus
currentTableName: string
currentBatchInfo: string
insertedCount: number
skippedCount: number
failedCount: number
failureReason: string
hourlyTimeResults: string[]
startTime: string
endTime: string
}
export interface NormalizedTemplateItem {
parameterName: string
tableName: string
phaseDisplay: string
phaseCodesText: string
displayText: string
showQualifiedText: string
maxValueRule: string
minValueRule: string
averageValueRule: string
cp95ValueRule: string
decimalScaleText: string
}
}

View File

@@ -29,3 +29,24 @@ export const getIcdMmsJsonApi = (params: MmsMapping.GetIcdMmsJsonParams) => {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export const getXmlFromJsonApi = (params: MmsMapping.GetXmlFromJsonParams) => {
const formData = new FormData()
// 关键业务节点XML 映射由后端根据已生成的 mappingJson 转换,前端保持 request JSON Part 的提交格式。
formData.append('request', new Blob([JSON.stringify(params.request)], { type: 'application/json' }))
return http.post<MmsMapping.MappingTaskResponse>('/api/mms-mapping/get-xml-from-json', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export const buildIndexConfirmDataApi = (params: MmsMapping.IndexCandidateGroup[]) => {
// 关键业务节点ICD 候选数据需要先转换成前端确认弹窗模型,后续人工确认才能继续生成正式索引配置。
return http.post<MmsMapping.IndexConfirmGroup[]>('/api/mms-mapping/build-index-confirm-data', params)
}
export const buildIndexSelectionApi = (params: MmsMapping.BuildIndexSelectionRequest) => {
// 关键业务节点:人工确认完成后,必须把 confirmData 和 confirmedData 一并提交给后端生成正式 request.indexSelection。
return http.post<MmsMapping.IndexSelectionGroup[]>('/api/mms-mapping/build-index-selection', params)
}

View File

@@ -3,6 +3,39 @@ export namespace MmsMapping {
icdFile: File
}
export interface IndexConfirmTarget {
reportName?: string
dataSetName?: string
reportDesc?: string
availableLnInstValues?: string[]
}
export interface IndexConfirmLabelItem {
label?: string
required?: boolean
configurableOnce?: boolean
defaultLnInst?: string
commonLnInstValues?: string[]
targets?: IndexConfirmTarget[]
}
export interface IndexConfirmGroup {
groupKey?: string
groupDesc?: string
labelItems?: IndexConfirmLabelItem[]
}
export interface ConfirmedIndexLabelItem {
label: string
enabled: boolean
lnInst: string
}
export interface ConfirmedIndexGroup {
groupKey: string
labelItems: ConfirmedIndexLabelItem[]
}
export interface IndexSelectionBinding {
reportName: string
dataSetName: string
@@ -30,10 +63,34 @@ export namespace MmsMapping {
request: GetIcdMmsJsonRequestPayload
}
export interface GetXmlFromJsonRequestPayload {
mappingJson: string
}
export interface GetXmlFromJsonParams {
request: GetXmlFromJsonRequestPayload
}
export interface BuildIndexSelectionRequest {
confirmData: IndexConfirmGroup[]
confirmedData: ConfirmedIndexGroup[]
}
export interface XmlFileResponse {
fileName?: string
contentType?: string
encoding?: string
content?: string
}
export interface IcdDocument {
[key: string]: unknown
}
export interface MappingDocument {
[key: string]: unknown
}
export interface IndexCandidateReport {
reportName?: string
dataSetName?: string
@@ -54,8 +111,14 @@ export namespace MmsMapping {
export interface MappingTaskResponse {
status?: MappingTaskStatus
message?: string
methodDescribe?: string
icdDocument?: IcdDocument
mappingDocument?: MappingDocument
mappingJson?: string
mappingXml?: string
xmlContent?: string
xmlText?: string
xmlFile?: XmlFileResponse
savedPath?: string
indexCandidates?: IndexCandidateGroup[]
problems?: string[]

View File

@@ -12,6 +12,10 @@ import * as echarts from 'echarts' // 全引入
// import 'echarts-liquidfill'
// import 'echarts/lib/component/dataZoom'
defineOptions({
name: 'LineChart'
})
const color = [
'var(--el-color-primary)',
'#07CCCA',
@@ -29,7 +33,162 @@ const color = [
const chartRef = ref<HTMLDivElement>()
const props = defineProps(['options', 'isInterVal', 'pieInterVal', 'group'])
const emit = defineEmits<{
'chart-data-zoom': [
value: {
start: number
end: number
}
]
'chart-click': [
value: {
dataIndex: number
axisValue: string | number
}
]
}>()
let chart: echarts.ECharts | any = null
let isPanPointerDown = false
const getChartViewportRoot = () => chart?.getZr()?.painter?.getViewportRoot?.() as HTMLElement | undefined
const getAxisPixel = (dataIndex: number) => {
const pixelValue = chart?.convertToPixel?.({ xAxisIndex: 0 }, dataIndex)
if (Array.isArray(pixelValue)) return Number(pixelValue[0])
return Number(pixelValue)
}
const getClosestAxisDataIndex = (axisValue: unknown, offsetX: number) => {
const xAxisData = props.options?.xAxis?.data
if (!Array.isArray(xAxisData) || !xAxisData.length) return -1
const candidateIndexes = new Set<number>()
const axisNumber = Number(axisValue)
const directIndex = xAxisData.findIndex((item: unknown) => item === axisValue)
if (Number.isFinite(axisNumber)) {
candidateIndexes.add(Math.round(axisNumber))
}
if (directIndex >= 0) {
candidateIndexes.add(directIndex)
}
xAxisData.forEach((item: unknown, index: number) => {
if (String(item) === String(axisValue)) {
candidateIndexes.add(index)
}
})
const validCandidates = Array.from(candidateIndexes).filter(index => index >= 0 && index < xAxisData.length)
if (validCandidates.length) {
return validCandidates.reduce((closestIndex, currentIndex) => {
const closestDistance = Math.abs(getAxisPixel(closestIndex) - offsetX)
const currentDistance = Math.abs(getAxisPixel(currentIndex) - offsetX)
return currentDistance < closestDistance ? currentIndex : closestIndex
}, validCandidates[0])
}
return xAxisData.reduce((closestIndex: number, _item: unknown, currentIndex: number) => {
const closestDistance = Math.abs(getAxisPixel(closestIndex) - offsetX)
const currentDistance = Math.abs(getAxisPixel(currentIndex) - offsetX)
return currentDistance < closestDistance ? currentIndex : closestIndex
}, 0)
}
const resetChartCursor = () => {
const viewportRoot = getChartViewportRoot()
if (viewportRoot) viewportRoot.style.cursor = ''
isPanPointerDown = false
}
const updatePanCursor = (event: { offsetX: number; offsetY: number }) => {
const viewportRoot = getChartViewportRoot()
if (!viewportRoot || (props.options?.activeTool !== 'pan' && props.options?.activeTool !== 'mark')) {
resetChartCursor()
return
}
// 平移只在图形绘图区内生效,鼠标样式同步限制到同一范围,避免坐标轴和空白区误导操作。
const isInGrid = chart?.containPixel?.({ gridIndex: 0 }, [event.offsetX, event.offsetY])
viewportRoot.style.cursor = isInGrid
? props.options?.activeTool === 'mark'
? 'crosshair'
: isPanPointerDown
? 'grabbing'
: 'grab'
: ''
}
const bindPanCursorEvents = () => {
const zr = chart?.getZr?.()
if (!zr) return
zr.off('mousemove', updatePanCursor)
zr.off('mousedown', handlePanCursorMouseDown)
zr.off('mouseup', handlePanCursorMouseUp)
zr.off('globalout', resetChartCursor)
zr.off('click', handleChartClick)
zr.on('mousemove', updatePanCursor)
zr.on('mousedown', handlePanCursorMouseDown)
zr.on('mouseup', handlePanCursorMouseUp)
zr.on('globalout', resetChartCursor)
zr.on('click', handleChartClick)
}
const unbindPanCursorEvents = () => {
const zr = chart?.getZr?.()
if (!zr) return
zr.off('mousemove', updatePanCursor)
zr.off('mousedown', handlePanCursorMouseDown)
zr.off('mouseup', handlePanCursorMouseUp)
zr.off('globalout', resetChartCursor)
zr.off('click', handleChartClick)
resetChartCursor()
}
function handleChartClick(params: any) {
if (props.options?.activeTool !== 'mark' || !chart) return
const event = params?.event?.event || params?.event || params
const offsetX = Number(event?.offsetX)
const offsetY = Number(event?.offsetY)
if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) return
if (!chart.containPixel?.({ gridIndex: 0 }, [offsetX, offsetY])) return
const convertedValue = chart.convertFromPixel?.({ xAxisIndex: 0 }, [offsetX, offsetY])
const rawAxisValue = Array.isArray(convertedValue) ? convertedValue[0] : convertedValue
const xAxisData = props.options?.xAxis?.data
const dataIndex = getClosestAxisDataIndex(rawAxisValue, offsetX)
const axisValue = Array.isArray(xAxisData) ? xAxisData[dataIndex] : dataIndex
if (!Number.isInteger(dataIndex) || dataIndex < 0 || axisValue === undefined) return
emit('chart-click', {
dataIndex,
axisValue
})
}
function handlePanCursorMouseDown(event: { offsetX: number; offsetY: number }) {
isPanPointerDown = true
updatePanCursor(event)
}
function handlePanCursorMouseUp(event: { offsetX: number; offsetY: number }) {
isPanPointerDown = false
updatePanCursor(event)
}
const resizeHandler = () => {
// 不在视野中的时候不进行resize
if (!chartRef.value) return
@@ -41,8 +200,8 @@ const resizeHandler = () => {
})
}
const initChart = () => {
if (!props.isInterVal && !props.pieInterVal) {
unbindPanCursorEvents()
chart?.dispose()
}
// chart?.dispose()
@@ -120,6 +279,15 @@ const initChart = () => {
end: 100
}
],
toolbox: {
show: false,
feature: {
dataZoom: {
yAxisIndex: 'none'
}
},
...(props.options?.toolbox || null)
},
color: props.options?.color || color,
series: props.options?.series,
...props.options?.options
@@ -127,8 +295,26 @@ const initChart = () => {
// console.log(options.series,"获取x轴");
handlerBar(options)
// 处理柱状图
chart.setOption(options, true)
chart.off('datazoom')
chart.on('datazoom', (params: any) => {
const zoomPayload = Array.isArray(params.batch) ? params.batch[0] : params
const start = Number(zoomPayload?.start)
const end = Number(zoomPayload?.end)
if (!Number.isFinite(start) || !Number.isFinite(end)) return
emit('chart-data-zoom', {
start,
end
})
})
chart.dispatchAction({
type: 'takeGlobalCursor',
key: 'dataZoomSelect',
dataZoomSelectActive: props.options?.activeTool === 'box-zoom'
})
bindPanCursorEvents()
setTimeout(() => {
chart.resize()
@@ -176,7 +362,7 @@ const handlerYAxis = () => {
axisLabel: {
color: '#000',
fontSize: 14,
formatter: function (value) {
formatter: function (value: number) {
return value.toFixed(0) // 格式化显示为一位小数
}
},
@@ -244,14 +430,14 @@ const handlerXAxis = () => {
let throttle: ReturnType<typeof setTimeout>
// 动态计算table高度
const resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
if (throttle) {
clearTimeout(throttle)
}
throttle = setTimeout(() => {
resizeHandler()
}, 100)
if (!entries.length) return
if (throttle) {
clearTimeout(throttle)
}
throttle = setTimeout(() => {
resizeHandler()
}, 100)
})
onMounted(() => {
initChart()
@@ -260,11 +446,12 @@ onMounted(() => {
defineExpose({ initChart })
onBeforeUnmount(() => {
resizeObserver.unobserve(chartRef.value!)
unbindPanCursorEvents()
chart?.dispose()
})
watch(
() => props.options,
(newVal, oldVal) => {
() => {
initChart()
}
)

View File

@@ -7,31 +7,31 @@
<el-dropdown-menu>
<el-dropdown-item @click="refresh">
<el-icon><Refresh /></el-icon>
{{ $t('tabs.refresh') }}
{{ t('tabs.refresh') }}
</el-dropdown-item>
<el-dropdown-item @click="maximize">
<el-icon><FullScreen /></el-icon>
{{ $t('tabs.maximize') }}
{{ t('tabs.maximize') }}
</el-dropdown-item>
<el-dropdown-item divided @click="closeCurrentTab">
<el-icon><Remove /></el-icon>
{{ $t('tabs.closeCurrent') }}
{{ t('tabs.closeCurrent') }}
</el-dropdown-item>
<el-dropdown-item @click="tabStore.closeTabsOnSide(route.fullPath, 'left')">
<el-dropdown-item @click="tabStore.closeTabsOnSide(currentTabPath, 'left')">
<el-icon><DArrowLeft /></el-icon>
{{ $t('tabs.closeLeft') }}
{{ t('tabs.closeLeft') }}
</el-dropdown-item>
<el-dropdown-item @click="tabStore.closeTabsOnSide(route.fullPath, 'right')">
<el-dropdown-item @click="tabStore.closeTabsOnSide(currentTabPath, 'right')">
<el-icon><DArrowRight /></el-icon>
{{ $t('tabs.closeRight') }}
{{ t('tabs.closeRight') }}
</el-dropdown-item>
<el-dropdown-item divided @click="tabStore.closeMultipleTab(route.fullPath)">
<el-dropdown-item divided @click="tabStore.closeMultipleTab(currentTabPath)">
<el-icon><CircleClose /></el-icon>
{{ $t('tabs.closeOther') }}
{{ t('tabs.closeOther') }}
</el-dropdown-item>
<el-dropdown-item @click="closeAllTab">
<el-icon><FolderDelete /></el-icon>
{{ $t('tabs.closeAll') }}
{{ t('tabs.closeAll') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -39,7 +39,8 @@
</template>
<script setup lang="ts">
import { inject, nextTick } from 'vue'
import { computed, inject, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { HOME_URL } from '@/config'
import { useTabsStore } from '@/stores/modules/tabs'
import { useGlobalStore } from '@/stores/modules/global'
@@ -48,10 +49,20 @@ import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const tabStore = useTabsStore()
const globalStore = useGlobalStore()
const keepAliveStore = useKeepAliveStore()
const currentTabPath = computed(() => {
const parentPath = route.meta.parentPath as string | undefined
return route.meta.hideTab ? parentPath || route.fullPath : route.fullPath
})
const currentTabRoute = computed(() => {
return router.getRoutes().find(item => item.path === currentTabPath.value)
})
// refresh current page
const refreshCurrentPage: Function = inject('refresh') as Function
const refresh = () => {
@@ -72,8 +83,8 @@ const maximize = () => {
// Close Current
const closeCurrentTab = () => {
if (route.meta.isAffix) return
tabStore.removeTabs(route.fullPath)
if (currentTabRoute.value?.meta.isAffix) return
tabStore.removeTabs(currentTabPath.value)
}
// Close All

View File

@@ -26,12 +26,16 @@
import Sortable from 'sortablejs'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { TabPaneName, TabsPaneContext } from 'element-plus'
import type { TabPaneName, TabsPaneContext } from 'element-plus'
import { HOME_URL } from '@/config'
import { useGlobalStore } from '@/stores/modules/global'
import { useTabsStore } from '@/stores/modules/tabs'
import MoreButton from './components/MoreButton.vue'
defineOptions({
name: 'LayoutTabs'
})
const route = useRoute()
const router = useRouter()
const tabStore = useTabsStore()
@@ -41,17 +45,41 @@ const tabsMenuValue = ref(route.fullPath)
const tabsMenuList = computed(() => tabStore.tabsMenuList)
const tabsIcon = computed(() => globalStore.tabsIcon)
const resolveCurrentTabPath = () => {
const parentPath = route.meta.parentPath as string | undefined
return route.meta.hideTab ? parentPath || route.fullPath : route.fullPath
}
onMounted(() => {
tabsDrop()
initTabs()
})
const ensureParentTab = () => {
const parentPath = resolveCurrentTabPath()
if (!parentPath || tabStore.tabsMenuList.some(item => item.path === parentPath)) return
const parentRoute = router.getRoutes().find(item => item.path === parentPath && item.name)
if (!parentRoute) return
// 直接打开隐藏子页时补齐父级主标签
tabStore.addTabs({
icon: parentRoute.meta.icon as string,
title: parentRoute.meta.title as string,
path: parentRoute.path,
name: parentRoute.name as string,
close: !parentRoute.meta.isAffix,
isKeepAlive: parentRoute.meta.isKeepAlive as boolean
})
}
watch(
() => route.fullPath,
() => {
if (route.meta.isFull) return
if (route.meta.hideTab) {
tabsMenuValue.value = route.meta.parentPath as string
ensureParentTab()
tabsMenuValue.value = resolveCurrentTabPath()
} else {
tabsMenuValue.value = route.fullPath
const tabsParams = {
@@ -112,7 +140,7 @@ const tabClick = (tabItem: TabsPaneContext) => {
}
const tabRemove = (fullPath: TabPaneName) => {
tabStore.removeTabs(fullPath as string, fullPath == route.fullPath)
tabStore.removeTabs(fullPath as string, fullPath === tabsMenuValue.value)
}
</script>

View File

@@ -5,8 +5,9 @@ import { ElNotification } from 'element-plus'
import { useUserStore } from '@/stores/modules/user'
import { useAuthStore } from '@/stores/modules/auth'
// 引入 views 文件夹下所有 vue 文件
const modules = import.meta.glob('@/views/**/*.vue')
const VIEWS_ALIAS_PREFIX = '@/views'
const VIEWS_SRC_PREFIX = '/src/views'
const STATIC_ROUTE_NAMES = new Set([
'layout',
'login',
@@ -14,6 +15,7 @@ const STATIC_ROUTE_NAMES = new Set([
'tools',
'toolWaveform',
'toolMmsMapping',
'toolAddData',
'systemMonitor',
'diskMonitor',
'403',
@@ -24,7 +26,7 @@ const STATIC_ROUTE_NAMES = new Set([
let isInitializing = false
/**
* 清除已有的动态路由
* 清除已有的动态路由,避免重复注入。
*/
const clearDynamicRoutes = () => {
const routes = router.getRoutes()
@@ -36,13 +38,19 @@ const clearDynamicRoutes = () => {
}
/**
* 根据菜单 component 路径查找对应的页面模块
* 兼容两种仓库写法:
* 1. /foo/bar.vue
* 2. /foo/bar/index.vue
* 统一菜单 component 配置格式,只允许映射到 views 目录
*/
const resolveComponentModule = (path: string) => {
let normalizedPath = path.trim()
const normalizeComponentPath = (path: string) => {
let normalizedPath = path.trim().replace(/\\/g, '/')
if (normalizedPath.startsWith(VIEWS_ALIAS_PREFIX)) {
normalizedPath = normalizedPath.slice(VIEWS_ALIAS_PREFIX.length)
} else if (normalizedPath.startsWith(VIEWS_SRC_PREFIX)) {
normalizedPath = normalizedPath.slice(VIEWS_SRC_PREFIX.length)
} else if (normalizedPath.startsWith('src/views')) {
normalizedPath = normalizedPath.slice('src/views'.length)
}
if (!normalizedPath.startsWith('/')) {
normalizedPath = '/' + normalizedPath
}
@@ -53,13 +61,31 @@ const resolveComponentModule = (path: string) => {
normalizedPath = normalizedPath.slice(0, -1)
}
const candidatePaths = [`/src/views${normalizedPath}.vue`, `/src/views${normalizedPath}/index.vue`]
return normalizedPath
}
/**
* 根据菜单 component 路径查找对应页面模块。
* 兼容菜单资源里常见的多种写法,避免因为前缀或 index 差异导致误判。
*/
const resolveComponentModule = (path: string) => {
const normalizedPath = normalizeComponentPath(path)
const viewPath = normalizedPath.endsWith('/index') ? normalizedPath.slice(0, -'/index'.length) : normalizedPath
const candidatePaths = Array.from(
new Set([
`${VIEWS_ALIAS_PREFIX}${normalizedPath}.vue`,
`${VIEWS_ALIAS_PREFIX}${normalizedPath}/index.vue`,
`${VIEWS_ALIAS_PREFIX}${viewPath}/index.vue`,
`${VIEWS_SRC_PREFIX}${normalizedPath}.vue`,
`${VIEWS_SRC_PREFIX}${normalizedPath}/index.vue`,
`${VIEWS_SRC_PREFIX}${viewPath}/index.vue`
])
)
for (const candidatePath of candidatePaths) {
const moduleLoader = modules[candidatePath]
if (moduleLoader) {
if (candidatePath in modules) {
return {
moduleLoader,
moduleLoader: modules[candidatePath],
resolvedPath: candidatePath
}
}
@@ -72,7 +98,7 @@ const resolveComponentModule = (path: string) => {
}
/**
* @description 初始化动态路由
* 初始化动态路由
*/
export const initDynamicRouter = async () => {
if (isInitializing) return Promise.reject(new Error('Dynamic router initialization in progress'))
@@ -83,15 +109,13 @@ export const initDynamicRouter = async () => {
const unresolvedRoutes: Array<{ name?: string; path?: string; component?: string; candidates: string[] }> = []
try {
// 1. 获取菜单列表 && 按钮权限列表
await authStore.getAuthMenuList()
await authStore.getAuthButtonList()
// 2. 判断当前用户有没有菜单权限
if (!authStore.authMenuListGet.length) {
ElNotification({
title: '无权限访问',
message: '当前账号无任何菜单权限,请联系系统管理员',
message: '当前账号无任何菜单权限,请联系系统管理员',
type: 'warning',
duration: 3000
})
@@ -102,21 +126,17 @@ export const initDynamicRouter = async () => {
return Promise.reject('No permission')
}
// 3. 清理之前的动态路由
clearDynamicRoutes()
// 4. 添加动态路由
for (const item of authStore.flatMenuListGet) {
// 删除 children 避免冗余嵌套
if (item.children) delete item.children
// 处理组件映射
// 动态菜单组件必须先映射成真实页面模块,否则 addRoute 后会直接落到 404。
if (item.component && typeof item.component === 'string') {
const { moduleLoader, resolvedPath } = resolveComponentModule(item.component)
if (moduleLoader) {
item.component = moduleLoader
} else {
// 动态路由组件一旦解析失败,对应菜单会落入 404这里必须打印清楚候选路径。
unresolvedRoutes.push({
name: item.name,
path: item.path,
@@ -127,7 +147,6 @@ export const initDynamicRouter = async () => {
}
}
// 类型守卫:确保满足 RouteRecordRaw 接口要求
if (
typeof item.path === 'string' &&
(typeof item.component === 'function' || typeof item.redirect === 'string')
@@ -147,7 +166,6 @@ export const initDynamicRouter = async () => {
console.error('[dynamic-router] unresolved route components', unresolvedRoutes)
}
} catch (error) {
// 当按钮 || 菜单请求出错时,重定向到登陆页
userStore.setAccessToken('')
userStore.setRefreshToken('')
userStore.setExp(0)

View File

@@ -54,11 +54,19 @@ export const staticRouter: RouteRecordRaw[] = [
path: '/tools/mmsMapping',
name: 'toolMmsMapping',
alias: ['/tools/mmsmapping', '/tools/mms-mapping'],
component: () => import('@/views/tools/mmsmapping/index.vue'),
component: () => import('@/views/tools/mmsMapping/index.vue'),
meta: {
title: 'MMS 映射'
}
},
{
path: '/tools/addData',
name: 'toolAddData',
component: () => import('@/views/tools/addData/index.vue'),
meta: {
title: '模拟数据'
}
},
{
path: '/403',
name: '403',
@@ -96,6 +104,10 @@ export const staticRouter: RouteRecordRaw[] = [
name: 'diskMonitor',
component: () => import('@/views/systemMonitor/diskMonitor/index.vue'),
meta: {
// 磁盘监控页复用系统监控主标签
activeMenu: '/systemMonitor',
hideTab: true,
parentPath: '/systemMonitor',
title: '磁盘监控'
}
},

View File

@@ -12,6 +12,8 @@ declare namespace Menu {
icon: string;
title: string;
activeMenu?: string;
hideTab?: boolean;
parentPath?: string;
isLink?: string;
isHide: boolean;
isFull: boolean;

View File

@@ -1,5 +1,6 @@
<template>
<el-drawer
class="job-detail-drawer"
:model-value="props.visible"
size="70%"
title="任务详情"
@@ -8,107 +9,128 @@
>
<div v-loading="props.loading" class="job-detail">
<template v-if="props.detail">
<div class="meta-grid">
<div class="meta-item">
<span class="meta-label">任务编号</span>
<span class="meta-value">{{ props.detail.job.jobNo }}</span>
</div>
<div class="meta-item">
<span class="meta-label">任务来源</span>
<span class="meta-value">{{ getSourceLabel(props.detail.job.jobSource) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">任务状态</span>
<span class="meta-value">{{ getJobStatusLabel(props.detail.job.jobStatus) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">预警数量</span>
<span class="meta-value">{{ props.detail.job.warningCount }}</span>
</div>
<div class="meta-item">
<span class="meta-label">告警数量</span>
<span class="meta-value">{{ props.detail.job.alarmCount }}</span>
</div>
<div class="meta-item">
<span class="meta-label">开始时间</span>
<span class="meta-value">{{ formatTime(props.detail.job.startedAt) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">结束时间</span>
<span class="meta-value">{{ formatTime(props.detail.job.finishedAt) }}</span>
<section class="card job-detail-meta-card">
<div class="meta-grid">
<div class="meta-item">
<span class="meta-label">任务编号</span>
<span class="meta-value">{{ props.detail.job.jobNo }}</span>
</div>
<div class="meta-item">
<span class="meta-label">任务来源</span>
<span class="meta-value">{{ getSourceLabel(props.detail.job.jobSource) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">任务状态</span>
<span class="meta-value">{{ getJobStatusLabel(props.detail.job.jobStatus) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">预警数量</span>
<span class="meta-value">{{ props.detail.job.warningCount }}</span>
</div>
<div class="meta-item">
<span class="meta-label">告警数量</span>
<span class="meta-value">{{ props.detail.job.alarmCount }}</span>
</div>
<div class="meta-item">
<span class="meta-label">开始时间</span>
<span class="meta-value">{{ formatTime(props.detail.job.startedAt) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">结束时间</span>
<span class="meta-value">{{ formatTime(props.detail.job.finishedAt) }}</span>
</div>
</div>
</section>
<div class="job-detail-table-sections">
<section class="table-section">
<h4 class="section-title">盘符结果</h4>
<div class="table-main card job-detail-table-card">
<el-table :data="props.detail.results" class="job-detail-el-table" border stripe height="100%">
<el-table-column prop="driveLetter" label="盘符" min-width="100" align="center" />
<el-table-column label="使用率" min-width="110" align="center">
<template #default="{ row }">{{ row.usedPercent }}%</template>
</el-table-column>
<el-table-column label="当前状态" min-width="120" align="center">
<template #default="{ row }">
<el-tag :type="getMonitorStatusType(row.currentStatus)" effect="light">
{{ getMonitorStatusLabel(row.currentStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="上次状态" min-width="120" align="center">
<template #default="{ row }">
<el-tag :type="getMonitorStatusType(row.previousStatus)" effect="light">
{{ getMonitorStatusLabel(row.previousStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态变化" min-width="100" align="center">
<template #default="{ row }">{{ formatBoolean(row.statusChanged) }}</template>
</el-table-column>
<el-table-column label="是否通知" min-width="100" align="center">
<template #default="{ row }">{{ formatBoolean(row.shouldNotify) }}</template>
</el-table-column>
<el-table-column label="通知原因" min-width="130" align="center">
<template #default="{ row }">{{ getNotifyReasonLabel(row.notifyReason) }}</template>
</el-table-column>
<el-table-column label="扫描时间" min-width="170" align="center">
<template #default="{ row }">{{ formatTime(row.scanTime) }}</template>
</el-table-column>
<el-table-column
prop="message"
label="说明"
min-width="180"
align="center"
show-overflow-tooltip
/>
</el-table>
</div>
</section>
<section class="table-section">
<h4 class="section-title">通知日志</h4>
<div class="table-main card job-detail-table-card">
<el-table :data="props.detail.notifyLogs" class="job-detail-el-table" border stripe height="100%">
<el-table-column prop="driveLetter" label="盘符" min-width="100" align="center" />
<el-table-column label="通知级别" min-width="110" align="center">
<template #default="{ row }">
<el-tag :type="getNotifyLevelType(row.notifyLevel)" effect="light">
{{ getNotifyLevelLabel(row.notifyLevel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="通知通道" min-width="120" align="center">
<template #default="{ row }">{{ getChannelTypeLabel(row.channelType) }}</template>
</el-table-column>
<el-table-column
prop="channelTarget"
label="通知目标"
min-width="220"
align="center"
show-overflow-tooltip
/>
<el-table-column label="发送状态" min-width="110" align="center">
<template #default="{ row }">
<el-tag :type="getSendStatusType(row.sendStatus)" effect="light">
{{ getSendStatusLabel(row.sendStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="responseMessage"
label="响应信息"
min-width="220"
align="center"
show-overflow-tooltip
/>
<el-table-column label="发送时间" min-width="170" align="center">
<template #default="{ row }">{{ formatTime(row.sentAt) }}</template>
</el-table-column>
</el-table>
</div>
</section>
</div>
<section class="table-section">
<h4 class="section-title">盘符结果</h4>
<el-table :data="props.detail.results" border stripe>
<el-table-column prop="driveLetter" label="盘符" min-width="100" />
<el-table-column label="使用率" min-width="110">
<template #default="{ row }">{{ row.usedPercent }}%</template>
</el-table-column>
<el-table-column label="当前状态" min-width="120">
<template #default="{ row }">
<el-tag :type="getMonitorStatusType(row.currentStatus)" effect="light">
{{ getMonitorStatusLabel(row.currentStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="上次状态" min-width="120">
<template #default="{ row }">
<el-tag :type="getMonitorStatusType(row.previousStatus)" effect="light">
{{ getMonitorStatusLabel(row.previousStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态变化" min-width="100">
<template #default="{ row }">{{ formatBoolean(row.statusChanged) }}</template>
</el-table-column>
<el-table-column label="是否通知" min-width="100">
<template #default="{ row }">{{ formatBoolean(row.shouldNotify) }}</template>
</el-table-column>
<el-table-column label="通知原因" min-width="130">
<template #default="{ row }">{{ getNotifyReasonLabel(row.notifyReason) }}</template>
</el-table-column>
<el-table-column label="扫描时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.scanTime) }}</template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="180" show-overflow-tooltip />
</el-table>
</section>
<section class="table-section">
<h4 class="section-title">通知日志</h4>
<el-table :data="props.detail.notifyLogs" border stripe>
<el-table-column prop="driveLetter" label="盘符" min-width="100" />
<el-table-column label="通知级别" min-width="110">
<template #default="{ row }">
<el-tag :type="getNotifyLevelType(row.notifyLevel)" effect="light">
{{ getNotifyLevelLabel(row.notifyLevel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="通知通道" min-width="120">
<template #default="{ row }">{{ getChannelTypeLabel(row.channelType) }}</template>
</el-table-column>
<el-table-column prop="channelTarget" label="通知目标" min-width="220" show-overflow-tooltip />
<el-table-column label="发送状态" min-width="110">
<template #default="{ row }">
<el-tag :type="getSendStatusType(row.sendStatus)" effect="light">
{{ getSendStatusLabel(row.sendStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="responseMessage"
label="响应信息"
min-width="220"
show-overflow-tooltip
/>
<el-table-column label="发送时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.sentAt) }}</template>
</el-table-column>
</el-table>
</section>
</template>
<el-empty v-else description="暂无详情数据" />
</div>
@@ -204,45 +226,121 @@ const formatTime = (value?: string | null) => {
</script>
<style scoped lang="scss">
.job-detail-drawer :deep(.el-drawer__body) {
display: flex;
min-height: 0;
overflow: hidden;
}
.job-detail {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.job-detail-meta-card {
flex: none;
height: auto;
}
.job-detail-table-sections {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.job-detail-table-card {
flex: 1;
min-height: 0;
}
.job-detail-el-table {
flex: 1;
}
.job-detail-el-table :deep(.el-table__inner-wrapper) {
display: flex;
flex-direction: column;
height: 100%;
}
.job-detail-el-table :deep(.el-table__body-wrapper) {
flex: 1;
height: 0;
min-height: 0;
}
.job-detail-el-table :deep(.el-scrollbar),
.job-detail-el-table :deep(.el-scrollbar__wrap),
.job-detail-el-table :deep(.el-scrollbar__view) {
height: 100%;
}
.job-detail-el-table :deep(.el-table__empty-block) {
height: 100%;
min-height: 100%;
}
.job-detail-el-table :deep(.el-table__empty-text) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
padding: 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.meta-item {
display: flex;
gap: 8px;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.meta-label {
color: #6b7280;
color: var(--el-text-color-secondary);
}
.meta-value {
color: #111827;
color: var(--el-text-color-primary);
font-weight: 500;
word-break: break-word;
}
.table-section {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.section-title {
margin: 0;
font-size: 16px;
color: #111827;
color: var(--el-text-color-primary);
}
@media (max-width: 1200px) {
.meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.meta-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,49 +1,102 @@
<template>
<div class="job-table-card">
<div class="table-main card disk-monitor-table-card">
<div class="table-header">
<div>
<h3 class="table-title">最近任务</h3>
<p class="table-description">展示最近 10 条磁盘监控任务执行记录</p>
<div class="header-button-ri table-tools">
<el-button circle :icon="Refresh" :loading="loading" @click="emit('refresh')" />
<el-popover trigger="click" placement="bottom-end" :width="220">
<div class="column-setting-panel">
<el-checkbox v-for="column in columnOptions" :key="column.key" v-model="column.visible">
{{ column.label }}
</el-checkbox>
</div>
<template #reference>
<el-button circle :icon="Operation" />
</template>
</el-popover>
<el-button circle :icon="Search" @click="showSearch = !showSearch" />
</div>
<el-button :loading="loading" @click="emit('refresh')">刷新</el-button>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<el-table-column prop="jobNo" label="任务编号" min-width="180" />
<el-table-column label="来源" min-width="120">
<template #default="{ row }">
{{ getSourceLabel(row.jobSource) }}
</template>
</el-table-column>
<el-table-column label="开始时间" min-width="170">
<template #default="{ row }">
{{ formatTime(row.startedAt) }}
</template>
</el-table-column>
<el-table-column label="结束时间" min-width="170">
<template #default="{ row }">
{{ formatTime(row.finishedAt) }}
</template>
</el-table-column>
<el-table-column label="状态" min-width="130">
<template #default="{ row }">
<el-tag :type="getStatusType(row.jobStatus)" effect="light">
{{ getStatusLabel(row.jobStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="warningCount" label="预警数量" min-width="100" />
<el-table-column prop="alarmCount" label="告警数量" min-width="100" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="emit('detail', row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
<div v-show="showSearch" class="table-search">
<el-form label-width="80px" class="disk-monitor-search-form">
<div class="search-grid">
<el-form-item label="任务编号">
<el-input v-model="searchForm.jobNo" clearable placeholder="请输入任务编号" />
</el-form-item>
<el-form-item label="任务来源">
<el-select v-model="searchForm.jobSource" clearable placeholder="请选择来源">
<el-option label="应用启动" value="APP_START" />
<el-option label="定时任务" value="DAILY_SCHEDULE" />
<el-option label="手动触发" value="MANUAL" />
</el-select>
</el-form-item>
<el-form-item label="任务状态">
<el-select v-model="searchForm.jobStatus" clearable placeholder="请选择状态">
<el-option label="成功" value="SUCCESS" />
<el-option label="部分成功" value="PARTIAL_SUCCESS" />
<el-option label="失败" value="FAILED" />
<el-option label="运行中" value="RUNNING" />
</el-select>
</el-form-item>
</div>
<div class="operation">
<el-button @click="resetSearch">重置</el-button>
</div>
</el-form>
</div>
<div class="disk-monitor-table-body">
<el-table v-loading="loading" class="disk-monitor-el-table" :data="filteredRows" border stripe height="100%">
<el-table-column v-if="isColumnVisible('jobNo')" prop="jobNo" label="任务编号" min-width="180" align="center" />
<el-table-column v-if="isColumnVisible('jobSource')" label="来源" min-width="120" align="center">
<template #default="{ row }">
{{ getSourceLabel(row.jobSource) }}
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('startedAt')" label="开始时间" min-width="170" align="center">
<template #default="{ row }">
{{ formatTime(row.startedAt) }}
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('finishedAt')" label="结束时间" min-width="170" align="center">
<template #default="{ row }">
{{ formatTime(row.finishedAt) }}
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('jobStatus')" label="状态" min-width="130" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row.jobStatus)" effect="light">
{{ getStatusLabel(row.jobStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('warningCount')"
prop="warningCount"
label="预警数量"
min-width="100"
align="center"
/>
<el-table-column
v-if="isColumnVisible('alarmCount')"
prop="alarmCount"
label="告警数量"
min-width="100"
align="center"
/>
<el-table-column label="操作" width="160" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" :icon="View" @click="emit('detail', row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { Operation, Refresh, Search, View } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
@@ -51,7 +104,7 @@ defineOptions({
name: 'DiskMonitorJobTable'
})
defineProps<{
const props = defineProps<{
rows: DiskMonitor.JobListItem[]
loading: boolean
}>()
@@ -61,6 +114,54 @@ const emit = defineEmits<{
detail: [row: DiskMonitor.JobListItem]
}>()
type JobColumnKey = 'jobNo' | 'jobSource' | 'startedAt' | 'finishedAt' | 'jobStatus' | 'warningCount' | 'alarmCount'
const showSearch = ref(false)
const searchForm = reactive({
jobNo: '',
jobSource: '',
jobStatus: ''
})
const columnOptions = reactive<{ key: JobColumnKey; label: string; visible: boolean }[]>([
{ key: 'jobNo', label: '任务编号', visible: true },
{ key: 'jobSource', label: '来源', visible: true },
{ key: 'startedAt', label: '开始时间', visible: true },
{ key: 'finishedAt', label: '结束时间', visible: true },
{ key: 'jobStatus', label: '状态', visible: true },
{ key: 'warningCount', label: '预警数量', visible: true },
{ key: 'alarmCount', label: '告警数量', visible: true }
])
const includesKeyword = (value?: string | number | null, keyword?: string) => {
const normalizedKeyword = String(keyword || '')
.trim()
.toLowerCase()
if (!normalizedKeyword) return true
return String(value ?? '')
.trim()
.toLowerCase()
.includes(normalizedKeyword)
}
const isColumnVisible = (key: JobColumnKey) => {
return columnOptions.find(column => column.key === key)?.visible ?? true
}
const filteredRows = computed(() => {
return props.rows.filter(row => {
const matchesJobNo = includesKeyword(row.jobNo, searchForm.jobNo)
const matchesSource = !searchForm.jobSource || row.jobSource === searchForm.jobSource
const matchesStatus = !searchForm.jobStatus || row.jobStatus === searchForm.jobStatus
return matchesJobNo && matchesSource && matchesStatus
})
})
const resetSearch = () => {
searchForm.jobNo = ''
searchForm.jobSource = ''
searchForm.jobStatus = ''
}
const getSourceLabel = (source: DiskMonitor.JobSource) => {
if (source === 'APP_START') return '应用启动'
if (source === 'DAILY_SCHEDULE') return '定时任务'
@@ -88,32 +189,66 @@ const formatTime = (value?: string | null) => {
</script>
<style scoped lang="scss">
.job-table-card {
.disk-monitor-table-card {
display: flex;
flex: 1;
flex-direction: column;
gap: 14px;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
min-height: 0;
overflow: hidden;
}
.table-header {
display: flow-root;
flex: none;
}
.table-tools {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.table-title {
margin: 0 0 6px;
font-size: 18px;
color: #111827;
.column-setting-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.table-description {
margin: 0;
font-size: 13px;
color: #6b7280;
.search-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 12px;
}
.disk-monitor-table-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.disk-monitor-el-table {
flex: 1;
min-height: 0;
}
.disk-monitor-table-body :deep(.el-table__inner-wrapper) {
height: 100%;
}
@media (max-width: 768px) {
.table-header {
display: flex;
flex-direction: column;
gap: 12px;
}
.header-button-ri {
float: none;
}
.search-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,149 @@
<template>
<el-dialog class="disk-monitor-dialog" :model-value="props.visible" title="全局策略" width="880px" @close="closeDialog">
<div class="dialog-section dialog-section--plain">
<el-alert
class="policy-alert"
title="通知规则"
description="预警按状态变化通知,告警每次命中都通知"
type="info"
:closable="false"
show-icon
/>
</div>
<div class="dialog-section">
<div class="section-title">基础配置</div>
<el-form label-width="130px" class="policy-form">
<el-form-item label="启用监控">
<el-switch
:model-value="props.modelValue.monitorEnabled"
:disabled="props.disabled"
@update:model-value="value => patchPolicy({ monitorEnabled: Boolean(value) })"
/>
</el-form-item>
<el-form-item label="启动即监控">
<el-switch
:model-value="props.modelValue.runOnAppStart"
:disabled="props.disabled"
@update:model-value="value => patchPolicy({ runOnAppStart: Boolean(value) })"
/>
</el-form-item>
<el-form-item label="每日执行时间">
<el-time-picker
:model-value="props.modelValue.dailyRunTime"
:disabled="props.disabled"
format="HH:mm:ss"
value-format="HH:mm:ss"
placeholder="选择时间"
@update:model-value="value => patchPolicy({ dailyRunTime: typeof value === 'string' ? value : '' })"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button :disabled="props.disabled" @click="closeDialog">取消</el-button>
<el-button type="primary" plain :loading="props.runLoading" :disabled="props.disabled" @click="emit('run')">
立即执行监控
</el-button>
<el-button type="primary" :loading="props.saveLoading" :disabled="props.disabled" @click="emit('confirm')">
保存配置
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
defineOptions({
name: 'DiskMonitorPolicyDialog'
})
const props = withDefaults(
defineProps<{
visible: boolean
modelValue: DiskMonitor.PolicyItem
disabled?: boolean
saveLoading?: boolean
runLoading?: boolean
}>(),
{
disabled: false,
saveLoading: false,
runLoading: false
}
)
const emit = defineEmits<{
'update:visible': [value: boolean]
'update:modelValue': [value: DiskMonitor.PolicyItem]
confirm: []
run: []
}>()
const closeDialog = () => {
emit('update:visible', false)
}
const patchPolicy = (patch: Partial<DiskMonitor.PolicyItem>) => {
emit('update:modelValue', {
...props.modelValue,
...patch
})
}
</script>
<style scoped lang="scss">
.disk-monitor-dialog :deep(.el-dialog__body) {
display: flex;
flex-direction: column;
gap: 16px;
}
.dialog-section {
padding: 16px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.dialog-section--plain {
padding: 0;
background: transparent;
}
.section-title {
margin-bottom: 14px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.policy-alert {
margin: 0;
}
.policy-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.policy-form :deep(.el-form-item:last-child) {
margin-bottom: 0;
}
.policy-form :deep(.el-form-item__content) {
align-items: center;
}
.policy-form :deep(.el-time-picker) {
width: 220px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

View File

@@ -1,149 +0,0 @@
<template>
<div class="policy-form-card">
<div class="policy-header">
<div>
<h3 class="policy-title">全局策略</h3>
<p class="policy-description">配置监控总开关启动监控与每日统一时间</p>
</div>
<div class="policy-actions">
<el-button :loading="runLoading" :disabled="disabled" @click="emit('run')">立即执行监控</el-button>
<el-button type="primary" :loading="saveLoading" :disabled="disabled" @click="emit('save')">保存配置</el-button>
</div>
</div>
<el-alert
class="policy-alert"
title="通知规则"
description="预警按状态变化通知,告警每次命中都通知"
type="info"
:closable="false"
show-icon
/>
<el-form label-width="130px" class="policy-form">
<el-form-item label="启用监控">
<el-switch
:model-value="modelValue.monitorEnabled"
:disabled="disabled"
@update:model-value="handleMonitorEnabledChange"
/>
</el-form-item>
<el-form-item label="启动即监控">
<el-switch
:model-value="modelValue.runOnAppStart"
:disabled="disabled"
@update:model-value="handleRunOnAppStartChange"
/>
</el-form-item>
<el-form-item label="每日执行时间">
<el-time-picker
:model-value="modelValue.dailyRunTime"
:disabled="disabled"
format="HH:mm:ss"
value-format="HH:mm:ss"
placeholder="选择时间"
@update:model-value="value => updatePolicy('dailyRunTime', typeof value === 'string' ? value : '')"
/>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
defineOptions({
name: 'DiskMonitorPolicyForm'
})
const props = withDefaults(
defineProps<{
modelValue: DiskMonitor.PolicyItem
disabled?: boolean
saveLoading?: boolean
runLoading?: boolean
}>(),
{
disabled: false,
saveLoading: false,
runLoading: false
}
)
const emit = defineEmits<{
'update:modelValue': [value: DiskMonitor.PolicyItem]
save: []
run: []
}>()
const handleMonitorEnabledChange = (value: string | number | boolean) => {
updatePolicy('monitorEnabled', Boolean(value))
}
const handleRunOnAppStartChange = (value: string | number | boolean) => {
updatePolicy('runOnAppStart', Boolean(value))
}
const updatePolicy = <K extends keyof DiskMonitor.PolicyItem>(key: K, value: DiskMonitor.PolicyItem[K]) => {
emit('update:modelValue', {
...props.modelValue,
[key]: value
})
}
</script>
<style scoped lang="scss">
.policy-form-card {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
}
.policy-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.policy-title {
margin: 0 0 8px;
font-size: 18px;
color: #111827;
}
.policy-description {
margin: 0;
font-size: 13px;
color: #6b7280;
line-height: 1.6;
}
.policy-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.policy-alert {
margin-bottom: 4px;
}
.policy-form :deep(.el-form-item) {
margin-bottom: 14px;
}
@media (max-width: 768px) {
.policy-header {
flex-direction: column;
}
.policy-actions {
width: 100%;
}
}
</style>

View File

@@ -1,32 +1,34 @@
<template>
<div class="disk-monitor-summary">
<div class="summary-card">
<div class="summary-label">监控状态</div>
<div class="summary-value">{{ policy.monitorEnabled ? '已启用' : '已停用' }}</div>
</div>
<div class="summary-card">
<div class="summary-label">启动即监控</div>
<div class="summary-value">{{ policy.runOnAppStart ? '是' : '否' }}</div>
</div>
<div class="summary-card">
<div class="summary-label">每日执行时间</div>
<div class="summary-value">{{ policy.dailyRunTime || '--' }}</div>
</div>
<div class="summary-card">
<div class="summary-label">监控盘符数量</div>
<div class="summary-value">{{ monitorTargetCount }}</div>
</div>
<div class="summary-card">
<div class="summary-label">当前告警盘符</div>
<div class="summary-value">{{ alarmCount }}</div>
</div>
<div class="summary-card">
<div class="summary-label">最近执行时间</div>
<div class="summary-value">{{ latestRunTime }}</div>
</div>
<div class="summary-card">
<div class="summary-label">最近执行状态</div>
<div class="summary-value">{{ latestJobStatus }}</div>
<div class="card disk-monitor-summary-card">
<div class="summary-grid">
<div class="summary-item">
<div class="summary-label">监控状态</div>
<div class="summary-value">{{ policy.monitorEnabled ? '已启用' : '已停用' }}</div>
</div>
<div class="summary-item">
<div class="summary-label">启动即监控</div>
<div class="summary-value">{{ policy.runOnAppStart ? '是' : '否' }}</div>
</div>
<div class="summary-item">
<div class="summary-label">每日执行时间</div>
<div class="summary-value">{{ policy.dailyRunTime || '--' }}</div>
</div>
<div class="summary-item">
<div class="summary-label">监控目标数量</div>
<div class="summary-value">{{ monitorTargetCount }}</div>
</div>
<div class="summary-item">
<div class="summary-label">当前告警目标</div>
<div class="summary-value">{{ alarmCount }}</div>
</div>
<div class="summary-item">
<div class="summary-label">最近执行时间</div>
<div class="summary-value">{{ latestRunTime }}</div>
</div>
<div class="summary-item">
<div class="summary-label">最近执行状态</div>
<div class="summary-value">{{ latestJobStatus }}</div>
</div>
</div>
</div>
</template>
@@ -67,40 +69,47 @@ const latestJobStatus = computed(() => {
</script>
<style scoped lang="scss">
.disk-monitor-summary {
.disk-monitor-summary-card {
display: flex;
flex-direction: column;
gap: 0;
overflow: visible;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.summary-card {
.summary-item {
padding: 14px 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.summary-label {
margin-bottom: 8px;
font-size: 13px;
color: #6b7280;
color: var(--el-text-color-secondary);
}
.summary-value {
font-size: 20px;
line-height: 1.2;
font-weight: 600;
color: #111827;
color: var(--el-text-color-primary);
word-break: break-word;
}
@media (max-width: 992px) {
.disk-monitor-summary {
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.disk-monitor-summary {
.summary-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,78 +1,104 @@
<template>
<el-dialog :model-value="props.visible" :title="props.title" width="880px" @close="closeDialog">
<el-form label-width="120px" class="target-form">
<el-form-item label="盘符">
<el-input
:model-value="props.modelValue.driveLetter"
placeholder="例如 C:"
maxlength="2"
@update:model-value="value => patchTarget({ driveLetter: String(value).trim().toUpperCase() })"
/>
</el-form-item>
<el-form-item label="启用监控">
<el-switch
:model-value="props.modelValue.monitorEnabled"
@update:model-value="value => patchTarget({ monitorEnabled: Boolean(value) })"
/>
</el-form-item>
<el-form-item label="预警阈值">
<el-input-number
:model-value="props.modelValue.warningUsagePercent"
:min="1"
:max="100"
:controls="false"
@update:model-value="handleWarningChange($event)"
/>
<span class="suffix-text">%</span>
</el-form-item>
<el-form-item label="告警阈值">
<el-input-number
:model-value="props.modelValue.alarmUsagePercent"
:min="1"
:max="100"
:controls="false"
@update:model-value="handleAlarmChange($event)"
/>
<span class="suffix-text">%</span>
</el-form-item>
<el-form-item label="路径通知">
<el-switch
:model-value="props.modelValue.notifyPathEnabled"
@update:model-value="value => patchTarget({ notifyPathEnabled: Boolean(value) })"
/>
</el-form-item>
<el-form-item v-if="props.modelValue.notifyPathEnabled" label="路径通知配置">
<NotificationPathEditor
:model-value="props.modelValue.notifyPathList"
@update:model-value="value => patchTarget({ notifyPathList: value })"
/>
</el-form-item>
<el-form-item label="HTTP 通知">
<el-switch
:model-value="props.modelValue.notifyHttpEnabled"
@update:model-value="value => patchTarget({ notifyHttpEnabled: Boolean(value) })"
/>
</el-form-item>
<el-form-item v-if="props.modelValue.notifyHttpEnabled" label="HTTP 通知配置">
<NotificationHttpEditor
:model-value="props.modelValue.notifyHttpList"
@update:model-value="value => patchTarget({ notifyHttpList: value })"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
type="textarea"
:rows="3"
:model-value="props.modelValue.remark"
placeholder="可选"
@update:model-value="value => patchTarget({ remark: String(value) })"
/>
</el-form-item>
<el-dialog
class="disk-monitor-dialog"
:model-value="props.visible"
:title="props.title"
:show-close="!props.disabled"
:close-on-click-modal="!props.disabled"
:close-on-press-escape="!props.disabled"
width="880px"
@close="closeDialog"
>
<el-form :disabled="props.disabled" label-width="120px" class="target-form">
<div class="dialog-section">
<div class="section-title">监控基础信息</div>
<el-form-item label="盘符">
<el-input
:model-value="props.modelValue.driveLetter"
placeholder="例如 C:"
maxlength="2"
@update:model-value="value => patchTarget({ driveLetter: String(value).trim().toUpperCase() })"
/>
</el-form-item>
<el-form-item label="启用监控">
<el-switch
:model-value="props.modelValue.monitorEnabled"
@update:model-value="value => patchTarget({ monitorEnabled: Boolean(value) })"
/>
</el-form-item>
<el-form-item label="预警阈值">
<div class="threshold-field">
<el-input-number
:model-value="props.modelValue.warningUsagePercent"
:min="1"
:max="100"
:controls="false"
@update:model-value="handleWarningChange($event)"
/>
<span class="suffix-text">%</span>
</div>
</el-form-item>
<el-form-item label="告警阈值">
<div class="threshold-field">
<el-input-number
:model-value="props.modelValue.alarmUsagePercent"
:min="1"
:max="100"
:controls="false"
@update:model-value="handleAlarmChange($event)"
/>
<span class="suffix-text">%</span>
</div>
</el-form-item>
</div>
<div class="dialog-section">
<div class="section-title">通知配置</div>
<el-form-item label="路径通知">
<el-switch
:model-value="props.modelValue.notifyPathEnabled"
@update:model-value="value => patchTarget({ notifyPathEnabled: Boolean(value) })"
/>
</el-form-item>
<el-form-item v-if="props.modelValue.notifyPathEnabled" class="form-item--editor" label="路径通知配置">
<NotificationPathEditor
:model-value="props.modelValue.notifyPathList"
@update:model-value="value => patchTarget({ notifyPathList: value })"
/>
</el-form-item>
<el-form-item label="HTTP 通知">
<el-switch
:model-value="props.modelValue.notifyHttpEnabled"
@update:model-value="value => patchTarget({ notifyHttpEnabled: Boolean(value) })"
/>
</el-form-item>
<el-form-item v-if="props.modelValue.notifyHttpEnabled" class="form-item--editor" label="HTTP 通知配置">
<NotificationHttpEditor
:model-value="props.modelValue.notifyHttpList"
@update:model-value="value => patchTarget({ notifyHttpList: value })"
/>
</el-form-item>
</div>
<div class="dialog-section">
<div class="section-title">补充说明</div>
<el-form-item class="form-item--remark" label="备注">
<el-input
type="textarea"
:rows="3"
:model-value="props.modelValue.remark"
placeholder="可选"
@update:model-value="value => patchTarget({ remark: String(value) })"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="emit('confirm')">确定</el-button>
<el-button :disabled="props.disabled" @click="closeDialog">取消</el-button>
<el-button type="primary" :loading="props.confirmLoading" :disabled="props.disabled" @click="emit('confirm')">
确定
</el-button>
</div>
</template>
</el-dialog>
@@ -87,11 +113,19 @@ defineOptions({
name: 'DiskMonitorTargetDialog'
})
const props = defineProps<{
visible: boolean
modelValue: DiskMonitor.TargetItem
title: string
}>()
const props = withDefaults(
defineProps<{
visible: boolean
modelValue: DiskMonitor.TargetItem
title: string
disabled?: boolean
confirmLoading?: boolean
}>(),
{
disabled: false,
confirmLoading: false
}
)
const emit = defineEmits<{
'update:visible': [value: boolean]
@@ -124,13 +158,65 @@ const patchTarget = (patch: Partial<DiskMonitor.TargetItem>) => {
</script>
<style scoped lang="scss">
.disk-monitor-dialog :deep(.el-dialog__body) {
display: flex;
flex-direction: column;
gap: 16px;
}
.target-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.dialog-section {
padding: 16px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.section-title {
margin-bottom: 14px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.target-form :deep(.el-form-item__content) {
align-items: flex-start;
}
.target-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.target-form :deep(.el-form-item:last-child) {
margin-bottom: 0;
}
.threshold-field {
display: inline-flex;
align-items: center;
}
.threshold-field :deep(.el-input-number) {
width: 140px;
}
.suffix-text {
margin-left: 8px;
color: #6b7280;
color: var(--el-text-color-secondary);
}
.form-item--editor :deep(.el-form-item__content),
.form-item--remark :deep(.el-form-item__content) {
align-items: stretch;
}
.form-item--editor :deep(.el-form-item__content > *),
.form-item--remark :deep(.el-form-item__content > *) {
width: 100%;
}
.dialog-footer {

View File

@@ -1,62 +1,119 @@
<template>
<div class="target-table-card">
<div class="table-main card disk-monitor-table-card">
<div class="table-header">
<div>
<h3 class="table-title">监控目标</h3>
<p class="table-description">维护需要监控的盘符与通知目标</p>
<div class="header-button-lf">
<el-button type="primary" :icon="CirclePlus" :disabled="props.disabled" @click="emit('add')">
新增目标
</el-button>
</div>
<div class="header-button-ri table-tools">
<el-button circle :icon="Refresh" :disabled="props.disabled" @click="emit('refresh')" />
<el-popover trigger="click" placement="bottom-end" :width="220">
<div class="column-setting-panel">
<el-checkbox v-for="column in columnOptions" :key="column.key" v-model="column.visible">
{{ column.label }}
</el-checkbox>
</div>
<template #reference>
<el-button circle :icon="Operation" />
</template>
</el-popover>
<el-button circle :icon="Search" @click="showSearch = !showSearch" />
</div>
<el-button type="primary" :disabled="props.disabled" @click="emit('add')">新增目标</el-button>
</div>
<el-table :data="props.rows" border stripe>
<el-table-column prop="driveLetter" label="盘符" min-width="90" />
<el-table-column label="是否监控" min-width="100">
<template #default="{ row }">
{{ row.monitorEnabled ? '是' : '否' }}
</template>
</el-table-column>
<el-table-column label="预警使用率" min-width="110">
<template #default="{ row }">
{{ row.warningUsagePercent }}%
</template>
</el-table-column>
<el-table-column label="告警使用率" min-width="110">
<template #default="{ row }">
{{ row.alarmUsagePercent }}%
</template>
</el-table-column>
<el-table-column label="当前状态" min-width="110">
<template #default="{ row }">
<el-tag :type="getStatusType(row.lastStatus)" effect="light">
{{ getStatusLabel(row.lastStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最近扫描时间" min-width="170">
<template #default="{ row }">
{{ formatScanTime(row.lastScanTime) }}
</template>
</el-table-column>
<el-table-column label="最近使用率" min-width="120">
<template #default="{ row }">
{{ formatUsedPercent(row.lastUsedPercent) }}
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row, $index }">
<el-button link type="primary" :disabled="props.disabled" @click="emit('edit', row, $index)">
编辑
</el-button>
<el-button link type="danger" :disabled="props.disabled" @click="emit('remove', $index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-show="showSearch" class="table-search">
<el-form label-width="80px" class="disk-monitor-search-form">
<div class="search-grid">
<el-form-item label="盘符">
<el-input v-model="searchForm.driveLetter" clearable placeholder="请输入盘符" />
</el-form-item>
<el-form-item label="当前状态">
<el-select v-model="searchForm.lastStatus" clearable placeholder="请选择状态">
<el-option label="正常" value="NORMAL" />
<el-option label="预警" value="WARNING" />
<el-option label="告警" value="ALARM" />
<el-option label="未知" value="UNKNOWN" />
</el-select>
</el-form-item>
</div>
<div class="operation">
<el-button @click="resetSearch">重置</el-button>
</div>
</el-form>
</div>
<div class="disk-monitor-table-body">
<el-table class="disk-monitor-el-table" :data="filteredRows" border stripe height="100%">
<el-table-column
v-if="isColumnVisible('driveLetter')"
prop="driveLetter"
label="盘符"
min-width="90"
align="center"
/>
<el-table-column v-if="isColumnVisible('monitorEnabled')" label="是否监控" min-width="100" align="center">
<template #default="{ row }">
{{ row.monitorEnabled ? '是' : '否' }}
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('warningUsagePercent')" label="预警使用率" min-width="110" align="center">
<template #default="{ row }">
{{ row.warningUsagePercent }}%
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('alarmUsagePercent')" label="告警使用率" min-width="110" align="center">
<template #default="{ row }">
{{ row.alarmUsagePercent }}%
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('lastStatus')" label="当前状态" min-width="110" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row.lastStatus)" effect="light">
{{ getStatusLabel(row.lastStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('lastScanTime')" label="最近扫描时间" min-width="170" align="center">
<template #default="{ row }">
{{ formatScanTime(row.lastScanTime) }}
</template>
</el-table-column>
<el-table-column v-if="isColumnVisible('lastUsedPercent')" label="最近使用率" min-width="120" align="center">
<template #default="{ row }">
{{ formatUsedPercent(row.lastUsedPercent) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row, $index }">
<el-button
link
type="primary"
:icon="EditPen"
:disabled="props.disabled"
@click="emit('edit', row, $index)"
>
编辑
</el-button>
<el-button
link
type="primary"
:icon="Delete"
:disabled="props.disabled"
@click="emit('remove', $index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { CirclePlus, Delete, EditPen, Operation, Refresh, Search } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
@@ -78,8 +135,63 @@ const emit = defineEmits<{
add: []
edit: [row: DiskMonitor.TargetItem, index: number]
remove: [index: number]
refresh: []
}>()
type TargetColumnKey =
| 'driveLetter'
| 'monitorEnabled'
| 'warningUsagePercent'
| 'alarmUsagePercent'
| 'lastStatus'
| 'lastScanTime'
| 'lastUsedPercent'
const showSearch = ref(false)
const searchForm = reactive({
driveLetter: '',
lastStatus: ''
})
const columnOptions = reactive<{ key: TargetColumnKey; label: string; visible: boolean }[]>([
{ key: 'driveLetter', label: '盘符', visible: true },
{ key: 'monitorEnabled', label: '是否监控', visible: true },
{ key: 'warningUsagePercent', label: '预警使用率', visible: true },
{ key: 'alarmUsagePercent', label: '告警使用率', visible: true },
{ key: 'lastStatus', label: '当前状态', visible: true },
{ key: 'lastScanTime', label: '最近扫描时间', visible: true },
{ key: 'lastUsedPercent', label: '最近使用率', visible: true }
])
const includesKeyword = (value?: string | number | null, keyword?: string) => {
const normalizedKeyword = String(keyword || '')
.trim()
.toLowerCase()
if (!normalizedKeyword) return true
return String(value ?? '')
.trim()
.toLowerCase()
.includes(normalizedKeyword)
}
const isColumnVisible = (key: TargetColumnKey) => {
return columnOptions.find(column => column.key === key)?.visible ?? true
}
const filteredRows = computed(() => {
return props.rows.filter(row => {
const matchesDriveLetter = includesKeyword(row.driveLetter, searchForm.driveLetter)
const selectedStatus = searchForm.lastStatus
const actualStatus = row.lastStatus || 'UNKNOWN'
const matchesStatus = !selectedStatus || actualStatus === selectedStatus
return matchesDriveLetter && matchesStatus
})
})
const resetSearch = () => {
searchForm.driveLetter = ''
searchForm.lastStatus = ''
}
const getStatusType = (status: DiskMonitor.MonitorStatus) => {
if (status === 'NORMAL') return 'success'
if (status === 'WARNING') return 'warning'
@@ -106,32 +218,67 @@ const formatUsedPercent = (value?: number | null) => {
</script>
<style scoped lang="scss">
.target-table-card {
.disk-monitor-table-card {
display: flex;
flex: 1;
flex-direction: column;
gap: 14px;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
min-height: 0;
overflow: hidden;
}
.table-header {
display: flow-root;
flex: none;
}
.table-tools {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.table-title {
margin: 0 0 6px;
font-size: 18px;
color: #111827;
.column-setting-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.table-description {
margin: 0;
font-size: 13px;
color: #6b7280;
.search-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 12px;
}
.disk-monitor-table-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.disk-monitor-el-table {
flex: 1;
min-height: 0;
}
.disk-monitor-table-body :deep(.el-table__inner-wrapper) {
height: 100%;
}
@media (max-width: 768px) {
.table-header {
display: flex;
flex-direction: column;
gap: 12px;
}
.header-button-lf,
.header-button-ri {
float: none;
}
.search-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -2,7 +2,7 @@
<div class="notification-editor">
<div class="editor-header">
<span class="editor-title">HTTP 通知目标</span>
<el-button type="primary" link @click="handleAdd">新增 HTTP 目标</el-button>
<el-button type="primary" plain size="small" :icon="CirclePlus" @click="handleAdd">新增 HTTP 目标</el-button>
</div>
<div v-if="!props.modelValue.length" class="empty-text">暂无 HTTP 通知目标</div>
<div v-else class="editor-list">
@@ -34,13 +34,14 @@
:model-value="item.enabled"
@update:model-value="value => patchRow(index, 'enabled', Boolean(value))"
/>
<el-button type="danger" link @click="handleRemove(index)">删除</el-button>
<el-button type="primary" link :icon="Delete" @click="handleRemove(index)">删除</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CirclePlus, Delete } from '@element-plus/icons-vue'
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
import { createEmptyHttpTarget } from '../utils/form'
@@ -95,22 +96,34 @@ const patchRow = <K extends keyof DiskMonitor.NotifyHttpTarget>(
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.editor-title {
font-size: 14px;
color: #111827;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-text {
padding: 14px 16px;
font-size: 13px;
color: #9ca3af;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-lighter);
border: 1px dashed var(--el-border-color);
border-radius: 6px;
}
.editor-list {
@@ -124,6 +137,15 @@ const patchRow = <K extends keyof DiskMonitor.NotifyHttpTarget>(
grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr) 100px 120px auto auto;
gap: 10px;
align-items: center;
padding: 12px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.editor-row :deep(.el-input),
.editor-row :deep(.el-select),
.editor-row :deep(.el-input-number) {
width: 100%;
}
@media (max-width: 992px) {

View File

@@ -2,7 +2,7 @@
<div class="notification-editor">
<div class="editor-header">
<span class="editor-title">路径通知目标</span>
<el-button type="primary" link @click="handleAdd">新增路径</el-button>
<el-button type="primary" plain size="small" :icon="CirclePlus" @click="handleAdd">新增路径</el-button>
</div>
<div v-if="!props.modelValue.length" class="empty-text">暂无路径通知目标</div>
<div v-else class="editor-list">
@@ -21,13 +21,14 @@
:model-value="item.enabled"
@update:model-value="value => patchRow(index, 'enabled', Boolean(value))"
/>
<el-button type="danger" link @click="handleRemove(index)">删除</el-button>
<el-button type="primary" link :icon="Delete" @click="handleRemove(index)">删除</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CirclePlus, Delete } from '@element-plus/icons-vue'
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
import { createEmptyPathTarget } from '../utils/form'
@@ -77,22 +78,34 @@ const patchRow = <K extends keyof DiskMonitor.NotifyPathTarget>(
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.editor-title {
font-size: 14px;
color: #111827;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-text {
padding: 14px 16px;
font-size: 13px;
color: #9ca3af;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-lighter);
border: 1px dashed var(--el-border-color);
border-radius: 6px;
}
.editor-list {
@@ -106,6 +119,13 @@ const patchRow = <K extends keyof DiskMonitor.NotifyPathTarget>(
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto auto;
gap: 10px;
align-items: center;
padding: 12px;
background: var(--el-fill-color-lighter);
border-radius: 6px;
}
.editor-row :deep(.el-input) {
width: 100%;
}
@media (max-width: 768px) {

View File

@@ -1,40 +1,61 @@
<template>
<div v-loading="loading.init" class="table-box disk-monitor-page">
<div class="page-header">
<div class="header-main">
<el-button class="back-button" link type="primary" @click="handleBack">返回系统监控</el-button>
<h2 class="page-title">磁盘监控</h2>
<p class="page-description">查看当前策略状态并维护全局执行配置</p>
<div class="disk-monitor-summary-section">
<DiskMonitorSummary :policy="policyForm" :targets="targetList" :latest-job="latestJob" />
<div class="disk-monitor-actions">
<el-button type="primary" plain :icon="Setting" :disabled="formBusy" @click="openPolicyDialog">
全局策略
</el-button>
</div>
</div>
<DiskMonitorSummary :policy="policyForm" :targets="targetList" :latest-job="latestJob" />
<DiskMonitorPolicyForm
v-model="policyForm"
<DiskMonitorPolicyDialog
v-model:visible="policyDialogVisible"
v-model="editingPolicy"
:disabled="formBusy"
:save-loading="loading.save"
:run-loading="loading.run"
@save="handleSave"
@confirm="confirmPolicy"
@run="handleRun"
/>
<DiskMonitorTargetTable
:rows="targetList"
:disabled="formBusy"
@add="openAddTarget"
@edit="openEditTarget"
@remove="removeTarget"
/>
<DiskMonitorTargetDialog
v-model:visible="targetDialogVisible"
v-model="editingTarget"
:title="editingTargetIndex >= 0 ? '编辑监控目标' : '新增监控目标'"
:disabled="formBusy"
:confirm-loading="loading.save"
@confirm="confirmTarget"
/>
<DiskMonitorJobTable :rows="jobList" :loading="loading.jobs" @refresh="loadJobList" @detail="openJobDetail" />
<section class="disk-monitor-tabs-card">
<el-tabs v-model="activeTab" class="disk-monitor-tabs">
<el-tab-pane label="监控记录" name="jobs">
<div class="disk-monitor-tab-panel">
<DiskMonitorJobTable
:rows="jobList"
:loading="loading.jobs"
@refresh="loadJobList"
@detail="openJobDetail"
/>
</div>
</el-tab-pane>
<el-tab-pane label="监测目标" name="targets">
<div class="disk-monitor-tab-panel">
<DiskMonitorTargetTable
:rows="targetList"
:disabled="formBusy"
@add="openAddTarget"
@edit="openEditTarget"
@remove="removeTarget"
@refresh="loadPageData"
/>
</div>
</el-tab-pane>
</el-tabs>
</section>
<DiskMonitorJobDetailDrawer
:visible="jobDetailVisible"
:detail="jobDetail"
@@ -47,7 +68,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { Setting } from '@element-plus/icons-vue'
import {
getDiskMonitorJobDetail,
getDiskMonitorJobList,
@@ -58,7 +79,7 @@ import {
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
import DiskMonitorJobDetailDrawer from './components/DiskMonitorJobDetailDrawer.vue'
import DiskMonitorJobTable from './components/DiskMonitorJobTable.vue'
import DiskMonitorPolicyForm from './components/DiskMonitorPolicyForm.vue'
import DiskMonitorPolicyDialog from './components/DiskMonitorPolicyDialog.vue'
import DiskMonitorSummary from './components/DiskMonitorSummary.vue'
import DiskMonitorTargetDialog from './components/DiskMonitorTargetDialog.vue'
import DiskMonitorTargetTable from './components/DiskMonitorTargetTable.vue'
@@ -76,9 +97,11 @@ defineOptions({
name: 'DiskMonitorPage'
})
const router = useRouter()
type DiskMonitorTab = 'targets' | 'jobs'
const policyForm = ref<DiskMonitor.PolicyItem>(createDefaultPolicy())
const policyDialogVisible = ref(false)
const editingPolicy = ref<DiskMonitor.PolicyItem>(createDefaultPolicy())
const targetList = ref<DiskMonitor.TargetItem[]>([])
const targetDialogVisible = ref(false)
const editingTargetIndex = ref(-1)
@@ -89,6 +112,7 @@ const jobDetailVisible = ref(false)
const jobDetail = ref<DiskMonitor.JobDetailData | null>(null)
const detailLoading = ref(false)
const jobDetailRequestSeq = ref(0)
const activeTab = ref<DiskMonitorTab>('jobs')
const loading = reactive({
init: false,
save: false,
@@ -107,9 +131,10 @@ const getTimeValue = (value?: string | null) => {
return Number.isNaN(timestamp) ? 0 : timestamp
}
const handleBack = async () => {
await router.push('/systemMonitor')
}
const clonePolicy = (policy: DiskMonitor.PolicyItem): DiskMonitor.PolicyItem => ({
...createDefaultPolicy(),
...policy
})
const cloneTarget = (target: DiskMonitor.TargetItem): DiskMonitor.TargetItem => {
const normalized = normalizeTargetItem(target)
@@ -120,6 +145,12 @@ const cloneTarget = (target: DiskMonitor.TargetItem): DiskMonitor.TargetItem =>
}
}
const openPolicyDialog = () => {
// 打开弹窗时复制当前策略,避免取消时污染页面已加载状态。
editingPolicy.value = clonePolicy(policyForm.value)
policyDialogVisible.value = true
}
const openAddTarget = () => {
editingTargetIndex.value = -1
editingTarget.value = createEmptyTarget()
@@ -128,13 +159,14 @@ const openAddTarget = () => {
const openEditTarget = (row: DiskMonitor.TargetItem, index: number) => {
editingTargetIndex.value = index
// 编辑时克隆当前行,避免未确认前直接污染列表数据
// 编辑时克隆当前行,避免未确认前直接改动表格数据
editingTarget.value = cloneTarget(normalizeTargetItem(row))
targetDialogVisible.value = true
}
const confirmTarget = () => {
// 提交前统一规范盘符并做去重、阈值与通知配置校验
if (formBusy.value) return
const normalizedDriveLetter = editingTarget.value.driveLetter.trim().toUpperCase()
const payload: DiskMonitor.TargetItem = {
...normalizeTargetItem(editingTarget.value),
@@ -156,16 +188,25 @@ const confirmTarget = () => {
return
}
const nextTargetList = targetList.value.map(item => cloneTarget(item))
if (editingTargetIndex.value >= 0) {
targetList.value.splice(editingTargetIndex.value, 1, payload)
nextTargetList.splice(editingTargetIndex.value, 1, payload)
} else {
targetList.value.push(payload)
nextTargetList.push(payload)
}
// 目标配置先保留在页面本地状态里,统一通过“全局策略”弹窗中的保存入口提交。
targetList.value = nextTargetList.map(item => cloneTarget(item))
targetDialogVisible.value = false
}
const removeTarget = (index: number) => {
targetList.value.splice(index, 1)
if (formBusy.value) return
// 删除目标时仅更新本地暂存列表,避免样式调整顺带改成“操作即落库”。
targetList.value = targetList.value
.filter((_, currentIndex) => currentIndex !== index)
.map(item => cloneTarget(item))
}
const loadPolicyDetail = async () => {
@@ -177,14 +218,14 @@ const loadPolicyDetail = async () => {
...createDefaultPolicy(),
...(detail.policy || {})
}
// 后端列表字段允许为空,这里统一归一化为数组,避免编辑器和克隆流程出现空引用
// 后端列表字段允许为空,这里统一归一化为数组,避免编辑器和克隆流程出现空引用
targetList.value = (detail.targets || []).map(item => normalizeTargetItem(item))
}
const loadJobList = async () => {
loading.jobs = true
try {
// 统一拉取最近任务列表,并按 startedAt 倒序确保摘要展示真实最新任务
// 统一按 startedAt 倒序拉取任务,确保顶部摘要展示最新一条记录。
const response = await getDiskMonitorJobList({
pageNum: 1,
pageSize: 100,
@@ -198,7 +239,6 @@ const loadJobList = async () => {
const sortedRecords = [...records].sort((a, b) => {
return getTimeValue(b.startedAt) - getTimeValue(a.startedAt)
})
// 前端保留 startedAt 的兜底排序,同时通过显式排序参数要求后端返回真正的最新任务。
jobList.value = sortedRecords.slice(0, 10)
latestJob.value = jobList.value[0] || null
} finally {
@@ -209,7 +249,7 @@ const loadJobList = async () => {
const handleJobDetailVisibleChange = (visible: boolean) => {
jobDetailVisible.value = visible
if (!visible) {
// 抽屉关闭时旧请求全部失效,避免回写已关闭的详情状态
// 抽屉关闭时使旧请求失效,避免晚到数据回写已关闭的详情面板。
jobDetailRequestSeq.value += 1
detailLoading.value = false
jobDetail.value = null
@@ -230,7 +270,7 @@ const openJobDetail = async (row: DiskMonitor.JobListItem) => {
detailLoading.value = true
try {
// 仅允许最一次详情请求回写,避免快速切换任务导致脏数据覆盖
// 仅允许最一次详情请求回写,避免快速切换任务时详情串台。
const response = await getDiskMonitorJobDetail(jobId)
if (currentSeq !== jobDetailRequestSeq.value || !jobDetailVisible.value) return
jobDetail.value = response.data || null
@@ -244,58 +284,85 @@ const openJobDetail = async (row: DiskMonitor.JobListItem) => {
const loadPageData = async () => {
loading.init = true
try {
// 页面刷新入口单一化:统一并行加载策略与最近任务
// 页面刷新统一并行拉取策略与任务列表,减少顶部和 tab 区的状态抖动。
await Promise.all([loadPolicyDetail(), loadJobList()])
} finally {
loading.init = false
}
}
const handleSave = async () => {
if (formBusy.value) return
const errorMessage = validatePolicy(policyForm.value)
const persistPolicyAndTargets = async (
policy: DiskMonitor.PolicyItem,
targets: DiskMonitor.TargetItem[] = targetList.value,
successMessage: string | null = '配置保存成功'
) => {
const errorMessage = validatePolicy(policy)
if (errorMessage) {
ElMessage.warning(errorMessage)
return
return false
}
const normalizedTargets = targetList.value.map(item => ({
const normalizedPolicy = clonePolicy(policy)
const normalizedTargets = targets.map(item => ({
...normalizeTargetItem(item),
driveLetter: item.driveLetter.trim().toUpperCase()
}))
const targetListErrorMessage = validateTargetList(normalizedTargets)
if (targetListErrorMessage) {
ElMessage.warning(targetListErrorMessage)
return
return false
}
loading.save = true
try {
// 整页保存前再次规范化所有盘符配置,避免历史脏数据绕过弹窗校验链路。
targetList.value = normalizedTargets
await saveDiskMonitorPolicy({
policy: policyForm.value,
policy: normalizedPolicy,
targets: normalizedTargets
})
ElMessage.success('配置保存成功')
// 保存完成后重新拉取数据,避免本地状态与服务端策略偏差
await loadPageData()
// 仅在服务端保存成功后回写本地状态,避免页面误以为已持久化。
policyForm.value = normalizedPolicy
targetList.value = normalizedTargets.map(item => cloneTarget(item))
if (successMessage) {
ElMessage.success(successMessage)
}
try {
await loadPageData()
} catch {
// 保存已成功时保留当前状态,后续刷新失败交由全局请求错误处理。
}
return true
} catch {
return false
} finally {
loading.save = false
}
}
const confirmPolicy = async () => {
if (formBusy.value) return
const saved = await persistPolicyAndTargets(clonePolicy(editingPolicy.value))
if (saved) {
policyDialogVisible.value = false
}
}
const handleRun = async () => {
if (formBusy.value) return
if (policyDialogVisible.value) {
// 弹窗内执行监控前先落当前策略草稿,避免运行的还是上一次已保存配置。
const saved = await persistPolicyAndTargets(clonePolicy(editingPolicy.value), targetList.value, null)
if (!saved) return
policyDialogVisible.value = false
}
loading.run = true
try {
await runDiskMonitorJob({
jobSource: 'MANUAL'
})
ElMessage.success('监控任务已启动')
// 手动触发任务后通过页面统一刷新流更新摘要和任务列表
await loadPageData()
} finally {
loading.run = false
@@ -312,35 +379,90 @@ onMounted(async () => {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
width: 100%;
height: 100%;
min-height: 0;
// 页面根节点跟随主内容区边距,不再额外叠加页面级外边距。
padding: 0;
overflow: hidden;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
.disk-monitor-summary-section {
position: relative;
flex: none;
}
.header-main {
.disk-monitor-summary-section :deep(.disk-monitor-summary-card) {
padding-bottom: 36px;
}
.disk-monitor-actions {
display: flex;
position: absolute;
right: 20px;
top: calc(50% + 44px);
z-index: 1;
}
.disk-monitor-tabs-card {
display: flex;
flex: 1;
min-height: 0;
padding: 16px;
overflow: hidden;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
}
.disk-monitor-tabs {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
width: 100%;
min-height: 0;
overflow: hidden;
}
.back-button {
align-self: flex-start;
padding-left: 0;
.disk-monitor-tabs :deep(.el-tabs__header) {
margin-bottom: 12px;
}
.page-title {
margin: 0;
font-size: 22px;
color: #111827;
.disk-monitor-tabs :deep(.el-tabs__nav-wrap::after) {
background-color: var(--el-border-color-light);
}
.page-description {
margin: 0;
.disk-monitor-tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: hidden;
}
.disk-monitor-tabs :deep(.el-tab-pane) {
display: flex;
height: 100%;
min-height: 0;
}
.disk-monitor-tabs :deep(.el-tabs__item) {
font-size: 13px;
color: #6b7280;
}
.disk-monitor-tab-panel {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
@media (max-width: 768px) {
.disk-monitor-tabs :deep(.el-tabs__nav) {
width: 100%;
}
.disk-monitor-tabs :deep(.el-tabs__item) {
flex: 1;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,526 @@
<template>
<section class="card add-data-card">
<div class="card-header">
<div>
<div class="section-title">补数任务</div>
<div class="section-description">
手工输入监测点 ID先预估本次写入规模再发起异步补数任务
</div>
</div>
</div>
<div class="card-body">
<el-alert
class="task-alert"
type="info"
:closable="false"
show-icon
title="时间步长仅影响 10 张基础实时类表data_flicker / data_fluc 固定 10 分钟data_plt 固定 2 小时。"
/>
<el-form ref="formRef" :model="localForm" :rules="formRules" label-width="108px" class="task-form">
<div class="form-row form-row-first">
<el-form-item class="form-item-line-ids" label="监测点 ID" prop="lineIds">
<div class="line-id-input-group">
<el-input
v-model="lineIdsText"
type="textarea"
:rows="4"
resize="vertical"
:placeholder="lineIdsPlaceholder"
/>
<div class="line-id-actions">
<el-button type="primary" plain :icon="CirclePlus" @click="handleOpenGuidDialog">
新增监测点
</el-button>
<el-button type="primary" plain :icon="Delete" @click="handleClearLineIds">
清空监测点
</el-button>
</div>
</div>
</el-form-item>
</div>
<div class="form-row form-row-second">
<el-form-item label="开始时间" prop="startTime">
<el-date-picker
v-model="localForm.startTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择开始时间"
/>
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-date-picker
v-model="localForm.endTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择结束时间"
/>
</el-form-item>
</div>
<div class="form-row form-row-third">
<el-form-item class="form-item-interval" label="时间步长" prop="intervalMinutes">
<el-radio-group v-model="localForm.intervalMinutes">
<el-radio-button v-for="item in intervalOptions" :key="item" :label="item">
{{ item }} 分钟
</el-radio-button>
</el-radio-group>
</el-form-item>
<div class="form-actions">
<el-button type="primary" plain :icon="Histogram" :loading="previewLoading" @click="emit('preview')">
预估写入量
</el-button>
<el-button
type="primary"
:icon="Promotion"
:loading="submitLoading"
:disabled="taskRunning"
@click="emit('submit')"
>
开始补数
</el-button>
</div>
</div>
</el-form>
<div class="preview-section">
<div class="preview-header">
<div class="preview-title">预估结果</div>
<div class="preview-text">参数发生变化后需要重新预估才能继续创建任务</div>
</div>
<div v-if="preview" class="preview-content">
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="监测点数量">{{ preview.lineCount }}</el-descriptions-item>
<el-descriptions-item label="时间步长">{{ preview.intervalMinutes }} 分钟</el-descriptions-item>
<el-descriptions-item label="总预计条数">{{ preview.totalRowCount }}</el-descriptions-item>
</el-descriptions>
<el-table class="preview-table" :data="preview.tableStats" border stripe :max-height="320">
<el-table-column prop="tableName" label="数据表" min-width="180" />
<el-table-column prop="timePointCount" label="时间点数量" min-width="120" align="right" />
<el-table-column prop="phaseCount" label="相别数量" min-width="120" align="right" />
<el-table-column prop="rowCount" label="预计条数" min-width="120" align="right" />
</el-table>
</div>
<div v-else class="empty-block">暂无预估结果请先填写参数并点击预估写入量</div>
</div>
</div>
</section>
<el-dialog v-model="guidDialogVisible" title="新增 guid" width="420px" @closed="handleGuidDialogClosed">
<el-form label-width="96px">
<el-form-item label="guid 数量">
<el-input-number v-model="guidCount" :min="1" :step="1" :precision="0" controls-position="right" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="guidDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleAppendGuids">确认</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { CirclePlus, Delete, Histogram, Promotion } from '@element-plus/icons-vue'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { AddData } from '@/api/tools/addData/interface'
defineOptions({
name: 'AddDataTaskPanel'
})
const props = defineProps<{
form: AddData.TaskFormModel
preview: AddData.NormalizedPreview | null
previewLoading: boolean
submitLoading: boolean
taskRunning: boolean
}>()
const emit = defineEmits<{
preview: []
submit: []
'update:form': [form: AddData.TaskFormModel]
}>()
const formRef = ref<FormInstance>()
const syncingFromProp = ref(false)
const guidDialogVisible = ref(false)
const guidCount = ref(1)
const intervalOptions: AddData.IntervalMinutes[] = [1, 3, 5, 10]
const localForm = reactive<AddData.TaskFormModel>({
lineMode: 'multiple',
lineIds: [...props.form.lineIds],
startTime: props.form.startTime,
endTime: props.form.endTime,
intervalMinutes: props.form.intervalMinutes
})
const syncLocalForm = (form: AddData.TaskFormModel) => {
localForm.lineMode = 'multiple'
localForm.lineIds = [...form.lineIds]
localForm.startTime = form.startTime
localForm.endTime = form.endTime
localForm.intervalMinutes = form.intervalMinutes
}
const normalizeLineIds = (lineIds: string[]) => {
return Array.from(
new Set(
(lineIds || [])
.map(item => item?.trim())
.filter((item): item is string => Boolean(item))
)
)
}
const splitLineIdsText = (value: string) => {
return value
.split(/[\s,\uFF0C]+/)
.map(item => item.trim())
.filter(Boolean)
}
const isValidLineId = (value: string) => {
const normalizedValue = value.trim()
return Boolean(normalizedValue) && normalizedValue.length <= 32
}
watch(
() => props.form,
value => {
syncingFromProp.value = true
syncLocalForm(value)
},
{ deep: true, immediate: true }
)
watch(
localForm,
value => {
if (syncingFromProp.value) {
syncingFromProp.value = false
return
}
emit('update:form', {
lineMode: 'multiple',
lineIds: [...value.lineIds],
startTime: value.startTime,
endTime: value.endTime,
intervalMinutes: value.intervalMinutes
})
},
{ deep: true }
)
const setLineIds = (lineIds: string[]) => {
localForm.lineIds = normalizeLineIds(lineIds)
}
const handleClearLineIds = () => {
setLineIds([])
}
const lineIdsText = computed({
get: () => localForm.lineIds.join(','),
set: value => {
setLineIds(splitLineIdsText(value))
}
})
const lineIdsPlaceholder = computed(
() => '请输入一个或多个监测点 ID多个值可用英文逗号、中文逗号、空格或换行分隔单个长度不超过 32'
)
const generateGuidText = () => {
return window.crypto.randomUUID().replace(/-/g, '')
}
const handleOpenGuidDialog = () => {
guidCount.value = 1
guidDialogVisible.value = true
}
const handleGuidDialogClosed = () => {
guidCount.value = 1
}
const handleAppendGuids = () => {
const count = Number(guidCount.value)
if (!Number.isInteger(count) || count <= 0) {
ElMessage.warning('请输入大于 0 的整数 guid 数量')
return
}
// 前端补充的 guid 仍需满足后端 lineIds 非空且长度不超过 32 的契约。
const nextLineIds = [...localForm.lineIds, ...Array.from({ length: count }, () => generateGuidText())]
setLineIds(nextLineIds)
guidDialogVisible.value = false
}
const formRules: FormRules<AddData.TaskFormModel> = {
lineIds: [
{
validator: (_rule, value: string[], callback) => {
const validLineIds = normalizeLineIds(value)
if (!validLineIds.length) {
callback(new Error('请至少输入一个监测点 ID'))
return
}
if (validLineIds.some(item => !isValidLineId(item))) {
callback(new Error('监测点 ID 不能为空,且长度不能超过 32'))
return
}
callback()
},
trigger: 'change'
}
],
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
endTime: [
{ required: true, message: '请选择结束时间', trigger: 'change' },
{
validator: (_rule, value: string, callback) => {
if (!value || !localForm.startTime) {
callback()
return
}
if (new Date(value).getTime() < new Date(localForm.startTime).getTime()) {
callback(new Error('开始时间不能大于结束时间'))
return
}
callback()
},
trigger: 'change'
}
],
intervalMinutes: [{ required: true, message: '请选择时间步长', trigger: 'change' }]
}
const validateTaskForm = async () => {
const result = await formRef.value?.validate().catch(() => false)
return Boolean(result)
}
defineExpose({
validateTaskForm
})
</script>
<style scoped lang="scss">
.add-data-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.section-description {
margin-top: 6px;
font-size: 13px;
line-height: 1.7;
color: var(--el-text-color-regular);
}
.card-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-height: 0;
padding: 16px;
overflow: hidden;
}
.task-alert,
.task-form {
flex: none;
}
.form-row {
display: grid;
gap: 0 16px;
}
.form-row + .form-row {
margin-top: 6px;
}
.form-row-first {
grid-template-columns: minmax(0, 1fr);
}
.form-row-second {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-row-third {
grid-template-columns: minmax(280px, auto) minmax(0, 1fr);
align-items: start;
}
.form-item-line-ids,
.form-item-interval {
margin-bottom: 0;
}
.form-item-line-ids :deep(.el-form-item__content),
.form-item-interval :deep(.el-form-item__content) {
width: 100%;
}
.task-form :deep(.el-date-editor) {
width: 100%;
}
.task-form :deep(.el-radio-group) {
flex-wrap: wrap;
}
.line-id-input-group {
display: flex;
width: 100%;
align-items: flex-start;
gap: 12px;
}
.line-id-input-group :deep(.el-textarea) {
flex: 1;
}
.line-id-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.line-id-actions :deep(.el-button) {
margin-left: 0;
}
.form-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12px;
}
.preview-section {
display: flex;
flex: none;
flex-direction: column;
gap: 6px;
min-height: 0;
}
.preview-header {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
justify-content: space-between;
}
.preview-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.preview-text {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.preview-content {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.preview-table {
width: 100%;
}
.preview-table :deep(.el-table__body-wrapper) {
max-height: 320px;
}
.empty-block {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 0;
padding: 16px;
color: var(--el-text-color-secondary);
background: var(--cn-color-canvas-bg);
border: 1px dashed var(--el-border-color);
border-radius: 4px;
text-align: center;
}
@media (max-width: 768px) {
.form-row,
.form-row-second,
.form-row-third {
grid-template-columns: 1fr;
}
.line-id-input-group {
flex-direction: column;
}
.line-id-input-group :deep(.el-button) {
width: 100%;
}
.line-id-actions {
width: 100%;
}
.form-actions {
flex-direction: column;
justify-content: flex-start;
}
.form-actions :deep(.el-button) {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<section class="card add-data-card">
<div class="card-header">
<div>
<div class="section-title">任务状态</div>
<div class="section-description">创建任务后自动轮询状态直到任务成功或失败</div>
</div>
<el-tag v-if="status" :type="statusMeta.type" effect="light">{{ statusMeta.label }}</el-tag>
</div>
<div v-loading="loading" class="card-body">
<div v-if="status" class="status-content">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="任务 ID">{{ taskId || status.taskId || '--' }}</el-descriptions-item>
<el-descriptions-item label="当前状态">{{ statusMeta.label }}</el-descriptions-item>
<el-descriptions-item label="当前表名">{{ status.currentTableName || '--' }}</el-descriptions-item>
<el-descriptions-item label="当前批次">{{ status.currentBatchInfo || '--' }}</el-descriptions-item>
<el-descriptions-item label="已写入数量">{{ status.insertedCount }}</el-descriptions-item>
<el-descriptions-item label="已跳过数量">{{ status.skippedCount }}</el-descriptions-item>
<el-descriptions-item label="失败数量">{{ status.failedCount }}</el-descriptions-item>
<el-descriptions-item label="失败原因">
<span class="failure-text">{{ status.failureReason || '--' }}</span>
</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ status.startTime || '--' }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ status.endTime || '--' }}</el-descriptions-item>
</el-descriptions>
<div class="hourly-block">
<div class="hourly-title">业务时刻</div>
<div v-if="status.hourlyTimeResults.length" class="hourly-scroll">
<div class="hourly-list">
<el-tag v-for="item in status.hourlyTimeResults" :key="item" effect="plain" type="info">
{{ item }}
</el-tag>
</div>
</div>
<div v-else class="hourly-empty">当前接口未返回业务时刻</div>
</div>
</div>
<div v-else class="empty-block">暂无补数任务创建任务后会在这里持续展示执行进度</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { AddData } from '@/api/tools/addData/interface'
defineOptions({
name: 'AddDataTaskStatusCard'
})
const props = defineProps<{
status: AddData.NormalizedTaskStatus | null
taskId: string
loading: boolean
}>()
const statusMeta = computed(() => {
const status = props.status?.status
if (status === 'SUCCESS') {
return { label: '成功', type: 'success' as const }
}
if (status === 'FAILED') {
return { label: '失败', type: 'danger' as const }
}
if (status === 'RUNNING') {
return { label: '执行中', type: 'warning' as const }
}
if (status === 'WAITING') {
return { label: '等待中', type: 'info' as const }
}
return {
label: status || '未知状态',
type: 'info' as const
}
})
</script>
<style scoped lang="scss">
.add-data-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.section-description {
margin-top: 6px;
font-size: 13px;
line-height: 1.7;
color: var(--el-text-color-regular);
}
.card-body {
display: flex;
flex: 1;
min-height: 0;
padding: 16px;
overflow: hidden;
}
.status-content {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
width: 100%;
}
.failure-text {
word-break: break-all;
color: var(--el-color-danger);
}
.hourly-block {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 12px;
background: var(--cn-color-canvas-bg);
border: 1px dashed var(--el-border-color);
border-radius: 4px;
}
.hourly-title {
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.hourly-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
}
.hourly-list {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 8px;
}
.hourly-empty {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.empty-block {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
width: 100%;
min-height: 0;
padding: 16px;
color: var(--el-text-color-secondary);
background: var(--cn-color-canvas-bg);
border: 1px dashed var(--el-border-color);
border-radius: 4px;
text-align: center;
}
</style>

View File

@@ -0,0 +1,109 @@
<template>
<section class="table-main card add-data-template-card">
<div class="table-header">
<div class="header-title-group">
<div class="section-title">参数规则</div>
<div class="section-description">模板规则来自后端配置用于说明参数展示字段相别映射和统计规则</div>
</div>
<div class="header-tools">
<el-button circle :icon="Refresh" :loading="loading" @click="emit('refresh')" />
</div>
</div>
<div class="table-body">
<el-table v-loading="loading" :data="rows" border stripe height="100%">
<el-table-column prop="parameterName" label="电能质量参数名称" min-width="220" fixed="left" />
<el-table-column prop="tableName" label="落库表" min-width="140" />
<el-table-column prop="phaseDisplay" label="展示相别" min-width="110" align="center" />
<el-table-column prop="phaseCodesText" label="落库相别" min-width="140" align="center" />
<el-table-column prop="displayText" label="是否展示" min-width="110" align="center" />
<el-table-column prop="showQualifiedText" label="是否展示合格" min-width="140" align="center" />
<el-table-column prop="maxValueRule" label="最大值规则" min-width="160" />
<el-table-column prop="minValueRule" label="最小值规则" min-width="160" />
<el-table-column prop="averageValueRule" label="平均值规则" min-width="160" />
<el-table-column prop="cp95ValueRule" label="95% 概率大值规则" min-width="180" />
<el-table-column prop="decimalScaleText" label="小数位数" min-width="120" align="center" />
</el-table>
</div>
</section>
</template>
<script setup lang="ts">
import { Refresh } from '@element-plus/icons-vue'
import type { AddData } from '@/api/tools/addData/interface'
defineOptions({
name: 'AddDataTemplateTable'
})
defineProps<{
rows: AddData.NormalizedTemplateItem[]
loading: boolean
}>()
const emit = defineEmits<{
refresh: []
}>()
</script>
<style scoped lang="scss">
.add-data-template-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 16px;
overflow: hidden;
}
.table-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex: none;
}
.header-title-group {
min-width: 0;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.section-description {
margin-top: 6px;
font-size: 13px;
line-height: 1.7;
color: var(--el-text-color-regular);
}
.header-tools {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.table-body {
display: flex;
flex: 1;
min-height: 0;
margin-top: 12px;
overflow: hidden;
}
.table-body :deep(.el-table__inner-wrapper) {
height: 100%;
}
@media (max-width: 768px) {
.table-header {
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@@ -0,0 +1,465 @@
<template>
<div class="table-box add-data-page">
<div class="add-data-layout">
<div class="add-data-main-column">
<AddDataTaskPanel
ref="taskPanelRef"
:form="taskForm"
:preview="previewSummary"
:preview-loading="loading.preview"
:submit-loading="loading.create"
:task-running="taskRunning"
@update:form="handleTaskFormChange"
@preview="handlePreview"
@submit="handleCreateTask"
/>
</div>
<section class="add-data-side-panel">
<el-tabs v-model="activeTab" class="add-data-tabs">
<el-tab-pane label="任务状态" name="taskStatus">
<AddDataTaskStatusCard :status="taskStatus" :task-id="currentTaskId" :loading="loading.status" />
</el-tab-pane>
<el-tab-pane label="参数规则" name="templateRules">
<AddDataTemplateTable :rows="templateRows" :loading="loading.template" @refresh="loadTemplateList" />
</el-tab-pane>
</el-tabs>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createAddDataTask,
getAddDataPreview,
getAddDataTaskStatus,
getAddDataTemplateList
} from '@/api/tools/addData'
import type { AddData } from '@/api/tools/addData/interface'
import AddDataTaskPanel from './components/AddDataTaskPanel.vue'
import AddDataTaskStatusCard from './components/AddDataTaskStatusCard.vue'
import AddDataTemplateTable from './components/AddDataTemplateTable.vue'
defineOptions({
name: 'AddDataView'
})
type AddDataTaskPanelExpose = {
validateTaskForm: () => Promise<boolean>
}
const taskPanelRef = ref<AddDataTaskPanelExpose | null>(null)
const activeTab = ref('taskStatus')
const templateRows = ref<AddData.NormalizedTemplateItem[]>([])
const previewSummary = ref<AddData.NormalizedPreview | null>(null)
const taskStatus = ref<AddData.NormalizedTaskStatus | null>(null)
const currentTaskId = ref('')
const previewSignature = ref('')
const pollTimer = ref<number | null>(null)
const pollingBusy = ref(false)
const loading = reactive({
template: false,
preview: false,
create: false,
status: false
})
const taskForm = reactive<AddData.TaskFormModel>({
lineMode: 'multiple',
lineIds: [],
startTime: '',
endTime: '',
intervalMinutes: 1
})
const handleTaskFormChange = (nextForm: AddData.TaskFormModel) => {
taskForm.lineMode = 'multiple'
taskForm.lineIds = [...nextForm.lineIds]
taskForm.startTime = nextForm.startTime
taskForm.endTime = nextForm.endTime
taskForm.intervalMinutes = nextForm.intervalMinutes
}
const normalizeLineIds = (lineIds: string[]) => {
return Array.from(
new Set(
(lineIds || [])
.map(item => item?.trim())
.filter((item): item is string => Boolean(item))
)
)
}
const parseLineIds = (lineIds: string[]) => {
return normalizeLineIds(lineIds)
}
const resetPreview = () => {
previewSummary.value = null
previewSignature.value = ''
}
const resolveNumber = (...values: unknown[]) => {
for (const value of values) {
if (value === null || value === undefined || value === '') continue
const parsed = Number(value)
if (Number.isFinite(parsed)) {
return parsed
}
}
return 0
}
const resolveText = (...values: unknown[]) => {
for (const value of values) {
if (value === null || value === undefined) continue
const text = String(value).trim()
if (text) {
return text
}
}
return ''
}
const resolveDisplayRule = (value: unknown, fallback = '--') => {
if (typeof value === 'boolean') {
return value ? '显示' : '不显示'
}
if (typeof value === 'number') {
return value ? '显示' : '不显示'
}
const text = resolveText(value)
return text || fallback
}
const normalizePreview = (data?: AddData.PreviewResponse | null): AddData.NormalizedPreview => {
const tableStats = (Array.isArray(data?.tableStats) ? data.tableStats : [])
.map(item => ({
tableName: resolveText(item.tableName) || '--',
timePointCount: resolveNumber(item.timePointCount),
phaseCount: resolveNumber(item.phaseCount),
rowCount: resolveNumber(item.rowCount)
}))
.sort((left, right) => right.rowCount - left.rowCount)
return {
lineCount: resolveNumber(data?.lineCount),
intervalMinutes: resolveNumber(data?.intervalMinutes),
totalRowCount: resolveNumber(data?.totalRowCount),
tableStats
}
}
const normalizeTaskStatus = (data?: AddData.TaskStatusResponse | null): AddData.NormalizedTaskStatus => {
return {
taskId: resolveText(data?.taskId),
status: (resolveText(data?.status) || 'WAITING') as AddData.TaskStatus,
currentTableName: resolveText(data?.currentTableName),
currentBatchInfo: resolveText(data?.currentBatchInfo),
insertedCount: resolveNumber(data?.insertedCount),
skippedCount: resolveNumber(data?.skippedCount),
failedCount: resolveNumber(data?.failedCount),
failureReason: resolveText(data?.failureReason),
hourlyTimeResults: Array.isArray(data?.hourlyTimeResults) ? data.hourlyTimeResults.filter(Boolean) : [],
startTime: resolveText(data?.startTime),
endTime: resolveText(data?.endTime)
}
}
const normalizeTemplateItem = (item: AddData.TemplateItem): AddData.NormalizedTemplateItem => {
const decimalScale = resolveText(item.decimalScale)
return {
parameterName: resolveText(item.parameterName) || '--',
tableName: resolveText(item.tableName) || '--',
phaseDisplay: resolveText(item.phaseDisplay) || '--',
phaseCodesText: Array.isArray(item.phaseCodes) && item.phaseCodes.length ? item.phaseCodes.join(' / ') : '--',
displayText: resolveDisplayRule(item.display),
showQualifiedText: resolveDisplayRule(item.showQualified),
maxValueRule: resolveText(item.maxValueRule) || '--',
minValueRule: resolveText(item.minValueRule) || '--',
averageValueRule: resolveText(item.averageValueRule) || '--',
cp95ValueRule: resolveText(item.cp95ValueRule) || '--',
decimalScaleText: decimalScale ? `${decimalScale} 位小数` : '--'
}
}
const buildTaskPayload = (): AddData.TaskRequestParams => {
return {
lineIds: parseLineIds(taskForm.lineIds),
startTime: taskForm.startTime,
endTime: taskForm.endTime,
intervalMinutes: taskForm.intervalMinutes
}
}
const buildPayloadSignature = (payload: AddData.TaskRequestParams) => {
return JSON.stringify(payload)
}
const buildPreviewDependencySignature = () => {
return buildPayloadSignature(buildTaskPayload())
}
const isTerminalStatus = (status?: AddData.TaskStatus) => {
return status === 'SUCCESS' || status === 'FAILED'
}
const taskRunning = computed(() => {
const status = taskStatus.value?.status
return Boolean(currentTaskId.value && (status === 'WAITING' || status === 'RUNNING'))
})
const stopPolling = () => {
if (pollTimer.value !== null) {
window.clearInterval(pollTimer.value)
pollTimer.value = null
}
}
const loadTemplateList = async () => {
loading.template = true
try {
const response = await getAddDataTemplateList()
const rows = Array.isArray(response.data) ? response.data : []
templateRows.value = rows.map(item => normalizeTemplateItem(item))
} finally {
loading.template = false
}
}
const getValidatedPayload = async () => {
const isValid = await taskPanelRef.value?.validateTaskForm()
if (!isValid) return null
const payload = buildTaskPayload()
if (!payload.lineIds.length) {
ElMessage.warning('请至少输入一个合法的监测点 ID')
return null
}
return payload
}
const loadTaskStatus = async (taskId = currentTaskId.value, silent = false) => {
if (!taskId || pollingBusy.value) return
pollingBusy.value = true
if (!silent) {
loading.status = true
}
try {
const response = await getAddDataTaskStatus(taskId)
// 状态接口是补数任务唯一的进度来源,统一在这里按正式契约归一化,避免页面层分散兼容字段。
const normalizedStatus = normalizeTaskStatus(response.data)
taskStatus.value = normalizedStatus
currentTaskId.value = normalizedStatus.taskId || taskId
if (isTerminalStatus(normalizedStatus.status)) {
stopPolling()
}
} catch (error) {
// 轮询失败时立即停止定时器,避免请求持续堆积并反复弹出相同错误。
stopPolling()
throw error
} finally {
if (!silent) {
loading.status = false
}
pollingBusy.value = false
}
}
const startPolling = (taskId: string) => {
stopPolling()
currentTaskId.value = taskId
// 创建任务后先立即拉一次状态,再进入固定轮询,避免页面长时间停留在初始态。
void loadTaskStatus(taskId).catch(() => null)
pollTimer.value = window.setInterval(() => {
void loadTaskStatus(taskId, true).catch(() => null)
}, 3000)
}
const handlePreview = async () => {
const payload = await getValidatedPayload()
if (!payload) return
loading.preview = true
try {
const response = await getAddDataPreview(payload)
// preview 是 create 前的唯一准入检查,必须严格按正式契约读取 totalRowCount 和 tableStats。
previewSummary.value = normalizePreview(response.data)
previewSignature.value = buildPayloadSignature(payload)
ElMessage.success('写入规模预估完成')
} finally {
loading.preview = false
}
}
const handleCreateTask = async () => {
if (taskRunning.value) {
ElMessage.warning('当前补数任务仍在执行,请等待结束后再创建新任务')
return
}
const payload = await getValidatedPayload()
if (!payload) return
const currentSignature = buildPayloadSignature(payload)
if (!previewSummary.value || previewSignature.value !== currentSignature) {
ElMessage.warning('参数已变化,请先重新预估写入量')
return
}
try {
await ElMessageBox.confirm(
`预计写入 ${previewSummary.value.totalRowCount} 条数据,涉及 ${payload.lineIds.length} 个监测点,确认开始补数?`,
'开始补数',
{
type: 'warning',
confirmButtonText: '开始补数',
cancelButtonText: '取消'
}
)
} catch {
return
}
loading.create = true
try {
const response = await createAddDataTask(payload)
const taskId = resolveText(response.data?.taskId)
taskStatus.value = normalizeTaskStatus({
taskId,
status: response.data?.status
})
if (!taskId) {
ElMessage.warning('任务已创建,但接口未返回 taskId无法继续轮询状态')
return
}
startPolling(taskId)
ElMessage.success('补数任务已创建,正在轮询状态')
} finally {
loading.create = false
}
}
watch(
// 预估失效判断必须与 preview/create 的正式请求参数保持同一口径,避免仅切换界面模式也误判为参数变化。
() => buildPreviewDependencySignature(),
() => {
resetPreview()
}
)
onMounted(async () => {
await loadTemplateList()
})
onBeforeUnmount(() => {
stopPolling()
})
</script>
<style scoped lang="scss">
.add-data-page {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
padding: 0;
gap: 16px;
overflow: hidden;
}
.add-data-layout {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(360px, 0.95fr);
gap: 12px;
width: 100%;
flex: 1;
height: 100%;
min-height: 0;
overflow: hidden;
}
.add-data-main-column {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.add-data-main-column > * {
flex: 1;
min-height: 0;
}
.add-data-side-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.add-data-side-panel :deep(.el-tabs) {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
min-height: 0;
}
.add-data-side-panel :deep(.el-tabs__header) {
margin-bottom: 12px;
}
.add-data-side-panel :deep(.el-tabs__nav-wrap::after) {
display: none;
}
.add-data-side-panel :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: hidden;
}
.add-data-tabs {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.add-data-tabs :deep(.el-tab-pane) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.add-data-tabs :deep(.el-tab-pane > *) {
flex: 1;
min-height: 0;
}
@media (max-width: 1280px) {
.add-data-layout {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -18,6 +18,11 @@
<div class="tool-name">MMS 映射</div>
<div class="tool-text">进入 MMS 映射页面后续可继续补充映射配置和预览能力</div>
</button>
<button class="tool-item" type="button" @click="handleNavigate('/tools/addData')">
<div class="tool-name">addData</div>
<div class="tool-text">进入 addData 页面壳子后续在此扩展补录数据能力和业务交互</div>
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,196 @@
<template>
<div class="json-mapping-tree-viewer">
<div class="json-tree-toolbar">
<div class="json-tree-meta">{{ metaText }}</div>
<div class="json-tree-actions">
<slot name="actions" />
<el-button type="primary" plain size="small" :disabled="!rootNode" @click="expandAll">全部展开</el-button>
<el-button plain size="small" :disabled="!rootNode" @click="collapseAll">全部收起</el-button>
</div>
</div>
<div v-if="rootNode" class="json-tree-body">
<JsonTreeNode :node="rootNode" :depth="0" :is-last="true" :expanded-keys="expandedKeys" @toggle="toggleNode" />
</div>
<pre v-else class="mapping-json-text">{{ source }}</pre>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import JsonTreeNode, { type JsonTreeNodeModel, type JsonValueType } from './JsonMappingTreeNode.vue'
defineOptions({
name: 'JsonMappingTree'
})
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
const props = defineProps<{
source: string
metaText?: string
}>()
const expandedKeys = ref<Set<string>>(new Set())
const parsedJson = computed<{ valid: true; value: JsonValue } | { valid: false }>(() => {
try {
return {
valid: true,
value: JSON.parse(props.source) as JsonValue
}
} catch {
return {
valid: false
}
}
})
const rootNode = computed(() => {
if (!parsedJson.value.valid) return null
return buildJsonNode(undefined, parsedJson.value.value, '$')
})
watch(
rootNode,
node => {
expandedKeys.value = new Set(node ? collectContainerKeys(node) : [])
},
{ immediate: true }
)
function buildJsonNode(keyName: string | undefined, source: JsonValue, path: string): JsonTreeNodeModel {
if (Array.isArray(source)) {
return {
id: path,
keyName,
openToken: '[',
closeToken: ']',
summary: ` ${source.length}`,
children: source.map((item, index) => buildJsonNode(undefined, item, `${path}/${index}`))
}
}
if (source && typeof source === 'object') {
const entries = Object.entries(source)
return {
id: path,
keyName,
openToken: '{',
closeToken: '}',
summary: ` ${entries.length}`,
children: entries.map(([key, value]) => buildJsonNode(key, value, `${path}/${escapePathKey(key)}`))
}
}
return {
id: path,
keyName,
valueText: formatPrimitiveValue(source),
valueType: getPrimitiveType(source)
}
}
function collectContainerKeys(node: JsonTreeNodeModel): string[] {
if (!node.children) return []
return [node.id, ...node.children.flatMap(collectContainerKeys)]
}
function escapePathKey(key: string) {
return key.replace(/~/g, '~0').replace(/\//g, '~1')
}
function formatPrimitiveValue(source: JsonValue) {
if (typeof source === 'string') return JSON.stringify(source)
if (source === null) return 'null'
return String(source)
}
function getPrimitiveType(source: JsonValue): JsonValueType {
if (source === null) return 'null'
if (typeof source === 'boolean') return 'boolean'
if (typeof source === 'number') return 'number'
return 'string'
}
function toggleNode(id: string) {
const nextKeys = new Set(expandedKeys.value)
if (nextKeys.has(id)) {
nextKeys.delete(id)
} else {
nextKeys.add(id)
}
expandedKeys.value = nextKeys
}
function expandAll() {
if (!rootNode.value) return
expandedKeys.value = new Set(collectContainerKeys(rootNode.value))
}
function collapseAll() {
expandedKeys.value = new Set()
}
</script>
<style scoped lang="scss">
.json-mapping-tree-viewer {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.json-tree-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 28px;
gap: 8px;
margin-bottom: 8px;
white-space: nowrap;
}
.json-tree-meta {
flex: 1;
min-width: 0;
overflow: hidden;
color: #64748b;
font-size: 13px;
text-overflow: ellipsis;
}
.json-tree-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.json-tree-body,
.mapping-json-text {
flex: 1;
min-height: 0;
margin: 0;
padding: 16px;
border: 1px solid #dbe3f0;
border-radius: 10px;
background: #ffffff;
overflow: auto;
font-family: Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
color: #172033;
}
.mapping-json-text {
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,153 @@
<template>
<div v-if="!node.children" class="json-tree-line" :style="indentStyle">
<span class="json-tree-spacer" />
<template v-if="node.keyName !== undefined">
<span class="json-tree-key">{{ JSON.stringify(node.keyName) }}</span>
<span class="json-tree-colon">: </span>
</template>
<span :class="['json-tree-value', `is-${node.valueType}`]">{{ node.valueText }}</span>
<span v-if="!isLast" class="json-tree-comma">,</span>
</div>
<div v-else class="json-tree-node">
<div class="json-tree-line is-container" :style="indentStyle">
<button class="json-tree-toggle" type="button" :title="isExpanded ? '收起' : '展开'" @click="emit('toggle', node.id)">
{{ isExpanded ? '' : '' }}
</button>
<template v-if="node.keyName !== undefined">
<span class="json-tree-key">{{ JSON.stringify(node.keyName) }}</span>
<span class="json-tree-colon">: </span>
</template>
<span class="json-tree-token">{{ node.openToken }}</span>
<span v-if="!isExpanded" class="json-tree-ellipsis"> ... </span>
<span v-if="!isExpanded" class="json-tree-token">{{ node.closeToken }}</span>
<span class="json-tree-summary">{{ node.summary }}</span>
<span v-if="!isExpanded && !isLast" class="json-tree-comma">,</span>
</div>
<template v-if="isExpanded">
<JsonMappingTreeNode
v-for="(child, index) in node.children"
:key="child.id"
:node="child"
:depth="depth + 1"
:is-last="index === node.children.length - 1"
:expanded-keys="expandedKeys"
@toggle="emit('toggle', $event)"
/>
<div class="json-tree-line is-close" :style="indentStyle">
<span class="json-tree-spacer" />
<span class="json-tree-token">{{ node.closeToken }}</span>
<span v-if="!isLast" class="json-tree-comma">,</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({
name: 'JsonMappingTreeNode'
})
export type JsonValueType = 'string' | 'number' | 'boolean' | 'null'
export interface JsonTreeNodeModel {
id: string
keyName?: string
openToken?: '{' | '['
closeToken?: '}' | ']'
summary?: string
children?: JsonTreeNodeModel[]
valueText?: string
valueType?: JsonValueType
}
const props = defineProps<{
node: JsonTreeNodeModel
depth: number
isLast: boolean
expandedKeys: Set<string>
}>()
const emit = defineEmits<{
(event: 'toggle', id: string): void
}>()
const isExpanded = computed(() => props.expandedKeys.has(props.node.id))
const indentStyle = computed(() => ({
paddingLeft: `${props.depth * 18}px`
}))
</script>
<style scoped lang="scss">
.json-tree-line {
display: flex;
align-items: flex-start;
min-height: 24px;
white-space: pre;
}
.json-tree-line.is-container {
color: #172033;
}
.json-tree-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 18px;
width: 18px;
height: 22px;
margin: 0 2px 0 0;
padding: 0;
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
font-family: inherit;
font-size: 15px;
line-height: 1;
}
.json-tree-toggle:hover {
color: #2563eb;
}
.json-tree-spacer {
flex: 0 0 20px;
width: 20px;
}
.json-tree-key {
color: #7c3aed;
}
.json-tree-colon,
.json-tree-comma,
.json-tree-token {
color: #334155;
}
.json-tree-ellipsis,
.json-tree-summary {
color: #94a3b8;
}
.json-tree-value.is-string {
color: #047857;
}
.json-tree-value.is-number {
color: #b45309;
}
.json-tree-value.is-boolean {
color: #2563eb;
}
.json-tree-value.is-null {
color: #64748b;
}
</style>

View File

@@ -0,0 +1,615 @@
<template>
<el-dialog
:model-value="visible"
title="人工索引配置"
width="960px"
destroy-on-close
top="6vh"
class="mapping-confirm-dialog"
@close="emit('update:visible', false)"
>
<div class="dialog-description">
这里展示 ICD 候选索引的人工确认结果请按分组确认每个标签是否启用并为已启用标签选择合法的
lnInst确认后会自动回填到索引配置
</div>
<el-empty v-if="!draftGroups.length" description="当前没有可确认的索引分组。" />
<template v-else>
<div class="dialog-search-bar">
<el-input
v-model="indexSearchKeyword"
:prefix-icon="Search"
clearable
placeholder="按分组、标签、目标报告、数据集或 lnInst 检索"
/>
<span class="dialog-search-count">{{ filteredLabelCount }} / {{ totalLabelCount }}</span>
</div>
<div v-if="filteredDraftGroups.length" class="dialog-content">
<section v-for="group in filteredDraftGroups" :key="group.groupKey" class="group-card">
<div class="group-header">
<div>
<h3 class="group-title">{{ group.groupDesc || group.groupKey }}</h3>
<p class="group-key">{{ group.groupKey }}</p>
</div>
<el-tag type="info" effect="light">{{ group.labelItems.length }} 个标签</el-tag>
</div>
<div class="label-list">
<article v-for="item in group.labelItems" :key="item.itemKey" class="label-card">
<div class="label-main">
<div class="label-meta">
<div class="label-title-row">
<span class="label-title">{{ item.label }}</span>
<el-tag v-if="item.required" type="danger" effect="light" size="small">必选</el-tag>
<el-tag v-if="item.configurableOnce" type="success" effect="light" size="small">
共享 lnInst
</el-tag>
</div>
<div class="label-options">
<span class="label-hint">共同可选值</span>
<template v-if="item.commonLnInstValues.length">
<el-tag
v-for="value in item.commonLnInstValues"
:key="`${item.label}-${value}`"
size="small"
effect="plain"
>
{{ value }}
</el-tag>
</template>
<span v-else class="label-hint">当前没有共同 lnInst</span>
</div>
</div>
<div class="label-actions">
<el-switch
v-model="item.enabled"
:disabled="item.required"
inline-prompt
active-text="启用"
inactive-text="停用"
/>
<el-select
v-model="item.lnInst"
class="lninst-select"
placeholder="请选择 lnInst"
clearable
:disabled="!item.enabled || !item.commonLnInstValues.length"
>
<el-option
v-for="value in item.commonLnInstValues"
:key="`${item.label}-option-${value}`"
:label="value"
:value="value"
/>
</el-select>
</div>
</div>
<el-alert
v-if="item.enabled && !item.lnInst"
title="已启用的标签必须选择 lnInst"
type="warning"
:closable="false"
class="label-alert"
/>
<div class="target-list">
<div v-for="target in item.targets" :key="target.targetKey" class="target-item">
<div class="target-name-row">
<span class="target-name">{{ target.reportDesc || target.reportName || '--' }}</span>
<span class="target-code">
{{ target.reportName || '--' }} / {{ target.dataSetName || '--' }}
</span>
</div>
<div class="target-lninst-row">
<span class="label-hint">目标报告可选值</span>
<template v-if="target.availableLnInstValues.length">
<el-tag
v-for="value in target.availableLnInstValues"
:key="`${target.reportName}-${target.dataSetName}-${value}`"
size="small"
effect="plain"
>
{{ value }}
</el-tag>
</template>
<span v-else class="label-hint">当前没有可用 lnInst</span>
</div>
</div>
</div>
</article>
</div>
</section>
</div>
<el-empty v-else description="当前检索条件下没有匹配的索引配置。" />
</template>
<template #footer>
<div class="dialog-footer">
<div v-if="validationMessage" class="footer-message">{{ validationMessage }}</div>
<div class="footer-actions">
<el-button :disabled="submitting" @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="submitting" :disabled="Boolean(validationMessage)" @click="handleConfirm">
确认并生成索引配置
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
import { computed, ref, watch } from 'vue'
import type { MmsMapping } from '@/api/tools/mmsmapping/interface'
defineOptions({
name: 'MappingConfirmDialog'
})
interface ConfirmDialogDraftTarget {
targetKey: string
reportName: string
dataSetName: string
reportDesc: string
availableLnInstValues: string[]
}
interface ConfirmDialogDraftLabelItem {
itemKey: string
label: string
required: boolean
configurableOnce: boolean
enabled: boolean
lnInst: string
commonLnInstValues: string[]
targets: ConfirmDialogDraftTarget[]
}
interface ConfirmDialogDraftGroup {
groupKey: string
groupDesc: string
labelItems: ConfirmDialogDraftLabelItem[]
}
interface PreparedConfirmDialogDraftLabelItem {
itemKey: string
label: string
required: boolean
configurableOnce: boolean
defaultLnInst: string
commonLnInstValues: string[]
targets: ConfirmDialogDraftTarget[]
}
const props = defineProps<{
visible: boolean
submitting: boolean
confirmData: MmsMapping.IndexConfirmGroup[]
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
(event: 'confirm', value: MmsMapping.ConfirmedIndexGroup[]): void
}>()
const normalizeStringArray = (values?: string[]) => (values || []).map(value => value?.trim() || '').filter(Boolean)
const sortLnInstValues = (values: string[]) =>
[...values].sort((left, right) => {
const leftNumber = Number(left)
const rightNumber = Number(right)
const bothNumeric = !Number.isNaN(leftNumber) && !Number.isNaN(rightNumber)
if (bothNumeric && leftNumber !== rightNumber) {
return leftNumber - rightNumber
}
return left.localeCompare(right, 'zh-CN', { numeric: true })
})
const buildLnInstCluster = (items: PreparedConfirmDialogDraftLabelItem[]) => {
const clusters: PreparedConfirmDialogDraftLabelItem[][] = []
items.forEach(item => {
const itemValueSet = new Set(item.commonLnInstValues)
const matchedCluster = clusters.find(cluster =>
cluster.some(clusterItem => clusterItem.commonLnInstValues.some(value => itemValueSet.has(value)))
)
if (matchedCluster) {
matchedCluster.push(item)
return
}
clusters.push([item])
})
return clusters
}
const resolveDefaultLnInst = (commonLnInstValues: string[], expectedLnInst: string) => {
if (!commonLnInstValues.length) return ''
if (!expectedLnInst) return ''
if (!commonLnInstValues.includes(expectedLnInst)) return ''
return expectedLnInst
}
const buildInitialDraftGroups = (groups: MmsMapping.IndexConfirmGroup[]): ConfirmDialogDraftGroup[] =>
groups
.map(group => {
const preparedItems = (group.labelItems || [])
.map<PreparedConfirmDialogDraftLabelItem | null>((item, itemIndex) => {
const commonLnInstValues = sortLnInstValues(normalizeStringArray(item.commonLnInstValues))
const defaultLnInst = resolveDefaultLnInst(commonLnInstValues, item.defaultLnInst?.trim() || '')
const itemKey = `${group.groupKey?.trim() || 'group'}-${itemIndex}-${item.label?.trim() || 'label'}`
return {
itemKey,
label: item.label?.trim() || '',
required: Boolean(item.required),
configurableOnce: Boolean(item.configurableOnce),
defaultLnInst,
commonLnInstValues,
targets: (item.targets || []).map((target, targetIndex) => ({
targetKey: `${itemKey}-target-${targetIndex}-${target.reportName?.trim() || ''}-${target.dataSetName?.trim() || ''}`,
reportName: target.reportName?.trim() || '',
dataSetName: target.dataSetName?.trim() || '',
reportDesc: target.reportDesc?.trim() || '',
availableLnInstValues: sortLnInstValues(normalizeStringArray(target.availableLnInstValues))
}))
}
})
.filter((item): item is PreparedConfirmDialogDraftLabelItem => Boolean(item?.label))
const clusters = buildLnInstCluster(preparedItems)
const defaultStateMap = new Map<
string,
{
enabled: boolean
lnInst: string
}
>()
clusters.forEach(cluster => {
const clusterValues = sortLnInstValues(
Array.from(new Set(cluster.flatMap(item => item.commonLnInstValues)))
)
cluster.forEach((item, index) => {
const expectedLnInst = index < clusterValues.length ? clusterValues[index] : ''
const defaultLnInst = item.defaultLnInst || resolveDefaultLnInst(item.commonLnInstValues, expectedLnInst)
const enabled = item.required || Boolean(defaultLnInst)
defaultStateMap.set(item.itemKey, {
enabled,
lnInst: defaultLnInst
})
})
})
return {
groupKey: group.groupKey?.trim() || '',
groupDesc: group.groupDesc?.trim() || '',
labelItems: preparedItems.map(item => {
const defaultState = defaultStateMap.get(item.itemKey) || {
enabled: item.required,
lnInst: ''
}
return {
itemKey: item.itemKey,
label: item.label,
required: item.required,
configurableOnce: item.configurableOnce,
enabled: defaultState.enabled,
lnInst: defaultState.lnInst,
commonLnInstValues: item.commonLnInstValues,
targets: item.targets
}
})
}
})
.filter(group => group.groupKey)
const draftGroups = ref<ConfirmDialogDraftGroup[]>([])
const indexSearchKeyword = ref('')
const totalLabelCount = computed(() => draftGroups.value.reduce((total, group) => total + group.labelItems.length, 0))
const normalizedIndexSearchKeyword = computed(() => indexSearchKeyword.value.trim().toLowerCase())
const matchText = (values: string[], keyword: string) => values.some(value => value.toLowerCase().includes(keyword))
const isLabelItemMatched = (item: ConfirmDialogDraftLabelItem, keyword: string) =>
matchText([item.label, item.lnInst, ...item.commonLnInstValues], keyword) ||
item.targets.some(target =>
matchText(
[target.reportDesc, target.reportName, target.dataSetName, ...target.availableLnInstValues],
keyword
)
)
const filteredDraftGroups = computed<ConfirmDialogDraftGroup[]>(() => {
const keyword = normalizedIndexSearchKeyword.value
if (!keyword) return draftGroups.value
return draftGroups.value
.map(group => {
const groupMatched = matchText([group.groupDesc, group.groupKey], keyword)
return {
...group,
labelItems: groupMatched
? group.labelItems
: group.labelItems.filter(item => isLabelItemMatched(item, keyword))
}
})
.filter(group => group.labelItems.length)
})
const filteredLabelCount = computed(() =>
filteredDraftGroups.value.reduce((total, group) => total + group.labelItems.length, 0)
)
watch(
() => [props.confirmData, props.visible] as const,
([confirmData, visible]) => {
if (!visible) return
// 关键业务节点:弹窗每次打开都基于最新 confirmData 重新生成草稿,避免不同 ICD 的确认状态串用。
draftGroups.value = buildInitialDraftGroups(confirmData)
indexSearchKeyword.value = ''
},
{ immediate: true }
)
const validationMessage = computed(() => {
for (const group of draftGroups.value) {
for (const item of group.labelItems) {
if (!item.enabled) continue
if (!item.lnInst) return `分组“${group.groupDesc || group.groupKey}”中的标签“${item.label}”必须选择 lnInst`
}
}
return ''
})
const buildConfirmedGroups = (): MmsMapping.ConfirmedIndexGroup[] =>
draftGroups.value.map(group => ({
groupKey: group.groupKey,
labelItems: group.labelItems.map(item => ({
label: item.label,
enabled: item.enabled,
lnInst: item.enabled ? item.lnInst : ''
}))
}))
const handleConfirm = () => {
if (validationMessage.value) return
emit('confirm', buildConfirmedGroups())
}
</script>
<style scoped lang="scss">
.dialog-description {
margin-bottom: 16px;
font-size: 14px;
line-height: 1.7;
color: #4b5563;
}
.dialog-search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.dialog-search-count {
flex: 0 0 auto;
font-size: 13px;
line-height: 1.6;
color: #64748b;
white-space: nowrap;
}
.dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 68vh;
padding-right: 4px;
overflow: auto;
}
.group-card {
padding: 20px;
border: 1px solid #dbe3f0;
border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.group-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.group-title {
margin: 0;
font-size: 18px;
font-weight: 600;
line-height: 1.5;
color: #1f2937;
}
.group-key {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.6;
color: #64748b;
word-break: break-all;
}
.label-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.label-card {
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #ffffff;
}
.label-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.label-meta,
.label-actions,
.target-list {
min-width: 0;
}
.label-meta {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
}
.label-title-row,
.label-options,
.target-lninst-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.label-title {
font-size: 16px;
font-weight: 600;
line-height: 1.5;
color: #111827;
}
.label-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.lninst-select {
width: 180px;
}
.label-hint {
font-size: 13px;
line-height: 1.6;
color: #64748b;
}
.label-alert {
margin-top: 12px;
}
.target-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 16px;
}
.target-item {
padding: 12px;
border-radius: 10px;
background: #f8fafc;
}
.target-name-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.target-name {
font-size: 14px;
font-weight: 600;
line-height: 1.6;
color: #1f2937;
}
.target-code {
font-family: Consolas, 'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
color: #64748b;
word-break: break-all;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.footer-message {
font-size: 13px;
line-height: 1.6;
color: #d97706;
text-align: left;
}
.footer-actions {
display: flex;
gap: 12px;
margin-left: auto;
}
@media (max-width: 768px) {
.dialog-content {
max-height: 62vh;
}
.group-card,
.label-card {
padding: 14px;
}
.group-header,
.label-main,
.dialog-search-bar,
.dialog-footer {
flex-direction: column;
align-items: flex-start;
}
.label-actions,
.footer-actions {
width: 100%;
}
.lninst-select {
width: 100%;
}
}
</style>

View File

@@ -5,10 +5,12 @@
<MappingRequestPanel
:selected-icd-file-name="selectedIcdFileName"
:is-submitting="isSubmitting"
:is-parsing="isParsing"
:icd-file-accept="icdFileAccept"
:request-status-text="requestStatusText"
:request-status-tag-type="requestStatusTagType"
:can-reset="Boolean(selectedIcdFile || responsePayload || indexSelectionJsonText.trim())"
:can-parse="canParseIcd"
:can-reset="canResetPage"
@file-change="handleIcdFileChange"
@parse="handleParseIcd"
@reset="resetPage"
@@ -18,11 +20,16 @@
v-if="showConfigPanel"
v-model:index-selection-json="indexSelectionJsonText"
:is-submitting="isSubmitting"
:is-generating="isGenerating"
:can-generate="canGenerate"
:json-error="indexSelectionError"
:show-generate-button="showGenerateButton"
:show-confirm-button="Boolean(confirmData.length)"
confirm-button-text="人工索引配置"
:can-confirm="!isSubmitting"
:has-default-json="Boolean(indexSelectionJsonText.trim())"
:empty-description="configEmptyDescription"
@confirm-config="confirmDialogVisible = true"
@generate="handleGenerateMapping"
/>
</div>
@@ -33,13 +40,31 @@
:response-status-tag-type="responseStatusTagType"
:mapping-meta-text="mappingMetaText"
:mapping-json-preview="mappingJsonPreview"
:xml-meta-text="xmlMetaText"
:xml-mapping-preview="xmlMappingPreview"
:xml-empty-text="xmlEmptyText"
:problem-tab-label="problemTabLabel"
:problem-list="problemList"
:problem-empty-text="problemEmptyText"
:can-export-mapping="Boolean(mappingJsonPreview)"
:method-describe="methodDescribe"
:can-export-json-mapping="canExportJsonMapping"
:can-export-xml-mapping="canExportXmlMapping"
:can-generate-xml-mapping="canGenerateXmlMapping"
:is-generating-xml="isGeneratingXml"
:show-xml-mapping-tab="showXmlMappingTab"
@export-mapping="handleExportMapping"
@generate-xml-mapping="handleGenerateXmlMapping"
@update-mapping-json="handleUpdateMappingJson"
/>
</div>
<MappingConfirmDialog
:visible="confirmDialogVisible"
:submitting="isConfirmingSelection"
:confirm-data="confirmData"
@update:visible="confirmDialogVisible = $event"
@confirm="handleConfirmIndexSelection"
/>
</div>
</template>
@@ -47,20 +72,28 @@
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { ResultData } from '@/api/interface'
import { getIcdApi, getIcdMmsJsonApi } from '@/api/tools/mmsmapping'
import {
buildIndexConfirmDataApi,
buildIndexSelectionApi,
getIcdApi,
getIcdMmsJsonApi,
getXmlFromJsonApi
} from '@/api/tools/mmsmapping'
import type { MmsMapping } from '@/api/tools/mmsmapping/interface'
import MappingRequestPanel from './components/MappingRequestPanel.vue'
import MappingResultPanel from './components/MappingResultPanel.vue'
import MappingConfigPanel from './components/MappingConfigPanel.vue'
import { buildDefaultIndexSelection, formatIndexSelectionJson, parseIndexSelectionJson } from './utils/indexSelection'
import MappingConfirmDialog from './components/MappingConfirmDialog.vue'
import { formatIndexSelectionJson, parseIndexSelectionJson } from './utils/indexSelection'
import { createBaseRequestPayload } from './utils/requestPayload'
defineOptions({
name: 'MmsMappingView'
})
type ResultTab = 'mapping' | 'problem'
type ResultTab = 'json' | 'xml' | 'problem'
type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
type ExportMappingType = 'json' | 'xml'
const DEFAULT_REQUEST_FORM: MmsMapping.BaseRequestForm = {
version: '1.0',
@@ -69,11 +102,16 @@ const DEFAULT_REQUEST_FORM: MmsMapping.BaseRequestForm = {
const selectedIcdFile = ref<File | null>(null)
const responsePayload = ref<MmsMapping.MappingTaskResponse | null>(null)
const activeResultTab = ref<ResultTab>('mapping')
const xmlResponsePayload = ref<MmsMapping.MappingTaskResponse | null>(null)
const activeResultTab = ref<ResultTab>('json')
const parsedCandidates = ref<MmsMapping.IndexCandidateGroup[]>([])
const confirmData = ref<MmsMapping.IndexConfirmGroup[]>([])
const indexSelectionJsonText = ref('')
const confirmDialogVisible = ref(false)
const isParsing = ref(false)
const isConfirmingSelection = ref(false)
const isGenerating = ref(false)
const isGeneratingXml = ref(false)
const icdFileAccept = '.icd,.cid,.scd,.xml'
const problemEmptyText = '当前返回未包含 problems'
@@ -86,10 +124,56 @@ function unwrapApiPayload<T>(response: ResultData<T> | T): T {
}
const getErrorMessage = (error: unknown) => {
if (error instanceof Error) return error.message
if (error instanceof Error && error.message) return error.message
return '接口调用失败,请检查后端服务和请求参数'
}
const logConfirmDataDiagnostics = (groups: MmsMapping.IndexConfirmGroup[]) => {
const interharmonicGroups = groups
.map(group => ({
groupKey: group.groupKey?.trim() || '',
groupDesc: group.groupDesc?.trim() || '',
labelItems: (group.labelItems || [])
.map(item => ({
label: item.label?.trim() || '',
defaultLnInst: item.defaultLnInst?.trim() || '',
commonLnInstValues: item.commonLnInstValues || [],
targets: (item.targets || []).map(target => ({
reportName: target.reportName?.trim() || '',
dataSetName: target.dataSetName?.trim() || '',
availableLnInstValues: target.availableLnInstValues || []
}))
}))
.filter(item =>
[item.label, ...item.commonLnInstValues, ...item.targets.map(target => target.dataSetName)].some(
value => String(value || '').includes('间谐波')
)
)
}))
.filter(group => group.labelItems.length)
// 关键业务节点:人工确认弹窗是否缺少某个 lnInst首先取决于 build-index-confirm-data 的返回内容,这里保留诊断日志便于核对接口是否漏数。
console.info('[mmsMapping] build-index-confirm-data result', {
groupCount: groups.length,
interharmonicGroups
})
const missingFiveItems = interharmonicGroups.flatMap(group =>
group.labelItems
.filter(item => item.commonLnInstValues.length && !item.commonLnInstValues.includes('5'))
.map(item => ({
groupKey: group.groupKey,
label: item.label,
defaultLnInst: item.defaultLnInst,
commonLnInstValues: item.commonLnInstValues
}))
)
if (missingFiveItems.length) {
console.warn('[mmsMapping] interharmonic lnInst missing "5"', missingFiveItems)
}
}
const getFileExtension = (fileName: string) => fileName.split('.').pop()?.toLowerCase() || ''
const isSupportedIcdFile = (fileName: string) => ['icd', 'cid', 'scd', 'xml'].includes(getFileExtension(fileName))
@@ -100,7 +184,7 @@ const parsedIndexSelectionState = computed(() => {
if (!source) {
return {
value: [] as MmsMapping.IndexSelectionGroup[],
error: parsedCandidates.value.length ? 'request.indexSelection 不能为空' : ''
error: ''
}
}
@@ -117,46 +201,77 @@ const parsedIndexSelectionState = computed(() => {
}
})
const isSubmitting = computed(() => isParsing.value || isGenerating.value)
const indexSelectionError = computed(() => parsedIndexSelectionState.value.error)
const canGenerate = computed(
() => Boolean(selectedIcdFile.value && indexSelectionJsonText.value.trim() && !indexSelectionError.value)
const isSubmitting = computed(
() => isParsing.value || isConfirmingSelection.value || isGenerating.value || isGeneratingXml.value
)
// 关键业务节点:请求配置区只在用户已经选择 ICD 后展示,避免初始态暴露无效的 JSON 编辑区和按钮。
const canResetPage = computed(() =>
Boolean(
selectedIcdFile.value ||
responsePayload.value ||
xmlResponsePayload.value ||
confirmData.value.length ||
indexSelectionJsonText.value.trim()
)
)
const indexSelectionError = computed(() => parsedIndexSelectionState.value.error)
const canParseIcd = computed(() => Boolean(selectedIcdFile.value && !isSubmitting.value))
const canGenerate = computed(() =>
Boolean(selectedIcdFile.value && indexSelectionJsonText.value.trim() && !indexSelectionError.value && !isSubmitting.value)
)
// 关键业务节点:请求配置区只在用户已经选择 ICD 后展示,避免初始态暴露无效的请求编辑区。
const showConfigPanel = computed(() => Boolean(selectedIcdFile.value))
const showGenerateButton = computed(() => Boolean(selectedIcdFile.value))
const configEmptyDescription = computed(() => {
if (isParsing.value) return '正在根据当前 ICD 生成 request.indexSelection请稍候。'
if (selectedIcdFile.value) return '已选择 ICD 文件,请先点击“解析 ICD”生成 request.indexSelection。'
return '当前 ICD 暂未生成可编辑的 request.indexSelection。'
})
const selectedIcdFileName = computed(() => selectedIcdFile.value?.name || '')
const configEmptyDescription = computed(() => {
if (isParsing.value) return '正在获取 ICD 候选数据并准备人工确认,请稍候。'
if (isConfirmingSelection.value) return '正在根据人工索引配置生成索引配置,请稍候。'
if (confirmDialogVisible.value || confirmData.value.length) {
return '请先在弹窗中完成人工索引配置,确认后会自动回填索引配置。'
}
if (selectedIcdFile.value) return '已选择 ICD 文件,请先点击“解析 ICD”进入人工确认流程。'
return '当前 ICD 暂未生成可编辑的索引配置。'
})
const requestStatusText = computed(() => {
if (isParsing.value) return '解析中'
if (isConfirmingSelection.value) return '确认中'
if (isGeneratingXml.value) return 'XML转换中'
if (isGenerating.value) return '生成中'
if (selectedIcdFile.value && indexSelectionJsonText.value.trim()) return '已生成默认配置'
if (confirmDialogVisible.value) return '待人工确认'
if (selectedIcdFile.value && indexSelectionJsonText.value.trim()) return '已确认'
if (selectedIcdFile.value && parsedCandidates.value.length) return '待确认'
if (selectedIcdFile.value) return '待解析'
return '未选择文件'
})
const requestStatusTagType = computed<TagType>(() => {
if (isParsing.value || isGenerating.value) return 'warning'
if (isParsing.value || isConfirmingSelection.value || isGenerating.value || isGeneratingXml.value) return 'warning'
if (confirmDialogVisible.value) return 'primary'
if (selectedIcdFile.value && indexSelectionJsonText.value.trim()) return 'success'
if (selectedIcdFile.value) return 'primary'
return 'info'
})
const responseStatusText = computed(() => {
if (isSubmitting.value) return '等待返回'
return responsePayload.value?.status || '暂无结果'
if (isGenerating.value) return 'JSON生成中'
if (isGeneratingXml.value) return 'XML生成中'
if (isParsing.value || isConfirmingSelection.value) return '处理中'
if (xmlResponsePayload.value?.status === 'FAILED') return 'XML失败'
if (responsePayload.value?.status === 'FAILED') return '失败'
if (responsePayload.value?.status === 'NEED_INDEX_SELECTION') return '待配置'
if (xmlResponsePayload.value) return 'XML已生成'
if (mappingJsonPreview.value) return 'JSON已生成'
if (responsePayload.value) return '已解析'
return '未生成'
})
const responseStatusTagType = computed<TagType>(() => {
if (isSubmitting.value) return 'warning'
if (xmlResponsePayload.value?.status === 'FAILED') return 'danger'
if (responsePayload.value?.status === 'FAILED') return 'danger'
if (responsePayload.value?.status === 'NEED_INDEX_SELECTION') return 'warning'
if (responsePayload.value) return 'success'
if (xmlResponsePayload.value || responsePayload.value) return 'success'
return 'info'
})
@@ -177,7 +292,64 @@ const mappingMetaText = computed(() => {
return `mappingJson ${mappingJsonPreview.value.length} 字符`
})
const problemList = computed(() => responsePayload.value?.problems?.filter(Boolean) || [])
// 关键业务节点getXmlFromJson 标准响应把 XML 文本放在 xmlFile.content旧字段仅保留为兼容兜底。
const xmlContentForExport = computed(
() =>
xmlResponsePayload.value?.xmlFile?.content?.trim() ||
xmlResponsePayload.value?.mappingXml?.trim() ||
xmlResponsePayload.value?.xmlContent?.trim() ||
xmlResponsePayload.value?.xmlText?.trim() ||
''
)
const canExportJsonMapping = computed(() => Boolean(mappingJsonPreview.value && !isSubmitting.value))
const canExportXmlMapping = computed(() => Boolean(xmlContentForExport.value && !isSubmitting.value))
const canGenerateXmlMapping = computed(() => Boolean(mappingJsonPreview.value && !isSubmitting.value))
const showXmlMappingTab = computed(() =>
Boolean(xmlResponsePayload.value && xmlResponsePayload.value.status !== 'FAILED')
)
const xmlMappingPreview = computed(() => {
const source = xmlContentForExport.value
if (source) return source
const savedPath = xmlResponsePayload.value?.savedPath?.trim()
if (savedPath) return `XML 文件已生成:\n${savedPath}`
return ''
})
const xmlMetaText = computed(() => {
const xmlFile = xmlResponsePayload.value?.xmlFile
if (xmlFile?.fileName) {
const encoding = xmlFile.encoding?.trim()
const contentType = xmlFile.contentType?.trim()
const suffixParts = [encoding, contentType].filter(Boolean)
return suffixParts.length ? `${xmlFile.fileName}${suffixParts.join('')}` : xmlFile.fileName
}
if (xmlResponsePayload.value?.savedPath) return `XML 文件路径:${xmlResponsePayload.value.savedPath}`
if (xmlMappingPreview.value) return `XML映射 ${xmlMappingPreview.value.length} 字符`
return '当前未生成 XML 映射'
})
const xmlEmptyText = computed(() => {
if (isGeneratingXml.value) return '正在根据 JSON 映射生成 XML 映射'
if (xmlResponsePayload.value && xmlResponsePayload.value.status !== 'FAILED') {
return '当前接口返回未包含 xmlFile.content'
}
if (mappingJsonPreview.value) return '当前接口返回未包含 XML 内容或文件路径'
return '请先生成 JSON 映射'
})
const problemList = computed(() => [
...(responsePayload.value?.problems?.filter(Boolean) || []),
...(xmlResponsePayload.value?.problems?.filter(Boolean) || [])
])
const methodDescribe = computed(() => xmlResponsePayload.value?.methodDescribe?.trim() || '')
const problemTabLabel = computed(() => {
if (!problemList.value.length) return '问题列表'
@@ -185,13 +357,57 @@ const problemTabLabel = computed(() => {
})
const resolveResultTab = (payload: MmsMapping.MappingTaskResponse | null): ResultTab => {
if (payload?.mappingJson?.trim()) return 'mapping'
if (payload?.mappingJson?.trim()) return 'json'
if (payload?.problems?.filter(Boolean).length) return 'problem'
return 'mapping'
return 'json'
}
const handleGenerateXmlMapping = async () => {
const mappingJson = responsePayload.value?.mappingJson?.trim()
if (!mappingJson) {
ElMessage.warning('请先生成 JSON 映射')
return
}
isGeneratingXml.value = true
activeResultTab.value = 'json'
xmlResponsePayload.value = null
try {
// 关键业务节点XML 映射依赖本次接口返回的完整 mappingJson避免使用旧结果生成不一致的 XML 文件。
const response = await getXmlFromJsonApi({
request: {
mappingJson
}
})
const payload = unwrapApiPayload<MmsMapping.MappingTaskResponse>(response)
xmlResponsePayload.value = payload
if (payload.status === 'FAILED') {
ElMessage.warning(payload.message || 'XML 映射生成失败')
activeResultTab.value = payload.problems?.filter(Boolean).length ? 'problem' : 'json'
return
}
activeResultTab.value = 'xml'
ElMessage.success(payload.message || 'XML 映射生成完成')
} catch (error) {
xmlResponsePayload.value = {
status: 'FAILED',
message: getErrorMessage(error),
problems: [getErrorMessage(error)]
}
activeResultTab.value = 'problem'
ElMessage.warning(getErrorMessage(error))
} finally {
isGeneratingXml.value = false
}
}
const stripProblemsFromIcdPayload = (payload: MmsMapping.MappingTaskResponse): MmsMapping.MappingTaskResponse => {
// 关键业务节点:解析 ICD 只消费候选数据和文档结构,不把后端返回的 problems 绑定到结果区。
// 关键业务节点:解析 ICD 阶段只消费候选数据和文档结构,不把后端问题列表直接绑定到结果区。
const sanitizedPayload = { ...payload }
delete sanitizedPayload.problems
@@ -201,9 +417,12 @@ const stripProblemsFromIcdPayload = (payload: MmsMapping.MappingTaskResponse): M
const resetParsedState = () => {
responsePayload.value = null
xmlResponsePayload.value = null
parsedCandidates.value = []
confirmData.value = []
indexSelectionJsonText.value = ''
activeResultTab.value = 'mapping'
confirmDialogVisible.value = false
activeResultTab.value = 'json'
}
const handleIcdFileChange = (event: Event) => {
@@ -217,7 +436,7 @@ const handleIcdFileChange = (event: Event) => {
return
}
// 关键业务节点:切换 ICD 文件后先只清空旧解析结果,等用户明确点击“解析 ICD”后再请求后台
// 关键业务节点:切换 ICD 文件后立即清空旧确认结果和旧请求配置,避免不同文件的索引配置串用
selectedIcdFile.value = file
resetParsedState()
input.value = ''
@@ -231,9 +450,12 @@ const handleParseIcd = async () => {
isParsing.value = true
responsePayload.value = null
xmlResponsePayload.value = null
confirmDialogVisible.value = false
confirmData.value = []
indexSelectionJsonText.value = ''
try {
// 关键业务节点:解析 ICD 时先走 get-icd拿到当前文件的候选数据后再生成默认 request.indexSelection。
const response = await getIcdApi({
icdFile: selectedIcdFile.value
})
@@ -247,14 +469,27 @@ const handleParseIcd = async () => {
if (payload.status === 'FAILED') {
parsedCandidates.value = []
indexSelectionJsonText.value = ''
ElMessage.error(payload.message || 'ICD 解析失败')
return
}
parsedCandidates.value = candidateGroups
indexSelectionJsonText.value = formatIndexSelectionJson(buildDefaultIndexSelection(candidateGroups))
ElMessage.success(payload.message || 'ICD 解析完成,已生成 request.indexSelection')
// 关键业务节点:拿到 ICD 候选结果后必须先走 buildIndexConfirmData生成人工确认弹窗所需模型。
const confirmResponse = await buildIndexConfirmDataApi(candidateGroups)
const confirmGroups = unwrapApiPayload<MmsMapping.IndexConfirmGroup[]>(confirmResponse) || []
confirmData.value = confirmGroups
logConfirmDataDiagnostics(confirmGroups)
if (!confirmGroups.length) {
indexSelectionJsonText.value = formatIndexSelectionJson([])
ElMessage.success(payload.message || 'ICD 解析完成,当前没有待确认的索引配置')
return
}
confirmDialogVisible.value = true
ElMessage.success(payload.message || 'ICD 解析完成,请在弹窗中完成人工确认')
} catch (error) {
resetParsedState()
ElMessage.error(getErrorMessage(error))
@@ -263,6 +498,32 @@ const handleParseIcd = async () => {
}
}
const handleConfirmIndexSelection = async (confirmedData: MmsMapping.ConfirmedIndexGroup[]) => {
if (!confirmData.value.length) {
ElMessage.warning('当前没有可确认的索引配置')
return
}
isConfirmingSelection.value = true
try {
const response = await buildIndexSelectionApi({
confirmData: confirmData.value,
confirmedData
})
const indexSelection = unwrapApiPayload<MmsMapping.IndexSelectionGroup[]>(response) || []
// 关键业务节点:只有 buildIndexSelection 返回的正式结果才能进入请求配置区,避免前端自行拼装绑定关系。
indexSelectionJsonText.value = formatIndexSelectionJson(indexSelection)
confirmDialogVisible.value = false
ElMessage.success('人工索引配置完成,已回填索引配置')
} catch (error) {
ElMessage.error(getErrorMessage(error))
} finally {
isConfirmingSelection.value = false
}
}
const handleGenerateMapping = async () => {
if (!selectedIcdFile.value) {
ElMessage.warning('请先选择 ICD 文件')
@@ -270,7 +531,12 @@ const handleGenerateMapping = async () => {
}
if (!indexSelectionJsonText.value.trim()) {
ElMessage.warning('请先解析 ICD系统会自动生成 request.indexSelection')
if (confirmData.value.length) {
ElMessage.warning('请先完成人工索引配置并生成索引配置')
return
}
ElMessage.warning('请先解析 ICD并在弹窗中完成人工确认')
return
}
@@ -278,7 +544,7 @@ const handleGenerateMapping = async () => {
if (error) {
responsePayload.value = {
status: 'NEED_INDEX_SELECTION',
message: 'request.indexSelection 格式有误,请继续修正',
message: '索引配置格式有误,请继续修正',
problems: [error]
}
activeResultTab.value = 'problem'
@@ -288,9 +554,10 @@ const handleGenerateMapping = async () => {
isGenerating.value = true
responsePayload.value = null
xmlResponsePayload.value = null
try {
// 关键业务节点:正式生成阶段只消费当前编辑区里的 request.indexSelection,确保导出的映射与页面编辑态一致。
// 关键业务节点:正式生成阶段只消费当前请求配置区里的索引配置,确保导出的映射与最终确认结果一致。
const response = await getIcdMmsJsonApi({
icdFile: selectedIcdFile.value,
request: {
@@ -317,34 +584,64 @@ const handleGenerateMapping = async () => {
ElMessage.success(payload.message || '映射生成完成')
} catch (error) {
responsePayload.value = null
xmlResponsePayload.value = null
ElMessage.error(getErrorMessage(error))
} finally {
isGenerating.value = false
}
}
const buildExportFileName = () => {
const baseFileName = selectedIcdFile.value?.name.replace(/\.[^.]+$/, '') || 'mapping-summary'
return `${baseFileName}-mapping-summary.json`
const buildExportFileName = (type: ExportMappingType) => {
const baseFileName = selectedIcdFile.value?.name.replace(/\.[^.]+$/, '')
const suffix = `${type}-mapping.${type}`
return baseFileName ? `${baseFileName}-${suffix}` : suffix
}
const handleExportMapping = () => {
if (!mappingJsonPreview.value) {
ElMessage.warning('当前没有可导出的映射摘要')
return
}
const blob = new Blob([mappingJsonPreview.value], { type: 'application/json;charset=utf-8' })
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 = buildExportFileName()
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(objectUrl)
ElMessage.success('映射摘要已导出')
}
const handleExportMapping = (type: ExportMappingType) => {
if (type === 'json' && !mappingJsonPreview.value) {
ElMessage.warning('当前没有可导出的 JSON 映射')
return
}
if (type === 'xml' && !xmlContentForExport.value) {
ElMessage.warning('当前没有可导出的 XML 映射')
return
}
if (type === 'json') {
downloadTextFile(mappingJsonPreview.value, buildExportFileName('json'), 'application/json;charset=utf-8')
ElMessage.success('JSON 映射已导出')
return
}
downloadTextFile(xmlContentForExport.value, buildExportFileName('xml'), 'application/xml;charset=utf-8')
ElMessage.success('XML 映射已导出')
}
const handleUpdateMappingJson = (mappingJson: string) => {
if (!responsePayload.value) return
// 关键业务节点:序列配置修改的是当前 JSON 映射结果XML 结果需要清空,避免继续展示旧 JSON 转换出的 XML。
responsePayload.value = {
...responsePayload.value,
mappingJson
}
xmlResponsePayload.value = null
activeResultTab.value = 'json'
}
const resetPage = () => {

View File

@@ -1,333 +0,0 @@
# generateAndSubmitIndexSelection 标准 API 调试文档
## 1. 接口信息
- 接口名称:上传 ICD 后直接生成并提交映射
- Controller 方法:`generateAndSubmitIndexSelection`
- 请求方式:`POST`
- 请求路径:`/api/mms-mapping/generate-and-submit-index-selection`
- Content-Type`multipart/form-data`
- 适用场景:前端或调试人员已经能直接给出 `indexSelection`,希望一次请求完成 ICD 解析、候选分析、索引校验和正式映射生成。
源码参考:
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/controller/MappingController.java`
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/service/MappingTaskService.java`
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/component/MappingRequestConverter.java`
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/component/MappingResponseConverter.java`
## 2. 处理链路
该接口内部按以下顺序执行:
1. 读取上传的 `icdFile`
2. 解析 ICD生成 `icdDocument`
3. 加载默认模板并分析候选索引。
4. 读取同一次请求中的 `indexSelection`
5. 校验分组、标签、报告、`lnInst` 是否有效。
6. 校验通过则生成正式映射 JSON校验不通过则返回 `NEED_INDEX_SELECTION`
因此,这个接口有两类主要调试目标:
- 调试一次成功生成映射。
- 调试为什么需要重新选择索引。
## 3. 请求定义
### 3.1 表单字段
| 字段 | 类型 | 是否必填 | 说明 |
| --- | --- | --- | --- |
| `icdFile` | file | 是 | ICD 文件 |
| `request` | json part | 是 | JSON 请求体,必须作为独立 part 传入 |
### 3.2 request JSON 字段
`request` 结构复用 `GenerateMappingFromIcdRequest`
| 字段 | 类型 | 是否必填 | 说明 |
| --- | --- | --- | --- |
| `version` | string | 否 | 输出版本号,空值时按服务端默认逻辑处理 |
| `author` | string | 否 | 作者,空值时回退到模块默认作者 |
| `saveToDisk` | boolean | 否 | 是否落盘保存生成结果 |
| `prettyJson` | boolean | 否 | 是否返回格式化 JSON |
| `outputDir` | string | 否 | 落盘目录,仅 `saveToDisk=true` 时有意义 |
| `indexSelection` | array | 否 | 直接提交的索引绑定关系;为空时会返回 `NEED_INDEX_SELECTION` |
### 3.3 indexSelection 字段
| 字段 | 类型 | 是否必填 | 说明 |
| --- | --- | --- | --- |
| `groupKey` | string | 建议必填 | 候选分组唯一键,建议直接使用候选接口返回值 |
| `groupDesc` | string | 否 | 分组中文描述,用于兼容匹配 |
| `bindings` | array | 是 | 当前分组下的绑定关系列表 |
### 3.4 bindings 子项字段
| 字段 | 类型 | 是否必填 | 说明 |
| --- | --- | --- | --- |
| `reportName` | string | 是 | 报告名称 |
| `dataSetName` | string | 是 | 数据集名称 |
| `label` | string | 是 | 模板业务标签 |
| `lnInst` | string | 是 | 最终绑定的 `lnInst` 数字 |
## 4. Postman 调试方式
### 4.1 请求设置
在 Postman 中选择:
- Method`POST`
- Body`form-data`
新增两个表单项:
1. `icdFile`
- 类型:`File`
- 值:选择本地 ICD 文件
2. `request`
- 类型:`Text`
- 值:填写 JSON 字符串
建议把 `request` 这个 part 的 Content-Type 显式设为 `application/json`
### 4.2 request 示例
```json
{
"version": "1.0",
"author": "system",
"saveToDisk": false,
"prettyJson": true,
"outputDir": "",
"indexSelection": [
{
"groupKey": "实时数据__DSSTHARM",
"groupDesc": "实时数据",
"bindings": [
{
"reportName": "brcbStHarm",
"dataSetName": "dsStHarm",
"label": "A相",
"lnInst": "1"
},
{
"reportName": "brcbStHarm",
"dataSetName": "dsStHarm",
"label": "B相",
"lnInst": "2"
}
]
}
]
}
```
## 5. cURL 调试示例
### 5.1 成功生成示例
```bash
curl -X POST "http://localhost:8080/api/mms-mapping/generate-and-submit-index-selection" \
-H "Accept: application/json" \
-F "icdFile=@D:/data/demo.icd" \
-F 'request={
"version":"1.0",
"author":"system",
"saveToDisk":false,
"prettyJson":true,
"outputDir":"",
"indexSelection":[
{
"groupKey":"实时数据__DSSTHARM",
"groupDesc":"实时数据",
"bindings":[
{
"reportName":"brcbStHarm",
"dataSetName":"dsStHarm",
"label":"A相",
"lnInst":"1"
}
]
}
]
};type=application/json'
```
### 5.2 触发 NEED_INDEX_SELECTION 示例
`indexSelection` 置空,或故意传入非法 `label` / `lnInst`
```bash
curl -X POST "http://localhost:8080/api/mms-mapping/generate-and-submit-index-selection" \
-H "Accept: application/json" \
-F "icdFile=@D:/data/demo.icd" \
-F 'request={
"version":"1.0",
"author":"system",
"saveToDisk":false,
"prettyJson":true,
"outputDir":"",
"indexSelection":[]
};type=application/json'
```
## 6. 最小响应定义
该接口已经按场景裁剪返回值。
### 6.1 SUCCESS
只返回:
- `status`
- `message`
- `mappingJson`
- `savedPath`,仅 `saveToDisk=true` 且保存成功时返回
- `problems`,仅存在问题时返回
### 6.2 NEED_INDEX_SELECTION
只返回:
- `status`
- `message`
- `icdDocument`
- `indexCandidates`
- `problems`
### 6.3 FAILED
只返回:
- `status`
- `message`
- `problems`,仅存在问题时返回
## 7. 响应示例
### 7.1 成功响应
```json
{
"status": "SUCCESS",
"message": "映射生成成功",
"mappingJson": "{\"Version\":\"1.0\",\"Author\":\"system\"}"
}
```
### 7.2 成功并落盘响应
```json
{
"status": "SUCCESS",
"message": "映射生成成功",
"mappingJson": "{\"Version\":\"1.0\",\"Author\":\"system\"}",
"savedPath": "D:/output/IED1-mapping-pretty.json"
}
```
### 7.3 需要重新选择索引响应
```json
{
"status": "NEED_INDEX_SELECTION",
"message": "索引配置不合法,请根据候选信息完成标签与数字索引的绑定后重新提交",
"icdDocument": {
"fileName": "demo.icd",
"iedName": "IED1",
"ldInst": "LD0"
},
"indexCandidates": [
{
"groupKey": "实时数据__DSSTHARM",
"groupDesc": "实时数据",
"reportCount": 1,
"templateLabels": ["A相", "B相", "C相"],
"reports": [
{
"reportName": "brcbStHarm",
"dataSetName": "dsStHarm",
"reportDesc": "实时数据",
"availableLnInstValues": ["1", "2", "3"]
}
]
}
],
"problems": [
"分组【实时数据】中 label【A相】不在模板候选中"
]
}
```
### 7.4 失败响应
```json
{
"status": "FAILED",
"message": "ICD 解析失败ICD 文件内容不能为空",
"problems": [
"ICD 文件内容不能为空"
]
}
```
## 8. 常见调试问题
### 8.1 `request` 没有按 JSON 传入
现象:
- Spring 无法正确绑定 `@RequestPart("request")`
- 返回 400 或参数解析异常
处理:
- 确保 `request` 是单独的 multipart part
- 建议显式设置 `type=application/json`
### 8.2 `indexSelection` 为空
现象:
- 返回 `NEED_INDEX_SELECTION`
- `message` 提示索引配置缺失
处理:
- 先调用 `generateFromIcdCandidates`
- 使用其返回的 `groupKey``templateLabels``availableLnInstValues` 重新组装绑定
### 8.3 `groupKey` 不匹配
现象:
- `problems` 中出现“未找到分组配置”
处理:
- 不要自己拼 `groupKey`
- 直接使用候选接口返回值原样回传
### 8.4 `label` 不合法
现象:
- `problems` 中出现“label 不在模板候选中”
处理:
- `label` 必须取自当前分组的 `templateLabels`
### 8.5 `lnInst` 不合法
现象:
- `problems` 中出现“lnInst 不在可选数字中”
处理:
- `lnInst` 必须取自当前报告的 `availableLnInstValues`
### 8.6 `saveToDisk=true` 但没有 `savedPath`
可能原因:
- 保存过程抛异常,接口直接返回 `FAILED`
- 输出目录无权限或路径非法
处理:
- 优先先用 `saveToDisk=false` 验证生成链路
- 再单独验证落盘目录权限
## 9. 联调建议
- 首次联调建议先走两步:
1. 先调用 `generateFromIcdCandidates`
2. 根据候选结果确认 `groupKey``label``lnInst` 后,再调当前接口
- 如果只是验证“提交链路”是否通,不建议一开始就打开 `saveToDisk`
- 如果需要排查返回值最小化是否生效,重点看:
- `SUCCESS` 不应再返回 `icdDocument``indexCandidates`
- `NEED_INDEX_SELECTION` 不应返回 `mappingJson`
- `FAILED` 不应返回候选或映射结果字段

View File

@@ -1,371 +0,0 @@
# getIcdMmsJson 标准 API 调试文档
## 1. 文档范围
本文档用于说明 `mms-mapping` 模块统一调试接口 `getIcdMmsJson` 的标准调用方式、请求结构、响应规则和联调注意事项。
本文档内容以当前源码为准,主要对照以下实现:
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/controller/MappingController.java`
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/component/MappingRequestConverter.java`
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/component/MappingResponseConverter.java`
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/service/impl/MappingTaskServiceImpl.java`
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/component/MappingGenerationService.java`
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/pojo/param/GenerateMappingFromIcdRequest.java`
- `tools/mms-mapping/src/main/java/com/njcn/gather/icd/mapping/pojo/vo/MappingTaskResponse.java`
说明:
- 本文档仅描述接口契约和调试方式,不改动业务代码。
- 本次未执行 `mvn` 编译、打包或真实接口联调。
- 如文档与运行结果冲突,以源码和实际部署配置为准。
## 2. 接口基本信息
| 项 | 说明 |
| --- | --- |
| 接口名称 | `getIcdMmsJson` |
| 请求方法 | `POST` |
| 请求路径 | `/api/mms-mapping/get-icd-mms-json` |
| Content-Type | `multipart/form-data` |
| 控制器入口 | `MappingController#getIcdMmsJson` |
| 请求组成 | `icdFile` 文件 Part + `request` JSON Part |
| 正常业务响应体 | `MappingTaskResponse` |
## 3. 接口职责
该接口是 `mms-mapping` 模块的统一调试入口,串联以下两个阶段:
1. 上传 ICD 文件并完成解析,生成 `icdDocument``indexCandidates`
2. 根据 `request.indexSelection` 判断是否继续生成正式 `mappingJson`
接口行为分为三种典型结果:
1. `request.indexSelection` 未传或为空
返回 `NEED_INDEX_SELECTION`,用于引导前端或调试人员先确认标签与 `lnInst` 的绑定关系。
2. `request.indexSelection` 已传但校验不通过
返回 `NEED_INDEX_SELECTION`,同时通过 `problems` 给出不合法原因,要求重新选择。
3. `request.indexSelection` 校验通过
返回 `SUCCESS`,输出正式 `mappingJson`,必要时同时落盘并返回 `savedPath`
补充说明:
- 该接口每次都会重新解析上传的 ICD 文件,因此第二次调试仍然必须重新上传 ICD 文件。
- 该接口正常进入业务编排后,返回体类型为 `MappingTaskResponse`
- 如果异常发生在控制器参数绑定或请求转换阶段例如文件为空、Part 缺失、JSON Part 解析失败,则由全局异常处理器统一包装为 `HttpResult<String>`,而不是 `MappingTaskResponse`
## 4. 请求规范
### 4.1 multipart/form-data Part 说明
| Part 名称 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `icdFile` | File | 是 | ICD 文件,不能为空 |
| `request` | JSON Part | 是 | 生成参数,必须按 `application/json` 发送 |
说明:
- `request` Part 不能省略。即使第一次只想拿候选结果,也必须传一个最小 JSON。
- `request.indexSelection` 可以省略或传空数组,此时接口只返回候选结果,不生成正式映射。
### 4.2 request JSON 结构
```json
{
"version": "2026-04-22",
"author": "debug-user",
"saveToDisk": false,
"prettyJson": true,
"outputDir": "D:/temp/mms-output",
"indexSelection": [
{
"groupKey": "harm",
"groupDesc": "谐波数据",
"bindings": [
{
"reportName": "brcbStHarm",
"dataSetName": "dsStHarm",
"label": "A相",
"lnInst": "1"
}
]
}
]
}
```
### 4.3 request 字段说明
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `version` | String | 否 | 输出版本号。未传或空白时,后端按当天日期补齐,格式为 `yyyy-MM-dd` |
| `author` | String | 否 | 作者。未传或空白时,回退到配置项 `icd.mapping.default-author`,默认值为 `system` |
| `saveToDisk` | boolean | 否 | 是否将生成结果写入磁盘 |
| `prettyJson` | boolean | 否 | 是否输出格式化 JSON。`true` 为美化 JSON`false` 为紧凑 JSON |
| `outputDir` | String | 否 | 输出目录。未传或空白时,先回退到配置项 `icd.mapping.default-output-dir`;如果配置也为空,最终落到当前工作目录 |
| `indexSelection` | Array | 否 | 标签与 `lnInst` 的最终绑定关系。未传或为空时,只返回候选结果 |
### 4.4 indexSelection 字段说明
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `groupKey` | String | 是 | 分组唯一键,必须使用第一次响应里返回的原值 |
| `groupDesc` | String | 否 | 分组中文描述,便于调试查看 |
| `bindings` | Array | 是 | 当前业务分组下最终确认的绑定关系列表 |
### 4.5 bindings 字段说明
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `reportName` | String | 是 | 绑定发生在哪个报告上,例如 `brcbStHarm` |
| `dataSetName` | String | 是 | 绑定发生在哪个数据集上,例如 `dsStHarm` |
| `label` | String | 是 | 业务标签,例如 `A相``最大值``实时数据` |
| `lnInst` | String | 是 | 标签最终绑定到的逻辑节点实例值,例如 `1``2``3` |
## 5. 标准调试流程
### 5.1 第一次调试:只获取候选结果
用途:
- 上传 ICD 文件
- 获取 `icdDocument`
- 获取 `indexCandidates`
- 确认每个业务分组下可选的 `reportName``dataSetName``availableLnInstValues`
调用要求:
- `request` Part 仍然必须传
- `request.indexSelection` 可以不传,或传空数组
预期结果:
- `status = NEED_INDEX_SELECTION`
- 响应中返回 `icdDocument`
- 响应中返回 `indexCandidates`
### 5.2 第二次调试:带索引绑定生成正式结果
用途:
- 根据第一次返回的 `indexCandidates` 组装 `request.indexSelection`
- 再次上传同一个 ICD 文件
- 生成正式 `mappingJson`
调用要求:
- 必须继续上传 `icdFile`
- `groupKey` 必须沿用第一次返回值
- `reportName``dataSetName``lnInst` 必须与第一次返回的候选结果匹配
预期结果:
- `status = SUCCESS`
- 响应中返回 `mappingJson`
-`saveToDisk = true` 时,响应中额外返回 `savedPath`
### 5.3 第二次调试但绑定不合法
适用场景:
- `groupKey` 与候选结果不匹配
- `reportName``dataSetName` 不在候选集中
- `lnInst` 不在 `availableLnInstValues`
- 绑定关系缺失、不完整或结构错误
预期结果:
- `status = NEED_INDEX_SELECTION`
- 响应中仍然返回 `icdDocument``indexCandidates`
- `problems` 返回具体问题列表,要求重新确认绑定关系
## 6. 响应规范
### 6.1 正常业务响应体
接口正常进入业务编排后,统一返回 `MappingTaskResponse`。该对象使用了 `@JsonInclude(JsonInclude.Include.NON_EMPTY)`,空字段和空集合不会参与序列化。
基础字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `status` | Enum | 本次处理状态,可能为 `SUCCESS``NEED_INDEX_SELECTION``FAILED` |
| `message` | String | 状态说明或错误提示 |
| `icdDocument` | Object | 需要重新选择索引时返回的 ICD 解析结果 |
| `mappingJson` | String | 正式生成成功后的映射 JSON 文本 |
| `savedPath` | String | 结果已落盘时返回的绝对路径 |
| `indexCandidates` | Array | 待绑定状态下返回的索引候选分组 |
| `problems` | Array | 模板校验、候选分析或绑定校验问题 |
字段出现规则:
| 状态 | 必有字段 | 可能出现字段 |
| --- | --- | --- |
| `SUCCESS` | `status``message``mappingJson` | `savedPath``problems` |
| `NEED_INDEX_SELECTION` | `status``message``icdDocument``indexCandidates` | `problems` |
| `FAILED` | `status``message` | `problems` |
### 6.2 NEED_INDEX_SELECTION 响应示例
```json
{
"status": "NEED_INDEX_SELECTION",
"message": "索引配置缺失,请根据候选信息完成标签与数字索引的绑定后重新提交",
"icdDocument": {
"fileName": "demo.icd",
"iedName": "IED1",
"ldInst": "LD0",
"ldPrefix": "LD",
"logicalNodes": [
{
"lnInst": "1"
}
]
},
"indexCandidates": [
{
"groupKey": "harm",
"groupDesc": "谐波数据",
"reportCount": 1,
"templateLabels": [
"A相",
"B相",
"C相"
],
"reports": [
{
"reportName": "brcbStHarm",
"dataSetName": "dsStHarm",
"reportDesc": "谐波报告",
"availableLnInstValues": [
"1",
"2",
"3"
]
}
]
}
]
}
```
说明:
- `icdDocument` 实际字段可能比示例更多。
- 如果本次是“索引配置不合法”而不是“索引配置缺失”,通常还会返回 `problems`
### 6.3 SUCCESS 响应示例
```json
{
"status": "SUCCESS",
"message": "映射生成成功",
"mappingJson": "{\n \"version\": \"2026-04-22\",\n \"author\": \"debug-user\",\n \"ied\": \"IED1\",\n \"ld\": \"LD\",\n \"instList\": []\n}"
}
```
说明:
- `mappingJson` 是字符串字段,字段值本身是一段 JSON 文本。
-`saveToDisk = true` 时,响应中还会额外返回 `savedPath`
### 6.4 FAILED 响应示例
```json
{
"status": "FAILED",
"message": "映射生成失败:加载 DefaultCfg.txt 失败默认模板文件不存在template/DefaultCfg.txt",
"problems": [
"加载 DefaultCfg.txt 失败默认模板文件不存在template/DefaultCfg.txt"
]
}
```
说明:
- `FAILED` 主要对应服务编排阶段捕获到的运行异常,例如 ICD 解析、模板加载、映射生成、序列化或落盘失败。
- 并非所有错误都会进入 `FAILED`。如果异常发生在控制器参数绑定或请求转换阶段,会走全局异常处理器,而不是这里的业务响应结构。
## 7. 全局异常响应说明
以下场景通常不会返回 `MappingTaskResponse`,而是由 `GlobalBusinessExceptionHandler` 统一包装:
- `icdFile` 缺失或为空
- `request` Part 缺失
- `request` Part 的 `Content-Type` 不是 `application/json`
- `multipart/form-data` 结构不合法
- JSON 反序列化失败或框架参数绑定失败
这类异常最终会包装为统一的 `HttpResult<String>` 响应,具体字段结构以全局公共响应定义为准,本文不展开其完整协议,只强调:
- 不能把这类错误等同理解为 `MappingTaskResponse.status = FAILED`
- 联调时应先区分“业务响应体”与“全局异常包装”
## 8. 调试示例
### 8.1 curl 示例:第一次调用,只获取候选结果
```powershell
curl.exe -X POST "http://localhost:8080/api/mms-mapping/get-icd-mms-json" `
-H "Accept: application/json" `
-F 'icdFile=@D:/data/demo.icd' `
-F 'request={"prettyJson":true,"saveToDisk":false};type=application/json'
```
### 8.2 curl 示例:第二次调用,带索引绑定直接生成 MMS JSON
```powershell
curl.exe -X POST "http://localhost:8080/api/mms-mapping/get-icd-mms-json" `
-H "Accept: application/json" `
-F 'icdFile=@D:/data/demo.icd' `
-F 'request={"version":"2026-04-22","author":"debug-user","prettyJson":true,"saveToDisk":false,"indexSelection":[{"groupKey":"harm","groupDesc":"谐波数据","bindings":[{"reportName":"brcbStHarm","dataSetName":"dsStHarm","label":"A相","lnInst":"1"},{"reportName":"brcbStHarm","dataSetName":"dsStHarm","label":"B相","lnInst":"2"},{"reportName":"brcbStHarm","dataSetName":"dsStHarm","label":"C相","lnInst":"3"}]}]};type=application/json'
```
## 9. Postman 调试要点
1. `Body` 选择 `form-data`
2. `icdFile` 类型选择 `File`
3. `request` 保持文本输入,但该 Part 的 `Content-Type` 必须显式设置为 `application/json`
4. 第一次调试不要省略 `request` Part只是不传 `indexSelection`
5. 第二次调试时必须继续上传 ICD 文件,并严格按第一次返回的候选结果组装绑定关系
## 10. 常见问题
### 10.1 为什么第一次调试也必须传 `request`
因为控制器方法签名使用的是 `@RequestPart("request") GenerateMappingFromIcdRequest request`,该 Part 本身就是必填参数。第一次调试可以只传最小 JSON但不能完全省略。
### 10.2 为什么没有传 `indexSelection`,却没有返回 `FAILED`
这是接口设计的正常行为。`indexSelection` 缺失或为空时,业务语义不是“接口执行失败”,而是“还需要前端继续确认索引绑定”,因此返回的是 `NEED_INDEX_SELECTION`
### 10.3 `saveToDisk=true` 但没有传 `outputDir`,结果会保存到哪里
处理顺序如下:
1. 先读取请求中的 `outputDir`
2. 如果请求空白,则回退到配置项 `icd.mapping.default-output-dir`
3. 如果配置项也为空,则最终落到当前工作目录
### 10.4 `version` 不传时会变成什么
后端在正式生成映射文档时,会把空白 `version` 自动补成当天日期,格式为 `yyyy-MM-dd`
### 10.5 `mappingJson` 为什么是字符串,不是嵌套对象
因为当前响应结构中 `mappingJson` 定义为 `String`,接口返回的是一段已经序列化好的 JSON 文本,而不是再次展开后的对象结构。
### 10.6 什么情况下会返回 `problems`
`problems` 主要用于承载以下问题:
- 默认模板校验问题
- 索引候选分析问题
- `indexSelection` 绑定校验问题
- 服务编排阶段捕获到的异常原因
## 11. 当前边界
- 当前文档仅覆盖 `getIcdMmsJson` 接口,不覆盖 `get-icd``get-mms-json` 的独立接口文档
- 当前文档重点描述业务返回体与调试方式,不展开全局 `HttpResult` 的完整协议
- 示例中的 `icdDocument``indexCandidates``mappingJson` 为结构化示意,实际字段数量与内容以运行结果为准

View File

@@ -2,19 +2,30 @@
<section class="mapping-panel config-panel">
<div class="panel-header">
<div>
<h2 class="panel-title">请求配置</h2>
<p class="panel-description">这里直接编辑 request.indexSelection默认值会在选择 ICD 文件后自动生成</p>
<h2 class="panel-title">人工索引配置</h2>
<p class="panel-description">展示现有的人工索引配置并允许继续编辑</p>
</div>
<div class="panel-actions">
<el-button
v-if="showConfirmButton"
type="primary"
:icon="EditPen"
:disabled="!canConfirm"
@click="emit('confirm-config')"
>
{{ confirmButtonText }}
</el-button>
<el-button
v-if="showGenerateButton"
type="primary"
:icon="Connection"
:loading="isGenerating"
:disabled="!canGenerate"
@click="emit('generate')"
>
生成JSON映射
</el-button>
</div>
<el-button
v-if="showGenerateButton"
type="primary"
:icon="Connection"
:loading="isSubmitting"
:disabled="!canGenerate"
@click="emit('generate')"
>
生成映射
</el-button>
</div>
<div class="panel-content">
@@ -28,7 +39,7 @@
:disabled="isSubmitting"
:rows="18"
resize="none"
placeholder="ICD 解析完成后,这里会自动填充 request.indexSelection可继续直接编辑。"
placeholder="人工索引配置完成后,这里会自动回填索引配置,仍可继续直接编辑。"
@update:model-value="value => emit('update:indexSelectionJson', String(value || ''))"
/>
</div>
@@ -39,7 +50,7 @@
</template>
<script setup lang="ts">
import { Connection } from '@element-plus/icons-vue'
import { Connection, EditPen } from '@element-plus/icons-vue'
defineOptions({
name: 'MappingConfigPanel'
@@ -48,15 +59,20 @@ defineOptions({
defineProps<{
indexSelectionJson: string
isSubmitting: boolean
isGenerating: boolean
canGenerate: boolean
jsonError: string
showGenerateButton: boolean
showConfirmButton: boolean
confirmButtonText: string
canConfirm: boolean
hasDefaultJson: boolean
emptyDescription: string
}>()
const emit = defineEmits<{
(event: 'update:indexSelectionJson', value: string): void
(event: 'confirm-config'): void
(event: 'generate'): void
}>()
</script>
@@ -86,6 +102,14 @@ const emit = defineEmits<{
gap: 16px;
}
.panel-actions {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 12px;
white-space: nowrap;
}
.panel-title {
margin: 0;
font-size: 22px;
@@ -111,9 +135,9 @@ const emit = defineEmits<{
}
.panel-section {
border: 1px solid #e5e7eb;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
border: none;
border-radius: 0;
background: transparent;
}
.result-card {
@@ -121,13 +145,17 @@ const emit = defineEmits<{
flex: 1;
flex-direction: column;
min-height: 0;
padding: 16px;
padding: 0;
overflow: hidden;
}
.index-selection-textarea {
flex: 1;
min-height: 0;
margin-top: 0;
}
.json-alert + .index-selection-textarea {
margin-top: 16px;
}
@@ -156,5 +184,9 @@ const emit = defineEmits<{
flex-direction: column;
align-items: flex-start;
}
.panel-actions {
width: 100%;
}
}
</style>

View File

@@ -17,7 +17,13 @@
placeholder="请选择 `.icd`、`.cid`、`.scd` 或 `.xml` 文件"
class="file-input"
/>
<el-button type="primary" :icon="FolderOpened" :loading="isSubmitting" @click="openIcdFilePicker">
<el-button
type="primary"
plain
:icon="FolderOpened"
:disabled="isSubmitting"
@click="openIcdFilePicker"
>
选择 ICD
</el-button>
<input
@@ -30,15 +36,16 @@
</div>
<el-button
type="primary"
plain
:icon="Search"
:loading="isSubmitting"
:disabled="!selectedIcdFileName"
:loading="isParsing"
:disabled="!canParse"
@click="emit('parse')"
>
解析 ICD
</el-button>
<el-button :icon="Delete" :disabled="!canReset" @click="emit('reset')">清空</el-button>
<el-button type="danger" plain :icon="Delete" :disabled="!canReset || isSubmitting" @click="emit('reset')">
清空
</el-button>
</div>
</div>
</section>
@@ -57,9 +64,11 @@ type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
defineProps<{
selectedIcdFileName: string
isSubmitting: boolean
isParsing: boolean
icdFileAccept: string
requestStatusText: string
requestStatusTagType: TagType
canParse: boolean
canReset: boolean
}>()

View File

@@ -2,13 +2,49 @@
<section class="mapping-panel">
<div class="panel-header">
<div>
<h2 class="panel-title">调试输出</h2>
<p class="panel-description">右侧展示最近一次接口返回的 mappingJson problems并支持导出当前映射摘要</p>
<h2 class="panel-title">映射结果</h2>
<p class="panel-description">展示和导出JSON与XML的映射结果以及JSON的映射序列配置</p>
</div>
<div class="panel-actions">
<el-button plain :icon="Download" :disabled="!canExportMapping" @click="emit('export-mapping')">
导出映射文件
<el-button
type="primary"
:icon="Connection"
:loading="isGeneratingXml"
:disabled="!canGenerateXmlMapping"
@click="emit('generate-xml-mapping')"
>
生成XML映射
</el-button>
<div class="export-actions">
<el-button
type="primary"
plain
:icon="Download"
:disabled="!canExportActiveMapping"
@click="emit('export-mapping', activeExportType)"
>
{{ exportButtonText }}
</el-button>
<el-dropdown trigger="click" :disabled="!canExportAnyMapping" @command="handleExportCommand">
<el-button
type="primary"
plain
class="export-menu-button"
:icon="ArrowDown"
:disabled="!canExportAnyMapping"
/>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="json" :disabled="!canExportJsonMapping">
导出JSON映射
</el-dropdown-item>
<el-dropdown-item command="xml" :disabled="!canExportXmlMapping">
导出XML映射
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-tag :type="responseStatusTagType" effect="light">{{ responseStatusText }}</el-tag>
</div>
</div>
@@ -16,63 +52,428 @@
<div class="panel-content panel-content--fixed">
<div class="panel-section result-card grow-card preview-tab-section">
<el-tabs v-model="activeTabProxy" class="preview-tabs">
<el-tab-pane label="映射摘要" name="mapping">
<div class="preview-header preview-header--compact">
<div class="preview-meta">{{ mappingMetaText }}</div>
</div>
<el-tab-pane label="JSON映射" name="json">
<div class="mapping-json-scroll">
<pre v-if="mappingJsonPreview" class="mapping-json-text">{{ mappingJsonPreview }}</pre>
<JsonMappingTree
v-if="mappingJsonPreview"
:source="mappingJsonPreview"
:meta-text="mappingMetaText"
>
<template #actions>
<el-button
type="primary"
plain
size="small"
:icon="Setting"
:disabled="!mappingJsonPreview"
@click="openSequenceDialog"
>
序列配置
</el-button>
<el-button
type="primary"
plain
size="small"
:icon="Warning"
@click="problemDialogVisible = true"
>
{{ problemButtonText }}
</el-button>
</template>
</JsonMappingTree>
<el-empty v-else description="当前返回未包含 mappingJson" />
</div>
</el-tab-pane>
<el-tab-pane :label="problemTabLabel" name="problem">
<div class="problem-section">
<div v-if="problemList.length" class="problem-list">
<div v-for="(problem, index) in problemList" :key="`${index}-${problem}`" class="problem-item">
<span class="problem-index">{{ index + 1 }}</span>
<span class="problem-text">{{ problem }}</span>
</div>
<el-tab-pane v-if="showXmlMappingTab" label="XML映射" name="xml">
<div class="mapping-json-scroll">
<div class="match-result-actions">
<el-button
type="primary"
plain
:icon="Document"
:disabled="!methodDescribe"
@click="matchResultDialogVisible = true"
>
匹配结果展示
</el-button>
</div>
<el-empty v-else :description="problemEmptyText" />
<div v-if="xmlMappingPreview" class="xml-file-viewer">
<div class="xml-file-header">
<span class="xml-file-name">XML 文件</span>
<span class="xml-file-meta">{{ xmlMetaText }}</span>
</div>
<pre class="xml-file-content">{{ xmlMappingPreview }}</pre>
</div>
<el-empty v-else :description="xmlEmptyText" />
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
<el-dialog
v-model="problemDialogVisible"
title="问题列表"
width="720px"
destroy-on-close
top="8vh"
class="mapping-problem-dialog"
>
<div v-if="problemList.length" class="problem-dialog-list">
<div v-for="(problem, index) in problemList" :key="`${index}-${problem}`" class="problem-item">
<span class="problem-index">{{ index + 1 }}</span>
<span class="problem-text">{{ problem }}</span>
</div>
</div>
<el-empty v-else :description="problemEmptyText" />
</el-dialog>
<el-dialog
v-model="matchResultDialogVisible"
title="匹配结果展示"
width="640px"
destroy-on-close
top="12vh"
>
<div class="match-result-detail">
{{ methodDescribe || '当前接口返回未包含 methodDescribe' }}
</div>
</el-dialog>
<el-dialog
v-model="sequenceDialogVisible"
title="序列配置"
width="920px"
destroy-on-close
top="8vh"
class="sequence-config-dialog"
>
<template v-if="sequenceConfigRows.length">
<div class="dialog-search-bar">
<el-input
v-model="sequenceSearchKeyword"
:prefix-icon="Search"
clearable
placeholder="按顶层、类型、上层描述、name 或路径检索"
/>
<span class="dialog-search-count">
{{ filteredSequenceRows.length }} / {{ sequenceConfigRows.length }}
</span>
</div>
<div v-if="sequenceConfigGroups.length" class="sequence-config-list">
<section v-for="group in sequenceConfigGroups" :key="group.topKey" class="sequence-top-group">
<div class="sequence-top-header">
<div>
<h3 class="sequence-top-title">{{ group.topDesc || group.topKey }}</h3>
<p class="sequence-top-key">{{ group.topKey }}</p>
</div>
<el-tag type="info" effect="light">{{ group.rowCount }} </el-tag>
</div>
<div class="sequence-type-list">
<article
v-for="typeGroup in group.typeGroups"
:key="typeGroup.typeName"
class="sequence-type-card"
>
<div class="sequence-type-header">
<div class="sequence-type-title">{{ typeGroup.typeName }}</div>
<el-tag type="primary" effect="plain" size="small">{{ typeGroup.rows.length }} </el-tag>
</div>
<div v-for="row in typeGroup.rows" :key="row.id" class="sequence-config-item">
<div class="sequence-config-info">
<div class="sequence-config-title">{{ row.parentDesc }}</div>
<div class="sequence-config-subtitle">
{{ row.name }}
<span class="sequence-config-path">{{ row.pathText }}</span>
</div>
</div>
<el-form label-position="top" size="small" class="sequence-config-form">
<el-form-item label="start">
<el-input v-model="row.start" />
</el-form-item>
<el-form-item label="end">
<el-input v-model="row.end" />
</el-form-item>
</el-form>
</div>
</article>
</div>
</section>
</div>
<el-empty v-else description="当前检索条件下没有匹配的序列。" />
</template>
<el-empty v-else description="当前 JSON 映射中未分析到包含 start 和 end 的序列。" />
<template #footer>
<el-button @click="sequenceDialogVisible = false">取消</el-button>
<el-button type="primary" :disabled="!sequenceConfigRows.length" @click="confirmSequenceConfig">
确定
</el-button>
</template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { Download } from '@element-plus/icons-vue'
import { computed } from 'vue'
import { ArrowDown, Connection, Document, Download, Search, Setting, Warning } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue'
import JsonMappingTree from './JsonMappingTree.vue'
defineOptions({
name: 'MappingResultPanel'
})
type TagType = 'success' | 'warning' | 'info' | 'primary' | 'danger'
type ResultTab = 'json' | 'xml' | 'problem'
type ExportMappingType = 'json' | 'xml'
type JsonObject = Record<string, unknown>
type JsonPath = Array<string | number>
interface SequenceConfigRow {
id: string
path: JsonPath
pathText: string
topKey: string
topDesc: string
parentDesc: string
desc: string
name: string
start: string
end: string
startValueType: string
endValueType: string
}
interface SequenceConfigTypeGroup {
typeName: string
rows: SequenceConfigRow[]
}
interface SequenceConfigGroup {
topKey: string
topDesc: string
rowCount: number
typeGroups: SequenceConfigTypeGroup[]
}
const props = defineProps<{
responseStatusText: string
responseStatusTagType: TagType
activeResultTab: 'mapping' | 'problem'
activeResultTab: ResultTab
mappingMetaText: string
mappingJsonPreview: string
xmlMetaText: string
xmlMappingPreview: string
xmlEmptyText: string
problemTabLabel: string
problemList: string[]
problemEmptyText: string
canExportMapping: boolean
methodDescribe: string
canExportJsonMapping: boolean
canExportXmlMapping: boolean
canGenerateXmlMapping: boolean
isGeneratingXml: boolean
showXmlMappingTab: boolean
}>()
const emit = defineEmits<{
(event: 'update:activeResultTab', value: 'mapping' | 'problem'): void
(event: 'export-mapping'): void
(event: 'update:activeResultTab', value: ResultTab): void
(event: 'export-mapping', value: ExportMappingType): void
(event: 'generate-xml-mapping'): void
(event: 'update-mapping-json', value: string): void
}>()
const activeTabProxy = computed({
get: () => props.activeResultTab,
get: () => (props.activeResultTab === 'problem' ? 'json' : props.activeResultTab),
set: value => emit('update:activeResultTab', value)
})
const canExportAnyMapping = computed(() => props.canExportJsonMapping || props.canExportXmlMapping)
const activeExportType = computed<ExportMappingType>(() => {
if (props.activeResultTab === 'xml') return 'xml'
if (props.canExportJsonMapping) return 'json'
if (props.canExportXmlMapping) return 'xml'
return 'json'
})
const canExportActiveMapping = computed(() =>
activeExportType.value === 'json' ? props.canExportJsonMapping : props.canExportXmlMapping
)
const exportButtonText = computed(() => (activeExportType.value === 'xml' ? '导出XML映射' : '导出JSON映射'))
const problemButtonText = computed(() =>
props.problemList.length ? `问题列表(${props.problemList.length}` : '问题列表'
)
const problemDialogVisible = ref(false)
const matchResultDialogVisible = ref(false)
const sequenceDialogVisible = ref(false)
const sequenceConfigRows = ref<SequenceConfigRow[]>([])
const sequenceSearchKeyword = ref('')
const normalizedSequenceSearchKeyword = computed(() => sequenceSearchKeyword.value.trim().toLowerCase())
const filteredSequenceRows = computed(() => {
const keyword = normalizedSequenceSearchKeyword.value
if (!keyword) return sequenceConfigRows.value
return sequenceConfigRows.value.filter(row =>
[row.topKey, row.topDesc, row.desc, row.parentDesc, row.name, row.pathText, row.start, row.end].some(value =>
value.toLowerCase().includes(keyword)
)
)
})
const sequenceConfigGroups = computed<SequenceConfigGroup[]>(() => {
const groupMap = new Map<string, SequenceConfigRow[]>()
filteredSequenceRows.value.forEach(row => {
const groupRows = groupMap.get(row.topKey) || []
groupRows.push(row)
groupMap.set(row.topKey, groupRows)
})
return Array.from(groupMap.entries()).map(([topKey, rows]) => {
const typeMap = new Map<string, SequenceConfigRow[]>()
rows.forEach(row => {
const typeRows = typeMap.get(row.desc) || []
typeRows.push(row)
typeMap.set(row.desc, typeRows)
})
return {
topKey,
topDesc: rows[0]?.topDesc || '',
rowCount: rows.length,
typeGroups: Array.from(typeMap.entries()).map(([typeName, typeRows]) => ({
typeName,
rows: typeRows
}))
}
})
})
const handleExportCommand = (command: string | number | object) => {
if (command === 'json' || command === 'xml') {
emit('export-mapping', command)
}
}
const isRecord = (value: unknown): value is JsonObject => Boolean(value && typeof value === 'object' && !Array.isArray(value))
const toDisplayText = (value: unknown, fallback: string) => {
if (typeof value === 'string' && value.trim()) return value.trim()
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return fallback
}
const isZeroValue = (value: unknown) => {
if (typeof value === 'number') return value === 0
if (typeof value === 'string') return Number(value.trim()) === 0
return false
}
const getPathText = (path: JsonPath) => (path.length ? path.map(item => String(item)).join(' / ') : '$')
const getTopKey = (path: JsonPath) => (path.length ? String(path[0]) : '$')
const resolveTopDesc = (source: unknown, path: JsonPath) => {
if (!path.length || !isRecord(source)) return ''
const topValue = source[String(path[0])]
return isRecord(topValue) ? toDisplayText(topValue.desc, '') : ''
}
const collectSequenceRows = (source: unknown, path: JsonPath = [], parentDesc = '', rootSource = source): SequenceConfigRow[] => {
if (Array.isArray(source)) {
return source.flatMap((item, index) => collectSequenceRows(item, [...path, index], parentDesc, rootSource))
}
if (!isRecord(source)) return []
const currentDesc = toDisplayText(source.desc, '')
const nextParentDesc = currentDesc || parentDesc
const childrenRows = Object.entries(source).flatMap(([key, value]) =>
collectSequenceRows(value, [...path, key], nextParentDesc, rootSource)
)
const hasStart = Object.prototype.hasOwnProperty.call(source, 'start')
const hasEnd = Object.prototype.hasOwnProperty.call(source, 'end')
if (!hasStart || !hasEnd) return childrenRows
if (isZeroValue(source.start) && isZeroValue(source.end)) return childrenRows
return [
{
id: path.length ? path.join('/') : '$',
path,
pathText: getPathText(path),
topKey: getTopKey(path),
topDesc: resolveTopDesc(rootSource, path),
parentDesc: parentDesc || '未配置上层描述',
desc: toDisplayText(source.desc, '未命名序列'),
name: toDisplayText(source.name, '未配置 name'),
start: source.start === undefined || source.start === null ? '' : String(source.start),
end: source.end === undefined || source.end === null ? '' : String(source.end),
startValueType: typeof source.start,
endValueType: typeof source.end
},
...childrenRows
]
}
const getObjectByPath = (source: unknown, path: JsonPath) => {
return path.reduce<unknown>((target, key) => {
if (!target || typeof target !== 'object') return undefined
return (target as Record<string | number, unknown>)[key]
}, source)
}
const normalizeSequenceValue = (value: string, valueType: string) => {
if (valueType !== 'number') return value
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) {
throw new Error('start 和 end 需要填写有效数字')
}
return numericValue
}
const openSequenceDialog = () => {
try {
const parsed = JSON.parse(props.mappingJsonPreview) as unknown
sequenceConfigRows.value = collectSequenceRows(parsed)
sequenceSearchKeyword.value = ''
sequenceDialogVisible.value = true
} catch {
ElMessage.warning('当前 JSON 映射内容无法解析,不能配置序列')
}
}
const confirmSequenceConfig = () => {
try {
const nextJson = JSON.parse(props.mappingJsonPreview) as unknown
sequenceConfigRows.value.forEach(row => {
const target = getObjectByPath(nextJson, row.path)
if (!isRecord(target)) return
// 关键业务节点:序列配置只回写 start/end避免弹窗编辑影响映射 JSON 的其他字段结构。
target.start = normalizeSequenceValue(row.start, row.startValueType)
target.end = normalizeSequenceValue(row.end, row.endValueType)
})
emit('update-mapping-json', JSON.stringify(nextJson, null, 4))
sequenceDialogVisible.value = false
ElMessage.success('序列配置已同步到 JSON 映射')
} catch (error) {
ElMessage.warning(error instanceof Error ? error.message : '序列配置同步失败')
}
}
</script>
<style scoped lang="scss">
@@ -98,10 +499,25 @@ const activeTabProxy = computed({
.panel-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.export-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.export-menu-button {
width: 32px;
padding: 8px;
}
.panel-title {
margin: 0;
font-size: 22px;
@@ -189,25 +605,7 @@ const activeTabProxy = computed({
min-height: 0;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.preview-header--compact {
margin-bottom: 12px;
}
.preview-meta {
font-size: 13px;
color: #64748b;
}
.mapping-json-scroll,
.problem-section {
.mapping-json-scroll {
display: flex;
flex: 1;
flex-direction: column;
@@ -229,10 +627,64 @@ const activeTabProxy = computed({
word-break: break-word;
}
.problem-list {
.xml-file-viewer {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
border: 1px solid #dbe3f0;
border-radius: 10px;
background: #ffffff;
overflow: hidden;
}
.xml-file-header {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 40px;
padding: 0 14px;
border-bottom: 1px solid #e5e7eb;
background: #f8fafc;
white-space: nowrap;
}
.xml-file-name {
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.xml-file-meta {
min-width: 0;
overflow: hidden;
font-size: 13px;
color: #64748b;
text-overflow: ellipsis;
}
.xml-file-content {
flex: 1;
min-height: 0;
margin: 0;
padding: 16px;
overflow: auto;
font-family: Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
color: #172033;
white-space: pre;
}
.problem-dialog-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 62vh;
padding-right: 4px;
overflow: auto;
}
.problem-item {
@@ -267,16 +719,189 @@ const activeTabProxy = computed({
word-break: break-word;
}
.match-result-actions {
display: flex;
flex: 0 0 auto;
justify-content: flex-end;
margin-bottom: 12px;
}
.match-result-detail {
max-height: 58vh;
padding: 14px 16px;
overflow: auto;
border: 1px solid #dbe3f0;
border-radius: 8px;
background: #f8fafc;
font-size: 14px;
line-height: 1.7;
color: #334155;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.dialog-search-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.dialog-search-count {
flex: 0 0 auto;
font-size: 13px;
line-height: 1.6;
color: #64748b;
white-space: nowrap;
}
.sequence-config-list {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 62vh;
padding-right: 4px;
overflow: auto;
}
.sequence-top-group {
padding: 16px;
border: 1px solid #dbe3f0;
border-radius: 10px;
background: #f8fafc;
}
.sequence-top-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.sequence-top-title {
margin: 0;
font-size: 16px;
font-weight: 600;
line-height: 1.5;
color: #1f2937;
}
.sequence-top-key {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.6;
color: #64748b;
word-break: break-all;
}
.sequence-type-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.sequence-type-card {
padding: 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
}
.sequence-type-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.sequence-type-title {
min-width: 0;
overflow: hidden;
font-size: 15px;
font-weight: 600;
line-height: 1.5;
color: #111827;
text-overflow: ellipsis;
white-space: nowrap;
}
.sequence-config-item {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 16px;
padding: 8px 0;
border: 1px solid #dbe3f0;
border-width: 1px 0 0;
background: transparent;
}
.sequence-config-item:last-child {
padding-bottom: 0;
}
.sequence-config-info {
min-width: 0;
}
.sequence-config-title {
overflow: hidden;
font-size: 15px;
font-weight: 600;
line-height: 1.5;
color: #1f2937;
text-overflow: ellipsis;
white-space: nowrap;
}
.sequence-config-subtitle {
margin-top: 4px;
overflow: hidden;
font-size: 13px;
line-height: 1.6;
color: #64748b;
text-overflow: ellipsis;
white-space: nowrap;
}
.sequence-config-path {
margin-left: 8px;
color: #94a3b8;
}
.sequence-config-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.sequence-config-form :deep(.el-form-item) {
margin-bottom: 0;
}
.sequence-config-form :deep(.el-form-item__label) {
margin-bottom: 2px;
line-height: 18px;
}
@media (max-width: 768px) {
.mapping-panel {
padding: 20px;
}
.panel-header,
.panel-actions,
.preview-header {
.panel-actions {
flex-direction: column;
align-items: flex-start;
}
.sequence-config-item {
grid-template-columns: 1fr;
}
.sequence-config-form {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -15,61 +15,35 @@ const normalizeOptionalString = (value: unknown) => (typeof value === 'string' ?
const normalizeBindings = (value: unknown, groupIndex: number): MmsMapping.IndexSelectionBinding[] => {
if (!Array.isArray(value) || !value.length) {
throw new Error(`request.indexSelection[${groupIndex}].bindings 必须是非空数组`)
throw new Error(`索引配置第 ${groupIndex + 1} 组的 bindings 必须是非空数组`)
}
return value.map((binding, bindingIndex) => {
if (!isRecord(binding)) {
throw new Error(`request.indexSelection[${groupIndex}].bindings[${bindingIndex}] 必须是对象`)
throw new Error(`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 必须是对象`)
}
return {
reportName: normalizeRequiredString(
binding.reportName,
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].reportName`
`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 reportName`
),
dataSetName: normalizeRequiredString(
binding.dataSetName,
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].dataSetName`
`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 dataSetName`
),
label: normalizeRequiredString(
binding.label,
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].label`
`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 label`
),
lnInst: normalizeRequiredString(
binding.lnInst,
`request.indexSelection[${groupIndex}].bindings[${bindingIndex}].lnInst`
`索引配置第 ${groupIndex + 1} 组第 ${bindingIndex + 1} 项的 lnInst`
)
}
})
}
export const buildDefaultIndexSelection = (
candidateGroups: MmsMapping.IndexCandidateGroup[]
): MmsMapping.IndexSelectionGroup[] =>
candidateGroups
.filter(candidate => candidate.groupKey?.trim())
.map(candidate => {
const defaultReport = (candidate.reports || []).find(
report => report.reportName?.trim() && report.dataSetName?.trim()
)
const defaultLnInst = (defaultReport?.availableLnInstValues || []).find(item => item?.trim())?.trim() || ''
return {
groupKey: candidate.groupKey!.trim(),
groupDesc: candidate.groupDesc?.trim() || '',
bindings: (candidate.templateLabels || [])
.map(label => label?.trim() || '')
.filter(Boolean)
.map(label => ({
reportName: defaultReport?.reportName?.trim() || '',
dataSetName: defaultReport?.dataSetName?.trim() || '',
label,
lnInst: defaultLnInst
}))
}
})
export const formatIndexSelectionJson = (value: MmsMapping.IndexSelectionGroup[]) => JSON.stringify(value, null, 4)
export const parseIndexSelectionJson = (source: string): MmsMapping.IndexSelectionGroup[] => {
@@ -78,22 +52,22 @@ export const parseIndexSelectionJson = (source: string): MmsMapping.IndexSelecti
try {
parsed = JSON.parse(source)
} catch {
throw new Error('request.indexSelection 不是合法 JSON')
throw new Error('索引配置不是合法 JSON')
}
if (!Array.isArray(parsed)) {
throw new Error('request.indexSelection 必须是数组')
throw new Error('索引配置必须是数组')
}
return parsed.map((group, groupIndex) => {
if (!isRecord(group)) {
throw new Error(`request.indexSelection[${groupIndex}] 必须是对象`)
throw new Error(`索引配置第 ${groupIndex + 1} 必须是对象`)
}
const groupDesc = normalizeOptionalString(group.groupDesc)
return {
groupKey: normalizeRequiredString(group.groupKey, `request.indexSelection[${groupIndex}].groupKey`),
groupKey: normalizeRequiredString(group.groupKey, `索引配置第 ${groupIndex + 1} 组的 groupKey`),
groupDesc,
bindings: normalizeBindings(group.bindings, groupIndex)
}

View File

@@ -15,6 +15,21 @@
</div>
</div>
<div class="marker-data-block">
<div class="marker-data-title">标记数据</div>
<div v-if="markerDataItems.length" class="marker-data-list">
<div v-for="marker in markerDataItems" :key="marker.name" class="marker-data-group">
<div class="marker-data-name">{{ marker.name }}{{ marker.axisValueText }}</div>
<div class="marker-data-rows">
<div v-for="row in marker.rows" :key="`${marker.name}-${row.label}`" class="marker-data-row">
<span class="marker-data-label">{{ row.label }}</span>
<span class="marker-data-value">{{ row.value }}</span>
</div>
</div>
</div>
</div>
<div v-else class="marker-data-empty">暂无标记数据</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="向量信息" name="vector">
@@ -43,7 +58,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Waveform } from '@/api/tools/waveform/interface'
import type { SummaryItem } from './types'
import type { MarkerDataItem, SummaryItem } from './types'
import WaveformVectorInfo from './WaveformVectorInfo.vue'
defineProps<{
@@ -53,6 +68,7 @@ defineProps<{
lastParseErrorMessage: string
lastVectorParseErrorMessage: string
activeVectorChannelName: string
markerDataItems: MarkerDataItem[]
}>()
// 右侧信息区仅切换展示内容,不影响波形与向量解析结果的联动状态。
@@ -198,6 +214,75 @@ const activeInfoTab = ref('waveform')
word-break: break-all;
}
.marker-data-block {
padding: 10px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--cn-color-canvas-bg);
}
.marker-data-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.marker-data-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 8px;
}
.marker-data-group + .marker-data-group {
padding-top: 10px;
border-top: 1px dashed var(--el-border-color-light);
}
.marker-data-name {
font-size: 13px;
font-weight: 600;
line-height: 1.6;
color: var(--el-text-color-primary);
}
.marker-data-rows {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.marker-data-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
min-width: fit-content;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
white-space: nowrap;
}
.marker-data-label {
flex-shrink: 0;
}
.marker-data-value {
flex-shrink: 0;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.marker-data-empty {
margin-top: 8px;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-placeholder);
}
@media (max-width: 1200px) {
@@ -211,4 +296,4 @@ const activeInfoTab = ref('waveform')
grid-template-columns: 1fr;
}
}
</style>
</style>

View File

@@ -1,6 +1,5 @@
<template>
<template>
<div class="page-header">
<div class="action-row">
<div class="file-select-row">
<el-input
@@ -31,7 +30,12 @@
placeholder="选择通道"
@update:model-value="handleChannelChange"
>
<el-option v-for="item in channelOptions" :key="item.value" :label="item.label" :value="item.value" />
<el-option
v-for="item in channelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
@@ -54,45 +58,43 @@
<div class="toolbar-item">
<div class="toolbar-label">数值类型</div>
<el-radio-group :model-value="activeValueMode" class="value-mode-switch" @update:model-value="handleValueModeChange">
<el-radio-group
:model-value="activeValueMode"
class="value-mode-switch"
@update:model-value="handleValueModeChange"
>
<el-radio-button v-for="item in valueModeOptions" :key="item.value" :label="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<el-button type="primary" :icon="Download" :disabled="!hasWaveformData" @click="emit('download')">
下载数据
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Download, FolderOpened } from '@element-plus/icons-vue'
import { FolderOpened } from '@element-plus/icons-vue'
import { ref } from 'vue'
import type { DisplayMode, LabelValueOption, ValueMode, WaveformDetailOption } from './types'
import type { ChannelSelectValue, DisplayMode, LabelValueOption, ValueMode, WaveformDetailOption } from './types'
defineProps<{
selectedWaveformFileName: string
isParsing: boolean
waveformFileAccept: string
channelOptions: WaveformDetailOption[]
activeChannelIndex: number
activeChannelIndex: ChannelSelectValue
displayModeOptions: LabelValueOption<DisplayMode>[]
activeDisplayMode: DisplayMode
valueModeOptions: LabelValueOption<ValueMode>[]
activeValueMode: ValueMode
hasWaveformData: boolean
}>()
const emit = defineEmits<{
'update:activeChannelIndex': [value: number]
'update:activeChannelIndex': [value: ChannelSelectValue]
'update:activeDisplayMode': [value: DisplayMode]
'update:activeValueMode': [value: ValueMode]
'waveform-file-change': [event: Event]
download: []
}>()
const waveformFileInputRef = ref<HTMLInputElement>()
@@ -108,7 +110,7 @@ const handleWaveformFileChange = (event: Event) => {
emit('waveform-file-change', event)
}
const handleChannelChange = (value: number) => {
const handleChannelChange = (value: ChannelSelectValue) => {
emit('update:activeChannelIndex', value)
}
@@ -202,6 +204,4 @@ const handleValueModeChange = (value: string | number | boolean | undefined) =>
max-width: none;
}
}
</style>

View File

@@ -0,0 +1,200 @@
<template>
<div class="panel-body">
<div v-if="hasWaveformData && isAllChannelsActive" class="all-channel-list">
<template v-for="group in allChannelTrendGroups" :key="group.key">
<div
v-if="activeDisplayMode === 'multi-channel'"
class="all-channel-chart"
:class="{ 'trend-chart-block--with-axis': group.isLastChart }"
>
<LineChart
:options="group.multiChannelOptions"
:group="group.group"
@chart-data-zoom="handleChartDataZoom"
@chart-click="handleChartClick"
/>
</div>
<template v-else>
<div
v-for="item in group.singleChannelOptionsList"
:key="item.key"
class="single-channel-card"
:class="{ 'trend-chart-block--with-axis': item.isLastChart }"
>
<div class="single-channel-chart">
<LineChart
:options="item.options"
:group="item.group"
@chart-data-zoom="handleChartDataZoom"
@chart-click="handleChartClick"
/>
</div>
</div>
</template>
</template>
</div>
<div v-else-if="hasWaveformData && activeDisplayMode === 'multi-channel'" class="chart-container">
<LineChart
:options="activeTrendOptions"
@chart-data-zoom="handleChartDataZoom"
@chart-click="handleChartClick"
/>
</div>
<div v-else-if="hasWaveformData" class="single-channel-list">
<div
v-for="item in singleChannelTrendOptionsList"
:key="item.key"
class="single-channel-card"
:class="{ 'trend-chart-block--with-axis': item.isLastChart }"
>
<div class="single-channel-chart">
<LineChart
:options="item.options"
:group="item.group"
@chart-data-zoom="handleChartDataZoom"
@chart-click="handleChartClick"
/>
</div>
</div>
</div>
<div v-else class="empty-block">
<div class="empty-title">暂无波形数据</div>
<div class="empty-text">请选择同一组 `.cfg` `.dat` 文件后自动解析并展示</div>
<div v-if="lastParseErrorMessage" class="empty-text error-text">
最近一次解析失败{{ lastParseErrorMessage }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import LineChart from '@/components/echarts/line/index.vue'
import type {
AllChannelTrendGroup,
DisplayMode,
SingleChannelTrendOption,
TrendChartClickPayload,
TrendChartZoomPayload
} from './types'
defineProps<{
hasWaveformData: boolean
isAllChannelsActive: boolean
activeDisplayMode: DisplayMode
activeTrendOptions: Record<string, unknown>
singleChannelTrendOptionsList: SingleChannelTrendOption[]
allChannelTrendGroups: AllChannelTrendGroup[]
lastParseErrorMessage: string
}>()
const emit = defineEmits<{
'chart-data-zoom': [value: TrendChartZoomPayload]
'chart-click': [value: TrendChartClickPayload]
}>()
const handleChartDataZoom = (value: TrendChartZoomPayload) => {
emit('chart-data-zoom', value)
}
const handleChartClick = (value: TrendChartClickPayload) => {
emit('chart-click', value)
}
</script>
<style scoped lang="scss">
.panel-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.chart-container,
.empty-block {
display: flex;
flex: 1;
min-height: 0;
padding: 6px 8px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
}
.empty-block {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px;
background: var(--cn-color-canvas-bg);
}
.single-channel-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow: hidden;
}
.all-channel-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-height: 0;
padding: 6px 8px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--cn-color-canvas-bg);
}
.all-channel-chart {
display: flex;
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.single-channel-card {
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: 4px;
min-height: 0;
padding: 6px 8px;
overflow: hidden;
background: var(--cn-color-canvas-bg);
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
}
.trend-chart-block--with-axis {
flex-basis: 17px;
}
.single-channel-chart {
flex: 1;
min-height: 0;
}
.empty-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-text {
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.error-text {
color: var(--el-color-danger);
word-break: break-all;
}
</style>

View File

@@ -1,57 +1,194 @@
<template>
<template>
<section class="waveform-panel">
<div class="panel-header">
<el-tabs :model-value="activeTrendTab" class="trend-tabs" @update:model-value="handleTrendTabChange">
<el-tab-pane v-for="item in trendTabs" :key="item.value" :label="item.label" :name="item.value" />
</el-tabs>
</div>
<div class="panel-body">
<div v-if="hasWaveformData && activeDisplayMode === 'multi-channel'" class="chart-container">
<LineChart :options="activeTrendOptions" />
</div>
<div v-else-if="hasWaveformData" class="single-channel-list">
<div
v-for="item in singleChannelTrendOptionsList"
:key="item.key"
class="single-channel-card"
:class="{ 'single-channel-card--with-axis': item.isLastChart }"
>
<div class="single-channel-chart">
<LineChart :options="item.options" :group="item.group" />
</div>
<div class="trend-tool-groups">
<div v-for="group in trendToolGroups" :key="group.key" class="trend-tool-group">
<el-tooltip
v-for="item in group.items"
:key="item.action"
:content="getTrendToolTooltip(item)"
placement="top"
>
<el-button
:type="isTrendToolActive(item.action) ? 'primary' : 'default'"
:icon="item.icon"
:disabled="isTrendToolDisabled(item.action)"
circle
@click="handleTrendToolClick(item.action)"
/>
</el-tooltip>
</div>
</div>
<div v-else class="empty-block">
<div class="empty-title">暂无波形数据</div>
<div class="empty-text">请选择同一组 `.cfg` `.dat` 文件后自动解析并展示</div>
<div v-if="lastParseErrorMessage" class="empty-text error-text">最近一次解析失败{{ lastParseErrorMessage }}</div>
</div>
</div>
<WaveformTrendChartArea
class="waveform-trend-export-target"
:has-waveform-data="hasWaveformData"
:active-display-mode="activeDisplayMode"
:active-trend-options="activeTrendOptions"
:single-channel-trend-options-list="singleChannelTrendOptionsList"
:all-channel-trend-groups="allChannelTrendGroups"
:is-all-channels-active="isAllChannelsActive"
:last-parse-error-message="lastParseErrorMessage"
@chart-data-zoom="handleChartDataZoom"
@chart-click="handleChartClick"
/>
<el-dialog
v-model="fullscreenVisible"
class="waveform-fullscreen-dialog"
title="波形全屏展示"
fullscreen
append-to-body
destroy-on-close
>
<WaveformTrendChartArea
class="fullscreen-chart-body"
:has-waveform-data="hasWaveformData"
:active-display-mode="activeDisplayMode"
:active-trend-options="activeTrendOptions"
:single-channel-trend-options-list="singleChannelTrendOptionsList"
:all-channel-trend-groups="allChannelTrendGroups"
:is-all-channels-active="isAllChannelsActive"
:last-parse-error-message="lastParseErrorMessage"
@chart-data-zoom="handleChartDataZoom"
@chart-click="handleChartClick"
/>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import LineChart from '@/components/echarts/line/index.vue'
import type { DisplayMode, LabelValueOption, SingleChannelTrendOption, TrendTabValue } from './types'
import {
ArrowDownBold,
ArrowLeftBold,
ArrowRightBold,
ArrowUpBold,
Crop,
Download,
FullScreen,
Location,
Picture,
Pointer,
RefreshLeft
} from '@element-plus/icons-vue'
import { ref } from 'vue'
import WaveformTrendChartArea from './WaveformTrendChartArea.vue'
import type {
AllChannelTrendGroup,
DisplayMode,
LabelValueOption,
SingleChannelTrendOption,
TrendChartClickPayload,
TrendChartZoomPayload,
TrendToolAction,
TrendTabValue
} from './types'
import type { Component } from 'vue'
defineProps<{
type TrendPanelToolAction = TrendToolAction | 'fullscreen'
const props = defineProps<{
hasWaveformData: boolean
isAllChannelsActive: boolean
activeDisplayMode: DisplayMode
activeTrendTab: TrendTabValue
trendTabs: LabelValueOption<TrendTabValue>[]
activeTrendOptions: Record<string, unknown>
singleChannelTrendOptionsList: SingleChannelTrendOption[]
allChannelTrendGroups: AllChannelTrendGroup[]
lastParseErrorMessage: string
activeTrendToolStates: Partial<Record<TrendToolAction, boolean>>
disabledTrendToolStates?: Partial<Record<TrendToolAction, boolean>>
}>()
const emit = defineEmits<{
'update:activeTrendTab': [value: TrendTabValue]
'trend-tool-action': [value: TrendToolAction]
'chart-data-zoom': [value: TrendChartZoomPayload]
'chart-click': [value: TrendChartClickPayload]
}>()
const fullscreenVisible = ref(false)
const trendToolGroups: Array<{
key: string
items: Array<{
action: TrendPanelToolAction
label: string
icon: Component
}>
}> = [
{
key: 'scale',
items: [
{ action: 'x-zoom-in', label: 'X坐标放大', icon: ArrowRightBold },
{ action: 'x-zoom-out', label: 'X坐标缩小', icon: ArrowLeftBold },
{ action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold },
{ action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold },
{ action: 'box-zoom', label: '框选放大', icon: Crop },
{ action: 'mark', label: '标记', icon: Location },
{ action: 'reset', label: '恢复', icon: RefreshLeft },
{ action: 'pan', label: '平移', icon: Pointer }
]
},
{
key: 'export',
items: [
{ action: 'fullscreen', label: '全屏展示', icon: FullScreen },
{ action: 'download-image', label: '下载图片', icon: Picture },
{ action: 'download-data', label: '下载数据', icon: Download }
]
}
]
const handleTrendTabChange = (value: string | number) => {
emit('update:activeTrendTab', value as TrendTabValue)
}
const isTrendToolActive = (action: TrendPanelToolAction) => {
if (action === 'fullscreen') return fullscreenVisible.value
return props.activeTrendToolStates[action]
}
const isTrendToolDisabled = (action: TrendPanelToolAction) => {
if (!props.hasWaveformData) return true
if (action === 'fullscreen') return false
return !!props.disabledTrendToolStates?.[action]
}
const getTrendToolTooltip = (item: { action: TrendPanelToolAction; label: string }) => {
if (item.action === 'pan' && isTrendToolDisabled(item.action) && props.hasWaveformData) {
return '请先放大 X 轴或框选局部区域后再平移'
}
return item.label
}
const handleTrendToolClick = (action: TrendPanelToolAction) => {
if (isTrendToolDisabled(action)) return
if (action === 'fullscreen') {
fullscreenVisible.value = true
return
}
emit('trend-tool-action', action)
}
const handleChartDataZoom = (value: TrendChartZoomPayload) => {
emit('chart-data-zoom', value)
}
const handleChartClick = (value: TrendChartClickPayload) => {
emit('chart-click', value)
}
</script>
<style scoped lang="scss">
@@ -68,15 +205,44 @@ const handleTrendTabChange = (value: string | number) => {
}
.panel-header {
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
margin-bottom: 12px;
}
.trend-tabs {
width: 100%;
min-width: 0;
flex-shrink: 0;
}
.trend-tool-groups {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
.trend-tool-group {
display: flex;
align-items: center;
gap: 4px;
}
.trend-tool-group + .trend-tool-group {
padding-left: 8px;
border-left: 1px solid var(--el-border-color-lighter);
}
.trend-tool-group :deep(.el-button.is-circle) {
width: 28px;
height: 28px;
padding: 6px;
}
.trend-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
@@ -89,86 +255,37 @@ const handleTrendTabChange = (value: string | number) => {
font-size: 13px;
}
.panel-body {
:global(.waveform-fullscreen-dialog) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.chart-container,
.empty-block {
:global(.waveform-fullscreen-dialog .el-dialog__body) {
display: flex;
flex: 1;
min-height: 0;
padding: 8px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
padding: 12px;
}
.empty-block {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
background: var(--cn-color-canvas-bg);
}
.single-channel-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
.fullscreen-chart-body {
min-height: 0;
overflow: hidden;
}
.single-channel-card {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-height: 0;
padding: 8px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--cn-color-canvas-bg);
overflow: hidden;
}
.single-channel-card--with-axis {
flex: 1.1;
}
.single-channel-chart {
flex: 1;
min-height: 0;
}
.empty-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-text {
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.error-text {
color: var(--el-color-danger);
word-break: break-all;
}
@media (max-width: 768px) {
.panel-header {
flex-direction: column;
align-items: stretch;
}
.trend-tabs {
width: 100%;
}
.trend-tool-groups {
flex-wrap: wrap;
justify-content: flex-start;
}
.trend-tabs :deep(.el-tabs__nav) {
width: 100%;
}

View File

@@ -1,6 +1,18 @@
export type TrendTabValue = 'instant' | 'rms'
export type TrendTabValue = 'instant' | 'rms'
export type ValueMode = 'primary' | 'secondary'
export type DisplayMode = 'single-channel' | 'multi-channel'
export type ChannelSelectValue = number | 'all'
export type TrendToolAction =
| 'x-zoom-in'
| 'x-zoom-out'
| 'y-zoom-in'
| 'y-zoom-out'
| 'box-zoom'
| 'mark'
| 'pan'
| 'reset'
| 'download-image'
| 'download-data'
export interface LabelValueOption<T extends string | number = string | number> {
label: string
@@ -9,7 +21,7 @@ export interface LabelValueOption<T extends string | number = string | number> {
export interface WaveformDetailOption {
label: string
value: number
value: ChannelSelectValue
}
export interface SingleChannelTrendOption {
@@ -19,11 +31,29 @@ export interface SingleChannelTrendOption {
options: Record<string, unknown>
}
export interface AllChannelTrendGroup {
key: string
title: string
group: string
isLastChart?: boolean
singleChannelOptionsList: SingleChannelTrendOption[]
multiChannelOptions: Record<string, unknown>
}
export interface SummaryItem {
label: string
value: string | number
}
export interface MarkerDataItem {
name: string
axisValueText: string
rows: Array<{
label: string
value: string
}>
}
export interface FeatureCardItem {
title: string
rows: Array<{
@@ -31,3 +61,13 @@ export interface FeatureCardItem {
value: string
}>
}
export interface TrendChartZoomPayload {
start: number
end: number
}
export interface TrendChartClickPayload {
dataIndex: number
axisValue: string | number
}

File diff suppressed because it is too large Load Diff