refactor(parsePqdif): 重构PQDIF解析页面为ProTable管理界面

- 将原有网格布局替换为ProTable组件进行数据管理
- 新增PQDIF路径表单对话框用于创建和编辑记录
- 实现PQDIF记录的增删改查功能
- 添加批量删除和JSON导出功能
- 集成文件上传和解析流程
- 更新API接口支持路径管理和解析结果存储
- 移除旧的请求面板、摘要面板和结果面板组件
- 调整MMS映射组件中的XML生成事件处理逻辑
This commit is contained in:
2026-06-23 08:24:40 +08:00
parent 92b7d3cd73
commit a6228faaba
14 changed files with 916 additions and 607 deletions

View File

@@ -1,6 +1,21 @@
import http from '@/api' import http from '@/api'
import type { ParsePqdif } from './interface' import type { ParsePqdif } from './interface'
const PQDIF_PATH_BASE_URL = '/api/parse-pqdif/pqdif-paths'
const buildPqdifPathFormData = (request: ParsePqdif.PqdifPathSaveParams, pqdifFile?: File | null) => {
const formData = new FormData()
if (pqdifFile) {
formData.append('pqdifFile', pqdifFile)
}
// 关键业务节点:记录上传接口固定读取 request 这个 JSON part字段名不能调整。
formData.append('request', new Blob([JSON.stringify(request)], { type: 'application/json' }))
return formData
}
export const parsePqdifApi = (pqdifFile: File) => { export const parsePqdifApi = (pqdifFile: File) => {
const formData = new FormData() const formData = new FormData()
@@ -11,3 +26,47 @@ export const parsePqdifApi = (pqdifFile: File) => {
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' }
}) })
} }
export const listPqdifPathsApi = (params: ParsePqdif.PqdifPathListParams = {}) => {
const request = { ...params }
delete request.pageNum
delete request.pageSize
return http.post<ParsePqdif.PqdifPathRecord[]>(`${PQDIF_PATH_BASE_URL}/list`, request)
}
export const addPqdifPathApi = (params: ParsePqdif.PqdifPathSaveParams, pqdifFile?: File | null) => {
if (pqdifFile) {
return http.post<boolean>(`${PQDIF_PATH_BASE_URL}/add`, buildPqdifPathFormData(params, pqdifFile), {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
return http.post<boolean>(`${PQDIF_PATH_BASE_URL}/add`, params)
}
export const updatePqdifPathApi = (params: ParsePqdif.PqdifPathSaveParams, pqdifFile?: File | null) => {
if (pqdifFile) {
return http.post<boolean>(`${PQDIF_PATH_BASE_URL}/update`, buildPqdifPathFormData(params, pqdifFile), {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
return http.post<boolean>(`${PQDIF_PATH_BASE_URL}/update`, params)
}
export const deletePqdifPathsApi = (ids: string[]) => {
return http.post<boolean>(`${PQDIF_PATH_BASE_URL}/delete`, ids)
}
export const getPqdifParseMsgApi = (id: string) => {
return http.post<ParsePqdif.PqdifParseMsg>(`${PQDIF_PATH_BASE_URL}/${id}/parse-msg`)
}
export const getPqdifParseDetailApi = (id: string) => {
return http.post<ParsePqdif.PqdifParseDetail>(`${PQDIF_PATH_BASE_URL}/${id}/parse-detail`)
}
export const savePqdifParseResultApi = (id: string, params: ParsePqdif.SaveParseResultParams) => {
return http.post<boolean>(`${PQDIF_PATH_BASE_URL}/${id}/parse-result`, params)
}

View File

@@ -1,6 +1,7 @@
export namespace ParsePqdif { export namespace ParsePqdif {
export type ParseStatus = 'SUCCESS' | 'FAILED' | (string & {}) export type ParseStatus = 'SUCCESS' | 'FAILED' | (string & {})
export type SeriesDataStatus = 'DATA_SUCCESS' | 'DATA_FAILED' | (string & {}) export type SeriesDataStatus = 'DATA_SUCCESS' | 'DATA_FAILED' | (string & {})
export type ParseResultValue = 0 | 1
export interface RecordItem { export interface RecordItem {
recordIndex?: number recordIndex?: number
@@ -53,4 +54,48 @@ export namespace ParsePqdif {
records?: RecordItem[] records?: RecordItem[]
observations?: ObservationItem[] observations?: ObservationItem[]
} }
export interface PqdifPathListParams {
keyword?: string
result?: ParseResultValue | ''
pageNum?: number
pageSize?: number
}
export interface PqdifPathRecord {
id?: string
name?: string
filePath?: string
recordCount?: number
observationCount?: number
sampleValueCount?: number
state?: number
result?: ParseResultValue
msg?: Record<string, unknown> | null
createBy?: string
createTime?: string
updateBy?: string
updateTime?: string
}
export interface PqdifPathSaveParams {
id?: string
name: string
}
export interface PqdifParseDetail {
id?: string
name?: string
filePath?: string
}
export type PqdifParseMsg = Record<string, unknown> | null
export interface SaveParseResultParams {
recordCount?: number
observationCount?: number
sampleValueCount?: number
result: ParseResultValue
msg?: Record<string, unknown> | null
}
} }

View File

@@ -86,7 +86,7 @@
:should-open-icd-consistency-problems="shouldOpenIcdConsistencyProblems" :should-open-icd-consistency-problems="shouldOpenIcdConsistencyProblems"
:show-description="false" :show-description="false"
@export-mapping="handleExportMappingEvent" @export-mapping="handleExportMappingEvent"
@generate-xml-mapping="handleGenerateXmlMapping" @generate-xml-mapping="handleGenerateXmlMappingEvent"
@icd-check="handleIcdConsistencyCheck" @icd-check="handleIcdConsistencyCheck"
@confirm-icd-consistency-problems="handleConfirmIcdConsistencyProblemsEvent" @confirm-icd-consistency-problems="handleConfirmIcdConsistencyProblemsEvent"
@remove-icd-consistency-problem="handleRemoveIcdConsistencyProblemEvent" @remove-icd-consistency-problem="handleRemoveIcdConsistencyProblemEvent"
@@ -224,6 +224,13 @@ const handleExportMappingEvent = (...args: unknown[]) => {
handleExportMapping(type) handleExportMapping(type)
} }
const handleGenerateXmlMappingEvent = (...args: unknown[]) => {
const [dataType] = args
if (typeof dataType !== 'number') return
handleGenerateXmlMapping(dataType)
}
const handleConfirmIcdConsistencyProblemsEvent = () => { const handleConfirmIcdConsistencyProblemsEvent = () => {
handleConfirmIcdConsistencyProblems() handleConfirmIcdConsistencyProblems()
} }

View File

@@ -0,0 +1,59 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(currentDir, '../../../..')
const files = {
resultPanel: path.resolve(rootDir, 'views/tools/mmsMapping/components/MappingResultPanel.vue'),
checkDialog: path.resolve(rootDir, 'views/tools/mmsMapping/components/IcdPathCheckDialog.vue'),
flow: path.resolve(rootDir, 'views/tools/mmsMapping/utils/useMmsMappingFlow.ts')
}
const read = file => fs.readFileSync(file, 'utf8')
const resultPanelSource = read(files.resultPanel)
const checkDialogSource = read(files.checkDialog)
const flowSource = read(files.flow)
const checks = [
['XML mapping opens data type dialog before generation', () => /openXmlDataTypeDialog/.test(resultPanelSource)],
['XML data type defaults to provincial platform version', () => /xmlDataType\s*=\s*ref<number>\(2\)/.test(resultPanelSource)],
[
'XML data type dialog exposes MMS and provincial platform options',
() =>
/label="1"[\s\S]*MMS版本/.test(resultPanelSource) &&
/label="2"[\s\S]*省级平台版本/.test(resultPanelSource)
],
[
'XML generation event carries selected data type',
() =>
/event:\s*'generate-xml-mapping',\s*value:\s*number/.test(resultPanelSource) &&
/emit\('generate-xml-mapping',\s*xmlDataType\.value\)/.test(resultPanelSource)
],
[
'ICD path check dialog forwards XML data type to shared flow',
() =>
/@generate-xml-mapping="handleGenerateXmlMappingEvent"/.test(checkDialogSource) &&
/const\s+handleGenerateXmlMappingEvent\s*=\s*\(\.\.\.args:\s*unknown\[\]\)/.test(checkDialogSource) &&
/handleGenerateXmlMapping\(dataType\)/.test(checkDialogSource)
],
[
'XML flow defaults and sends configType as backend data type',
() =>
/const\s+handleGenerateXmlMapping\s*=\s*async\s*\(dataType\s*=\s*2\)/.test(flowSource) &&
/mappingJson,\s*configType:\s*dataType/.test(flowSource)
]
]
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
if (failures.length) {
console.error('mmsMapping XML data type contract failed:')
for (const failure of failures) {
console.error(`- ${failure}`)
}
process.exit(1)
}
console.log('mmsMapping XML data type contract passed')

View File

@@ -354,7 +354,7 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
return 'json' return 'json'
} }
const handleGenerateXmlMapping = async () => { const handleGenerateXmlMapping = async (dataType = 2) => {
const mappingJson = responsePayload.value?.mappingJson?.trim() const mappingJson = responsePayload.value?.mappingJson?.trim()
if (!mappingJson) { if (!mappingJson) {
@@ -370,7 +370,8 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
// 关键业务节点XML 映射依赖本次接口返回的完整 mappingJson避免使用旧结果生成不一致的 XML 文件。 // 关键业务节点XML 映射依赖本次接口返回的完整 mappingJson避免使用旧结果生成不一致的 XML 文件。
const response = await getXmlFromJsonApi({ const response = await getXmlFromJsonApi({
request: { request: {
mappingJson mappingJson,
configType: dataType
} }
}) })
const payload = unwrapApiPayload<MmsMapping.MappingTaskResponse>(response) const payload = unwrapApiPayload<MmsMapping.MappingTaskResponse>(response)

View File

@@ -21,7 +21,7 @@
:icon="Connection" :icon="Connection"
:loading="isGeneratingXml" :loading="isGeneratingXml"
:disabled="!canGenerateXmlMapping" :disabled="!canGenerateXmlMapping"
@click="emit('generate-xml-mapping')" @click="openXmlDataTypeDialog"
> >
XML映射 XML映射
</el-button> </el-button>
@@ -239,6 +239,23 @@
</div> </div>
</el-dialog> </el-dialog>
<el-dialog
v-model="xmlDataTypeDialogVisible"
title="XML映射类型"
width="420px"
destroy-on-close
top="18vh"
>
<el-radio-group v-model="xmlDataType" class="xml-data-type-options">
<el-radio :label="1">MMS版本</el-radio>
<el-radio :label="2">省级平台版本</el-radio>
</el-radio-group>
<template #footer>
<el-button @click="xmlDataTypeDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="isGeneratingXml" @click="confirmXmlDataType">确定</el-button>
</template>
</el-dialog>
<el-dialog <el-dialog
v-model="sequenceDialogVisible" v-model="sequenceDialogVisible"
title="序列配置" title="序列配置"
@@ -417,7 +434,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'update:activeResultTab', value: ResultTab): void (event: 'update:activeResultTab', value: ResultTab): void
(event: 'export-mapping', value: ExportMappingType): void (event: 'export-mapping', value: ExportMappingType): void
(event: 'generate-xml-mapping'): void (event: 'generate-xml-mapping', value: number): void
(event: 'update-mapping-json', value: string): void (event: 'update-mapping-json', value: string): void
(event: 'update:sequenceDialogVisible', value: boolean): void (event: 'update:sequenceDialogVisible', value: boolean): void
(event: 'sequence-config-complete'): void (event: 'sequence-config-complete'): void
@@ -446,6 +463,8 @@ const icdConsistencyProblemCount = computed(() => props.icdConsistencyProblemLis
const problemDialogVisible = ref(false) const problemDialogVisible = ref(false)
const icdConsistencyProblemDialogVisible = ref(false) const icdConsistencyProblemDialogVisible = ref(false)
const matchResultDialogVisible = ref(false) const matchResultDialogVisible = ref(false)
const xmlDataTypeDialogVisible = ref(false)
const xmlDataType = ref<number>(2)
const sequenceConfigRows = ref<SequenceConfigRow[]>([]) const sequenceConfigRows = ref<SequenceConfigRow[]>([])
const sequenceSearchKeyword = ref('') const sequenceSearchKeyword = ref('')
const sequenceDialogVisible = computed({ const sequenceDialogVisible = computed({
@@ -634,6 +653,16 @@ const closeSequenceDialog = () => {
sequenceDialogVisible.value = false sequenceDialogVisible.value = false
} }
const openXmlDataTypeDialog = () => {
xmlDataType.value = 2
xmlDataTypeDialogVisible.value = true
}
const confirmXmlDataType = () => {
xmlDataTypeDialogVisible.value = false
emit('generate-xml-mapping', xmlDataType.value)
}
watch( watch(
() => props.sequenceDialogVisible, () => props.sequenceDialogVisible,
visible => { visible => {
@@ -1001,6 +1030,12 @@ const confirmSequenceConfig = () => {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.xml-data-type-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.dialog-search-bar { .dialog-search-bar {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -0,0 +1,127 @@
<template>
<el-dialog
v-model="visibleProxy"
:title="recordName || 'PQDIF解析详情'"
width="960px"
append-to-body
destroy-on-close
class="pqdif-path-detail-dialog"
>
<div v-loading="loading" class="detail-dialog-body">
<el-alert v-if="parseError" :title="parseError" type="warning" show-icon :closable="false" />
<el-descriptions :column="1" border>
<el-descriptions-item label="原始文件路径">{{ detail?.filePath || '-' }}</el-descriptions-item>
</el-descriptions>
<el-tabs v-model="activeTab" class="detail-tabs">
<el-tab-pane label="解析结构" name="structured">
<PqdifResultPanel
v-model:active-tab="resultActiveTab"
:records="parseResult?.records || []"
:observations="parseResult?.observations || []"
/>
</el-tab-pane>
<el-tab-pane label="原始JSON" name="json">
<pre class="json-content">{{ formattedJson }}</pre>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<el-button @click="visibleProxy = false">关闭</el-button>
<el-button
type="primary"
plain
:loading="saving"
:disabled="!canSaveParseResult"
@click="emit('save-result')"
>
保存解析结果
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ParsePqdif } from '@/api/tools/parsePqdif/interface'
import PqdifResultPanel from './PqdifResultPanel.vue'
defineOptions({
name: 'PqdifPathDetailDialog'
})
const props = defineProps<{
visible: boolean
recordName: string
loading: boolean
saving: boolean
detail: ParsePqdif.PqdifParseDetail | null
parseResult: ParsePqdif.ParseResponse | null
parseError: string
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
(event: 'save-result'): void
}>()
const activeTab = ref('structured')
const resultActiveTab = ref<'records' | 'observations'>('records')
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const formattedJson = computed(() => {
if (!props.parseResult) return '暂无完整解析结果'
return JSON.stringify(props.parseResult, null, 4)
})
const canSaveParseResult = computed(() => Boolean(props.detail?.id && props.parseResult))
watch(
() => props.parseResult,
result => {
resultActiveTab.value = result?.observations?.length ? 'observations' : 'records'
}
)
watch(
() => props.visible,
visible => {
if (visible) activeTab.value = 'structured'
}
)
</script>
<style scoped lang="scss">
.detail-dialog-body {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 480px;
}
.detail-tabs {
min-height: 0;
:deep(.el-tabs__content) {
min-height: 0;
}
}
.json-content {
max-height: 520px;
margin: 0;
padding: 12px;
overflow: auto;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #0f172a;
color: #e5e7eb;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<el-dialog
v-model="visibleProxy"
:title="mode === 'create' ? '新增PQDIF记录' : '编辑PQDIF记录'"
width="560px"
append-to-body
destroy-on-close
class="pqdif-path-form-dialog"
@closed="resetForm"
>
<el-form ref="formRef" :model="formModel" :rules="formRules" label-width="100px">
<el-form-item label="PQDIF名称" prop="name">
<el-input v-model="formModel.name" maxlength="120" show-word-limit placeholder="请输入PQDIF名称" />
</el-form-item>
<el-form-item label="PQDIF文件">
<div class="file-select-row">
<el-input :model-value="selectedFileName" readonly placeholder="可选择 .pqd 或 .pqdif 文件" />
<el-button type="primary" plain :icon="FolderOpened" :disabled="saving" @click="openFilePicker">
选择文件
</el-button>
<input
ref="fileInputRef"
class="hidden-file-input"
type="file"
accept=".pqd,.pqdif"
@change="handleFileChange"
/>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="saving" @click="visibleProxy = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { FolderOpened } from '@element-plus/icons-vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { computed, reactive, ref, watch } from 'vue'
import type { ParsePqdif } from '@/api/tools/parsePqdif/interface'
defineOptions({
name: 'PqdifPathFormDialog'
})
const SUPPORTED_PQDIF_EXTENSIONS = ['pqd', 'pqdif']
const props = defineProps<{
visible: boolean
mode: 'create' | 'edit'
record: ParsePqdif.PqdifPathRecord | null
saving: boolean
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
(event: 'submit', value: { payload: ParsePqdif.PqdifPathSaveParams; file: File | null }): void
}>()
const formRef = ref<FormInstance>()
const fileInputRef = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const formModel = reactive({
id: '',
name: ''
})
const formRules: FormRules = {
name: [{ required: true, message: '请输入PQDIF名称', trigger: 'blur' }]
}
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const selectedFileName = computed(() => selectedFile.value?.name || '')
const getFileExtension = (fileName: string) => fileName.split('.').pop()?.toLowerCase() || ''
const isSupportedPqdifFile = (fileName: string) => SUPPORTED_PQDIF_EXTENSIONS.includes(getFileExtension(fileName))
const resetForm = () => {
formRef.value?.clearValidate()
selectedFile.value = null
formModel.id = ''
formModel.name = ''
}
const fillForm = () => {
formModel.id = props.record?.id || ''
formModel.name = props.record?.name || ''
selectedFile.value = null
}
watch(
() => [props.visible, props.mode, props.record?.id, props.record?.name],
([visible]) => {
if (visible) fillForm()
},
{ immediate: true }
)
const openFilePicker = () => {
fileInputRef.value?.click()
}
const handleFileChange = (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0] || null
input.value = ''
if (!file) return
if (!isSupportedPqdifFile(file.name)) {
ElMessage.warning('仅支持 .pqd 或 .pqdif 格式文件')
return
}
selectedFile.value = file
if (!formModel.name.trim()) {
formModel.name = file.name
}
}
const submitForm = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
if (props.mode === 'create' && !selectedFile.value) {
ElMessage.warning('新增PQDIF记录必须选择 .pqd 或 .pqdif 文件')
return
}
if (props.mode === 'edit' && !formModel.id) {
ElMessage.warning('当前PQDIF记录缺少 ID不能保存修改')
return
}
emit('submit', {
payload: {
id: formModel.id || undefined,
name: formModel.name.trim()
},
file: selectedFile.value
})
}
</script>
<style scoped lang="scss">
.file-select-row {
display: flex;
width: 100%;
gap: 12px;
}
.hidden-file-input {
display: none;
}
</style>

View File

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

View File

@@ -1,5 +1,5 @@
<template> <template>
<section class="mapping-panel result-panel"> <section class="result-panel">
<div class="panel-header"> <div class="panel-header">
<div class="result-view-tabs"> <div class="result-view-tabs">
<button <button
@@ -156,11 +156,13 @@ const formatFirstValues = (values?: number[]) => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.mapping-panel { .result-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
min-width: 0; min-width: 0;
height: 100%;
min-height: 0;
padding: 24px; padding: 24px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 12px; border-radius: 12px;
@@ -218,10 +220,6 @@ const formatFirstValues = (values?: number[]) => {
content: ''; content: '';
} }
.result-panel {
min-height: 0;
}
.panel-content { .panel-content {
display: flex; display: flex;
flex: 1; flex: 1;
@@ -320,7 +318,7 @@ const formatFirstValues = (values?: number[]) => {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.mapping-panel { .result-panel {
padding: 20px; padding: 20px;
} }

View File

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

View File

@@ -7,9 +7,7 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(currentDir, '../../../..') const rootDir = path.resolve(currentDir, '../../../..')
const files = { const files = {
page: path.resolve(rootDir, 'views/tools/parsePqdif/index.vue'), page: path.resolve(rootDir, 'views/tools/parsePqdif/index.vue'),
flow: path.resolve(rootDir, 'views/tools/parsePqdif/utils/useParsePqdifFlow.ts'), formDialog: path.resolve(rootDir, 'views/tools/parsePqdif/components/PqdifPathFormDialog.vue'),
requestPanel: path.resolve(rootDir, 'views/tools/parsePqdif/components/PqdifRequestPanel.vue'),
summaryPanel: path.resolve(rootDir, 'views/tools/parsePqdif/components/PqdifSummaryPanel.vue'),
resultPanel: path.resolve(rootDir, 'views/tools/parsePqdif/components/PqdifResultPanel.vue'), resultPanel: path.resolve(rootDir, 'views/tools/parsePqdif/components/PqdifResultPanel.vue'),
api: path.resolve(rootDir, 'api/tools/parsePqdif/index.ts'), api: path.resolve(rootDir, 'api/tools/parsePqdif/index.ts'),
apiTypes: path.resolve(rootDir, 'api/tools/parsePqdif/interface/index.ts') apiTypes: path.resolve(rootDir, 'api/tools/parsePqdif/interface/index.ts')
@@ -19,39 +17,73 @@ const exists = file => fs.existsSync(file)
const read = file => (exists(file) ? fs.readFileSync(file, 'utf8') : '') const read = file => (exists(file) ? fs.readFileSync(file, 'utf8') : '')
const pageSource = read(files.page) const pageSource = read(files.page)
const flowSource = read(files.flow) const formDialogSource = read(files.formDialog)
const apiSource = read(files.api) const apiSource = read(files.api)
const apiTypesSource = read(files.apiTypes) const apiTypesSource = read(files.apiTypes)
const checks = [ const checks = [
['parsePqdif API file exists', () => exists(files.api)], ['parsePqdif API file exists', () => exists(files.api)],
['parsePqdif API type file exists', () => exists(files.apiTypes)], ['parsePqdif API type file exists', () => exists(files.apiTypes)],
['parsePqdif API uses documented endpoint', () => /\/api\/parse-pqdif\/parse/.test(apiSource)], ['parse API keeps documented endpoint', () => /\/api\/parse-pqdif\/parse/.test(apiSource)],
['parsePqdif API appends documented pqdifFile field', () => /append\('pqdifFile',\s*pqdifFile\)/.test(apiSource)], ['path API uses documented base endpoint', () => /\/api\/parse-pqdif\/pqdif-paths/.test(apiSource)],
['parsePqdif API sends multipart form data', () => /multipart\/form-data/.test(apiSource)], ['path API includes list endpoint', () => /\/list/.test(apiSource) && /listPqdifPathsApi/.test(apiSource)],
['path API includes add endpoint', () => /\/add/.test(apiSource) && /addPqdifPathApi/.test(apiSource)],
['path API includes update endpoint', () => /\/update/.test(apiSource) && /updatePqdifPathApi/.test(apiSource)],
['path API includes delete endpoint', () => /\/delete/.test(apiSource) && /deletePqdifPathsApi/.test(apiSource)],
['path API includes parse-msg endpoint', () => /parse-msg/.test(apiSource) && /getPqdifParseMsgApi/.test(apiSource)],
['path API includes parse-detail endpoint', () => /parse-detail/.test(apiSource) && /getPqdifParseDetailApi/.test(apiSource)],
['path API includes parse-result endpoint', () => /parse-result/.test(apiSource) && /savePqdifParseResultApi/.test(apiSource)],
['multipart upload appends documented pqdifFile field', () => /append\('pqdifFile',\s*pqdifFile\)/.test(apiSource)],
['multipart upload appends documented request JSON part', () => /append\('request'/.test(apiSource) && /application\/json/.test(apiSource)],
['parsePqdif types include response status', () => /export\s+type\s+ParseStatus/.test(apiTypesSource)], ['parsePqdif types include response status', () => /export\s+type\s+ParseStatus/.test(apiTypesSource)],
['parsePqdif types include observations', () => /interface\s+ObservationItem/.test(apiTypesSource)], ['parsePqdif types include stored path record', () => /interface\s+PqdifPathRecord/.test(apiTypesSource)],
['parsePqdif flow exists', () => exists(files.flow)], ['parsePqdif types include parse detail', () => /interface\s+PqdifParseDetail/.test(apiTypesSource)],
['parsePqdif flow imports API', () => /parsePqdifApi/.test(flowSource)], ['stored path record includes documented filePath', () => /interface\s+PqdifPathRecord[\s\S]*filePath\?:\s*string/.test(apiTypesSource)],
['parsePqdif flow validates .pqd and .pqdif suffixes', () => /SUPPORTED_PQDIF_EXTENSIONS/.test(flowSource)], ['parse detail uses documented filePath field', () => /interface\s+PqdifParseDetail[\s\S]*filePath\?:\s*string/.test(apiTypesSource)],
['request panel exists', () => exists(files.requestPanel)], ['save parse result request excludes undocumented jsonStr field', () => !/interface\s+SaveParseResultParams[\s\S]*jsonStr\?:/.test(apiTypesSource)],
['summary panel exists', () => exists(files.summaryPanel)], ['save parse result request excludes undocumented nativeVersion field', () => !/interface\s+SaveParseResultParams[\s\S]*nativeVersion\?:/.test(apiTypesSource)],
['result panel exists', () => exists(files.resultPanel)],
['page uses mmsMapping-style parse layout', () => /parse-pqdif-grid/.test(pageSource)],
['request panel reuses mapping panel class', () => /class="mapping-panel/.test(read(files.requestPanel))],
['request panel keeps compact action section', () => /panel-section file-action-row/.test(read(files.requestPanel))],
['summary panel exposes metric cards', () => /summary-metric-list/.test(read(files.summaryPanel))],
['result panel exposes records and observations tabs', () => /result-view-tabs/.test(read(files.resultPanel))],
['result panel highlights failed series rows', () => /DATA_FAILED/.test(read(files.resultPanel))],
[
'page imports parsePqdif components',
() =>
/PqdifRequestPanel/.test(pageSource) &&
/PqdifSummaryPanel/.test(pageSource) &&
/PqdifResultPanel/.test(pageSource)
],
['page keeps route component name', () => /name:\s*'ParsePqdifView'/.test(pageSource)], ['page keeps route component name', () => /name:\s*'ParsePqdifView'/.test(pageSource)],
['page uses parsePqdif flow composable', () => /useParsePqdifFlow/.test(pageSource)] ['page imports shared ProTable', () => /import\s+ProTable\s+from\s+'@\/components\/ProTable\/index\.vue'/.test(pageSource)],
['page uses ProTable request API', () => /<ProTable[\s\S]*ref="proTable"[\s\S]*:request-api="getTableList"/.test(pageSource)],
['page keeps default refresh and column setting tools', () => /:tool-button="\['refresh', 'setting'\]"/.test(pageSource)],
['page exposes create and batch delete actions', () => /openCreateDialog/.test(pageSource) && /handleBatchDelete/.test(pageSource)],
[
'page table header exports selected PQDIF records as JSON',
() => /<template\s+#tableHeader="scope">[\s\S]*handleExportSelectedJson\(scope\.selectedList\)/.test(pageSource)
],
[
'PQDIF JSON export reads saved parse msg before download',
() => /getPqdifParseMsgApi/.test(pageSource) && /parseResult/.test(pageSource) && /downloadTextFile/.test(pageSource)
],
[
'PQDIF JSON export downloads json file',
() => /handleExportSelectedJson[\s\S]*\.json[\s\S]*application\/json;charset=utf-8/.test(pageSource)
],
['page imports parse API for create flow', () => /parsePqdifApi/.test(pageSource)],
[
'page create flow parses uploaded file before saving documented parse result',
() =>
/parsePqdifApi\(file\)/.test(pageSource) &&
/savePqdifParseResultApi\([^)]*buildSaveParseResultPayload\(parseResult\)/.test(pageSource)
],
['page stores full parse structure under documented msg object', () => /msg:\s*\{[\s\S]*parseResult[\s\S]*\}/.test(pageSource)],
[
'page shows PQDIF storage path immediately after name column',
() =>
/prop:\s*'name'[\s\S]*label:\s*'PQDIF名称'[\s\S]*prop:\s*'filePath'[\s\S]*label:\s*'PQDIF存储路径'/.test(pageSource) &&
!/\{[^{}]*prop:\s*'filePath'[^{}]*isShow:\s*false[^{}]*\}/.test(pageSource)
],
['page hides row detail and summary actions', () => !/openDetailDialog/.test(pageSource) && !/openMsgDialog/.test(pageSource)],
['page exposes row edit and delete actions', () => /openEditDialog/.test(pageSource) && /handleDeletePqdifPath/.test(pageSource)],
['page adapts array list API to ProTable page structure', () => /records:\s*records\.slice/.test(pageSource) && /total:\s*records\.length/.test(pageSource)],
['form dialog exists', () => exists(files.formDialog)],
['form dialog validates .pqd and .pqdif suffixes', () => /SUPPORTED_PQDIF_EXTENSIONS/.test(formDialogSource)],
['form dialog requires file when creating record', () => /props\.mode\s*===\s*'create'[\s\S]*!selectedFile\.value/.test(formDialogSource)],
['form dialog backfills current record name while editing', () => /formModel\.name\s*=\s*props\.record\?\.name\s*\|\|\s*''/.test(formDialogSource)],
['form dialog refreshes when current edit record changes', () => /props\.record\?\.id/.test(formDialogSource) && /props\.record\?\.name/.test(formDialogSource)],
['form dialog emits payload and file', () => /event:\s*'submit'/.test(formDialogSource) && /file:\s*File\s*\|\s*null/.test(formDialogSource)],
['result panel still exposes records and observations tabs', () => /result-view-tabs/.test(read(files.resultPanel))],
['result panel highlights failed series rows', () => /DATA_FAILED/.test(read(files.resultPanel))]
] ]
const failures = checks.filter(([, check]) => !check()).map(([name]) => name) const failures = checks.filter(([, check]) => !check()).map(([name]) => name)

View File

@@ -1,58 +1,359 @@
<template> <template>
<div class="table-box parse-pqdif-page"> <div class="table-box parse-pqdif-page">
<section class="parse-pqdif-grid"> <ProTable
<PqdifRequestPanel ref="proTable"
:selected-file-name="selectedFileName" :columns="columns"
:selected-file-size-text="selectedFileSizeText" :request-api="getTableList"
:is-parsing="isParsing" :request-error="handleTableRequestError"
:can-parse="canParse" :search-col="{ xs: 1, sm: 2, md: 2, lg: 4, xl: 4 }"
:can-reset="canReset" :tool-button="['refresh', 'setting']"
:request-status-text="requestStatusText" row-key="id"
:request-status-tag-type="requestStatusTagType" >
@file-change="handleFileChange" <template #tableHeader="scope">
@parse="parseSelectedFile" <el-button type="primary" :icon="Plus" @click="openCreateDialog">新增</el-button>
@reset="resetPage" <el-button
/> type="danger"
plain
:icon="Delete"
:disabled="!scope.isSelected"
@click="handleBatchDelete(scope.selectedListIds)"
>
批量删除
</el-button>
<el-button
type="primary"
plain
:icon="Download"
:disabled="!scope.isSelected"
@click="handleExportSelectedJson(scope.selectedList)"
>
导出JSON
</el-button>
</template>
<template #operation="{ row }">
<el-button link type="primary" :icon="Edit" @click="openEditDialog(row)">编辑</el-button>
<el-button link type="danger" :icon="Delete" @click="handleDeletePqdifPath(row)">删除</el-button>
</template>
</ProTable>
<PqdifSummaryPanel :result="parseResult" :is-business-failed="isBusinessFailed" /> <PqdifPathFormDialog
v-model:visible="formDialogVisible"
<PqdifResultPanel :mode="formMode"
v-model:active-tab="activeResultTab" :record="currentFormRecord"
:records="records" :saving="formSaving"
:observations="observations" @submit="handleSavePqdifPath"
class="parse-pqdif-result" />
/>
</section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="tsx">
import PqdifRequestPanel from './components/PqdifRequestPanel.vue' import { Delete, Download, Edit, Plus } from '@element-plus/icons-vue'
import PqdifResultPanel from './components/PqdifResultPanel.vue' import { ElMessage, ElMessageBox, type TagProps } from 'element-plus'
import PqdifSummaryPanel from './components/PqdifSummaryPanel.vue' import { computed, ref } from 'vue'
import { useParsePqdifFlow } from './utils/useParsePqdifFlow' import type { ResultData } from '@/api/interface'
import ProTable from '@/components/ProTable/index.vue'
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
import {
addPqdifPathApi,
deletePqdifPathsApi,
getPqdifParseMsgApi,
listPqdifPathsApi,
parsePqdifApi,
savePqdifParseResultApi,
updatePqdifPathApi
} from '@/api/tools/parsePqdif'
import type { ParsePqdif } from '@/api/tools/parsePqdif/interface'
import PqdifPathFormDialog from './components/PqdifPathFormDialog.vue'
defineOptions({ defineOptions({
name: 'ParsePqdifView' name: 'ParsePqdifView'
}) })
const { type TagType = TagProps['type']
selectedFileName,
selectedFileSizeText, const proTable = ref<ProTableInstance>()
parseResult, const formDialogVisible = ref(false)
records, const formSaving = ref(false)
observations, const formMode = ref<'create' | 'edit'>('create')
isParsing, const currentFormRecord = ref<ParsePqdif.PqdifPathRecord | null>(null)
isBusinessFailed,
activeResultTab, const resultOptions = [
requestStatusText, { label: '成功', value: 1, tagType: 'success' },
requestStatusTagType, { label: '失败', value: 0, tagType: 'danger' }
canParse, ]
canReset,
handleFileChange, function unwrapApiPayload<T>(response: ResultData<T> | T): T {
parseSelectedFile, if (response && typeof response === 'object' && 'data' in response) {
resetPage return (response as ResultData<T>).data
} = useParsePqdifFlow() }
return response as T
}
const getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message) return error.message
if (error && typeof error === 'object' && 'message' in error) return String((error as { message?: unknown }).message || fallback)
return fallback
}
const getResultText = (result?: number) => {
if (result === 1) return '成功'
if (result === 0) return '失败'
return '-'
}
const getResultTagType = (result?: number): TagType => {
if (result === 1) return 'success'
if (result === 0) return 'danger'
return 'info'
}
const buildSaveParseResultPayload = (parseResult: ParsePqdif.ParseResponse): ParsePqdif.SaveParseResultParams => ({
recordCount: parseResult.recordCount,
observationCount: parseResult.observationCount,
sampleValueCount: parseResult.sampleValueCount,
result: parseResult.status === 'FAILED' ? 0 : 1,
// 关键业务节点2026-06-22 接口只接收 msg 对象,完整解析结构放入 msg.parseResult 供详情页回显。
msg: {
summary: parseResult.message || (parseResult.status === 'FAILED' ? 'PQDIF解析失败' : 'PQDIF解析完成'),
status: parseResult.status,
fileName: parseResult.fileName,
nativeVersion: parseResult.nativeVersion,
parseResult
}
})
const getParseResultFromMsg = (msg: ParsePqdif.PqdifParseMsg): unknown => {
if (!msg || typeof msg !== 'object') return null
return 'parseResult' in msg ? msg.parseResult : null
}
const buildPqdifPathJsonRecord = (row: ParsePqdif.PqdifPathRecord, msg: ParsePqdif.PqdifParseMsg) => ({
id: row.id,
name: row.name,
filePath: row.filePath,
recordCount: row.recordCount,
observationCount: row.observationCount,
sampleValueCount: row.sampleValueCount,
state: row.state,
result: row.result,
msg,
parseResult: getParseResultFromMsg(msg),
createBy: row.createBy,
createTime: row.createTime,
updateBy: row.updateBy,
updateTime: row.updateTime
})
const downloadTextFile = (content: string, fileName: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType })
const objectUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = objectUrl
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(objectUrl)
}
const fetchPqdifPathRecords = async (params: ParsePqdif.PqdifPathListParams = {}) => {
const response = await listPqdifPathsApi(params)
return unwrapApiPayload<ParsePqdif.PqdifPathRecord[]>(response) || []
}
const resolveCreatedPqdifRecord = (
beforeRecords: ParsePqdif.PqdifPathRecord[],
afterRecords: ParsePqdif.PqdifPathRecord[],
recordName: string
) => {
const beforeIds = new Set(beforeRecords.map(record => record.id).filter(Boolean))
return (
afterRecords.find(record => record.id && !beforeIds.has(record.id) && record.name === recordName) ||
afterRecords.find(record => record.id && record.name === recordName)
)
}
const columns = computed<ColumnProps<ParsePqdif.PqdifPathRecord>[]>(() => [
{ type: 'selection', fixed: 'left', width: 70 },
{
prop: 'name',
label: 'PQDIF名称',
minWidth: 220,
search: {
el: 'input',
label: 'PQDIF名称',
order: 8
}
},
{ prop: 'filePath', label: 'PQDIF存储路径', minWidth: 260, showOverflowTooltip: true },
{
prop: 'result',
label: '解析结果',
width: 120,
render: scope => (
<el-tag type={getResultTagType(scope.row.result)} effect="light">
{getResultText(scope.row.result)}
</el-tag>
),
enum: resultOptions,
isFilterEnum: false,
search: {
el: 'select',
label: '解析结果',
order: 7
}
},
{ prop: 'recordCount', label: 'Record', width: 110 },
{ prop: 'observationCount', label: 'Observation', width: 130 },
{ prop: 'sampleValueCount', label: '采样上限', width: 120 },
{ prop: 'createBy', label: '创建人', width: 120, isShow: false },
{ prop: 'createTime', label: '创建时间', minWidth: 170 },
{ prop: 'updateTime', label: '更新时间', minWidth: 170, isShow: false },
{ prop: 'operation', label: '操作', fixed: 'right', width: 150 }
])
const getTableList = async (params: ParsePqdif.PqdifPathListParams = {}) => {
const records = await fetchPqdifPathRecords(params)
const pageNum = params.pageNum || 1
const pageSize = params.pageSize || 10
const startIndex = (pageNum - 1) * pageSize
// 关键业务节点:后端当前返回记录数组,这里适配 ProTable 分页结构以复用统一表格交互。
return {
data: {
records: records.slice(startIndex, startIndex + pageSize),
total: records.length,
current: pageNum,
size: pageSize
}
}
}
const refreshPqdifPaths = () => {
proTable.value?.clearSelection()
proTable.value?.getTableList()
}
const handleTableRequestError = (error: unknown) => {
ElMessage.error(getErrorMessage(error, 'PQDIF记录列表查询失败'))
}
const openCreateDialog = () => {
formMode.value = 'create'
currentFormRecord.value = null
formDialogVisible.value = true
}
const openEditDialog = (row: ParsePqdif.PqdifPathRecord) => {
formMode.value = 'edit'
currentFormRecord.value = row
formDialogVisible.value = true
}
const handleSavePqdifPath = async ({ payload, file }: { payload: ParsePqdif.PqdifPathSaveParams; file: File | null }) => {
formSaving.value = true
try {
if (formMode.value === 'create') {
if (!file) {
ElMessage.warning('新增PQDIF记录必须选择文件')
return
}
const beforeRecords = await fetchPqdifPathRecords()
await addPqdifPathApi(payload, file)
// 关键业务节点:新增记录后立即解析同一文件,并把解析摘要与完整 JSON 写回新记录。
const parseResponse = await parsePqdifApi(file)
const parseResult = unwrapApiPayload<ParsePqdif.ParseResponse>(parseResponse)
const afterRecords = await fetchPqdifPathRecords()
const createdRecord = resolveCreatedPqdifRecord(beforeRecords, afterRecords, payload.name)
if (!createdRecord?.id) {
throw new Error('PQDIF记录新增成功但未能定位新记录解析结果未保存')
}
await savePqdifParseResultApi(createdRecord.id, buildSaveParseResultPayload(parseResult))
ElMessage.success('PQDIF记录新增、解析并保存成功')
} else {
await updatePqdifPathApi(payload, file)
ElMessage.success('PQDIF记录编辑成功')
}
formDialogVisible.value = false
refreshPqdifPaths()
} catch (error) {
ElMessage.error(getErrorMessage(error, 'PQDIF记录保存失败'))
} finally {
formSaving.value = false
}
}
const deletePqdifPaths = async (ids: string[], successMessage: string) => {
if (!ids.length) {
ElMessage.warning('请选择要删除的PQDIF记录')
return
}
try {
await ElMessageBox.confirm('删除后PQDIF记录将被置为无效状态是否继续', '删除PQDIF记录', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
try {
await deletePqdifPathsApi(ids)
ElMessage.success(successMessage)
refreshPqdifPaths()
} catch (error) {
ElMessage.error(getErrorMessage(error, 'PQDIF记录删除失败'))
}
}
const handleDeletePqdifPath = async (row: ParsePqdif.PqdifPathRecord) => {
if (!row.id) {
ElMessage.warning('当前PQDIF记录缺少 ID不能删除')
return
}
await deletePqdifPaths([row.id], 'PQDIF记录删除成功')
}
const handleBatchDelete = async (ids: string[]) => {
await deletePqdifPaths(ids, 'PQDIF记录批量删除成功')
}
const handleExportSelectedJson = async (records: ParsePqdif.PqdifPathRecord[]) => {
if (!records.length) {
ElMessage.warning('请选择要导出的 PQDIF 记录')
return
}
try {
const exportRecords = await Promise.all(
records.map(async row => {
const msg = row.id ? unwrapApiPayload<ParsePqdif.PqdifParseMsg>(await getPqdifParseMsgApi(row.id)) : null
return buildPqdifPathJsonRecord(row, msg)
})
)
const json = JSON.stringify(exportRecords, null, 4)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
downloadTextFile(json, `pqdif-path-${timestamp}.json`, 'application/json;charset=utf-8')
ElMessage.success('JSON 已导出')
} catch (error) {
ElMessage.error(getErrorMessage(error, 'PQDIF 记录 JSON 导出失败'))
}
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -62,23 +363,14 @@ const {
overflow: hidden; overflow: hidden;
} }
.parse-pqdif-grid { :deep(.pqdif-path-form-dialog .el-dialog__header) {
display: grid; margin-right: 0;
grid-template-columns: minmax(0, 1fr) 420px; padding: 12px 16px;
gap: 12px; background: #536fe5;
height: 100%;
min-height: 0;
} }
.parse-pqdif-result { :deep(.pqdif-path-form-dialog .el-dialog__title),
grid-column: 1 / -1; :deep(.pqdif-path-form-dialog .el-dialog__headerbtn .el-dialog__close) {
min-height: 0; color: #ffffff;
}
@media (max-width: 1200px) {
.parse-pqdif-grid {
grid-template-columns: 1fr;
overflow-y: auto;
}
} }
</style> </style>

View File

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