Compare commits
10 Commits
287de846a6
...
2026-04
| Author | SHA1 | Date | |
|---|---|---|---|
| a1e1fb124a | |||
| fe3ab1f679 | |||
| 32f324909d | |||
| 2babe9d99d | |||
| 407ab0a7f6 | |||
| bf9f3719a4 | |||
| 483b3d7ae4 | |||
| 297f89ef52 | |||
| 0dc0e4ecdc | |||
| 398a2cf1dc |
33
AGENTS.md
33
AGENTS.md
@@ -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`。
|
||||
|
||||
98
frontend/src/api/tools/addData/index.ts
Normal file
98
frontend/src/api/tools/addData/index.ts
Normal 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')
|
||||
}
|
||||
109
frontend/src/api/tools/addData/interface/index.ts
Normal file
109
frontend/src/api/tools/addData/interface/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '磁盘监控'
|
||||
}
|
||||
},
|
||||
|
||||
2
frontend/src/types/global.d.ts
vendored
2
frontend/src/types/global.d.ts
vendored
@@ -12,6 +12,8 @@ declare namespace Menu {
|
||||
icon: string;
|
||||
title: string;
|
||||
activeMenu?: string;
|
||||
hideTab?: boolean;
|
||||
parentPath?: string;
|
||||
isLink?: string;
|
||||
isHide: boolean;
|
||||
isFull: boolean;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
526
frontend/src/views/tools/addData/components/AddDataTaskPanel.vue
Normal file
526
frontend/src/views/tools/addData/components/AddDataTaskPanel.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
465
frontend/src/views/tools/addData/index.vue
Normal file
465
frontend/src/views/tools/addData/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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` 不应返回候选或映射结果字段
|
||||
@@ -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` 为结构化示意,实际字段数量与内容以运行结果为准
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user