refactor(steady-checksquare): 更新稳态校验功能的数据结构和接口规范

- 移除 lineId 字段,统一使用 lineIds 数组字段
- 更新创建任务请求体构建逻辑,不再包含 lineId 属性
- 修改任务详情和检测项明细的接口契约验证
- 更新稳态校验 API 文档说明,移除对 lineId 的支持
- 重构稳态校验表单项验证规则
- 移除磁盘监控作业详情抽屉组件
- 移除磁盘监控作业表格组件
- 更新路由配置以支持新的系统监控路径
- 添加测试报告相关字典代码常量
- 更新 ICD 路径表单和结果面板功能
This commit is contained in:
2026-07-01 13:18:17 +08:00
parent a6228faaba
commit 7c8e7d1b7e
74 changed files with 5838 additions and 105 deletions

View File

@@ -21,6 +21,7 @@
"axios": "^1.7.3",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.9",
"docx-preview": "^0.3.7",
"driver.js": "^1.3.0",
"echarts": "^5.4.3",
"echarts-gl": "^2.0.9",

View File

@@ -0,0 +1,48 @@
import http from '@/api'
import type { ClientUnit } from './interface'
const buildClientUnitImportFormData = (file: File) => {
const formData = new FormData()
formData.append('file', file)
return formData
}
export const listClientUnitsApi = (params: ClientUnit.ClientUnitListParams) => {
return http.post('/clientUnit/list', params)
}
export const createClientUnitApi = (params: ClientUnit.ClientUnitSaveRequest) => {
return http.post<boolean>('/clientUnit/add', params)
}
export const updateClientUnitApi = (params: ClientUnit.ClientUnitSaveRequest) => {
return http.post<boolean>('/clientUnit/update', params)
}
export const getClientUnitDetailApi = (id: string) => {
return http.get<ClientUnit.ClientUnitRecord>('/clientUnit/getById', { id })
}
export const deleteClientUnitsApi = (ids: string[]) => {
return http.post<boolean>('/clientUnit/delete', ids)
}
export const exportClientUnitsApi = (params: ClientUnit.ClientUnitListParams) => {
return http.post('/clientUnit/export', params, { responseType: 'blob' })
}
export const downloadClientUnitImportTemplateApi = () => {
return http.get('/clientUnit/importTemplate', undefined, { responseType: 'blob' })
}
export const importClientUnitsApi = (file: File) => {
return http.post<ClientUnit.ClientUnitImportResult>(
'/clientUnit/import',
buildClientUnitImportFormData(file),
{
headers: { 'Content-Type': 'multipart/form-data' }
}
)
}

View File

@@ -0,0 +1,36 @@
export namespace ClientUnit {
export interface ClientUnitRecord {
id?: string
name?: string | null
contactName?: string | null
phonenumber?: string | null
address?: string | null
createBy?: string | null
createTime?: string | null
updateBy?: string | null
updateTime?: string | null
}
export interface ClientUnitListParams {
name?: string
searchBeginTime?: string
searchEndTime?: string
timeRange?: string[]
pageNum?: number
pageSize?: number
}
export interface ClientUnitSaveRequest {
id?: string
name: string
contactName?: string | null
phonenumber?: string | null
address?: string | null
}
export interface ClientUnitImportResult {
successCount?: number
failCount?: number
failReasons?: string[]
}
}

View File

@@ -0,0 +1,16 @@
import http from '@/api'
import type { ReportApproval } from './interface'
const REPORT_APPROVAL_BASE_URL = '/api/report-approval'
export const listPendingReportApprovalsApi = (params: ReportApproval.PendingListParams) =>
http.get(`${REPORT_APPROVAL_BASE_URL}/pending-page`, params)
export const passReportApprovalApi = (params: ReportApproval.ApprovalActionRequest) =>
http.post<boolean>(`${REPORT_APPROVAL_BASE_URL}/pass`, params)
export const rejectReportApprovalApi = (params: ReportApproval.ApprovalActionRequest) =>
http.post<boolean>(`${REPORT_APPROVAL_BASE_URL}/reject`, params)
export const listReportApprovalLogsApi = (params: ReportApproval.LogListParams) =>
http.get(`${REPORT_APPROVAL_BASE_URL}/log-page`, params)

View File

@@ -0,0 +1,46 @@
export namespace ReportApproval {
export type ReportType = 'REPORT_MODEL' | 'TEST_REPORT' | (string & {})
export type ReportState = '01' | '02' | '03' | '04' | '05' | (string & {})
export type ApprovalResult = '03' | '04' | (string & {})
export interface PendingRecord {
reportType?: ReportType
reportId?: string
reportName?: string | null
state?: ReportState
submitterId?: string | null
submitterName?: string | null
submitTime?: string | null
}
export interface PendingListParams {
current?: number
size?: number
reportType?: ReportType | ''
}
export interface ApprovalActionRequest {
reportType: ReportType
reportId: string
approvalReason: string
}
export interface LogRecord {
id?: string
reportType?: ReportType
reportId?: string
reportName?: string | null
approvalResult?: ApprovalResult
approvalReason?: string | null
beforeState?: ReportState
afterState?: ReportState
approverName?: string | null
approvalTime?: string | null
}
export interface LogListParams {
current?: number
size?: number
reportType?: ReportType | ''
}
}

View File

@@ -0,0 +1,49 @@
import http from '@/api'
import type { AxiosResponse } from 'axios'
import type { ReportModel } from './interface'
const REPORT_MODEL_BASE_URL = '/api/report-model'
const buildReportModelFormData = (request: ReportModel.ReportModelSaveRequest, templateFile?: File | null) => {
const formData = new FormData()
// 关键业务节点:后端按 @RequestPart("request") 读取 JSON 分段,字段名不能改成普通表单字段。
formData.append('request', new Blob([JSON.stringify(request)], { type: 'application/json' }))
if (templateFile) {
formData.append('templateFile', templateFile)
}
return formData
}
export const listReportModelsApi = (params: ReportModel.ReportModelListParams) => {
return http.post('/api/report-model/list', params)
}
export const getReportModelDetailApi = (id: string) => {
return http.get<ReportModel.ReportModelRecord>(`${REPORT_MODEL_BASE_URL}/${id}`)
}
export const createReportModelApi = (request: ReportModel.ReportModelSaveRequest, templateFile: File) => {
return http.post<boolean>(`${REPORT_MODEL_BASE_URL}/add`, buildReportModelFormData(request, templateFile), {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export const updateReportModelApi = (request: ReportModel.ReportModelSaveRequest, templateFile?: File | null) => {
return http.post<boolean>(`${REPORT_MODEL_BASE_URL}/update`, buildReportModelFormData(request, templateFile), {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export const deleteReportModelsApi = (ids: string[]) => {
return http.post<boolean>(`${REPORT_MODEL_BASE_URL}/delete`, ids)
}
export const downloadReportModelApi = (id: string): Promise<AxiosResponse<Blob>> =>
http.get(`${REPORT_MODEL_BASE_URL}/${id}/download`, undefined, { responseType: 'blob' }) as unknown as Promise<AxiosResponse<Blob>>
export const submitReportModelAuditApi = (params: ReportModel.ReportModelAuditRequest) => {
return http.post<boolean>(`${REPORT_MODEL_BASE_URL}/submit-audit`, params)
}

View File

@@ -0,0 +1,43 @@
export namespace ReportModel {
export type ReportModelState = '01' | '02' | '03' | '04' | (string & {})
export interface ReportModelRecord {
id?: string
name?: string
state?: ReportModelState
checkId?: string | null
checkerName?: string | null
checkTime?: string | null
checkResult?: string | null
path?: string | null
fileName?: string | null
file_name?: string | null
status?: number
createBy?: string | null
createTime?: string | null
updateBy?: string | null
editorName?: string | null
updateTime?: string | null
}
export interface ReportModelListParams {
name?: string
state?: ReportModelState | ''
searchBeginTime?: string
searchEndTime?: string
timeRange?: string[]
pageNum?: number
pageSize?: number
}
export interface ReportModelSaveRequest {
id?: string
name: string
fileName?: string
}
export interface ReportModelAuditRequest {
id: string
checkResult?: string
}
}

View File

@@ -0,0 +1,77 @@
import http from '@/api'
import type { TestDevice } from './interface'
const TEST_DEVICE_BASE_URL = '/api/test-device'
const buildTestDeviceFormData = (request: TestDevice.TestDeviceSaveRequest, reportFile?: File | null) => {
const formData = new FormData()
// 关键业务节点:检测设备新增/编辑接口按 request JSON 分段读取业务字段,报告文件单独传 reportFile。
formData.append('request', new Blob([JSON.stringify(request)], { type: 'application/json' }))
if (reportFile) {
formData.append('reportFile', reportFile)
}
return formData
}
const buildTestDeviceImportFormData = (file: File, reportZip: File) => {
const formData = new FormData()
formData.append('file', file)
formData.append('reportZip', reportZip)
return formData
}
export const listTestDevicesApi = (params: TestDevice.TestDeviceListParams) => {
return http.post(`${TEST_DEVICE_BASE_URL}/list`, params)
}
export const createTestDeviceApi = (request: TestDevice.TestDeviceSaveRequest, reportFile: File) => {
return http.post<boolean>(`${TEST_DEVICE_BASE_URL}/add`, buildTestDeviceFormData(request, reportFile), {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export const updateTestDeviceApi = (request: TestDevice.TestDeviceSaveRequest, reportFile?: File | null) => {
return http.post<boolean>(`${TEST_DEVICE_BASE_URL}/update`, buildTestDeviceFormData(request, reportFile), {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export const deleteTestDevicesApi = (ids: string[]) => {
return http.post<boolean>(`${TEST_DEVICE_BASE_URL}/delete`, ids)
}
export const getTestDeviceDetailApi = (id: string) => {
return http.get<TestDevice.TestDeviceRecord>(`${TEST_DEVICE_BASE_URL}/${id}`)
}
export const downloadTestDeviceImportTemplateApi = () => {
return http.get(`${TEST_DEVICE_BASE_URL}/template`, undefined, { responseType: 'blob' })
}
export const importTestDevicesApi = (file: File, reportZip: File) => {
return http.post<TestDevice.TestDeviceImportResult>(
`${TEST_DEVICE_BASE_URL}/import`,
buildTestDeviceImportFormData(file, reportZip),
{
headers: { 'Content-Type': 'multipart/form-data' }
}
)
}
export const exportTestDevicesApi = (params: TestDevice.TestDeviceListParams) => {
return http.get(`${TEST_DEVICE_BASE_URL}/export`, params, { responseType: 'blob' })
}
export const previewTestDeviceReportApi = (id: string) => {
// 关键业务节点test-device 当前仅提供 /{id}/report预览与下载统一复用同一报告流避免前端误调不存在的 preview 接口。
return http.get(`${TEST_DEVICE_BASE_URL}/${id}/report`, undefined, { responseType: 'blob' })
}
export const downloadTestDeviceReportApi = (id: string) => {
return http.get(`${TEST_DEVICE_BASE_URL}/${id}/report`, undefined, { responseType: 'blob' })
}

View File

@@ -0,0 +1,45 @@
export namespace TestDevice {
export type TestDeviceState = '01' | '02' | (string & {})
export interface TestDeviceRecord {
id?: string
type?: string | null
typeName?: string | null
no?: string | null
validityPeriod?: string | null
validityReport?: string | null
validityReportName?: string | null
validityReportUrl?: string | null
state?: TestDeviceState
status?: number
createBy?: string | null
createByName?: string | null
createTime?: string | null
updateBy?: string | null
updateByName?: string | null
updateTime?: string | null
}
export interface TestDeviceListParams {
keyword?: string
searchBeginTime?: string
searchEndTime?: string
timeRange?: string[]
pageNum?: number
pageSize?: number
}
export interface TestDeviceSaveRequest {
id?: string
type: string
no: string
validityPeriod: string
state?: TestDeviceState
}
export interface TestDeviceImportResult {
successCount?: number
failCount?: number
failReasons?: string[]
}
}

View File

@@ -0,0 +1,32 @@
import http from '@/api'
import type { AxiosResponse } from 'axios'
import type { ReportTask } from './interface'
const REPORT_TASK_BASE_URL = '/api/test-report'
export const listReportTasksApi = (params: ReportTask.ReportTaskListParams) => {
return http.post(`${REPORT_TASK_BASE_URL}/list`, params)
}
export const createReportTaskApi = (params: ReportTask.ReportTaskSaveRequest) => {
return http.post<boolean>(`${REPORT_TASK_BASE_URL}/add`, params)
}
export const updateReportTaskApi = (params: ReportTask.ReportTaskSaveRequest) => {
return http.post<boolean>(`${REPORT_TASK_BASE_URL}/update`, params)
}
export const deleteReportTasksApi = (ids: string[]) => {
return http.post<boolean>(`${REPORT_TASK_BASE_URL}/delete`, ids)
}
export const getReportTaskDetailApi = (id: string) => {
return http.get<ReportTask.ReportTaskRecord>(`${REPORT_TASK_BASE_URL}/${id}`)
}
export const exportReportTasksApi = (params: ReportTask.ReportTaskListParams): Promise<AxiosResponse<Blob>> =>
http.get(`${REPORT_TASK_BASE_URL}/export`, params, { responseType: 'blob' }) as unknown as Promise<AxiosResponse<Blob>>
export const submitReportTaskAuditApi = (params: ReportTask.ReportTaskAuditRequest) => {
return http.post<boolean>(`${REPORT_TASK_BASE_URL}/submit-audit`, params)
}

View File

@@ -0,0 +1,52 @@
export namespace ReportTask {
export interface ReportTaskRecord {
id?: string
no?: string | null
clientUnitId?: string | null
clientUnitName?: string | null
reportModelId?: string | null
reportModelName?: string | null
createUnit?: string | null
contractNumber?: string | null
standard?: string[] | string | null
data?: string | null
phonenumber?: string | null
checkId?: string | null
checkerName?: string | null
checkTime?: string | null
checkResult?: string | null
status?: number
createBy?: string | null
createByName?: string | null
createTime?: string | null
updateBy?: string | null
updateByName?: string | null
updateTime?: string | null
}
export interface ReportTaskListParams {
keyword?: string
searchBeginTime?: string
searchEndTime?: string
timeRange?: string[]
pageNum?: number
pageSize?: number
}
export interface ReportTaskSaveRequest {
id?: string
no: string
clientUnitId: string
reportModelId: string
createUnit: string
contractNumber: string
standard: string[]
data: string
phonenumber?: string
}
export interface ReportTaskAuditRequest {
id: string
checkResult?: string
}
}

View File

@@ -97,8 +97,7 @@ export namespace SteadyDataView {
}
export interface SteadyChecksquareCreateParams {
lineId?: string
lineIds?: string[]
lineIds: string[]
indicatorCodes: string[]
timeStart: string
timeEnd: string

View File

@@ -1,6 +1,8 @@
export const DICT_CODES = {
USER_STATE: 'state',
EVENT_TYPE: 'event_type',
TEST_REPORT_COMPANY: 'test_report_company',
TEST_REPORT_STANDARD: 'test_report_standard',
LEDGER_DEVICE_TYPE: 'ledger_device_type',
LEDGER_DEVICE_MODEL: 'Ex-factory_Dev_Type',
LEDGER_TERMINAL_MODEL: 'Dev_Type',

View File

@@ -17,8 +17,8 @@ const routeContracts = [
['toolAddData', 'src/views/tools/addData/index.vue'],
['toolAddLedger', 'src/views/tools/addLedger/index.vue'],
['eventList', 'src/views/event/eventList/index.vue'],
['systemMonitor', 'src/views/systemMonitor/index.vue'],
['diskMonitor', 'src/views/systemMonitor/diskMonitor/index.vue']
['system-monitor', 'src/views/system-monitor/index.vue'],
['diskMonitor', 'src/views/system-monitor/diskMonitor/index.vue']
]
const extractRouteBlock = routeName => {

View File

@@ -9,6 +9,12 @@ const staticRouterSource = read('routers/modules/staticRouter.ts')
const authStoreSource = read('stores/modules/auth.ts')
const expectedPaths = [
'/system-monitor/dbms',
'/system-monitor/dbms/index',
'/system-monitor/databaseMonitor',
'/system-monitor/databaseMonitor/index',
'/system-monitor/database-monitor',
'/system-monitor/database-monitor/index',
'/systemMonitor/dbms',
'/systemMonitor/dbms/index',
'/systemMonitor/databaseMonitor',

View File

@@ -27,6 +27,12 @@ const COMPONENT_PATH_ALIASES: Record<string, string> = {
'/steady/check-square': '/steady/checksquare',
'/steady/check-square/index': '/steady/checksquare/index',
// 数据库监控菜单统一落到 system-ops/dbms 页面,兼容后端菜单常见 component 写法。
'/system-monitor/dbms': '/system-ops/dbms',
'/system-monitor/dbms/index': '/system-ops/dbms/index',
'/system-monitor/databaseMonitor': '/system-ops/dbms',
'/system-monitor/databaseMonitor/index': '/system-ops/dbms/index',
'/system-monitor/database-monitor': '/system-ops/dbms',
'/system-monitor/database-monitor/index': '/system-ops/dbms/index',
'/systemMonitor/dbms': '/system-ops/dbms',
'/systemMonitor/dbms/index': '/system-ops/dbms/index',
'/systemMonitor/databaseMonitor': '/system-ops/dbms',
@@ -34,7 +40,20 @@ const COMPONENT_PATH_ALIASES: Record<string, string> = {
'/systemMonitor/database-monitor': '/system-ops/dbms',
'/systemMonitor/database-monitor/index': '/system-ops/dbms/index',
'/system-ops/database-monitor': '/system-ops/dbms',
'/system-ops/database-monitor/index': '/system-ops/dbms/index'
'/system-ops/database-monitor/index': '/system-ops/dbms/index',
// 报告菜单后端使用 aiReport前端目录沿用既有 aireport 命名。
'/aiReport/reportTask': '/aireport/testReport',
'/aiReport/reportTask/index': '/aireport/testReport/index',
'/aiReport/testReport': '/aireport/testReport',
'/aiReport/testReport/index': '/aireport/testReport/index',
'/aiReport/reportModel': '/aireport/reportModel',
'/aiReport/reportModel/index': '/aireport/reportModel/index',
'/aiReport/clientUnit': '/aireport/clientUnit',
'/aiReport/clientUnit/index': '/aireport/clientUnit/index',
'/aiReport/testDevice': '/aireport/testDevice',
'/aiReport/testDevice/index': '/aireport/testDevice/index',
'/aiReport/reportApproval': '/aireport/reportApproval',
'/aiReport/reportApproval/index': '/aireport/reportApproval/index'
}
const STATIC_ROUTE_NAMES = new Set([
'layout',
@@ -51,6 +70,7 @@ const STATIC_ROUTE_NAMES = new Set([
'steadyDataView',
'steadyTrend',
'checksquare',
'system-monitor',
'systemMonitor',
'diskMonitor',
'systemOpsDbms',

View File

@@ -195,24 +195,26 @@ export const staticRouter: RouteRecordRaw[] = [
}
},
{
path: '/systemMonitor',
name: 'systemMonitor',
component: () => import('@/views/systemMonitor/index.vue'),
path: '/system-monitor',
name: 'system-monitor',
alias: ['/systemMonitor'],
component: () => import('@/views/system-monitor/index.vue'),
meta: {
cacheName: 'SystemMonitorPage',
title: '系统监控'
}
},
{
path: '/systemMonitor/diskMonitor',
path: '/system-monitor/diskMonitor',
name: 'diskMonitor',
component: () => import('@/views/systemMonitor/diskMonitor/index.vue'),
alias: ['/systemMonitor/diskMonitor'],
component: () => import('@/views/system-monitor/diskMonitor/index.vue'),
meta: {
cacheName: 'DiskMonitorPage',
// 磁盘监控页复用系统监控主标签
activeMenu: '/systemMonitor',
activeMenu: '/system-monitor',
hideTab: true,
parentPath: '/systemMonitor',
parentPath: '/system-monitor',
title: '磁盘监控'
}
},
@@ -220,6 +222,12 @@ export const staticRouter: RouteRecordRaw[] = [
path: '/system-ops/dbms',
name: 'systemOpsDbms',
alias: [
'/system-monitor/dbms',
'/system-monitor/dbms/index',
'/system-monitor/databaseMonitor',
'/system-monitor/databaseMonitor/index',
'/system-monitor/database-monitor',
'/system-monitor/database-monitor/index',
'/systemMonitor/dbms',
'/systemMonitor/dbms/index',
'/systemMonitor/databaseMonitor',

View File

@@ -0,0 +1,50 @@
<template>
<el-dialog v-model="visibleProxy" title="委托单位详情" width="720px" append-to-body destroy-on-close>
<div v-loading="loading" class="client-unit-detail-dialog">
<el-descriptions :column="2" border>
<el-descriptions-item label="单位名称">{{ resolveClientUnitText(detail?.name) }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ resolveClientUnitText(detail?.contactName) }}</el-descriptions-item>
<el-descriptions-item label="联系方式">{{ resolveClientUnitText(detail?.phonenumber) }}</el-descriptions-item>
<el-descriptions-item label="地址">{{ resolveClientUnitText(detail?.address) }}</el-descriptions-item>
<el-descriptions-item label="创建人">{{ resolveClientUnitText(detail?.createBy) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ resolveClientUnitText(detail?.createTime) }}</el-descriptions-item>
<el-descriptions-item label="更新人">{{ resolveClientUnitText(detail?.updateBy) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ resolveClientUnitText(detail?.updateTime) }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button type="primary" @click="visibleProxy = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ClientUnit } from '@/api/aireport/clientUnit/interface'
import { resolveClientUnitText } from '../utils/clientUnit'
defineOptions({
name: 'ClientUnitDetailDialog'
})
const props = defineProps<{
visible: boolean
loading: boolean
detail: ClientUnit.ClientUnitRecord | null
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
}>()
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
</script>
<style scoped lang="scss">
.client-unit-detail-dialog {
min-height: 180px;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<el-dialog
v-model="visibleProxy"
:title="mode === 'create' ? '新增委托单位' : '编辑委托单位'"
width="620px"
append-to-body
destroy-on-close
@closed="resetForm"
>
<el-form ref="formRef" :model="formModel" :rules="formRules" label-width="96px">
<el-form-item label="单位名称" prop="name">
<el-input v-model="formModel.name" maxlength="32" show-word-limit placeholder="请输入单位名称" />
</el-form-item>
<el-form-item label="联系人" prop="contactName">
<el-input v-model="formModel.contactName" maxlength="200" show-word-limit placeholder="请输入联系人" />
</el-form-item>
<el-form-item label="联系方式" prop="phonenumber">
<el-input v-model="formModel.phonenumber" maxlength="32" show-word-limit placeholder="请输入联系方式" />
</el-form-item>
<el-form-item label="地址" prop="address">
<el-input
v-model="formModel.address"
type="textarea"
maxlength="128"
show-word-limit
:rows="3"
placeholder="请输入地址"
/>
</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 { computed, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormItemRule, type FormRules } from 'element-plus'
import type { ClientUnit } from '@/api/aireport/clientUnit/interface'
import { sanitizeClientUnitFormText } from '../utils/clientUnit'
defineOptions({
name: 'ClientUnitFormDialog'
})
const props = defineProps<{
visible: boolean
mode: 'create' | 'edit'
record: ClientUnit.ClientUnitRecord | null
saving: boolean
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
(event: 'submit', value: ClientUnit.ClientUnitSaveRequest): void
}>()
const formRef = ref<FormInstance>()
const formModel = reactive({
id: '',
name: '',
contactName: '',
phonenumber: '',
address: ''
})
const MOBILE_PHONE_REGEXP = /^1[3-9]\d{9}$/
const validatePhoneNumber: FormItemRule['validator'] = (_rule, value, callback) => {
const normalizedValue = String(value || '').trim()
if (!normalizedValue || MOBILE_PHONE_REGEXP.test(normalizedValue)) {
callback()
return
}
callback(new Error('请输入正确的手机号'))
}
const formRules: FormRules = {
name: [{ required: true, message: '请输入单位名称', trigger: 'blur' }],
phonenumber: [{ validator: validatePhoneNumber, trigger: 'blur' }]
}
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const resetForm = () => {
formRef.value?.clearValidate()
formModel.id = ''
formModel.name = ''
formModel.contactName = ''
formModel.phonenumber = ''
formModel.address = ''
}
const fillForm = () => {
formModel.id = props.record?.id || ''
formModel.name = sanitizeClientUnitFormText(props.record?.name)
formModel.contactName = sanitizeClientUnitFormText(props.record?.contactName)
formModel.phonenumber = sanitizeClientUnitFormText(props.record?.phonenumber)
formModel.address = sanitizeClientUnitFormText(props.record?.address)
}
watch(
() => [props.visible, props.mode, props.record?.id],
([visible]) => {
if (visible) fillForm()
},
{ immediate: true }
)
const submitForm = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
if (props.mode === 'edit' && !formModel.id) {
ElMessage.warning('当前委托单位缺少 ID无法保存编辑')
return
}
emit('submit', {
id: formModel.id || undefined,
name: formModel.name.trim(),
contactName: formModel.contactName.trim() || null,
phonenumber: formModel.phonenumber.trim() || null,
address: formModel.address.trim() || null
})
}
</script>

View File

@@ -0,0 +1,113 @@
<template>
<el-dialog v-model="visibleProxy" title="导入委托单位" width="640px" append-to-body destroy-on-close @closed="resetImport">
<div class="client-unit-import-dialog">
<div class="file-select-row">
<el-input :model-value="selectedFileName" readonly placeholder="请选择 .xlsx 文件" />
<el-button type="primary" plain :icon="FolderOpened" :disabled="importing" @click="openFilePicker">
选择文件
</el-button>
<input ref="fileInputRef" class="hidden-file-input" type="file" accept=".xlsx" @change="handleFileChange" />
</div>
<el-alert
v-if="result"
class="import-result"
:title="`导入完成:成功 ${result.successCount || 0} 条,失败 ${result.failCount || 0} 条`"
:type="result.failCount ? 'warning' : 'success'"
show-icon
:closable="false"
/>
<el-table v-if="result?.failReasons?.length" class="fail-reason-table" :data="failReasonRows" border>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="reason" label="失败原因" min-width="220" show-overflow-tooltip />
</el-table>
</div>
<template #footer>
<el-button :disabled="importing" @click="visibleProxy = false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImport">开始导入</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { FolderOpened } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { ClientUnit } from '@/api/aireport/clientUnit/interface'
defineOptions({
name: 'ClientUnitImportDialog'
})
const props = defineProps<{
visible: boolean
importing: boolean
result: ClientUnit.ClientUnitImportResult | null
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
(event: 'submit', value: File): void
}>()
const fileInputRef = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const selectedFileName = computed(() => selectedFile.value?.name || '')
const failReasonRows = computed(() => (props.result?.failReasons || []).map(reason => ({ reason })))
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
selectedFile.value = file
}
const resetImport = () => {
selectedFile.value = null
}
const submitImport = () => {
if (!selectedFile.value) {
ElMessage.warning('请选择要导入的文件')
return
}
emit('submit', selectedFile.value)
}
</script>
<style scoped lang="scss">
.client-unit-import-dialog {
display: flex;
flex-direction: column;
gap: 14px;
}
.file-select-row {
display: flex;
width: 100%;
gap: 12px;
}
.hidden-file-input {
display: none;
}
.fail-reason-table {
width: 100%;
}
</style>

View File

@@ -0,0 +1,102 @@
import fs from 'fs'
import path from 'path'
const rootDir = path.resolve(process.cwd(), 'frontend/src')
const pageFile = path.resolve(rootDir, 'views/aireport/clientUnit/index.vue')
const apiFile = path.resolve(rootDir, 'api/aireport/clientUnit/index.ts')
const formDialogFile = path.resolve(rootDir, 'views/aireport/clientUnit/components/ClientUnitFormDialog.vue')
const detailDialogFile = path.resolve(rootDir, 'views/aireport/clientUnit/components/ClientUnitDetailDialog.vue')
const importDialogFile = path.resolve(rootDir, 'views/aireport/clientUnit/components/ClientUnitImportDialog.vue')
const read = file => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '')
const pageSource = read(pageFile)
const apiSource = read(apiFile)
const formDialogSource = read(formDialogFile)
const detailDialogSource = read(detailDialogFile)
const importDialogSource = read(importDialogFile)
const checks = [
['clientUnit page imports ProTable', () => /import\s+ProTable\s+from\s+'@\/components\/ProTable\/index\.vue'/.test(pageSource)],
['clientUnit page uses ProTable request API', () => /<ProTable[\s\S]*:request-api="getTableList"/.test(pageSource)],
[
'clientUnit page has create batch delete export template and import header actions',
() =>
/<template\s+#tableHeader="scope">[\s\S]*openCreateDialog[\s\S]*handleBatchDelete\(scope\.selectedListIds\)[\s\S]*handleExport[\s\S]*handleDownloadTemplate[\s\S]*openImportDialog/.test(
pageSource
)
],
[
'clientUnit page uses form detail and import dialogs',
() => /<ClientUnitFormDialog[\s\S]*<ClientUnitDetailDialog[\s\S]*<ClientUnitImportDialog/.test(pageSource)
],
[
'clientUnit row actions include detail edit and delete',
() =>
/<template\s+#operation="\{\s*row\s*\}">[\s\S]*openDetailDialog\(row\)[\s\S]*openEditDialog\(row\)[\s\S]*handleDelete\(row\)/.test(
pageSource
)
],
[
'clientUnit search uses unit name and update time range',
() =>
/prop:\s*'name'[\s\S]*search:\s*\{[\s\S]*el:\s*'input'/.test(pageSource) &&
/prop:\s*'timeRange'[\s\S]*label:\s*'更新时间'[\s\S]*el:\s*'date-picker'/.test(pageSource)
],
[
'clientUnit page normalizes table params and empty text',
() => /normalizeClientUnitListParams/.test(pageSource) && /resolveClientUnitText/.test(pageSource)
],
[
'clientUnit api uses list add update getById delete export importTemplate and import endpoints',
() =>
/clientUnit\/list/.test(apiSource) &&
/clientUnit\/add/.test(apiSource) &&
/clientUnit\/update/.test(apiSource) &&
/clientUnit\/getById/.test(apiSource) &&
/clientUnit\/delete/.test(apiSource) &&
/clientUnit\/export/.test(apiSource) &&
/clientUnit\/importTemplate/.test(apiSource) &&
/clientUnit\/import/.test(apiSource)
],
[
'clientUnit form dialog validates required fields',
() =>
/prop="name"/.test(formDialogSource) &&
/prop="contactName"/.test(formDialogSource) &&
/prop="phonenumber"/.test(formDialogSource) &&
/formRules/.test(formDialogSource)
],
[
'clientUnit form dialog sanitizes placeholder dash when filling edit form',
() => /sanitizeClientUnitFormText/.test(formDialogSource) && /fillForm[\s\S]*sanitizeClientUnitFormText/.test(formDialogSource)
],
[
'clientUnit detail dialog displays core fields',
() =>
/单位名称/.test(detailDialogSource) &&
/联系人/.test(detailDialogSource) &&
/联系方式/.test(detailDialogSource) &&
/地址/.test(detailDialogSource)
],
[
'clientUnit import dialog selects file and displays import result',
() =>
/type="file"/.test(importDialogSource) &&
/successCount/.test(importDialogSource) &&
/failCount/.test(importDialogSource) &&
/failReasons/.test(importDialogSource)
]
]
const failedChecks = checks.filter(([, predicate]) => !predicate())
if (failedChecks.length) {
console.error('clientUnit page contract failed:')
failedChecks.forEach(([label]) => {
console.error(`- ${label}`)
})
process.exit(1)
}
console.log('clientUnit page contract passed')

View File

@@ -0,0 +1,311 @@
<template>
<div class="table-box client-unit-page">
<ProTable
ref="proTable"
:columns="columns"
:request-api="getTableList"
:request-error="handleTableRequestError"
:search-col="{ xs: 1, sm: 2, md: 2, lg: 4, xl: 4 }"
:tool-button="['refresh', 'setting']"
row-key="id"
>
<template #tableHeader="scope">
<el-button type="primary" :icon="Plus" @click="openCreateDialog">新增</el-button>
<el-button
type="danger"
plain
:icon="Delete"
:disabled="!scope.isSelected"
@click="handleBatchDelete(scope.selectedListIds)"
>
批量删除
</el-button>
<el-button type="primary" plain :icon="Download" @click="handleExport">导出</el-button>
<el-button type="primary" plain :icon="Document" @click="handleDownloadTemplate">下载模板</el-button>
<el-button type="primary" plain :icon="Upload" @click="openImportDialog">导入</el-button>
</template>
<template #operation="{ row }">
<el-button link type="primary" :icon="View" @click="openDetailDialog(row)">详情</el-button>
<el-button link type="primary" :icon="Edit" @click="openEditDialog(row)">编辑</el-button>
<el-button link type="danger" :icon="Delete" @click="handleDelete(row)">删除</el-button>
</template>
</ProTable>
<ClientUnitFormDialog
v-model:visible="formDialogVisible"
:mode="formMode"
:record="currentRecord"
:saving="formSaving"
@submit="handleSaveClientUnit"
/>
<ClientUnitDetailDialog
v-model:visible="detailDialogVisible"
:loading="detailLoading"
:detail="detailRecord"
/>
<ClientUnitImportDialog
v-model:visible="importDialogVisible"
:importing="importing"
:result="importResult"
@submit="handleImportClientUnits"
/>
</div>
</template>
<script setup lang="tsx">
import { Delete, Document, Download, Edit, Plus, Upload, View } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, reactive, ref } from 'vue'
import type { ResPage } from '@/api/interface'
import ProTable from '@/components/ProTable/index.vue'
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
import {
createClientUnitApi,
deleteClientUnitsApi,
downloadClientUnitImportTemplateApi,
exportClientUnitsApi,
getClientUnitDetailApi,
importClientUnitsApi,
listClientUnitsApi,
updateClientUnitApi
} from '@/api/aireport/clientUnit'
import type { ClientUnit } from '@/api/aireport/clientUnit/interface'
import { useDownloadWithServerFileName } from '@/hooks/useDownload'
import ClientUnitDetailDialog from './components/ClientUnitDetailDialog.vue'
import ClientUnitFormDialog from './components/ClientUnitFormDialog.vue'
import ClientUnitImportDialog from './components/ClientUnitImportDialog.vue'
import {
getClientUnitErrorMessage,
normalizeClientUnitListParams,
normalizeClientUnitPage,
resolveClientUnitText,
unwrapClientUnitPayload
} from './utils/clientUnit'
defineOptions({
name: 'ClientUnitPage'
})
const proTable = ref<ProTableInstance>()
const formDialogVisible = ref(false)
const detailDialogVisible = ref(false)
const importDialogVisible = ref(false)
const formSaving = ref(false)
const detailLoading = ref(false)
const importing = ref(false)
const formMode = ref<'create' | 'edit'>('create')
const currentRecord = ref<ClientUnit.ClientUnitRecord | null>(null)
const detailRecord = ref<ClientUnit.ClientUnitRecord | null>(null)
const importResult = ref<ClientUnit.ClientUnitImportResult | null>(null)
const currentSearchParams = computed(() =>
normalizeClientUnitListParams((proTable.value?.searchParam || {}) as ClientUnit.ClientUnitListParams)
)
const columns = reactive<ColumnProps<ClientUnit.ClientUnitRecord>[]>([
{ type: 'selection', fixed: 'left', width: 70 },
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
{
prop: 'name',
label: '单位名称',
minWidth: 180,
showOverflowTooltip: true,
search: {
el: 'input',
order: 1,
props: {
placeholder: '请输入单位名称'
}
},
render: ({ row }) => resolveClientUnitText(row.name)
},
{
prop: 'timeRange',
label: '更新时间',
minWidth: 220,
isShow: false,
search: {
el: 'date-picker',
order: 2,
key: 'timeRange',
props: {
type: 'datetimerange',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
startPlaceholder: '开始更新时间',
endPlaceholder: '结束更新时间'
}
}
},
{ prop: 'contactName', label: '联系人', minWidth: 130, render: ({ row }) => resolveClientUnitText(row.contactName) },
{ prop: 'phonenumber', label: '联系方式', minWidth: 150, render: ({ row }) => resolveClientUnitText(row.phonenumber) },
{
prop: 'address',
label: '地址',
minWidth: 220,
showOverflowTooltip: true,
render: ({ row }) => resolveClientUnitText(row.address)
},
{ prop: 'createBy', label: '创建人', width: 120, render: ({ row }) => resolveClientUnitText(row.createBy) },
{ prop: 'createTime', label: '创建时间', minWidth: 170, render: ({ row }) => resolveClientUnitText(row.createTime) },
{ prop: 'updateBy', label: '更新人', width: 120, render: ({ row }) => resolveClientUnitText(row.updateBy) },
{ prop: 'updateTime', label: '更新时间', minWidth: 170, render: ({ row }) => resolveClientUnitText(row.updateTime) },
{ prop: 'operation', label: '操作', fixed: 'right', width: 260 }
])
const getTableList = async (params: ClientUnit.ClientUnitListParams = {}) => {
const response = await listClientUnitsApi(normalizeClientUnitListParams(params))
const pageData = normalizeClientUnitPage<ClientUnit.ClientUnitRecord>(
response as unknown as ResPage<ClientUnit.ClientUnitRecord>
)
return {
data: pageData
}
}
const refreshClientUnits = () => {
proTable.value?.clearSelection()
proTable.value?.getTableList()
}
const handleTableRequestError = (error: unknown) => {
ElMessage.error(getClientUnitErrorMessage(error, '委托单位列表查询失败'))
}
const openCreateDialog = () => {
formMode.value = 'create'
currentRecord.value = null
formDialogVisible.value = true
}
const openEditDialog = (row: ClientUnit.ClientUnitRecord) => {
formMode.value = 'edit'
currentRecord.value = row
formDialogVisible.value = true
}
const openDetailDialog = async (row: ClientUnit.ClientUnitRecord) => {
if (!row.id) {
ElMessage.warning('当前委托单位缺少 ID无法查看详情')
return
}
detailRecord.value = null
detailDialogVisible.value = true
detailLoading.value = true
try {
// 详情字段以 getById 为准,避免列表字段缺失时展示不完整。
const response = await getClientUnitDetailApi(row.id)
detailRecord.value = unwrapClientUnitPayload<ClientUnit.ClientUnitRecord>(response)
} catch (error) {
ElMessage.error(getClientUnitErrorMessage(error, '委托单位详情查询失败'))
} finally {
detailLoading.value = false
}
}
const openImportDialog = () => {
importResult.value = null
importDialogVisible.value = true
}
const handleSaveClientUnit = async (params: ClientUnit.ClientUnitSaveRequest) => {
formSaving.value = true
try {
if (formMode.value === 'create') {
await createClientUnitApi(params)
ElMessage.success('委托单位新增成功')
} else {
await updateClientUnitApi(params)
ElMessage.success('委托单位编辑成功')
}
formDialogVisible.value = false
refreshClientUnits()
} catch (error) {
ElMessage.error(getClientUnitErrorMessage(error, '委托单位保存失败'))
} finally {
formSaving.value = false
}
}
const deleteClientUnits = async (ids: string[], successMessage: string) => {
if (!ids.length) {
ElMessage.warning('请选择要删除的委托单位')
return
}
try {
await ElMessageBox.confirm('删除后委托单位将不可见,是否继续?', '删除委托单位', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
try {
await deleteClientUnitsApi(ids)
ElMessage.success(successMessage)
refreshClientUnits()
} catch (error) {
ElMessage.error(getClientUnitErrorMessage(error, '委托单位删除失败'))
}
}
const handleDelete = async (row: ClientUnit.ClientUnitRecord) => {
if (!row.id) {
ElMessage.warning('当前委托单位缺少 ID无法删除')
return
}
await deleteClientUnits([row.id], '委托单位删除成功')
}
const handleBatchDelete = async (ids: string[]) => {
await deleteClientUnits(ids, '委托单位批量删除成功')
}
const handleExport = async () => {
await useDownloadWithServerFileName(exportClientUnitsApi, '委托单位列表', currentSearchParams.value, false, '.xlsx')
}
const handleDownloadTemplate = async () => {
await useDownloadWithServerFileName(
() => downloadClientUnitImportTemplateApi(),
'委托单位导入模板',
undefined,
false,
'.xlsx'
)
}
const handleImportClientUnits = async (file: File) => {
importing.value = true
try {
// 导入接口支持部分成功,结果保留在弹窗内供用户核对失败原因。
const response = await importClientUnitsApi(file)
importResult.value = unwrapClientUnitPayload<ClientUnit.ClientUnitImportResult>(response)
ElMessage.success('委托单位导入完成')
refreshClientUnits()
} catch (error) {
ElMessage.error(getClientUnitErrorMessage(error, '委托单位导入失败'))
} finally {
importing.value = false
}
}
</script>
<style scoped lang="scss">
.client-unit-page {
min-height: 0;
padding: 0;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,61 @@
import type { ResultData, ResPage } from '@/api/interface'
import type { ClientUnit } from '@/api/aireport/clientUnit/interface'
export const resolveClientUnitText = (value: unknown) => {
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
export const sanitizeClientUnitFormText = (value: unknown) => {
if (value === null || value === undefined) return ''
const text = String(value).trim()
return text === '-' ? '' : text
}
export const normalizeClientUnitListParams = (
params: ClientUnit.ClientUnitListParams = {}
): ClientUnit.ClientUnitListParams => {
const timeRange = Array.isArray(params.timeRange) ? params.timeRange : []
const [searchBeginTime = '', searchEndTime = ''] = timeRange
return {
name: String(params.name || '').trim() || undefined,
searchBeginTime: searchBeginTime || params.searchBeginTime || undefined,
searchEndTime: searchEndTime || params.searchEndTime || undefined,
pageNum: params.pageNum || 1,
pageSize: params.pageSize || 10
}
}
export const unwrapClientUnitPayload = <T>(response: ResultData<T> | T): T => {
if (response && typeof response === 'object' && 'data' in response) {
return (response as ResultData<T>).data
}
return response as T
}
export const normalizeClientUnitPage = <T>(
response: ResultData<ResPage<T>> | ResPage<T> | null | undefined
): ResPage<T> => {
const payload = unwrapClientUnitPayload<ResPage<T> | null | undefined>(response as ResultData<ResPage<T>>)
return {
records: payload?.records || [],
current: payload?.current || 1,
size: payload?.size || 10,
total: payload?.total || 0
}
}
export const getClientUnitErrorMessage = (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
}

View File

@@ -0,0 +1,114 @@
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="560px"
append-to-body
destroy-on-close
@close="handleClose"
>
<el-form ref="formRef" :model="formState" :rules="formRules" label-width="88px">
<el-form-item label="报告名称">
<div class="report-approval-action-dialog__text">{{ resolveReportApprovalText(record?.reportName) }}</div>
</el-form-item>
<el-form-item label="报告类型">
<div class="report-approval-action-dialog__text">{{ getReportApprovalTypeText(record?.reportType) }}</div>
</el-form-item>
<el-form-item label="审批原因" prop="approvalReason">
<el-input
v-model="formState.approvalReason"
type="textarea"
:rows="5"
maxlength="128"
show-word-limit
placeholder="请输入审批原因"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import type { ReportApproval } from '@/api/aireport/reportApproval/interface'
import {
getReportApprovalTypeText,
resolveReportApprovalText,
validateReportApprovalReason
} from '../utils/reportApproval'
defineOptions({
name: 'ReportApprovalActionDialog'
})
const props = defineProps<{
visible: boolean
mode: 'pass' | 'reject'
record: ReportApproval.PendingRecord | null
saving?: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
submit: [payload: ReportApproval.ApprovalActionRequest]
}>()
const formRef = ref<FormInstance>()
const formState = reactive({
approvalReason: ''
})
const dialogTitle = computed(() => (props.mode === 'pass' ? '审批通过' : '审批退回'))
const formRules: FormRules = {
approvalReason: [
{
validator: (_rule, value: string, callback) => {
const errorMessage = validateReportApprovalReason(String(value || ''))
callback(errorMessage ? new Error(errorMessage) : undefined)
},
trigger: 'blur'
}
]
}
watch(
() => props.visible,
visible => {
if (!visible) {
formState.approvalReason = ''
formRef.value?.clearValidate()
}
}
)
const handleClose = () => {
emit('update:visible', false)
}
const handleSubmit = async () => {
if (!props.record?.reportId || !props.record.reportType) return
await formRef.value?.validate()
emit('submit', {
reportId: props.record.reportId,
reportType: props.record.reportType,
approvalReason: formState.approvalReason.trim()
})
}
</script>
<style scoped lang="scss">
.report-approval-action-dialog__text {
min-height: 32px;
color: var(--el-text-color-regular);
line-height: 32px;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,149 @@
<template>
<div class="table-box report-approval-log-table">
<ProTable
ref="proTable"
:columns="columns"
:request-api="getTableList"
:request-error="handleTableRequestError"
:search-col="{ xs: 1, sm: 2, md: 2, lg: 4, xl: 4 }"
:tool-button="['refresh', 'setting']"
row-key="id"
>
<template #tableHeader>
<el-button type="primary" plain :icon="Refresh" @click="refreshLogs">刷新</el-button>
</template>
</ProTable>
</div>
</template>
<script setup lang="tsx">
import { Refresh } from '@element-plus/icons-vue'
import { ElMessage, type TagProps } from 'element-plus'
import { reactive, ref } from 'vue'
import type { ResPage } from '@/api/interface'
import ProTable from '@/components/ProTable/index.vue'
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
import { listReportApprovalLogsApi } from '@/api/aireport/reportApproval'
import type { ReportApproval } from '@/api/aireport/reportApproval/interface'
import {
REPORT_APPROVAL_TYPE_OPTIONS,
getReportApprovalErrorMessage,
getReportApprovalResultText,
getReportApprovalStateTagType,
getReportApprovalStateText,
getReportApprovalTypeText,
normalizeReportApprovalLogListParams,
normalizeReportApprovalPage,
resolveReportApprovalText
} from '../utils/reportApproval'
defineOptions({
name: 'ReportApprovalLogTable'
})
type TagType = TagProps['type']
const RESULT_OPTIONS: Array<{ label: string; value: ReportApproval.ApprovalResult; tagType: TagType }> = [
{ label: '通过', value: '03', tagType: 'success' },
{ label: '退回', value: '04', tagType: 'danger' }
]
const proTable = ref<ProTableInstance>()
const columns = reactive<ColumnProps<ReportApproval.LogRecord>[]>([
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
{
prop: 'reportType',
label: '报告类型',
width: 140,
enum: REPORT_APPROVAL_TYPE_OPTIONS,
isFilterEnum: false,
render: ({ row }) => getReportApprovalTypeText(row.reportType),
search: {
el: 'select',
order: 1,
props: {
placeholder: '请选择报告类型'
}
}
},
{
prop: 'reportName',
label: '报告名称',
minWidth: 220,
showOverflowTooltip: true,
render: ({ row }) => resolveReportApprovalText(row.reportName)
},
{
prop: 'approvalResult',
label: '审批结果',
width: 120,
enum: RESULT_OPTIONS,
isFilterEnum: false,
render: ({ row }) => (
<el-tag type={getReportApprovalStateTagType(row.approvalResult)} effect="light">
{getReportApprovalResultText(row.approvalResult)}
</el-tag>
)
},
{
prop: 'approvalReason',
label: '审批原因',
minWidth: 260,
showOverflowTooltip: true,
render: ({ row }) => resolveReportApprovalText(row.approvalReason)
},
{
prop: 'beforeState',
label: '审批前状态',
width: 120,
render: ({ row }) => (
<el-tag type={getReportApprovalStateTagType(row.beforeState)} effect="light">
{getReportApprovalStateText(row.beforeState)}
</el-tag>
)
},
{
prop: 'afterState',
label: '审批后状态',
width: 120,
render: ({ row }) => (
<el-tag type={getReportApprovalStateTagType(row.afterState)} effect="light">
{getReportApprovalStateText(row.afterState)}
</el-tag>
)
},
{ prop: 'approverName', label: '审批人', width: 140, render: ({ row }) => resolveReportApprovalText(row.approverName) },
{ prop: 'approvalTime', label: '审批时间', minWidth: 180, render: ({ row }) => resolveReportApprovalText(row.approvalTime) }
])
const getTableList = async (params: ReportApproval.LogListParams = {}) => {
const response = await listReportApprovalLogsApi(normalizeReportApprovalLogListParams(params))
const pageData = normalizeReportApprovalPage<ReportApproval.LogRecord>(response as unknown as ResPage<ReportApproval.LogRecord>)
return {
data: pageData
}
}
const handleTableRequestError = (error: unknown) => {
ElMessage.error(getReportApprovalErrorMessage(error, '审批日志查询失败'))
}
const refreshLogs = () => {
proTable.value?.clearSelection()
proTable.value?.getTableList()
}
defineExpose({
refreshLogs
})
</script>
<style scoped lang="scss">
.report-approval-log-table {
min-height: 0;
padding: 0;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,140 @@
<template>
<div class="table-box report-approval-pending-table">
<ProTable
ref="proTable"
:columns="columns"
:request-api="getTableList"
:request-error="handleTableRequestError"
:search-col="{ xs: 1, sm: 2, md: 2, lg: 4, xl: 4 }"
:tool-button="['refresh', 'setting']"
row-key="reportId"
>
<template #tableHeader>
<el-button type="primary" plain :icon="Refresh" @click="refreshPendingReports">刷新</el-button>
</template>
<template #operation="{ row }">
<el-button link type="primary" :icon="CircleCheck" @click="handlePass(row)">通过</el-button>
<el-button link type="danger" :icon="RefreshLeft" @click="handleReject(row)">退回</el-button>
</template>
</ProTable>
</div>
</template>
<script setup lang="tsx">
import { CircleCheck, Refresh, RefreshLeft } from '@element-plus/icons-vue'
import { ElMessage, type TagProps } from 'element-plus'
import { reactive, ref } from 'vue'
import type { ResPage } from '@/api/interface'
import ProTable from '@/components/ProTable/index.vue'
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
import { listPendingReportApprovalsApi } from '@/api/aireport/reportApproval'
import type { ReportApproval } from '@/api/aireport/reportApproval/interface'
import {
REPORT_APPROVAL_TYPE_OPTIONS,
getReportApprovalErrorMessage,
getReportApprovalStateTagType,
getReportApprovalStateText,
getReportApprovalTypeText,
normalizePendingReportApprovalListParams,
normalizeReportApprovalPage,
resolveReportApprovalText
} from '../utils/reportApproval'
defineOptions({
name: 'ReportApprovalPendingTable'
})
type TagType = TagProps['type']
const emit = defineEmits<{
pass: [record: ReportApproval.PendingRecord]
reject: [record: ReportApproval.PendingRecord]
}>()
const STATE_OPTIONS: Array<{ label: string; value: ReportApproval.ReportState; tagType: TagType }> = [
{ label: '待审', value: '02', tagType: 'warning' }
]
const proTable = ref<ProTableInstance>()
const columns = reactive<ColumnProps<ReportApproval.PendingRecord>[]>([
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
{
prop: 'reportType',
label: '报告类型',
width: 140,
enum: REPORT_APPROVAL_TYPE_OPTIONS,
isFilterEnum: false,
render: ({ row }) => getReportApprovalTypeText(row.reportType),
search: {
el: 'select',
order: 1,
props: {
placeholder: '请选择报告类型'
}
}
},
{
prop: 'reportName',
label: '报告名称',
minWidth: 220,
showOverflowTooltip: true,
render: ({ row }) => resolveReportApprovalText(row.reportName)
},
{
prop: 'state',
label: '当前状态',
width: 120,
enum: STATE_OPTIONS,
isFilterEnum: false,
render: ({ row }) => (
<el-tag type={getReportApprovalStateTagType(row.state)} effect="light">
{getReportApprovalStateText(row.state)}
</el-tag>
)
},
{ prop: 'submitterName', label: '提交人', width: 140, render: ({ row }) => resolveReportApprovalText(row.submitterName) },
{ prop: 'submitTime', label: '提交时间', minWidth: 180, render: ({ row }) => resolveReportApprovalText(row.submitTime) },
{ prop: 'operation', label: '操作', fixed: 'right', width: 180 }
])
const getTableList = async (params: ReportApproval.PendingListParams = {}) => {
const response = await listPendingReportApprovalsApi(normalizePendingReportApprovalListParams(params))
const pageData = normalizeReportApprovalPage<ReportApproval.PendingRecord>(
response as unknown as ResPage<ReportApproval.PendingRecord>
)
return {
data: pageData
}
}
const handleTableRequestError = (error: unknown) => {
ElMessage.error(getReportApprovalErrorMessage(error, '待审批报告查询失败'))
}
const refreshPendingReports = () => {
proTable.value?.clearSelection()
proTable.value?.getTableList()
}
const handlePass = (row: ReportApproval.PendingRecord) => {
emit('pass', row)
}
const handleReject = (row: ReportApproval.PendingRecord) => {
emit('reject', row)
}
defineExpose({
refreshPendingReports
})
</script>
<style scoped lang="scss">
.report-approval-pending-table {
min-height: 0;
padding: 0;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,89 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const pageDir = path.resolve(__dirname, '..')
const srcDir = path.resolve(pageDir, '../../..')
const routerFile = path.resolve(srcDir, 'routers/modules/dynamicRouter.ts')
const pageFile = path.resolve(pageDir, 'index.vue')
const pendingTableFile = path.resolve(pageDir, 'components/ReportApprovalPendingTable.vue')
const logTableFile = path.resolve(pageDir, 'components/ReportApprovalLogTable.vue')
const actionDialogFile = path.resolve(pageDir, 'components/ReportApprovalActionDialog.vue')
const utilsFile = path.resolve(pageDir, 'utils/reportApproval.ts')
const apiFile = path.resolve(srcDir, 'api/aireport/reportApproval/index.ts')
const apiInterfaceFile = path.resolve(srcDir, 'api/aireport/reportApproval/interface/index.ts')
const read = file => fs.readFileSync(file, 'utf8')
const assertFileIncludes = (file, snippets) => {
const content = read(file)
const missing = snippets.filter(snippet => !content.includes(snippet))
if (missing.length) {
throw new Error(`${path.relative(srcDir, file)} missing: ${missing.join(', ')}`)
}
}
assertFileIncludes(routerFile, [
"'/aiReport/reportApproval': '/aireport/reportApproval'",
"'/aiReport/reportApproval/index': '/aireport/reportApproval/index'"
])
assertFileIncludes(apiFile, [
"const REPORT_APPROVAL_BASE_URL = '/api/report-approval'",
'listPendingReportApprovalsApi',
'passReportApprovalApi',
'rejectReportApprovalApi',
'listReportApprovalLogsApi'
])
assertFileIncludes(apiInterfaceFile, [
'export namespace ReportApproval',
'PendingRecord',
'PendingListParams',
'ApprovalActionRequest',
'LogRecord',
'LogListParams'
])
assertFileIncludes(utilsFile, [
'REPORT_APPROVAL_TYPE_OPTIONS',
'getReportApprovalStateText',
'getReportApprovalResultText',
'normalizePendingReportApprovalListParams',
'normalizeReportApprovalLogListParams',
'validateReportApprovalReason'
])
assertFileIncludes(actionDialogFile, [
"name: 'ReportApprovalActionDialog'",
'approvalReason',
'validateReportApprovalReason'
])
assertFileIncludes(pendingTableFile, [
"name: 'ReportApprovalPendingTable'",
'<ProTable',
'tableHeader',
'handlePass(row)',
'handleReject(row)'
])
assertFileIncludes(logTableFile, [
"name: 'ReportApprovalLogTable'",
'<ProTable',
'tableHeader',
'listReportApprovalLogsApi'
])
assertFileIncludes(pageFile, [
"name: 'ReportApprovalPage'",
'el-tabs',
'ReportApprovalPendingTable',
'ReportApprovalLogTable',
'ReportApprovalActionDialog',
'passReportApprovalApi',
'rejectReportApprovalApi'
])
console.log('reportApproval page contract passed')

View File

@@ -0,0 +1,108 @@
<template>
<div class="table-box report-approval-page">
<el-tabs v-model="activeTab" class="report-approval-page__tabs">
<el-tab-pane label="待审批报告" name="pending">
<ReportApprovalPendingTable ref="pendingTableRef" @pass="openPassDialog" @reject="openRejectDialog" />
</el-tab-pane>
<el-tab-pane label="审批日志" name="logs">
<ReportApprovalLogTable ref="logTableRef" />
</el-tab-pane>
</el-tabs>
<ReportApprovalActionDialog
:visible="actionDialogVisible"
:mode="actionMode"
:record="currentRecord"
:saving="actionSaving"
@update:visible="handleDialogVisibleChange"
@submit="handleSubmitAction"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { passReportApprovalApi, rejectReportApprovalApi } from '@/api/aireport/reportApproval'
import type { ReportApproval } from '@/api/aireport/reportApproval/interface'
import ReportApprovalActionDialog from './components/ReportApprovalActionDialog.vue'
import ReportApprovalLogTable from './components/ReportApprovalLogTable.vue'
import ReportApprovalPendingTable from './components/ReportApprovalPendingTable.vue'
import { getReportApprovalErrorMessage } from './utils/reportApproval'
defineOptions({
name: 'ReportApprovalPage'
})
const activeTab = ref<'pending' | 'logs'>('pending')
const pendingTableRef = ref<InstanceType<typeof ReportApprovalPendingTable>>()
const logTableRef = ref<InstanceType<typeof ReportApprovalLogTable>>()
const actionDialogVisible = ref(false)
const actionSaving = ref(false)
const actionMode = ref<'pass' | 'reject'>('pass')
const currentRecord = ref<ReportApproval.PendingRecord | null>(null)
const openPassDialog = (record: ReportApproval.PendingRecord) => {
currentRecord.value = record
actionMode.value = 'pass'
actionDialogVisible.value = true
}
const openRejectDialog = (record: ReportApproval.PendingRecord) => {
currentRecord.value = record
actionMode.value = 'reject'
actionDialogVisible.value = true
}
const refreshTables = () => {
pendingTableRef.value?.refreshPendingReports()
logTableRef.value?.refreshLogs()
}
const handleDialogVisibleChange = (visible: boolean) => {
actionDialogVisible.value = visible
if (!visible) {
currentRecord.value = null
}
}
const handleSubmitAction = async (payload: ReportApproval.ApprovalActionRequest) => {
actionSaving.value = true
try {
// 关键业务节点:审批通过和审批退回共用同一弹窗收集原因,但必须按当前操作调用不同后端接口。
if (actionMode.value === 'pass') {
await passReportApprovalApi(payload)
ElMessage.success('报告审批通过成功')
} else {
await rejectReportApprovalApi(payload)
ElMessage.success('报告审批退回成功')
}
activeTab.value = 'logs'
handleDialogVisibleChange(false)
refreshTables()
} catch (error) {
ElMessage.error(getReportApprovalErrorMessage(error, actionMode.value === 'pass' ? '报告审批通过失败' : '报告审批退回失败'))
} finally {
actionSaving.value = false
}
}
</script>
<style scoped lang="scss">
.report-approval-page {
min-height: 0;
padding: 0;
overflow: hidden;
}
.report-approval-page__tabs {
min-height: 0;
}
.report-approval-page__tabs :deep(.el-tabs__content) {
min-height: 0;
}
</style>

View File

@@ -0,0 +1,103 @@
import type { TagProps } from 'element-plus'
import type { ResultData, ResPage } from '@/api/interface'
import type { ReportApproval } from '@/api/aireport/reportApproval/interface'
type TagType = TagProps['type']
const REPORT_APPROVAL_STATE_MAP: Record<string, { text: string; tagType: TagType }> = {
'01': { text: '编制', tagType: 'info' },
'02': { text: '待审', tagType: 'warning' },
'03': { text: '通过', tagType: 'success' },
'04': { text: '退回', tagType: 'danger' },
'05': { text: '删除', tagType: 'info' }
}
const REPORT_APPROVAL_TYPE_MAP: Record<string, string> = {
REPORT_MODEL: '报告模板',
TEST_REPORT: '普测报告'
}
const REPORT_APPROVAL_RESULT_MAP: Record<string, string> = {
'03': '通过',
'04': '退回'
}
export const REPORT_APPROVAL_REASON_MAX_LENGTH = 128
export const REPORT_APPROVAL_TYPE_OPTIONS: Array<{ label: string; value: ReportApproval.ReportType }> = [
{ label: '报告模板', value: 'REPORT_MODEL' },
{ label: '普测报告', value: 'TEST_REPORT' }
]
export const resolveReportApprovalText = (value: unknown) => {
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
export const getReportApprovalStateText = (state?: string) => REPORT_APPROVAL_STATE_MAP[state || '']?.text || '未知状态'
export const getReportApprovalStateTagType = (state?: string): TagType =>
REPORT_APPROVAL_STATE_MAP[state || '']?.tagType || 'info'
export const getReportApprovalTypeText = (reportType?: string) => REPORT_APPROVAL_TYPE_MAP[reportType || ''] || reportType || '-'
export const getReportApprovalResultText = (result?: string) => REPORT_APPROVAL_RESULT_MAP[result || ''] || result || '-'
export const normalizePendingReportApprovalListParams = (
params: ReportApproval.PendingListParams = {}
): ReportApproval.PendingListParams => ({
current: params.current || 1,
size: params.size || 10,
reportType: params.reportType || undefined
})
export const normalizeReportApprovalLogListParams = (
params: ReportApproval.LogListParams = {}
): ReportApproval.LogListParams => ({
current: params.current || 1,
size: params.size || 10,
reportType: params.reportType || undefined
})
export const unwrapReportApprovalPayload = <T>(response: ResultData<T> | T): T => {
if (response && typeof response === 'object' && 'data' in response) {
return (response as ResultData<T>).data
}
return response as T
}
export const normalizeReportApprovalPage = <T>(
response: ResultData<ResPage<T>> | ResPage<T> | null | undefined
): ResPage<T> => {
const payload = unwrapReportApprovalPayload<ResPage<T> | null | undefined>(response as ResultData<ResPage<T>>)
return {
records: payload?.records || [],
current: payload?.current || 1,
size: payload?.size || 10,
total: payload?.total || 0
}
}
export const validateReportApprovalReason = (value: string) => {
const text = value.trim()
if (!text) return '请输入审批原因'
if (text.length > REPORT_APPROVAL_REASON_MAX_LENGTH) {
return `审批原因不能超过 ${REPORT_APPROVAL_REASON_MAX_LENGTH} 个字符`
}
return ''
}
export const getReportApprovalErrorMessage = (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
}

View File

@@ -0,0 +1,72 @@
<template>
<el-dialog
v-model="visibleProxy"
title="模板详情"
width="720px"
append-to-body
destroy-on-close
>
<div v-loading="loading" class="report-model-detail-dialog">
<el-descriptions class="detail-descriptions" :column="2" border>
<el-descriptions-item label="模板名称">{{ detail?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="模板状态">
<el-tag :type="getReportModelStateTagType(detail?.state)" effect="light">
{{ getReportModelStateText(detail?.state) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="审核人">{{ detail?.checkerName || '-' }}</el-descriptions-item>
<el-descriptions-item label="审核时间">{{ detail?.checkTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="编辑人">{{ detail?.editorName || '-' }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ detail?.updateTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建人">{{ detail?.createBy || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detail?.createTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="模板文件" :span="2">{{ resolveReportModelFileName(detail) || '-' }}</el-descriptions-item>
<el-descriptions-item label="模板路径" :span="2">{{ detail?.path || '-' }}</el-descriptions-item>
<el-descriptions-item label="审核意见" :span="2">{{ detail?.checkResult || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button type="primary" @click="visibleProxy = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ReportModel } from '@/api/aireport/reportModel/interface'
import { getReportModelStateTagType, getReportModelStateText, resolveReportModelFileName } from '../utils/reportModel'
defineOptions({
name: 'ReportModelDetailDialog'
})
const props = defineProps<{
visible: boolean
loading: boolean
detail: ReportModel.ReportModelRecord | null
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
}>()
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
</script>
<style scoped lang="scss">
.report-model-detail-dialog {
min-height: 180px;
}
.report-model-detail-dialog :deep(.detail-descriptions .el-descriptions__table) {
width: 100%;
table-layout: fixed;
}
.report-model-detail-dialog :deep(.detail-descriptions .el-descriptions__label.el-descriptions__cell.is-bordered-label) {
width: 112px;
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<el-dialog
v-model="visibleProxy"
:title="mode === 'create' ? '新增模板' : '编辑模板'"
width="560px"
append-to-body
destroy-on-close
@closed="resetForm"
>
<el-form ref="formRef" :model="formModel" :rules="formRules" label-width="96px">
<el-form-item label="模板名称" prop="name">
<el-input
v-model="formModel.name"
maxlength="32"
show-word-limit
placeholder="请输入模板名称"
:disabled="mode === 'edit'"
/>
</el-form-item>
<el-form-item label="模板文件" :required="mode === 'create'">
<div class="file-select-row">
<el-input :model-value="selectedFileName" readonly placeholder="请选择 Word 模板文件" />
<el-button type="primary" plain :icon="FolderOpened" :disabled="saving" @click="openFilePicker">
选择文件
</el-button>
<input
ref="fileInputRef"
class="hidden-file-input"
type="file"
accept=".doc,.docx"
@change="handleFileChange"
/>
</div>
<div class="file-select-tip">
{{
mode === 'create'
? '新增模板时必须上传 Word 模板文件,支持 .doc/.docx'
: '编辑时不上传则保留原文件,新上传文件仅支持 .doc/.docx'
}}
</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 { ReportModel } from '@/api/aireport/reportModel/interface'
import { resolveReportModelFileName, validateReportModelTemplateFile } from '../utils/reportModel'
defineOptions({
name: 'ReportModelFormDialog'
})
const props = defineProps<{
visible: boolean
mode: 'create' | 'edit'
record: ReportModel.ReportModelRecord | null
saving: boolean
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
(event: 'submit', value: { request: ReportModel.ReportModelSaveRequest; 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: '请输入模板名称', trigger: 'blur' }]
}
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const selectedFileName = computed(() => selectedFile.value?.name || resolveReportModelFileName(props.record))
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, props.record?.path, props.record?.fileName, props.record?.file_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
const validationMessage = validateReportModelTemplateFile(file)
if (validationMessage) {
ElMessage.warning(validationMessage)
return
}
selectedFile.value = file
}
const submitForm = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
if (props.mode === 'create' && !selectedFile.value) {
ElMessage.warning('新增模板时必须上传 Word 模板文件')
return
}
if (props.mode === 'edit' && !formModel.id) {
ElMessage.warning('当前模板缺少 ID无法保存编辑')
return
}
emit('submit', {
request: {
id: formModel.id || undefined,
name: formModel.name.trim(),
// 关键业务节点:新增/编辑都需要把模板文件框当前展示的文件名同步给后端 request.fileName。
fileName: selectedFileName.value || undefined
},
file: selectedFile.value
})
}
</script>
<style scoped lang="scss">
.file-select-row {
display: flex;
width: 100%;
gap: 12px;
}
.file-select-tip {
margin-top: 8px;
color: #909399;
font-size: 12px;
line-height: 1.4;
}
.hidden-file-input {
display: none;
}
</style>

View File

@@ -0,0 +1,121 @@
<template>
<el-dialog
v-model="visibleProxy"
:title="previewTitle"
width="960px"
append-to-body
destroy-on-close
@closed="clearPreview"
>
<div v-loading="loading" class="report-model-preview-dialog">
<div v-if="activeErrorMessage" class="preview-error-wrapper">
<el-alert :title="activeErrorMessage" type="warning" :closable="false" show-icon />
</div>
<div v-else ref="previewContainer" class="preview-container"></div>
</div>
<template #footer>
<el-button type="primary" @click="visibleProxy = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { renderAsync } from 'docx-preview'
import { computed, nextTick, ref, watch } from 'vue'
defineOptions({
name: 'ReportModelPreviewDialog'
})
const props = defineProps<{
visible: boolean
loading: boolean
fileBlob: Blob | null
fileName: string
errorMessage: string
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
}>()
const previewContainer = ref<HTMLDivElement | null>(null)
const renderErrorMessage = ref('')
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const previewTitle = computed(() => (props.fileName ? `模板预览 - ${props.fileName}` : '模板预览'))
const activeErrorMessage = computed(() => props.errorMessage || renderErrorMessage.value)
const clearPreview = () => {
renderErrorMessage.value = ''
if (previewContainer.value) {
previewContainer.value.innerHTML = ''
}
}
const renderPreview = async () => {
if (!props.visible || props.loading || !props.fileBlob || props.errorMessage || !previewContainer.value) return
clearPreview()
try {
// 关键业务节点:仅对可预览的 docx 内容执行页面内渲染,渲染失败时保留明确提示,避免弹窗空白无反馈。
await renderAsync(props.fileBlob, previewContainer.value, undefined, {
className: 'report-model-docx-preview',
ignoreWidth: false,
ignoreHeight: true,
renderHeaders: true,
renderFooters: true,
useBase64URL: true
})
} catch (error) {
renderErrorMessage.value = error instanceof Error ? error.message : '模板预览失败,请稍后重试'
}
}
watch(
() => [props.visible, props.loading, props.fileBlob, props.errorMessage],
async () => {
if (!props.visible) {
clearPreview()
return
}
renderErrorMessage.value = ''
await nextTick()
await renderPreview()
},
{ flush: 'post' }
)
</script>
<style scoped lang="scss">
.report-model-preview-dialog {
min-height: 520px;
max-height: 70vh;
overflow: auto;
background: #f5f7fa;
}
.preview-error-wrapper {
padding: 16px;
}
.preview-container {
min-height: 520px;
padding: 24px;
}
.preview-container:deep(.report-model-docx-preview-wrapper) {
padding: 0;
}
.preview-container:deep(section.docx) {
margin: 0 auto 24px;
box-shadow: 0 8px 24px rgb(15 23 42 / 10%);
}
</style>

View File

@@ -0,0 +1,51 @@
import fs from 'fs'
import path from 'path'
const rootDir = path.resolve(process.cwd(), 'frontend/src')
const pageFile = path.resolve(rootDir, 'views/aireport/reportModel/index.vue')
const apiFile = path.resolve(rootDir, 'api/aireport/reportModel/index.ts')
const formFile = path.resolve(rootDir, 'views/aireport/reportModel/components/ReportModelFormDialog.vue')
const pageSource = fs.readFileSync(pageFile, 'utf8')
const apiSource = fs.readFileSync(apiFile, 'utf8')
const formSource = fs.readFileSync(formFile, 'utf8')
const checks = [
['reportModel page imports ProTable', () => /import\s+ProTable\s+from\s+'@\/components\/ProTable\/index\.vue'/.test(pageSource)],
['reportModel page uses ProTable request API', () => /<ProTable[\s\S]*:request-api="getTableList"/.test(pageSource)],
['reportModel page has create and batch delete header actions', () => /<template\s+#tableHeader="scope">[\s\S]*openCreateDialog[\s\S]*handleBatchDelete\(scope\.selectedListIds\)/.test(pageSource)],
['reportModel page uses form detail and preview dialogs', () => /<ReportModelFormDialog[\s\S]*<ReportModelDetailDialog[\s\S]*<ReportModelPreviewDialog/.test(pageSource)],
['reportModel row actions include detail edit download submit audit and delete', () => /<template\s+#operation="\{\s*row\s*\}">[\s\S]*openDetailDialog\(row\)[\s\S]*openEditDialog\(row\)[\s\S]*handleDownload\(row\)[\s\S]*openAuditDialog\(row\)[\s\S]*handleDelete\(row\)/.test(pageSource)],
['reportModel search uses update time label', () => /prop:\s*'timeRange'[\s\S]*label:\s*'更新时间'/.test(pageSource) && /startPlaceholder:\s*'开始更新时间'/.test(pageSource) && /endPlaceholder:\s*'结束更新时间'/.test(pageSource)],
[
'reportModel list shows template file after name and supports file-name preview',
() =>
/prop:\s*'name'[\s\S]*prop:\s*'path'[\s\S]*label:\s*'模版文件'[\s\S]*resolveReportModelFileName\(row\)[\s\S]*openPreviewDialog\(row\)/.test(
pageSource
)
],
[
'reportModel list columns keep index name path state editor update checker check-time check-result order',
() =>
/type:\s*'index'[\s\S]*prop:\s*'name'[\s\S]*prop:\s*'path'[\s\S]*prop:\s*'state'[\s\S]*prop:\s*'editorName'[\s\S]*prop:\s*'updateTime'[\s\S]*prop:\s*'checkerName'[\s\S]*prop:\s*'checkTime'[\s\S]*prop:\s*'checkResult'/.test(
pageSource
)
],
['reportModel list hides create time and keeps update time empty fallback', () => /prop:\s*'updateTime'[\s\S]*render:\s*\(\{\s*row\s*\}\)\s*=>\s*resolveReportModelText\(row\.updateTime\)/.test(pageSource) && !/prop:\s*'createTime'/.test(pageSource)],
['reportModel api uses list add update delete detail download submit-audit endpoints', () => /report-model\/list/.test(apiSource) && /\/add/.test(apiSource) && /\/update/.test(apiSource) && /\/delete/.test(apiSource) && /submit-audit/.test(apiSource) && /\/download/.test(apiSource)],
['reportModel api keeps multipart request part name', () => /formData\.append\('request'/.test(apiSource) && /formData\.append\('templateFile'/.test(apiSource)],
['reportModel form only accepts Word documents', () => /accept="\.doc,\.docx"/.test(formSource)],
['reportModel form keeps template name disabled while editing', () => /:disabled="mode === 'edit'"/.test(formSource)]
]
const failedChecks = checks.filter(([, predicate]) => !predicate())
if (failedChecks.length) {
console.error('reportModel page contract failed:')
failedChecks.forEach(([label]) => {
console.error(`- ${label}`)
})
process.exit(1)
}
console.log('reportModel page contract passed')

View File

@@ -0,0 +1,400 @@
<template>
<div class="table-box report-model-page">
<ProTable
ref="proTable"
:columns="columns"
:request-api="getTableList"
:request-error="handleTableRequestError"
:search-col="{ xs: 1, sm: 2, md: 2, lg: 4, xl: 4 }"
:tool-button="['refresh', 'setting']"
row-key="id"
>
<template #tableHeader="scope">
<el-button type="primary" :icon="Plus" @click="openCreateDialog">新增</el-button>
<el-button
type="danger"
plain
:icon="Delete"
:disabled="!scope.isSelected"
@click="handleBatchDelete(scope.selectedListIds)"
>
批量删除
</el-button>
</template>
<template #operation="{ row }">
<el-button link type="primary" :icon="View" @click="openDetailDialog(row)">详情</el-button>
<el-button link type="primary" :icon="Edit" :disabled="!isDraftReportModel(row)" @click="openEditDialog(row)">
编辑
</el-button>
<el-button link type="primary" :icon="Download" :disabled="!row.id" @click="handleDownload(row)">下载</el-button>
<el-button link type="primary" :icon="Upload" :disabled="!isDraftReportModel(row)" @click="openAuditDialog(row)">
提交审核
</el-button>
<el-button link type="danger" :icon="Delete" @click="handleDelete(row)">删除</el-button>
</template>
</ProTable>
<ReportModelFormDialog
v-model:visible="formDialogVisible"
:mode="formMode"
:record="currentRecord"
:saving="formSaving"
@submit="handleSaveReportModel"
/>
<ReportModelDetailDialog v-model:visible="detailDialogVisible" :loading="detailLoading" :detail="detailRecord" />
<ReportModelPreviewDialog
v-model:visible="previewDialogVisible"
:loading="previewLoading"
:file-name="previewFileName"
:file-blob="previewFileBlob"
:error-message="previewErrorMessage"
/>
</div>
</template>
<script setup lang="tsx">
import { Delete, Download, Edit, Plus, Upload, View } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, type TagProps } from 'element-plus'
import { reactive, ref } from 'vue'
import type { ResPage } from '@/api/interface'
import ProTable from '@/components/ProTable/index.vue'
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
import {
createReportModelApi,
deleteReportModelsApi,
downloadReportModelApi,
getReportModelDetailApi,
listReportModelsApi,
submitReportModelAuditApi,
updateReportModelApi
} from '@/api/aireport/reportModel'
import type { ReportModel } from '@/api/aireport/reportModel/interface'
import { useDownloadWithServerFileName } from '@/hooks/useDownload'
import ReportModelDetailDialog from './components/ReportModelDetailDialog.vue'
import ReportModelFormDialog from './components/ReportModelFormDialog.vue'
import ReportModelPreviewDialog from './components/ReportModelPreviewDialog.vue'
import {
canPreviewReportModelFile,
getReportModelErrorMessage,
getReportModelStateTagType,
getReportModelStateText,
isDraftReportModel,
normalizeReportModelListParams,
normalizeReportModelPage,
resolveReportModelDownloadFileName,
resolveReportModelFileName,
resolveReportModelText,
unwrapApiPayload
} from './utils/reportModel'
defineOptions({
name: 'ReportModelPage'
})
type TagType = TagProps['type']
const STATE_OPTIONS: Array<{ label: string; value: ReportModel.ReportModelState; tagType: TagType }> = [
{ label: '编制', value: '01', tagType: 'info' },
{ label: '审核中', value: '02', tagType: 'warning' },
{ label: '已发布', value: '03', tagType: 'success' },
{ label: '已删除', value: '04', tagType: 'danger' }
]
const proTable = ref<ProTableInstance>()
const formDialogVisible = ref(false)
const detailDialogVisible = ref(false)
const previewDialogVisible = ref(false)
const formSaving = ref(false)
const detailLoading = ref(false)
const auditSaving = ref(false)
const previewLoading = ref(false)
const formMode = ref<'create' | 'edit'>('create')
const currentRecord = ref<ReportModel.ReportModelRecord | null>(null)
const detailRecord = ref<ReportModel.ReportModelRecord | null>(null)
const previewFileBlob = ref<Blob | null>(null)
const previewFileName = ref('')
const previewErrorMessage = ref('')
const columns = reactive<ColumnProps<ReportModel.ReportModelRecord>[]>([
{ type: 'selection', fixed: 'left', width: 70 },
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
{
prop: 'name',
label: '模板名称',
minWidth: 180,
search: {
el: 'input',
order: 1,
props: {
placeholder: '请输入模板名称'
}
}
},
{
prop: 'path',
label: '模版文件',
minWidth: 260,
showOverflowTooltip: true,
render: ({ row }) =>
resolveReportModelFileName(row) ? (
<el-button link type="primary" onClick={() => openPreviewDialog(row)}>
{resolveReportModelFileName(row)}
</el-button>
) : (
resolveReportModelText(resolveReportModelFileName(row))
)
},
{
prop: 'state',
label: '模板状态',
width: 120,
enum: STATE_OPTIONS,
isFilterEnum: false,
render: scope => (
<el-tag type={getReportModelStateTagType(scope.row.state)} effect="light">
{getReportModelStateText(scope.row.state)}
</el-tag>
),
search: {
el: 'select',
order: 2,
props: {
placeholder: '请选择模板状态'
}
}
},
{
prop: 'timeRange',
label: '更新时间',
minWidth: 220,
isShow: false,
search: {
el: 'date-picker',
order: 3,
key: 'timeRange',
props: {
type: 'datetimerange',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
startPlaceholder: '开始更新时间',
endPlaceholder: '结束更新时间'
}
}
},
{ prop: 'editorName', label: '编辑人', width: 120, render: ({ row }) => resolveReportModelText(row.editorName) },
{ prop: 'updateTime', label: '更新时间', minWidth: 170, render: ({ row }) => resolveReportModelText(row.updateTime) },
{ prop: 'checkerName', label: '审核人', width: 120 },
{ prop: 'checkTime', label: '审核时间', minWidth: 170, render: ({ row }) => resolveReportModelText(row.checkTime) },
{ prop: 'checkResult', label: '审核意见', minWidth: 220, showOverflowTooltip: true, render: ({ row }) => resolveReportModelText(row.checkResult) },
{ prop: 'createBy', label: '创建人', width: 120, isShow: false },
{ prop: 'operation', label: '操作', fixed: 'right', width: 380 }
])
const getTableList = async (params: ReportModel.ReportModelListParams = {}) => {
const response = await listReportModelsApi(normalizeReportModelListParams(params))
const pageData = normalizeReportModelPage<ReportModel.ReportModelRecord>(response as unknown as ResPage<ReportModel.ReportModelRecord>)
return {
data: pageData
}
}
const refreshReportModels = () => {
proTable.value?.clearSelection()
proTable.value?.getTableList()
}
const handleTableRequestError = (error: unknown) => {
ElMessage.error(getReportModelErrorMessage(error, '模板列表查询失败'))
}
const resetPreviewState = () => {
previewLoading.value = false
previewFileBlob.value = null
previewFileName.value = ''
previewErrorMessage.value = ''
}
const openCreateDialog = () => {
formMode.value = 'create'
currentRecord.value = null
formDialogVisible.value = true
}
const openEditDialog = (row: ReportModel.ReportModelRecord) => {
formMode.value = 'edit'
currentRecord.value = row
formDialogVisible.value = true
}
const openDetailDialog = async (row: ReportModel.ReportModelRecord) => {
if (!row.id) {
ElMessage.warning('当前模板缺少 ID无法查看详情')
return
}
detailRecord.value = null
detailDialogVisible.value = true
detailLoading.value = true
try {
// 关键业务节点:详情字段单独走 GET /{id},列表页不假设审核意见和模板路径一定完整返回。
const response = await getReportModelDetailApi(row.id)
detailRecord.value = unwrapApiPayload<ReportModel.ReportModelRecord>(response)
} catch (error) {
ElMessage.error(getReportModelErrorMessage(error, '模板详情查询失败'))
} finally {
detailLoading.value = false
}
}
const openPreviewDialog = async (row: ReportModel.ReportModelRecord) => {
if (!row.id) {
ElMessage.warning('当前模板缺少 ID无法预览')
return
}
resetPreviewState()
previewDialogVisible.value = true
previewLoading.value = true
try {
// 关键业务节点:页面内预览复用现有下载接口,优先从响应头解析真实文件名,再决定是否可进入 docx 渲染。
const response = await downloadReportModelApi(row.id)
const fallbackExtension = row.path?.toLowerCase().endsWith('.doc') ? '.doc' : '.docx'
const fileName = resolveReportModelDownloadFileName(response.headers, row.name || 'report-model', fallbackExtension)
previewFileName.value = fileName
if (!canPreviewReportModelFile(fileName) && !canPreviewReportModelFile(resolveReportModelFileName(row))) {
previewErrorMessage.value = '当前仅支持 .docx 模板页面内预览,请下载 .doc 文件后查看'
return
}
previewFileBlob.value = response.data
} catch (error) {
previewErrorMessage.value = getReportModelErrorMessage(error, '模板预览加载失败')
} finally {
previewLoading.value = false
}
}
const openAuditDialog = async (row: ReportModel.ReportModelRecord) => {
if (!row.id) {
ElMessage.warning('当前模板缺少 ID无法提交审核')
return
}
try {
await ElMessageBox.confirm(`确认提交模板“${row.name || '-'}”进入审核中状态吗?`, '提交审核', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
auditSaving.value = true
try {
// 关键业务节点:提交审核只允许编制态记录调用,前端在按钮态和提交前都保持同一约束。
await submitReportModelAuditApi({ id: row.id })
ElMessage.success('模板提交审核成功')
refreshReportModels()
} catch (error) {
ElMessage.error(getReportModelErrorMessage(error, '模板提交审核失败'))
} finally {
auditSaving.value = false
}
}
const handleSaveReportModel = async ({
request,
file
}: {
request: ReportModel.ReportModelSaveRequest
file: File | null
}) => {
formSaving.value = true
try {
if (formMode.value === 'create') {
if (!file) {
ElMessage.warning('新增模板时必须上传 Word 模板文件')
return
}
await createReportModelApi(request, file)
ElMessage.success('模板新增成功')
} else {
await updateReportModelApi(request, file)
ElMessage.success('模板编辑成功')
}
formDialogVisible.value = false
refreshReportModels()
} catch (error) {
ElMessage.error(getReportModelErrorMessage(error, '模板保存失败'))
} finally {
formSaving.value = false
}
}
const deleteReportModels = async (ids: string[], successMessage: string) => {
if (!ids.length) {
ElMessage.warning('请选择要删除的模板')
return
}
try {
await ElMessageBox.confirm('删除后模板将不可见,是否继续?', '删除模板', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
try {
await deleteReportModelsApi(ids)
ElMessage.success(successMessage)
refreshReportModels()
} catch (error) {
ElMessage.error(getReportModelErrorMessage(error, '模板删除失败'))
}
}
const handleDelete = async (row: ReportModel.ReportModelRecord) => {
if (!row.id) {
ElMessage.warning('当前模板缺少 ID无法删除')
return
}
await deleteReportModels([row.id], '模板删除成功')
}
const handleBatchDelete = async (ids: string[]) => {
await deleteReportModels(ids, '模板批量删除成功')
}
const handleDownload = async (row: ReportModel.ReportModelRecord) => {
if (!row.id) {
ElMessage.warning('当前模板缺少 ID无法下载')
return
}
await useDownloadWithServerFileName(() => downloadReportModelApi(row.id!), row.name || 'report-model', undefined, false, '.docx')
}
</script>
<style scoped lang="scss">
.report-model-page {
min-height: 0;
padding: 0;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,125 @@
import type { TagProps } from 'element-plus'
import type { ResultData, ResPage } from '@/api/interface'
import type { ReportModel } from '@/api/aireport/reportModel/interface'
type TagType = TagProps['type']
type ResponseHeaders = Record<string, unknown>
const REPORT_MODEL_STATE_MAP: Record<string, { text: string; tagType: TagType }> = {
'01': { text: '编制', tagType: 'info' },
'02': { text: '审核中', tagType: 'warning' },
'03': { text: '已发布', tagType: 'success' },
'04': { text: '已删除', tagType: 'danger' }
}
const REPORT_MODEL_WORD_EXTENSIONS = ['doc', 'docx']
export const REPORT_MODEL_TEMPLATE_ACCEPT = '.doc,.docx'
export const getReportModelStateText = (state?: string) => REPORT_MODEL_STATE_MAP[state || '']?.text || '未知状态'
export const getReportModelStateTagType = (state?: string): TagType => REPORT_MODEL_STATE_MAP[state || '']?.tagType || 'info'
export const isDraftReportModel = (row?: ReportModel.ReportModelRecord | null) => row?.state === '01'
export const resolveReportModelFileName = (row?: ReportModel.ReportModelRecord | null) =>
row?.fileName || row?.file_name || row?.path || ''
export const resolveReportModelText = (value: unknown) => {
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
export const getReportModelFileExtension = (fileName?: string | null) => {
if (!fileName) return ''
const segments = fileName.split('.')
return segments.length > 1 ? segments.pop()!.toLowerCase() : ''
}
export const validateReportModelTemplateFile = (file: File) => {
if (!REPORT_MODEL_WORD_EXTENSIONS.includes(getReportModelFileExtension(file.name))) {
return '报告模板仅支持 Word 文档(.doc/.docx'
}
return ''
}
const decodeReportModelFileName = (value: string) => {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
export const resolveReportModelDownloadFileName = (
headers?: ResponseHeaders | null,
fallbackName = 'report-model',
fallbackExtension = '.docx'
) => {
const contentDisposition = String(headers?.['content-disposition'] || headers?.['Content-Disposition'] || '')
const utf8Match = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(contentDisposition)
if (utf8Match?.[1]) {
return decodeReportModelFileName(utf8Match[1].trim())
}
const fallbackMatch = /filename\s*=\s*([^;]+)/i.exec(contentDisposition)
if (fallbackMatch?.[1]) {
return decodeReportModelFileName(fallbackMatch[1].trim().replace(/^["']|["']$/g, ''))
}
return `${fallbackName}${fallbackExtension}`
}
export const canPreviewReportModelFile = (fileName?: string | null) => getReportModelFileExtension(fileName) === 'docx'
export const normalizeReportModelListParams = (
params: ReportModel.ReportModelListParams = {}
): ReportModel.ReportModelListParams => {
const timeRange = Array.isArray(params.timeRange) ? params.timeRange : []
const [searchBeginTime = '', searchEndTime = ''] = timeRange
return {
name: String(params.name || '').trim() || undefined,
state: params.state || undefined,
searchBeginTime: searchBeginTime || params.searchBeginTime || undefined,
searchEndTime: searchEndTime || params.searchEndTime || undefined,
pageNum: params.pageNum || 1,
pageSize: params.pageSize || 10
}
}
export 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
}
export const normalizeReportModelPage = <T>(
response: ResultData<ResPage<T>> | ResPage<T> | null | undefined
): ResPage<T> => {
const payload = unwrapApiPayload<ResPage<T> | null | undefined>(response as ResultData<ResPage<T>>)
return {
records: payload?.records || [],
current: payload?.current || 1,
size: payload?.size || 10,
total: payload?.total || 0
}
}
export const getReportModelErrorMessage = (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
}

View File

@@ -0,0 +1,72 @@
<template>
<el-dialog v-model="visibleProxy" title="检测设备详情" width="720px" append-to-body destroy-on-close>
<div v-loading="loading" class="test-device-detail-dialog">
<el-descriptions class="detail-descriptions" :column="2" border>
<el-descriptions-item label="设备型号">{{ resolveTestDeviceText(detail?.typeName) }}</el-descriptions-item>
<el-descriptions-item label="设备编号">{{ resolveTestDeviceText(detail?.no) }}</el-descriptions-item>
<el-descriptions-item label="设备状态">
<el-tag :type="getTestDeviceStateTagType(detail?.state)" effect="light">
{{ getTestDeviceStateText(detail?.state) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="校准有效期">
{{ resolveTestDeviceText(detail?.validityPeriod) }}
</el-descriptions-item>
<el-descriptions-item label="校准报告" :span="2">
{{ resolveTestDeviceText(detail?.validityReportName || detail?.validityReport) }}
</el-descriptions-item>
<el-descriptions-item label="创建人">
{{ resolveTestDeviceText(detail?.createByName || detail?.createBy) }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ resolveTestDeviceText(detail?.createTime) }}</el-descriptions-item>
<el-descriptions-item label="更新人">
{{ resolveTestDeviceText(detail?.updateByName || detail?.updateBy) }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ resolveTestDeviceText(detail?.updateTime) }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button type="primary" @click="visibleProxy = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { TestDevice } from '@/api/aireport/testDevice/interface'
import { getTestDeviceStateTagType, getTestDeviceStateText, resolveTestDeviceText } from '../utils/testDevice'
defineOptions({
name: 'TestDeviceDetailDialog'
})
const props = defineProps<{
visible: boolean
loading: boolean
detail: TestDevice.TestDeviceRecord | null
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
}>()
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
</script>
<style scoped lang="scss">
.test-device-detail-dialog {
min-height: 160px;
}
.test-device-detail-dialog :deep(.detail-descriptions .el-descriptions__table) {
width: 100%;
table-layout: fixed;
}
.test-device-detail-dialog :deep(.detail-descriptions .el-descriptions__label.el-descriptions__cell.is-bordered-label) {
width: 112px;
}
</style>

View File

@@ -0,0 +1,228 @@
<template>
<el-dialog
v-model="visibleProxy"
:title="mode === 'create' ? '新增检测设备' : '编辑检测设备'"
width="640px"
append-to-body
destroy-on-close
@closed="resetForm"
>
<el-form ref="formRef" :model="formModel" :rules="formRules" label-width="108px">
<el-form-item label="设备型号" prop="type">
<el-select v-model="formModel.type" filterable clearable placeholder="请选择设备型号">
<el-option
v-for="option in deviceTypeSelectOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="设备编号" prop="no">
<el-input v-model="formModel.no" maxlength="64" clearable placeholder="请输入设备编号" />
</el-form-item>
<el-form-item label="校准有效期" prop="validityPeriod">
<el-date-picker
v-model="formModel.validityPeriod"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择校准有效期"
/>
</el-form-item>
<el-form-item label="校准报告" :required="mode === 'create'">
<div class="file-select-row">
<el-input :model-value="selectedFileName" readonly placeholder="请选择 PDF 校准报告" />
<el-button type="primary" plain :icon="FolderOpened" :disabled="saving" @click="openFilePicker">
选择文件
</el-button>
<input ref="fileInputRef" class="hidden-file-input" type="file" accept=".pdf" @change="handleFileChange" />
</div>
<div class="file-select-tip">
{{
mode === 'create'
? `新增检测设备时必须上传 PDF 校准报告,文件大小不能超过 ${TEST_DEVICE_REPORT_MAX_SIZE_TEXT}`
: `编辑时不上传则保留原报告,新上传文件需为 PDF 且不能超过 ${TEST_DEVICE_REPORT_MAX_SIZE_TEXT}`
}}
</div>
</el-form-item>
<el-form-item label="设备状态" prop="state">
<el-select v-model="formModel.state" placeholder="请选择设备状态">
<el-option
v-for="option in TEST_DEVICE_STATE_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</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 { TestDevice } from '@/api/aireport/testDevice/interface'
import {
TEST_DEVICE_REPORT_MAX_SIZE_TEXT,
TEST_DEVICE_STATE_OPTIONS,
validateTestDeviceReportFile
} from '../utils/testDevice'
defineOptions({
name: 'TestDeviceFormDialog'
})
const props = defineProps<{
visible: boolean
mode: 'create' | 'edit'
record: TestDevice.TestDeviceRecord | null
saving: boolean
deviceTypeOptions: Array<{ label: string; value: string }>
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
(event: 'submit', value: { request: TestDevice.TestDeviceSaveRequest; reportFile: File | null }): void
}>()
const formRef = ref<FormInstance>()
const fileInputRef = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const formModel = reactive({
id: '',
type: '',
no: '',
validityPeriod: '',
state: '01'
})
const formRules: FormRules = {
type: [{ required: true, message: '请选择设备型号', trigger: 'change' }],
no: [{ required: true, message: '请输入设备编号', trigger: 'blur' }],
validityPeriod: [{ required: true, message: '请选择校准有效期', trigger: 'change' }],
state: [{ required: true, message: '请选择设备状态', trigger: 'change' }]
}
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const selectedFileName = computed(() => selectedFile.value?.name || props.record?.validityReportName || '')
const deviceTypeSelectOptions = computed(() => {
if (!formModel.type || props.deviceTypeOptions.some(option => option.value === formModel.type)) {
return props.deviceTypeOptions
}
return [
...props.deviceTypeOptions,
{
label: props.record?.typeName || formModel.type,
value: formModel.type
}
]
})
const resetForm = () => {
formRef.value?.clearValidate()
selectedFile.value = null
formModel.id = ''
formModel.type = ''
formModel.no = ''
formModel.validityPeriod = ''
formModel.state = '01'
}
const fillForm = () => {
formModel.id = props.record?.id || ''
formModel.type = props.record?.type || ''
formModel.no = props.record?.no || ''
formModel.validityPeriod = props.record?.validityPeriod || ''
formModel.state = props.record?.state || '01'
selectedFile.value = null
}
watch(
() => [props.visible, props.mode, props.record?.id],
([visible]) => {
if (visible) fillForm()
},
{ immediate: true }
)
const openFilePicker = () => {
fileInputRef.value?.click()
}
const handleFileChange = (event: Event) => {
const input = event.target as HTMLInputElement
const reportFile = input.files?.[0] || null
input.value = ''
if (!reportFile) return
const validationMessage = validateTestDeviceReportFile(reportFile)
if (validationMessage) {
ElMessage.warning(validationMessage)
return
}
selectedFile.value = reportFile
}
const submitForm = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
if (props.mode === 'create' && !selectedFile.value) {
ElMessage.warning('新增检测设备时必须上传 PDF 校准报告')
return
}
if (props.mode === 'edit' && !formModel.id) {
ElMessage.warning('当前检测设备缺少 ID无法保存编辑')
return
}
emit('submit', {
request: {
id: formModel.id || undefined,
type: formModel.type.trim(),
no: formModel.no.trim(),
validityPeriod: formModel.validityPeriod,
state: formModel.state
},
reportFile: selectedFile.value
})
}
</script>
<style scoped lang="scss">
.file-select-row {
display: flex;
width: 100%;
gap: 12px;
}
.file-select-tip {
margin-top: 8px;
color: #909399;
font-size: 12px;
line-height: 1.4;
}
.hidden-file-input {
display: none;
}
:deep(.el-select),
:deep(.el-date-editor.el-input) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<el-dialog v-model="visibleProxy" title="导入检测设备" width="680px" append-to-body destroy-on-close @closed="resetImport">
<div class="test-device-import-dialog">
<div class="file-select-row">
<el-input :model-value="selectedFileName" readonly placeholder="请选择检测设备 Excel 文件" />
<el-button type="primary" plain :icon="FolderOpened" :disabled="importing" @click="openFilePicker">
选择Excel
</el-button>
<input ref="fileInputRef" class="hidden-file-input" type="file" accept=".xlsx,.xls" @change="handleFileChange" />
</div>
<div class="file-select-row">
<el-input :model-value="selectedZipName" readonly placeholder="请选择校准报告 ZIP" />
<el-button type="primary" plain :icon="FolderOpened" :disabled="importing" @click="openZipPicker">
选择ZIP
</el-button>
<input ref="zipInputRef" class="hidden-file-input" type="file" accept=".zip" @change="handleZipChange" />
</div>
<el-alert
v-if="result"
class="import-result"
:title="`导入完成:成功 ${result.successCount || 0} 条,失败 ${result.failCount || 0} 条`"
:type="result.failCount ? 'warning' : 'success'"
show-icon
:closable="false"
/>
<el-table v-if="result?.failReasons?.length" class="fail-reason-table" :data="failReasonRows" border>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="reason" label="失败原因" min-width="260" show-overflow-tooltip />
</el-table>
</div>
<template #footer>
<el-button :disabled="importing" @click="visibleProxy = false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImport">开始导入</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { FolderOpened } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue'
import type { TestDevice } from '@/api/aireport/testDevice/interface'
import { validateTestDeviceImportExcelFile, validateTestDeviceImportZipFile } from '../utils/testDevice'
defineOptions({
name: 'TestDeviceImportDialog'
})
const props = defineProps<{
visible: boolean
importing: boolean
result: TestDevice.TestDeviceImportResult | null
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
(event: 'submit', value: { file: File; reportZip: File }): void
}>()
const fileInputRef = ref<HTMLInputElement | null>(null)
const zipInputRef = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const reportZip = ref<File | null>(null)
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const selectedFileName = computed(() => selectedFile.value?.name || '')
const selectedZipName = computed(() => reportZip.value?.name || '')
const failReasonRows = computed(() => (props.result?.failReasons || []).map(reason => ({ reason })))
const openFilePicker = () => {
fileInputRef.value?.click()
}
const openZipPicker = () => {
zipInputRef.value?.click()
}
const handleFileChange = (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0] || null
input.value = ''
if (!file) return
const validationMessage = validateTestDeviceImportExcelFile(file)
if (validationMessage) {
ElMessage.warning(validationMessage)
return
}
selectedFile.value = file
}
const handleZipChange = (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0] || null
input.value = ''
if (!file) return
const validationMessage = validateTestDeviceImportZipFile(file)
if (validationMessage) {
ElMessage.warning(validationMessage)
return
}
reportZip.value = file
}
const resetImport = () => {
selectedFile.value = null
reportZip.value = null
}
const submitImport = () => {
if (!selectedFile.value) {
ElMessage.warning('请选择检测设备 Excel 文件')
return
}
if (!reportZip.value) {
ElMessage.warning('请选择校准报告 ZIP')
return
}
emit('submit', {
file: selectedFile.value,
reportZip: reportZip.value
})
}
</script>
<style scoped lang="scss">
.test-device-import-dialog {
display: flex;
flex-direction: column;
gap: 14px;
}
.file-select-row {
display: flex;
width: 100%;
gap: 12px;
}
.hidden-file-input {
display: none;
}
.fail-reason-table {
width: 100%;
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<el-dialog
v-model="visibleProxy"
:title="previewTitle"
width="960px"
append-to-body
destroy-on-close
@closed="clearPreview"
>
<div v-loading="loading" class="test-device-report-preview-dialog">
<div v-if="errorMessage" class="preview-error-wrapper">
<el-alert :title="errorMessage" type="warning" :closable="false" show-icon />
</div>
<iframe
v-else-if="previewUrl"
:src="previewUrl"
class="preview-frame"
frameborder="0"
title="校准报告预览"
/>
<div v-else class="preview-empty-wrapper">
<el-empty description="暂无可预览的校准报告" />
</div>
</div>
<template #footer>
<el-button type="primary" @click="visibleProxy = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
defineOptions({
name: 'TestDeviceReportPreviewDialog'
})
const props = defineProps<{
visible: boolean
loading: boolean
fileBlob: Blob | null
fileName: string
errorMessage: string
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
}>()
const previewUrl = ref('')
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const previewTitle = computed(() => (props.fileName ? `校准报告预览 - ${props.fileName}` : '校准报告预览'))
const revokePreviewUrl = () => {
if (!previewUrl.value) return
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
const clearPreview = () => {
revokePreviewUrl()
}
watch(
() => [props.visible, props.fileBlob],
() => {
revokePreviewUrl()
if (!props.visible || !props.fileBlob) return
// 关键业务节点PDF 预览必须为当前 blob 重新创建对象地址,并在弹窗关闭或文件切换时立即释放,避免旧报告串页或内存泄漏。
previewUrl.value = URL.createObjectURL(props.fileBlob)
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.test-device-report-preview-dialog {
min-height: 520px;
height: 70vh;
background: #f5f7fa;
}
.preview-error-wrapper,
.preview-empty-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 24px;
}
.preview-frame {
width: 100%;
height: 100%;
min-height: 520px;
border: 0;
background: #fff;
}
</style>

View File

@@ -0,0 +1,119 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const pageDir = path.resolve(__dirname, '..')
const srcDir = path.resolve(pageDir, '../../..')
const apiDir = path.resolve(srcDir, 'api/aireport/testDevice')
const read = file => fs.readFileSync(file, 'utf8')
const assertFileIncludes = (file, snippets) => {
const content = read(file)
const missing = snippets.filter(snippet => !content.includes(snippet))
if (missing.length) {
throw new Error(`${path.relative(srcDir, file)} missing: ${missing.join(', ')}`)
}
}
assertFileIncludes(path.join(apiDir, 'index.ts'), [
"const TEST_DEVICE_BASE_URL = '/api/test-device'",
'listTestDevicesApi',
'createTestDeviceApi',
'updateTestDeviceApi',
'deleteTestDevicesApi',
'getTestDeviceDetailApi',
'downloadTestDeviceImportTemplateApi',
'importTestDevicesApi',
'exportTestDevicesApi',
'downloadTestDeviceReportApi',
'previewTestDeviceReportApi',
"return http.get(`${TEST_DEVICE_BASE_URL}/${id}/report`, undefined, { responseType: 'blob' })",
"formData.append('request'",
"formData.append('reportFile'",
"formData.append('reportZip'"
])
const apiIndexContent = read(path.join(apiDir, 'index.ts'))
if (apiIndexContent.includes('/api/test-report/')) {
throw new Error('api/aireport/testDevice/index.ts should not reference /api/test-report preview endpoint')
}
assertFileIncludes(path.join(apiDir, 'interface/index.ts'), [
'export namespace TestDevice',
'TestDeviceRecord',
'TestDeviceListParams',
'TestDeviceSaveRequest',
'TestDeviceImportResult',
"export type TestDeviceState = '01' | '02' | (string & {})"
])
assertFileIncludes(path.join(pageDir, 'utils/testDevice.ts'), [
'normalizeTestDeviceListParams',
'normalizeTestDevicePage',
'getTestDeviceStateText',
'getTestDeviceStateTagType',
'unwrapTestDevicePayload'
])
assertFileIncludes(path.join(pageDir, 'components/TestDeviceFormDialog.vue'), [
"name: 'TestDeviceFormDialog'",
'reportFile',
'validityPeriod',
'新增检测设备时必须上传 PDF 校准报告'
])
assertFileIncludes(path.join(pageDir, 'components/TestDeviceImportDialog.vue'), [
"name: 'TestDeviceImportDialog'",
'reportZip',
'failReasons',
'请选择校准报告 ZIP'
])
assertFileIncludes(path.join(pageDir, 'components/TestDeviceDetailDialog.vue'), [
"name: 'TestDeviceDetailDialog'",
'设备型号',
'校准报告',
"detail?.updateByName || detail?.updateBy",
':span="2"',
'table-layout: fixed',
'width: 112px'
])
const detailDialogContent = read(path.join(pageDir, 'components/TestDeviceDetailDialog.vue'))
if (detailDialogContent.includes('设备型号ID')) {
throw new Error('views/aireport/testDevice/components/TestDeviceDetailDialog.vue should not display 设备型号ID')
}
assertFileIncludes(path.join(pageDir, 'index.vue'), [
"name: 'TestDevicePage'",
'<ProTable',
'TestDeviceFormDialog',
'TestDeviceDetailDialog',
'TestDeviceImportDialog',
'TestDeviceReportPreviewDialog',
'handleDownloadReport',
'openPreviewDialog',
'previewTestDeviceReportApi',
'handleDownloadTemplate',
'handleImportTestDevices',
'type="primary" :icon="Plus"',
'type="danger"',
'type="primary" plain'
])
const pageSource = read(path.join(pageDir, 'index.vue'))
if (!/prop:\s*'timeRange'[\s\S]*label:\s*'更新时间'[\s\S]*startPlaceholder:\s*'开始更新时间'[\s\S]*endPlaceholder:\s*'结束更新时间'/.test(pageSource)) {
throw new Error('views/aireport/testDevice/index.vue should use 更新时间 as the search time range label')
}
assertFileIncludes(path.join(pageDir, 'components/TestDeviceReportPreviewDialog.vue'), [
"name: 'TestDeviceReportPreviewDialog'",
'fileBlob',
'fileName',
'errorMessage',
'URL.createObjectURL'
])
console.log('testDevice page contract passed')

View File

@@ -0,0 +1,449 @@
<template>
<div class="table-box test-device-page">
<ProTable
ref="proTable"
:columns="columns"
:request-api="getTableList"
:request-error="handleTableRequestError"
:search-col="{ xs: 1, sm: 2, md: 2, lg: 4, xl: 4 }"
:tool-button="['refresh', 'setting']"
row-key="id"
>
<template #tableHeader="scope">
<el-button type="primary" :icon="Plus" @click="openCreateDialog">新增</el-button>
<el-button
type="danger"
plain
:icon="Delete"
:disabled="!scope.isSelected"
@click="handleBatchDelete(scope.selectedListIds)"
>
批量删除
</el-button>
<el-button type="primary" plain :icon="Download" @click="handleExport">导出</el-button>
<el-button type="primary" plain :icon="Document" @click="handleDownloadTemplate">下载模板</el-button>
<el-button type="primary" plain :icon="Upload" @click="openImportDialog">导入</el-button>
</template>
<template #operation="{ row }">
<el-button link type="primary" :icon="View" @click="openDetailDialog(row)">详情</el-button>
<el-button link type="primary" :icon="Edit" @click="openEditDialog(row)">编辑</el-button>
<el-button link type="primary" :icon="Download" :disabled="!row.id" @click="handleDownloadReport(row)">
报告
</el-button>
<el-button link type="danger" :icon="Delete" @click="handleDelete(row)">删除</el-button>
</template>
</ProTable>
<TestDeviceFormDialog
v-model:visible="formDialogVisible"
:mode="formMode"
:record="currentRecord"
:saving="formSaving"
:device-type-options="deviceTypeOptions"
@submit="handleSaveTestDevice"
/>
<TestDeviceDetailDialog
v-model:visible="detailDialogVisible"
:loading="detailLoading"
:detail="detailRecord"
/>
<TestDeviceImportDialog
v-model:visible="importDialogVisible"
:importing="importing"
:result="importResult"
@submit="handleImportTestDevices"
/>
<TestDeviceReportPreviewDialog
v-model:visible="previewDialogVisible"
:loading="previewLoading"
:file-name="previewFileName"
:file-blob="previewFileBlob"
:error-message="previewErrorMessage"
/>
</div>
</template>
<script setup lang="tsx">
import { Delete, Document, Download, Edit, Plus, Upload, View } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, reactive, ref } from 'vue'
import type { ResPage } from '@/api/interface'
import ProTable from '@/components/ProTable/index.vue'
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
import {
createTestDeviceApi,
deleteTestDevicesApi,
downloadTestDeviceImportTemplateApi,
downloadTestDeviceReportApi,
exportTestDevicesApi,
getTestDeviceDetailApi,
importTestDevicesApi,
listTestDevicesApi,
previewTestDeviceReportApi,
updateTestDeviceApi
} from '@/api/aireport/testDevice'
import type { TestDevice } from '@/api/aireport/testDevice/interface'
import { listDeviceTypesApi } from '@/api/tools/mmsmapping'
import { useDownloadWithServerFileName } from '@/hooks/useDownload'
import TestDeviceDetailDialog from './components/TestDeviceDetailDialog.vue'
import TestDeviceFormDialog from './components/TestDeviceFormDialog.vue'
import TestDeviceImportDialog from './components/TestDeviceImportDialog.vue'
import TestDeviceReportPreviewDialog from './components/TestDeviceReportPreviewDialog.vue'
import {
TEST_DEVICE_STATE_OPTIONS,
getTestDeviceErrorMessage,
getTestDeviceImportResultMessage,
getTestDeviceStateTagType,
getTestDeviceStateText,
normalizeTestDeviceListParams,
normalizeTestDevicePage,
resolveTestDeviceText,
unwrapTestDevicePayload
} from './utils/testDevice'
defineOptions({
name: 'TestDevicePage'
})
const proTable = ref<ProTableInstance>()
const formDialogVisible = ref(false)
const detailDialogVisible = ref(false)
const importDialogVisible = ref(false)
const previewDialogVisible = ref(false)
const formSaving = ref(false)
const detailLoading = ref(false)
const importing = ref(false)
const previewLoading = ref(false)
const formMode = ref<'create' | 'edit'>('create')
const currentRecord = ref<TestDevice.TestDeviceRecord | null>(null)
const detailRecord = ref<TestDevice.TestDeviceRecord | null>(null)
const importResult = ref<TestDevice.TestDeviceImportResult | null>(null)
const deviceTypeOptions = ref<Array<{ label: string; value: string }>>([])
const previewFileBlob = ref<Blob | null>(null)
const previewFileName = ref('')
const previewErrorMessage = ref('')
const currentSearchParams = computed(() =>
normalizeTestDeviceListParams((proTable.value?.searchParam || {}) as TestDevice.TestDeviceListParams)
)
const columns = reactive<ColumnProps<TestDevice.TestDeviceRecord>[]>([
{ type: 'selection', fixed: 'left', width: 70 },
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
{
prop: 'keyword',
label: '关键字',
minWidth: 180,
isShow: false,
search: {
el: 'input',
order: 1,
props: {
placeholder: '请输入设备型号或设备编号'
}
}
},
{
prop: 'timeRange',
label: '更新时间',
minWidth: 220,
isShow: false,
search: {
el: 'date-picker',
order: 2,
key: 'timeRange',
props: {
type: 'datetimerange',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
startPlaceholder: '开始更新时间',
endPlaceholder: '结束更新时间'
}
}
},
{ prop: 'typeName', label: '设备型号', minWidth: 180, fixed: 'left', render: ({ row }) => resolveTestDeviceText(row.typeName) },
{ prop: 'no', label: '设备编号', minWidth: 170, showOverflowTooltip: true, render: ({ row }) => resolveTestDeviceText(row.no) },
{ prop: 'validityPeriod', label: '校准有效期', width: 130, render: ({ row }) => resolveTestDeviceText(row.validityPeriod) },
{
prop: 'validityReportName',
label: '校准报告',
minWidth: 180,
showOverflowTooltip: true,
render: ({ row }) => {
const reportName = row.validityReportName || row.validityReport
return row.id && reportName ? (
<el-button link type="primary" onClick={() => openPreviewDialog(row)}>
{reportName}
</el-button>
) : (
resolveTestDeviceText(reportName)
)
}
},
{
prop: 'state',
label: '设备状态',
width: 110,
enum: TEST_DEVICE_STATE_OPTIONS,
isFilterEnum: false,
render: scope => (
<el-tag type={getTestDeviceStateTagType(scope.row.state)} effect="light">
{getTestDeviceStateText(scope.row.state)}
</el-tag>
)
},
{ prop: 'createByName', label: '创建人', width: 120, render: ({ row }) => resolveTestDeviceText(row.createByName || row.createBy) },
{ prop: 'createTime', label: '创建时间', minWidth: 170, render: ({ row }) => resolveTestDeviceText(row.createTime) },
{ prop: 'updateByName', label: '更新人', width: 120, render: ({ row }) => resolveTestDeviceText(row.updateByName || row.updateBy) },
{ prop: 'updateTime', label: '更新时间', minWidth: 170, render: ({ row }) => resolveTestDeviceText(row.updateTime) },
{ prop: 'operation', label: '操作', fixed: 'right', width: 300 }
])
const getTableList = async (params: TestDevice.TestDeviceListParams = {}) => {
const response = await listTestDevicesApi(normalizeTestDeviceListParams(params))
const pageData = normalizeTestDevicePage<TestDevice.TestDeviceRecord>(
response as unknown as ResPage<TestDevice.TestDeviceRecord>
)
return {
data: pageData
}
}
const refreshTestDevices = () => {
proTable.value?.clearSelection()
proTable.value?.getTableList()
}
const handleTableRequestError = (error: unknown) => {
ElMessage.error(getTestDeviceErrorMessage(error, '检测设备列表查询失败'))
}
const resetPreviewState = () => {
previewLoading.value = false
previewFileBlob.value = null
previewFileName.value = ''
previewErrorMessage.value = ''
}
const loadDeviceTypeOptions = async () => {
try {
const response = await listDeviceTypesApi()
const records = unwrapTestDevicePayload<Array<{ id?: string; name?: string }>>(response) || []
deviceTypeOptions.value = records
.filter(record => record.id && record.name)
.map(record => ({
label: record.name!,
value: record.id!
}))
} catch (error) {
ElMessage.error(getTestDeviceErrorMessage(error, '设备型号列表查询失败'))
deviceTypeOptions.value = []
}
}
const openCreateDialog = () => {
formMode.value = 'create'
currentRecord.value = null
loadDeviceTypeOptions()
formDialogVisible.value = true
}
const openEditDialog = (row: TestDevice.TestDeviceRecord) => {
formMode.value = 'edit'
currentRecord.value = row
loadDeviceTypeOptions()
formDialogVisible.value = true
}
const openDetailDialog = async (row: TestDevice.TestDeviceRecord) => {
if (!row.id) {
ElMessage.warning('当前检测设备缺少 ID无法查看详情')
return
}
detailRecord.value = null
detailDialogVisible.value = true
detailLoading.value = true
try {
// 详情以 GET /{id} 为准,避免列表缺失报告名称或创建人等回显字段。
const response = await getTestDeviceDetailApi(row.id)
detailRecord.value = unwrapTestDevicePayload<TestDevice.TestDeviceRecord>(response)
} catch (error) {
ElMessage.error(getTestDeviceErrorMessage(error, '检测设备详情查询失败'))
} finally {
detailLoading.value = false
}
}
const openImportDialog = () => {
importResult.value = null
importDialogVisible.value = true
}
const openPreviewDialog = async (row: TestDevice.TestDeviceRecord) => {
if (!row.id) {
ElMessage.warning('当前检测设备缺少 ID无法预览校准报告')
return
}
resetPreviewState()
previewDialogVisible.value = true
previewLoading.value = true
try {
// 关键业务节点test-device 当前仅提供 /{id}/report报告名预览与“报告”下载统一复用同一报告流。
const response = await previewTestDeviceReportApi(row.id)
previewFileBlob.value = response.data as Blob
previewFileName.value = row.validityReportName || row.validityReport || `${row.no || 'test-device-report'}.pdf`
} catch (error) {
previewErrorMessage.value = getTestDeviceErrorMessage(error, '检测设备校准报告预览加载失败')
} finally {
previewLoading.value = false
}
}
const handleSaveTestDevice = async ({
request,
reportFile
}: {
request: TestDevice.TestDeviceSaveRequest
reportFile: File | null
}) => {
formSaving.value = true
try {
if (formMode.value === 'create') {
if (!reportFile) {
ElMessage.warning('新增检测设备时必须上传 PDF 校准报告')
return
}
await createTestDeviceApi(request, reportFile)
ElMessage.success('检测设备新增成功')
} else {
await updateTestDeviceApi(request, reportFile)
ElMessage.success('检测设备编辑成功')
}
formDialogVisible.value = false
refreshTestDevices()
} catch (error) {
ElMessage.error(getTestDeviceErrorMessage(error, '检测设备保存失败'))
} finally {
formSaving.value = false
}
}
const deleteTestDevices = async (ids: string[], successMessage: string) => {
if (!ids.length) {
ElMessage.warning('请选择要删除的检测设备')
return
}
try {
await ElMessageBox.confirm('删除后检测设备将不可见,是否继续?', '删除检测设备', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
try {
await deleteTestDevicesApi(ids)
ElMessage.success(successMessage)
refreshTestDevices()
} catch (error) {
ElMessage.error(getTestDeviceErrorMessage(error, '检测设备删除失败'))
}
}
const handleDelete = async (row: TestDevice.TestDeviceRecord) => {
if (!row.id) {
ElMessage.warning('当前检测设备缺少 ID无法删除')
return
}
await deleteTestDevices([row.id], '检测设备删除成功')
}
const handleBatchDelete = async (ids: string[]) => {
await deleteTestDevices(ids, '检测设备批量删除成功')
}
const handleDownloadTemplate = async () => {
try {
await useDownloadWithServerFileName(
() => downloadTestDeviceImportTemplateApi(),
'检测设备导入模板',
undefined,
false,
'.xlsx'
)
} catch (error) {
ElMessage.error(getTestDeviceErrorMessage(error, '检测设备导入模板下载失败'))
}
}
const handleExport = async () => {
try {
await useDownloadWithServerFileName(exportTestDevicesApi, '检测设备列表', currentSearchParams.value, false, '.xlsx')
} catch (error) {
ElMessage.error(getTestDeviceErrorMessage(error, '检测设备导出失败'))
}
}
const handleDownloadReport = async (row: TestDevice.TestDeviceRecord) => {
if (!row.id) {
ElMessage.warning('当前检测设备缺少 ID无法下载报告')
return
}
try {
await useDownloadWithServerFileName(
() => downloadTestDeviceReportApi(row.id!),
row.validityReportName || row.no || '检测设备校准报告',
undefined,
false,
'.pdf'
)
} catch (error) {
ElMessage.error(getTestDeviceErrorMessage(error, '检测设备校准报告下载失败'))
}
}
const handleImportTestDevices = async ({ file, reportZip }: { file: File; reportZip: File }) => {
importing.value = true
try {
// 导入接口按整批校验返回成功/失败明细,结果保留在弹窗内便于核对。
const response = await importTestDevicesApi(file, reportZip)
importResult.value = unwrapTestDevicePayload<TestDevice.TestDeviceImportResult>(response)
const importFeedback = getTestDeviceImportResultMessage(importResult.value)
ElMessage({
type: importFeedback.type,
message: importFeedback.message
})
refreshTestDevices()
} catch (error) {
ElMessage.error(getTestDeviceErrorMessage(error, '检测设备导入失败'))
} finally {
importing.value = false
}
}
</script>
<style scoped lang="scss">
.test-device-page {
min-height: 0;
padding: 0;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,138 @@
import type { TagProps } from 'element-plus'
import type { ResultData, ResPage } from '@/api/interface'
import type { TestDevice } from '@/api/aireport/testDevice/interface'
type TagType = TagProps['type']
const TEST_DEVICE_REPORT_MAX_SIZE = 10 * 1024 * 1024
const TEST_DEVICE_REPORT_EXTENSIONS = ['pdf']
const TEST_DEVICE_IMPORT_EXCEL_EXTENSIONS = ['xls', 'xlsx']
const TEST_DEVICE_IMPORT_ZIP_EXTENSIONS = ['zip']
const TEST_DEVICE_STATE_MAP: Record<string, { text: string; tagType: TagType }> = {
'01': { text: '在运', tagType: 'success' },
'02': { text: '退运', tagType: 'info' }
}
export const TEST_DEVICE_STATE_OPTIONS = [
{ label: '在运', value: '01', tagType: 'success' },
{ label: '退运', value: '02', tagType: 'info' }
]
export const TEST_DEVICE_REPORT_MAX_SIZE_TEXT = '10MB'
export const getTestDeviceStateText = (state?: string) => TEST_DEVICE_STATE_MAP[state || '']?.text || '未知状态'
export const getTestDeviceStateTagType = (state?: string): TagType => TEST_DEVICE_STATE_MAP[state || '']?.tagType || 'info'
const getFileExtension = (fileName?: string | null) => {
if (!fileName) return ''
const segments = fileName.split('.')
return segments.length > 1 ? segments.pop()!.toLowerCase() : ''
}
const isFileExtensionAllowed = (fileName: string, extensions: string[]) => extensions.includes(getFileExtension(fileName))
export const validateTestDeviceReportFile = (file: File) => {
if (!isFileExtensionAllowed(file.name, TEST_DEVICE_REPORT_EXTENSIONS)) {
return '校准报告仅支持PDF文件'
}
if (file.size > TEST_DEVICE_REPORT_MAX_SIZE) {
return `校准报告不能超过${TEST_DEVICE_REPORT_MAX_SIZE_TEXT}`
}
return ''
}
export const validateTestDeviceImportExcelFile = (file: File) => {
if (!isFileExtensionAllowed(file.name, TEST_DEVICE_IMPORT_EXCEL_EXTENSIONS)) {
return '请选择 Excel 文件(.xls 或 .xlsx'
}
return ''
}
export const validateTestDeviceImportZipFile = (file: File) => {
if (!isFileExtensionAllowed(file.name, TEST_DEVICE_IMPORT_ZIP_EXTENSIONS)) {
return '请选择 ZIP 压缩包'
}
return ''
}
export const getTestDeviceImportResultMessage = (result?: TestDevice.TestDeviceImportResult | null) => {
const successCount = result?.successCount || 0
const failCount = result?.failCount || 0
if (failCount > 0 && successCount > 0) {
return {
type: 'warning' as const,
message: `检测设备导入完成,成功 ${successCount} 条,失败 ${failCount}`
}
}
if (failCount > 0) {
return {
type: 'warning' as const,
message: `检测设备导入失败,共 ${failCount} 条,请根据失败原因修正后重试`
}
}
return {
type: 'success' as const,
message: `检测设备导入完成,成功 ${successCount}`
}
}
export const resolveTestDeviceText = (value: unknown) => {
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
export const normalizeTestDeviceListParams = (
params: TestDevice.TestDeviceListParams = {}
): TestDevice.TestDeviceListParams => {
const timeRange = Array.isArray(params.timeRange) ? params.timeRange : []
const [searchBeginTime = '', searchEndTime = ''] = timeRange
return {
keyword: String(params.keyword || '').trim() || undefined,
searchBeginTime: searchBeginTime || params.searchBeginTime || undefined,
searchEndTime: searchEndTime || params.searchEndTime || undefined,
pageNum: params.pageNum || 1,
pageSize: params.pageSize || 10
}
}
export const unwrapTestDevicePayload = <T>(response: ResultData<T> | T): T => {
if (response && typeof response === 'object' && 'data' in response) {
return (response as ResultData<T>).data
}
return response as T
}
export const normalizeTestDevicePage = <T>(
response: ResultData<ResPage<T>> | ResPage<T> | null | undefined
): ResPage<T> => {
const payload = unwrapTestDevicePayload<ResPage<T> | null | undefined>(response as ResultData<ResPage<T>>)
return {
records: payload?.records || [],
current: payload?.current || 1,
size: payload?.size || 10,
total: payload?.total || 0
}
}
export const getTestDeviceErrorMessage = (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
}

View File

@@ -0,0 +1,91 @@
<template>
<el-dialog
v-model="visibleProxy"
title="提交审核"
width="560px"
append-to-body
destroy-on-close
@closed="resetForm"
>
<el-form ref="formRef" :model="formModel" :rules="formRules" label-width="96px">
<el-form-item label="审核意见" prop="checkResult">
<el-input
v-model="formModel.checkResult"
type="textarea"
:rows="4"
maxlength="128"
show-word-limit
placeholder="请输入审核意见,可为空"
/>
</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 { type FormInstance, type FormRules } from 'element-plus'
import { computed, reactive, ref, watch } from 'vue'
import type { ReportTask } from '@/api/aireport/testReport/interface'
defineOptions({
name: 'ReportTaskAuditDialog'
})
const props = defineProps<{
visible: boolean
saving: boolean
record: ReportTask.ReportTaskRecord | null
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
(event: 'submit', value: ReportTask.ReportTaskAuditRequest): void
}>()
const formRef = ref<FormInstance>()
const formModel = reactive({
id: '',
checkResult: ''
})
const formRules: FormRules = {}
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const resetForm = () => {
formRef.value?.clearValidate()
formModel.id = ''
formModel.checkResult = ''
}
const fillForm = () => {
formModel.id = props.record?.id || ''
formModel.checkResult = props.record?.checkResult || ''
}
watch(
() => [props.visible, props.record?.id],
([visible]) => {
if (visible) fillForm()
},
{ immediate: true }
)
const submitForm = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
emit('submit', {
id: formModel.id,
checkResult: formModel.checkResult.trim() || undefined
})
}
</script>

View File

@@ -0,0 +1,83 @@
<template>
<el-dialog v-model="visibleProxy" title="报告任务详情" width="760px" append-to-body destroy-on-close>
<div v-loading="loading" class="report-task-detail-dialog">
<el-descriptions class="detail-descriptions" :column="2" border>
<el-descriptions-item label="报告编号">{{ resolveReportTaskText(detail?.no) }}</el-descriptions-item>
<el-descriptions-item label="委托单位">{{ resolveReportTaskText(detail?.clientUnitName) }}</el-descriptions-item>
<el-descriptions-item label="报告模板">{{ resolveReportTaskText(detail?.reportModelName) }}</el-descriptions-item>
<el-descriptions-item label="检测公司">
{{ resolveReportTaskCreateUnitText(detail?.createUnit, companyLabelMap) }}
</el-descriptions-item>
<el-descriptions-item label="合同编号">{{ resolveReportTaskText(detail?.contractNumber) }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ resolveReportTaskText(detail?.phonenumber) }}</el-descriptions-item>
<el-descriptions-item label="测试依据" :span="2">
{{ resolveReportTaskStandardText(detail?.standard, standardLabelMap) }}
</el-descriptions-item>
<el-descriptions-item label="送资数据" :span="2">{{ resolveReportTaskText(detail?.data) }}</el-descriptions-item>
<el-descriptions-item label="审核人">{{ resolveReportTaskText(detail?.checkerName) }}</el-descriptions-item>
<el-descriptions-item label="审核时间">{{ resolveReportTaskText(detail?.checkTime) }}</el-descriptions-item>
<el-descriptions-item label="审核意见" :span="2">{{ resolveReportTaskText(detail?.checkResult) }}</el-descriptions-item>
<el-descriptions-item label="创建人">
{{ resolveReportTaskText(detail?.createByName || detail?.createBy) }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ resolveReportTaskText(detail?.createTime) }}</el-descriptions-item>
<el-descriptions-item label="更新人">
{{ resolveReportTaskText(detail?.updateByName || detail?.updateBy) }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ resolveReportTaskText(detail?.updateTime) }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button type="primary" @click="visibleProxy = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ReportTask } from '@/api/aireport/testReport/interface'
import {
resolveReportTaskCreateUnitText,
resolveReportTaskStandardText,
resolveReportTaskText
} from '../utils/testReport'
defineOptions({
name: 'ReportTaskDetailDialog'
})
const props = defineProps<{
visible: boolean
loading: boolean
detail: ReportTask.ReportTaskRecord | null
companyLabelMap: Record<string, string>
standardLabelMap: Record<string, string>
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
}>()
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const companyLabelMap = computed(() => props.companyLabelMap)
const standardLabelMap = computed(() => props.standardLabelMap)
</script>
<style scoped lang="scss">
.report-task-detail-dialog {
min-height: 180px;
}
.report-task-detail-dialog :deep(.detail-descriptions .el-descriptions__table) {
width: 100%;
table-layout: fixed;
}
.report-task-detail-dialog :deep(.detail-descriptions .el-descriptions__label.el-descriptions__cell.is-bordered-label) {
width: 112px;
}
</style>

View File

@@ -0,0 +1,270 @@
<template>
<el-dialog
v-model="visibleProxy"
:title="mode === 'create' ? '新增报告任务' : '编辑报告任务'"
width="720px"
append-to-body
destroy-on-close
@closed="resetForm"
>
<el-form ref="formRef" :model="formModel" :rules="formRules" label-width="108px" class="report-task-form">
<el-row :gutter="16">
<el-col :xs="24" :sm="12">
<el-form-item label="报告编号" prop="no">
<el-input v-model="formModel.no" maxlength="32" clearable placeholder="请输入报告编号" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="委托单位" prop="clientUnitId">
<el-select v-model="formModel.clientUnitId" filterable clearable placeholder="请选择委托单位">
<el-option
v-for="option in clientUnitSelectOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="报告模板" prop="reportModelId">
<el-select v-model="formModel.reportModelId" filterable clearable placeholder="请选择报告模板">
<el-option
v-for="option in reportModelSelectOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="检测公司" prop="createUnit">
<el-select v-model="formModel.createUnit" filterable clearable placeholder="请选择检测公司">
<el-option
v-for="option in companySelectOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="合同编号" prop="contractNumber">
<el-input v-model="formModel.contractNumber" maxlength="32" clearable placeholder="请输入合同编号" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="测试依据" prop="standardIds">
<el-select
v-model="formModel.standardIds"
multiple
filterable
clearable
collapse-tags
placeholder="请选择测试依据"
>
<el-option
v-for="option in standardSelectOptions"
:key="option.value"
:label="option.label"
:value="option.value"
>
<div class="standard-option">
<el-checkbox
class="standard-option-checkbox"
:model-value="formModel.standardIds.includes(option.value)"
/>
<span class="standard-option-label">{{ option.label }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="联系电话" prop="phonenumber">
<el-input v-model="formModel.phonenumber" maxlength="32" clearable placeholder="请输入联系电话" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="送资数据" prop="data">
<el-input
v-model="formModel.data"
type="textarea"
:rows="4"
maxlength="256"
show-word-limit
placeholder="请输入送资数据"
/>
</el-form-item>
</el-col>
</el-row>
</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 { type FormInstance, type FormRules } from 'element-plus'
import { computed, reactive, ref, watch } from 'vue'
import type { ReportTask } from '@/api/aireport/testReport/interface'
import { parseReportTaskStandardIds, stringifyReportTaskStandardIds } from '../utils/testReport'
defineOptions({
name: 'ReportTaskFormDialog'
})
const props = defineProps<{
visible: boolean
mode: 'create' | 'edit'
record: ReportTask.ReportTaskRecord | null
saving: boolean
clientUnitOptions: Array<{ label: string; value: string }>
reportModelOptions: Array<{ label: string; value: string }>
companyOptions: Array<{ label: string; value: string }>
standardOptions: Array<{ label: string; value: string }>
}>()
const emit = defineEmits<{
(event: 'update:visible', value: boolean): void
(event: 'submit', value: ReportTask.ReportTaskSaveRequest): void
}>()
const formRef = ref<FormInstance>()
const formModel = reactive({
id: '',
no: '',
clientUnitId: '',
reportModelId: '',
createUnit: '',
contractNumber: '',
standardIds: [] as string[],
data: '',
phonenumber: ''
})
const formRules: FormRules = {
no: [{ required: true, message: '请输入报告编号', trigger: 'blur' }],
clientUnitId: [{ required: true, message: '请选择委托单位', trigger: 'change' }],
reportModelId: [{ required: true, message: '请选择报告模板', trigger: 'change' }],
createUnit: [{ required: true, message: '请选择检测公司', trigger: 'change' }],
contractNumber: [{ required: true, message: '请输入合同编号', trigger: 'blur' }],
standardIds: [{ required: true, message: '请选择测试依据', trigger: 'change' }],
data: [{ required: true, message: '请输入送资数据', trigger: 'blur' }]
}
const visibleProxy = computed({
get: () => props.visible,
set: value => emit('update:visible', value)
})
const ensureCurrentOption = (
options: Array<{ label: string; value: string }>,
value: string,
fallbackLabel: string
) => {
if (!value || options.some(option => option.value === value)) return options
return [...options, { label: fallbackLabel || value, value }]
}
const clientUnitSelectOptions = computed(() =>
ensureCurrentOption(props.clientUnitOptions, formModel.clientUnitId, props.record?.clientUnitName || '')
)
const reportModelSelectOptions = computed(() =>
ensureCurrentOption(props.reportModelOptions, formModel.reportModelId, props.record?.reportModelName || '')
)
const companySelectOptions = computed(() =>
ensureCurrentOption(props.companyOptions, formModel.createUnit, props.record?.createUnit || '')
)
const standardSelectOptions = computed(() => {
const optionMap = new Map(props.standardOptions.map(option => [option.value, option]))
const extraOptions = formModel.standardIds
.filter(item => !optionMap.has(item))
.map(item => ({ label: item, value: item }))
return [...props.standardOptions, ...extraOptions]
})
const resetForm = () => {
formRef.value?.clearValidate()
formModel.id = ''
formModel.no = ''
formModel.clientUnitId = ''
formModel.reportModelId = ''
formModel.createUnit = ''
formModel.contractNumber = ''
formModel.standardIds = []
formModel.data = ''
formModel.phonenumber = ''
}
const fillForm = () => {
formModel.id = props.record?.id || ''
formModel.no = props.record?.no || ''
formModel.clientUnitId = props.record?.clientUnitId || ''
formModel.reportModelId = props.record?.reportModelId || ''
formModel.createUnit = props.record?.createUnit || ''
formModel.contractNumber = props.record?.contractNumber || ''
formModel.standardIds = parseReportTaskStandardIds(props.record?.standard)
formModel.data = props.record?.data || ''
formModel.phonenumber = props.record?.phonenumber || ''
}
watch(
() => [props.visible, props.mode, props.record?.id],
([visible]) => {
if (visible) fillForm()
},
{ immediate: true }
)
const submitForm = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
emit('submit', {
id: formModel.id || undefined,
no: formModel.no.trim(),
clientUnitId: formModel.clientUnitId,
reportModelId: formModel.reportModelId,
createUnit: formModel.createUnit,
contractNumber: formModel.contractNumber.trim(),
standard: stringifyReportTaskStandardIds(formModel.standardIds),
data: formModel.data.trim(),
phonenumber: formModel.phonenumber.trim() || undefined
})
}
</script>
<style scoped lang="scss">
:deep(.report-task-form .el-form-item) {
margin-bottom: 18px;
}
:deep(.el-select),
:deep(.el-textarea),
:deep(.el-input) {
width: 100%;
}
.standard-option {
display: flex;
align-items: center;
gap: 8px;
}
.standard-option-checkbox {
pointer-events: none;
}
.standard-option-label {
flex: 1;
min-width: 0;
}
</style>

View File

@@ -0,0 +1,95 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const pageDir = path.resolve(__dirname, '..')
const srcDir = path.resolve(pageDir, '../../..')
const apiDir = path.resolve(srcDir, 'api/aireport/testReport')
const read = file => fs.readFileSync(file, 'utf8')
const assertFileIncludes = (file, snippets) => {
const content = read(file)
const missing = snippets.filter(snippet => !content.includes(snippet))
if (missing.length) {
throw new Error(`${path.relative(srcDir, file)} missing: ${missing.join(', ')}`)
}
}
assertFileIncludes(path.join(srcDir, 'constants/dictCodes.ts'), ['TEST_REPORT_COMPANY', 'TEST_REPORT_STANDARD'])
assertFileIncludes(path.join(apiDir, 'index.ts'), [
"const REPORT_TASK_BASE_URL = '/api/test-report'",
'listReportTasksApi',
'createReportTaskApi',
'updateReportTaskApi',
'deleteReportTasksApi',
'getReportTaskDetailApi',
'exportReportTasksApi',
'submitReportTaskAuditApi'
])
assertFileIncludes(path.join(apiDir, 'interface/index.ts'), [
'export namespace ReportTask',
'ReportTaskRecord',
'ReportTaskListParams',
'ReportTaskSaveRequest',
'ReportTaskAuditRequest',
'standard?: string[] | string | null',
'standard: string[]'
])
assertFileIncludes(path.join(pageDir, 'utils/testReport.ts'), [
'normalizeReportTaskListParams',
'normalizeReportTaskPage',
'parseReportTaskStandardIds',
'stringifyReportTaskStandardIds',
'resolveReportTaskStandardText',
'buildDictSelectOptions',
'value?: string[] | string | null',
"return text ? [text] : []"
])
assertFileIncludes(path.join(pageDir, 'components/ReportTaskFormDialog.vue'), [
"name: 'ReportTaskFormDialog'",
'clientUnitId',
'reportModelId',
'createUnit',
'standardIds',
'stringifyReportTaskStandardIds',
'el-checkbox',
'standard-option-checkbox'
])
assertFileIncludes(path.join(pageDir, 'components/ReportTaskDetailDialog.vue'), [
"name: 'ReportTaskDetailDialog'",
'报告任务详情',
'resolveReportTaskCreateUnitText',
'resolveReportTaskStandardText',
'table-layout: fixed'
])
assertFileIncludes(path.join(pageDir, 'components/ReportTaskAuditDialog.vue'), [
"name: 'ReportTaskAuditDialog'",
'checkResult',
'提交审核'
])
assertFileIncludes(path.join(pageDir, 'index.vue'), [
"name: 'ReportTaskPage'",
'<ProTable',
'ReportTaskFormDialog',
'ReportTaskDetailDialog',
'ReportTaskAuditDialog',
'listReportTasksApi',
'submitReportTaskAuditApi',
'exportReportTasksApi',
'DICT_CODES.TEST_REPORT_COMPANY',
'DICT_CODES.TEST_REPORT_STANDARD',
'type="primary" :icon="Plus"',
'type="danger"',
'type="primary" plain'
])
console.log('testReport page contract passed')

View File

@@ -0,0 +1,418 @@
<template>
<div class="table-box report-task-page">
<ProTable
ref="proTable"
:columns="columns"
:request-api="getTableList"
:request-error="handleTableRequestError"
:search-col="{ xs: 1, sm: 2, md: 2, lg: 4, xl: 4 }"
:tool-button="['refresh', 'setting']"
row-key="id"
>
<template #tableHeader="scope">
<el-button type="primary" :icon="Plus" @click="openCreateDialog">新增</el-button>
<el-button
type="danger"
plain
:icon="Delete"
:disabled="!scope.isSelected"
@click="handleBatchDelete(scope.selectedListIds)"
>
批量删除
</el-button>
<el-button type="primary" plain :icon="Download" @click="handleExport">导出</el-button>
</template>
<template #operation="{ row }">
<el-button link type="primary" :icon="View" @click="openDetailDialog(row)">详情</el-button>
<el-button link type="primary" :icon="Edit" @click="openEditDialog(row)">编辑</el-button>
<el-button link type="primary" :icon="Upload" @click="openAuditDialog(row)">提交审核</el-button>
<el-button link type="danger" :icon="Delete" @click="handleDelete(row)">删除</el-button>
</template>
</ProTable>
<ReportTaskFormDialog
v-model:visible="formDialogVisible"
:mode="formMode"
:record="currentRecord"
:saving="formSaving"
:client-unit-options="clientUnitOptions"
:report-model-options="reportModelOptions"
:company-options="companyOptions"
:standard-options="standardOptions"
@submit="handleSaveReportTask"
/>
<ReportTaskDetailDialog
v-model:visible="detailDialogVisible"
:loading="detailLoading"
:detail="detailRecord"
:company-label-map="companyLabelMap"
:standard-label-map="standardLabelMap"
/>
<ReportTaskAuditDialog
v-model:visible="auditDialogVisible"
:saving="auditSaving"
:record="currentAuditRecord"
@submit="handleSubmitAudit"
/>
</div>
</template>
<script setup lang="tsx">
import { Delete, Download, Edit, Plus, Upload, View } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import type { Dict, ResPage } from '@/api/interface'
import ProTable from '@/components/ProTable/index.vue'
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
import {
deleteReportTasksApi,
exportReportTasksApi,
getReportTaskDetailApi,
listReportTasksApi,
submitReportTaskAuditApi,
createReportTaskApi,
updateReportTaskApi
} from '@/api/aireport/testReport'
import type { ReportTask } from '@/api/aireport/testReport/interface'
import { listClientUnitsApi } from '@/api/aireport/clientUnit'
import { listReportModelsApi } from '@/api/aireport/reportModel'
import { getDictList } from '@/api/user/login'
import { DICT_CODES } from '@/constants/dictCodes'
import { useDownloadWithServerFileName } from '@/hooks/useDownload'
import { useDictStore } from '@/stores/modules/dict'
import ReportTaskAuditDialog from './components/ReportTaskAuditDialog.vue'
import ReportTaskDetailDialog from './components/ReportTaskDetailDialog.vue'
import ReportTaskFormDialog from './components/ReportTaskFormDialog.vue'
import {
buildDictSelectOptions,
buildOptionLabelMap,
getReportTaskErrorMessage,
normalizeReportTaskListParams,
normalizeReportTaskPage,
resolveReportTaskCreateUnitText,
resolveReportTaskStandardText,
resolveReportTaskText,
unwrapReportTaskPayload
} from './utils/testReport'
defineOptions({
name: 'ReportTaskPage'
})
const dictStore = useDictStore()
const proTable = ref<ProTableInstance>()
const formDialogVisible = ref(false)
const detailDialogVisible = ref(false)
const auditDialogVisible = ref(false)
const formSaving = ref(false)
const detailLoading = ref(false)
const auditSaving = ref(false)
const formMode = ref<'create' | 'edit'>('create')
const currentRecord = ref<ReportTask.ReportTaskRecord | null>(null)
const detailRecord = ref<ReportTask.ReportTaskRecord | null>(null)
const currentAuditRecord = ref<ReportTask.ReportTaskRecord | null>(null)
const clientUnitOptions = ref<Array<{ label: string; value: string }>>([])
const reportModelOptions = ref<Array<{ label: string; value: string }>>([])
const companyOptions = computed(() => buildDictSelectOptions(dictStore.getDictData(DICT_CODES.TEST_REPORT_COMPANY)))
const standardOptions = computed(() => buildDictSelectOptions(dictStore.getDictData(DICT_CODES.TEST_REPORT_STANDARD)))
const companyLabelMap = computed(() => buildOptionLabelMap(companyOptions.value))
const standardLabelMap = computed(() => buildOptionLabelMap(standardOptions.value))
const requiredDictCodes = [DICT_CODES.TEST_REPORT_COMPANY, DICT_CODES.TEST_REPORT_STANDARD]
const currentSearchParams = computed(() =>
normalizeReportTaskListParams((proTable.value?.searchParam || {}) as ReportTask.ReportTaskListParams)
)
const columns = reactive<ColumnProps<ReportTask.ReportTaskRecord>[]>([
{ type: 'selection', fixed: 'left', width: 70 },
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
{
prop: 'keyword',
label: '关键字',
minWidth: 180,
isShow: false,
search: {
el: 'input',
order: 1,
props: {
placeholder: '请输入报告编号、合同编号、委托单位或模板'
}
}
},
{
prop: 'timeRange',
label: '创建时间',
minWidth: 220,
isShow: false,
search: {
el: 'date-picker',
order: 2,
key: 'timeRange',
props: {
type: 'datetimerange',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
startPlaceholder: '开始创建时间',
endPlaceholder: '结束创建时间'
}
}
},
{ prop: 'no', label: '报告编号', minWidth: 180, fixed: 'left', render: ({ row }) => resolveReportTaskText(row.no) },
{ prop: 'clientUnitName', label: '委托单位', minWidth: 160, render: ({ row }) => resolveReportTaskText(row.clientUnitName) },
{ prop: 'reportModelName', label: '报告模板', minWidth: 160, render: ({ row }) => resolveReportTaskText(row.reportModelName) },
{
prop: 'createUnit',
label: '检测公司',
minWidth: 160,
render: ({ row }) => resolveReportTaskCreateUnitText(row.createUnit, companyLabelMap.value)
},
{ prop: 'contractNumber', label: '合同编号', minWidth: 150, render: ({ row }) => resolveReportTaskText(row.contractNumber) },
{
prop: 'standard',
label: '测试依据',
minWidth: 220,
showOverflowTooltip: true,
render: ({ row }) => resolveReportTaskStandardText(row.standard, standardLabelMap.value)
},
{ prop: 'phonenumber', label: '联系电话', minWidth: 140, render: ({ row }) => resolveReportTaskText(row.phonenumber) },
{ prop: 'checkerName', label: '审核人', minWidth: 120, render: ({ row }) => resolveReportTaskText(row.checkerName) },
{ prop: 'checkTime', label: '审核时间', minWidth: 120, render: ({ row }) => resolveReportTaskText(row.checkTime) },
{ prop: 'checkResult', label: '审核意见', minWidth: 200, showOverflowTooltip: true, render: ({ row }) => resolveReportTaskText(row.checkResult) },
{ prop: 'createByName', label: '创建人', minWidth: 120, render: ({ row }) => resolveReportTaskText(row.createByName || row.createBy) },
{ prop: 'createTime', label: '创建时间', minWidth: 170, render: ({ row }) => resolveReportTaskText(row.createTime) },
{ prop: 'updateByName', label: '更新人', minWidth: 120, isShow: false, render: ({ row }) => resolveReportTaskText(row.updateByName || row.updateBy) },
{ prop: 'updateTime', label: '更新时间', minWidth: 170, isShow: false, render: ({ row }) => resolveReportTaskText(row.updateTime) },
{ prop: 'operation', label: '操作', fixed: 'right', width: 280 }
])
const ensureReportTaskDictOptionsReady = async () => {
const missingCodes = requiredDictCodes.filter(code => !dictStore.getDictData(code).length)
if (!missingCodes.length) return
const response = await getDictList()
const dictData = (response.data as unknown as Dict[]) || []
if (!dictData.length) return
const nextDictMap = new Map((dictStore.dictData || []).map(item => [item.code, item]))
dictData.forEach(item => {
if (item?.code) {
nextDictMap.set(item.code, item)
}
})
// 关键业务节点:检测公司和测试依据展示依赖字典缓存,需要在页面缺失时补载后再做 ID 到名称映射。
dictStore.setDictData(Array.from(nextDictMap.values()))
}
const loadClientUnitOptions = async () => {
const response = await listClientUnitsApi({ pageNum: 1, pageSize: 200 })
const pageData = normalizeReportTaskPage<{ id?: string; name?: string }>(
response as unknown as ResPage<{ id?: string; name?: string }>
)
clientUnitOptions.value = (pageData.records || [])
.filter(item => item.id && item.name)
.map(item => ({
label: item.name!,
value: item.id!
}))
}
const loadReportModelOptions = async () => {
const response = await listReportModelsApi({ pageNum: 1, pageSize: 200 })
const pageData = normalizeReportTaskPage<{ id?: string; name?: string }>(
response as unknown as ResPage<{ id?: string; name?: string }>
)
reportModelOptions.value = (pageData.records || [])
.filter(item => item.id && item.name)
.map(item => ({
label: item.name!,
value: item.id!
}))
}
const ensureFormOptionsReady = async () => {
try {
await Promise.all([ensureReportTaskDictOptionsReady(), loadClientUnitOptions(), loadReportModelOptions()])
} catch (error) {
ElMessage.error(getReportTaskErrorMessage(error, '报告任务关联选项加载失败'))
clientUnitOptions.value = []
reportModelOptions.value = []
}
}
const getTableList = async (params: ReportTask.ReportTaskListParams = {}) => {
const response = await listReportTasksApi(normalizeReportTaskListParams(params))
const pageData = normalizeReportTaskPage<ReportTask.ReportTaskRecord>(
response as unknown as ResPage<ReportTask.ReportTaskRecord>
)
return {
data: pageData
}
}
const refreshReportTasks = () => {
proTable.value?.clearSelection()
proTable.value?.getTableList()
}
const handleTableRequestError = (error: unknown) => {
ElMessage.error(getReportTaskErrorMessage(error, '报告任务列表查询失败'))
}
const openCreateDialog = async () => {
formMode.value = 'create'
currentRecord.value = null
await ensureFormOptionsReady()
formDialogVisible.value = true
}
const openEditDialog = async (row: ReportTask.ReportTaskRecord) => {
if (!row.id) {
ElMessage.warning('当前报告任务缺少 ID无法编辑')
return
}
await ensureFormOptionsReady()
formMode.value = 'edit'
try {
// 关键业务节点:编辑表单提交时依赖 clientUnitId、reportModelId、createUnit、standard 等原始值,不能只用列表展示字段回填。
const response = await getReportTaskDetailApi(row.id)
currentRecord.value = unwrapReportTaskPayload<ReportTask.ReportTaskRecord>(response)
formDialogVisible.value = true
} catch (error) {
ElMessage.error(getReportTaskErrorMessage(error, '报告任务详情查询失败'))
}
}
const openDetailDialog = async (row: ReportTask.ReportTaskRecord) => {
if (!row.id) {
ElMessage.warning('当前报告任务缺少 ID无法查看详情')
return
}
detailRecord.value = null
detailDialogVisible.value = true
detailLoading.value = true
try {
const response = await getReportTaskDetailApi(row.id)
detailRecord.value = unwrapReportTaskPayload<ReportTask.ReportTaskRecord>(response)
} catch (error) {
ElMessage.error(getReportTaskErrorMessage(error, '报告任务详情查询失败'))
} finally {
detailLoading.value = false
}
}
const openAuditDialog = (row: ReportTask.ReportTaskRecord) => {
if (!row.id) {
ElMessage.warning('当前报告任务缺少 ID无法提交审核')
return
}
currentAuditRecord.value = row
auditDialogVisible.value = true
}
const handleSaveReportTask = async (params: ReportTask.ReportTaskSaveRequest) => {
formSaving.value = true
try {
if (formMode.value === 'create') {
await createReportTaskApi(params)
ElMessage.success('报告任务新增成功')
} else {
await updateReportTaskApi(params)
ElMessage.success('报告任务编辑成功')
}
formDialogVisible.value = false
refreshReportTasks()
} catch (error) {
ElMessage.error(getReportTaskErrorMessage(error, '报告任务保存失败'))
} finally {
formSaving.value = false
}
}
const handleSubmitAudit = async (params: ReportTask.ReportTaskAuditRequest) => {
auditSaving.value = true
try {
await submitReportTaskAuditApi(params)
ElMessage.success('报告任务提交审核成功')
auditDialogVisible.value = false
refreshReportTasks()
} catch (error) {
ElMessage.error(getReportTaskErrorMessage(error, '报告任务提交审核失败'))
} finally {
auditSaving.value = false
}
}
const deleteReportTasks = async (ids: string[], successMessage: string) => {
if (!ids.length) {
ElMessage.warning('请选择要删除的报告任务')
return
}
try {
await ElMessageBox.confirm('删除后报告任务将不可见,是否继续?', '删除报告任务', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
try {
await deleteReportTasksApi(ids)
ElMessage.success(successMessage)
refreshReportTasks()
} catch (error) {
ElMessage.error(getReportTaskErrorMessage(error, '报告任务删除失败'))
}
}
const handleDelete = async (row: ReportTask.ReportTaskRecord) => {
if (!row.id) {
ElMessage.warning('当前报告任务缺少 ID无法删除')
return
}
await deleteReportTasks([row.id], '报告任务删除成功')
}
const handleBatchDelete = async (ids: string[]) => {
await deleteReportTasks(ids, '报告任务批量删除成功')
}
const handleExport = async () => {
try {
await useDownloadWithServerFileName(exportReportTasksApi, '普测报告列表', currentSearchParams.value, false, '.xlsx')
} catch (error) {
ElMessage.error(getReportTaskErrorMessage(error, '报告任务导出失败'))
}
}
onMounted(async () => {
try {
await ensureReportTaskDictOptionsReady()
} catch (error) {
ElMessage.error(getReportTaskErrorMessage(error, '鎶ュ憡浠诲姟瀛楀吀鍔犺浇澶辫触'))
}
})
</script>
<style scoped lang="scss">
.report-task-page {
min-height: 0;
padding: 0;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,111 @@
import type { Dict, ResultData, ResPage } from '@/api/interface'
import type { ReportTask } from '@/api/aireport/testReport/interface'
export const resolveReportTaskText = (value: unknown) => {
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
export const normalizeReportTaskListParams = (
params: ReportTask.ReportTaskListParams = {}
): ReportTask.ReportTaskListParams => {
const timeRange = Array.isArray(params.timeRange) ? params.timeRange : []
const [searchBeginTime = '', searchEndTime = ''] = timeRange
return {
keyword: String(params.keyword || '').trim() || undefined,
searchBeginTime: searchBeginTime || params.searchBeginTime || undefined,
searchEndTime: searchEndTime || params.searchEndTime || undefined,
pageNum: params.pageNum || 1,
pageSize: params.pageSize || 10
}
}
export const unwrapReportTaskPayload = <T>(response: ResultData<T> | T): T => {
if (response && typeof response === 'object' && 'data' in response) {
return (response as ResultData<T>).data
}
return response as T
}
export const normalizeReportTaskPage = <T>(
response: ResultData<ResPage<T>> | ResPage<T> | null | undefined
): ResPage<T> => {
const payload = unwrapReportTaskPayload<ResPage<T> | null | undefined>(response as ResultData<ResPage<T>>)
return {
records: payload?.records || [],
current: payload?.current || 1,
size: payload?.size || 10,
total: payload?.total || 0
}
}
export const getReportTaskErrorMessage = (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
}
export const buildDictSelectOptions = (dictData: Dict[] = []) =>
dictData
.filter(item => item.id && item.name)
.map(item => ({
label: item.name,
value: item.id
}))
export const buildOptionLabelMap = (options: Array<{ label: string; value: string }>) =>
options.reduce<Record<string, string>>((result, option) => {
result[option.value] = option.label
return result
}, {})
export const parseReportTaskStandardIds = (value?: string[] | string | null) => {
if (Array.isArray(value)) {
return value.map(item => String(item).trim()).filter(Boolean)
}
const text = String(value || '').trim()
if (!text) return []
try {
const parsedValue = JSON.parse(text)
if (Array.isArray(parsedValue)) {
return parsedValue.map(item => String(item).trim()).filter(Boolean)
}
} catch {
return text ? [text] : []
}
return []
}
export const stringifyReportTaskStandardIds = (value: string[]) =>
value.map(item => String(item).trim()).filter(Boolean)
export const resolveReportTaskCreateUnitText = (
value: string | null | undefined,
companyLabelMap: Record<string, string>
) => {
const text = String(value || '').trim()
if (!text) return '-'
return companyLabelMap[text] || text
}
export const resolveReportTaskStandardText = (
value: string[] | string | null | undefined,
standardLabelMap: Record<string, string>
) => {
const standardIds = parseReportTaskStandardIds(value)
if (!standardIds.length) return resolveReportTaskText(value)
return standardIds.map(item => standardLabelMap[item] || item).join('、') || '-'
}

View File

@@ -4,21 +4,23 @@
- 模块路径:`steady/check-square`
- 接口基础路径:`/steady/checksquare`
- 返回包装:接口统一返回 `HttpResult<T>`,调试时重点查看响应体中的业务数据字段 `data`
- 返回包装:接口统一返回 `HttpResult<T>`,调试时重点查看响应体中的 `data`
- 时间格式:`yyyy-MM-dd HH:mm:ss`
- Content-Type`application/json`
本模块用于按监测点、时间范围和指标执行稳态数据校验,并提供任务列表、任务详情、检测项明细查询、失败任务重启和任务删除能力。当前标准支持单监测点和多监测点任务:单监测点可继续传 `lineId`,多监测点推荐传 `lineIds`
本模块用于按监测点、时间范围和指标执行稳态数据校验,并提供任务列表、任务详情、检测项明细查询、失败任务重启和任务删除能力。
## 2. 通用约定
当前标准统一使用 `lineIds` 表示任务监测点列表。单监测点也传单元素数组,例如:
### 2.1 请求头
```json
{
"lineIds": ["LINE_001"]
}
```
| 名称 | 示例 | 说明 |
| --- | --- | --- |
| `Content-Type` | `application/json` | `POST` 请求使用 JSON 请求体 |
| `Authorization` | 登录态 Token | 如当前环境开启认证,需携带现有登录接口返回的认证信息 |
## 2. 通用枚举
### 2.2 任务状态
### 2.1 任务状态
| 值 | 说明 |
| --- | --- |
@@ -26,7 +28,7 @@
| `SUCCESS` | 执行成功 |
| `FAIL` | 执行失败 |
### 2.3 明细类型
### 2.2 明细类型
| 值 | 说明 |
| --- | --- |
@@ -34,7 +36,7 @@
| `VALUE_ORDER` | 指标值大小关系异常明细 |
| `HARMONIC_PARITY` | 谐波奇偶关系异常明细 |
### 2.4 统计类型
### 2.3 统计类型
| 值 | 说明 |
| --- | --- |
@@ -43,19 +45,17 @@
| `MIN` | 最小值 |
| `CP95` | CP95 值 |
## 3. 调试顺序建议
## 3. 调试顺序
1. 调用 `POST /steady/checksquare/create`按监测点列表、时间范围和指标列表创建或复用任务。
2. 读取 `/create` 返回的 `data.taskId`:该返回值是任务列表中的行信息
1. 调用 `POST /steady/checksquare/create`按监测点列表、时间范围和指标列表创建或复用任务。
2. 读取 `/create` 返回的 `data.taskId`
3. 如果 `taskStatus=RUNNING`,轮询 `POST /steady/checksquare/query`,或稍后调用 `GET /steady/checksquare/detail` 查看任务详情。
4. 调用 `GET /steady/checksquare/detail``taskId` 查询任务下的检测项。
5. 读取 `/detail` 返回的 `items[].itemId`:按检测项继续查询连续性区间或异常明细
6. 调用 `GET /steady/checksquare/item-detail``itemId + detailType` 查询具体明细。
4. 调用 `GET /steady/checksquare/detail``taskId` 查询任务下的检测项。
5. 读取 `/detail` 返回的 `items[].itemId`
6. 调用 `GET /steady/checksquare/item-detail``itemId + detailType` 查询具体明细。
7. 任务失败后如需重新执行,调用 `POST /steady/checksquare/restart`
8. 需要清理任务时调用 `POST /steady/checksquare/delete`
注意:旧的获取或创建兼容接口已移除。创建或复用任务的逻辑已经合并到 `/create` 中。
## 4. 创建或复用任务
### 4.1 接口
@@ -64,28 +64,27 @@
### 4.2 行为说明
接口开始执行前会先按 `lineIds + timeStart + timeEnd` 查询是否存在未删除任务:
接口执行前会先按 `lineIds + timeStart + timeEnd` 查询是否存在未删除任务:
- 已存在:直接返回该任务的任务列表行信息。
- 不存在:创建 `RUNNING` 任务并立即返回任务列表行信息,后台继续执行校验;任务完成后状态更新为 `SUCCESS`,失败时更新为 `FAIL`
- 已存在:直接返回该任务的任务列表行信息,不生成重复任务
- 不存在:创建 `RUNNING` 任务并立即返回任务列表行信息,后台继续执行校验。
`lineIds` 会去重并清理空字符串。若未传 `lineIds`,后端会兼容读取 `lineId` 并转换为单元素监测点列表。返回对象为 `SteadyChecksquareTaskVO`
`lineIds` 会去重并清理空字符串。任务执行成功后状态更新为 `SUCCESS`,失败时更新为 `FAIL`
### 4.3 请求字段
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `lineId` | `String` | 条件必填 | 监测点 ID;当 `lineIds` 为空时使用 |
| `lineIds` | `Array<String>` | 条件必填 | 监测点 ID 列表;多监测点推荐使用该字段 |
| `lineIds` | `Array<String>` | | 监测点 ID 列表;单监测点也传单元素数组 |
| `indicatorCodes` | `Array<String>` | 是 | 指标编码列表 |
| `timeStart` | `String` | 是 | 开始时间,格式 `yyyy-MM-dd HH:mm:ss` |
| `timeEnd` | `String` | 是 | 结束时间,格式 `yyyy-MM-dd HH:mm:ss` |
`lineId``lineIds` 至少传一个;`indicatorCodes` 是数组,不要传成单个字符串。
`indicatorCodes` 是数组,不要传成单个字符串。
### 4.4 请求示例
单监测点兼容写法
单监测点:
```http
POST /steady/checksquare/create
@@ -94,14 +93,14 @@ Content-Type: application/json
```json
{
"lineId": "LINE_001",
"lineIds": ["LINE_001"],
"indicatorCodes": ["VOLTAGE_A", "CURRENT_A"],
"timeStart": "2026-06-18 00:00:00",
"timeEnd": "2026-06-18 01:00:00"
}
```
多监测点标准写法
多监测点:
```json
{
@@ -118,7 +117,6 @@ Content-Type: application/json
| --- | --- | --- |
| `taskId` | `String` | 任务 ID |
| `taskNo` | `String` | 任务编号 |
| `lineId` | `String` | 任务首个监测点 ID兼容旧页面字段 |
| `lineIds` | `Array<String>` | 本次任务的监测点 ID 列表 |
| `lineName` | `String` | 监测点名称;多监测点时为多个名称拼接结果 |
| `timeStart` | `String` | 开始时间 |
@@ -139,7 +137,6 @@ Content-Type: application/json
"data": {
"taskId": "1812345678901234567",
"taskNo": "CS202606180001",
"lineId": "LINE_001",
"lineIds": ["LINE_001", "LINE_002"],
"lineName": "1号监测点,2号监测点",
"timeStart": "2026-06-18 00:00:00",
@@ -216,7 +213,6 @@ Content-Type: application/json
{
"taskId": "1812345678901234567",
"taskNo": "CS202606180001",
"lineId": "LINE_001",
"lineIds": ["LINE_001", "LINE_002"],
"lineName": "1号监测点,2号监测点",
"timeStart": "2026-06-18 00:00:00",
@@ -261,7 +257,6 @@ GET /steady/checksquare/detail?taskId=1812345678901234567
| --- | --- | --- |
| `taskId` | `String` | 任务 ID |
| `taskNo` | `String` | 任务编号 |
| `lineId` | `String` | 任务首个监测点 ID兼容旧页面字段 |
| `lineIds` | `Array<String>` | 本次任务的监测点 ID 列表 |
| `lineName` | `String` | 监测点名称 |
| `timeStart` | `String` | 开始时间 |
@@ -318,7 +313,6 @@ GET /steady/checksquare/detail?taskId=1812345678901234567
"data": {
"taskId": "1812345678901234567",
"taskNo": "CS202606180001",
"lineId": "LINE_001",
"lineIds": ["LINE_001", "LINE_002"],
"lineName": "1号监测点,2号监测点",
"timeStart": "2026-06-18 00:00:00",
@@ -327,7 +321,7 @@ GET /steady/checksquare/detail?taskId=1812345678901234567
"items": [
{
"itemId": "1812345678901234568",
"itemKey": "LINE_001:VOLTAGE_A",
"itemKey": "LINE_001|VOLTAGE_A",
"lineId": "LINE_001",
"lineName": "1号监测点",
"indicatorCode": "VOLTAGE_A",
@@ -378,8 +372,8 @@ GET /steady/checksquare/detail?taskId=1812345678901234567
| `itemId` | `String` | 是 | 检测项 ID |
| `detailType` | `String` | 是 | 明细类型:`SEGMENT``VALUE_ORDER``HARMONIC_PARITY` |
| `statType` | `String` | 否 | 统计类型;查询统计类型连续性或谐波奇偶明细时使用 |
| `pageNum` | `Integer` | 否 | 页码;和 `pageSize` 同时为正数时启用分页 |
| `pageSize` | `Integer` | 否 | 每页条数;和 `pageNum` 同时为正数时启用分页 |
| `pageNum` | `Integer` | 否 | 页码;和 `pageSize` 同时为正数时启用分页 |
| `pageSize` | `Integer` | 否 | 每页条数;和 `pageNum` 同时为正数时启用分页 |
### 7.3 请求示例
@@ -530,7 +524,7 @@ GET /steady/checksquare/item-detail?itemId=1812345678901234568&detailType=HARMON
- 仅允许重启 `taskStatus=FAIL` 的任务。
- 重启复用原任务记录,`taskId` 不变。
- 重启前会清理该任务旧的检测项、统计摘要和明细数据,避免重复结果键。
- 接口返回时任务状态已更新为 `RUNNING`;后台执行结束后更新为 `SUCCESS``FAIL`
- 接口返回时任务状态已更新为 `RUNNING`
### 8.3 请求参数
@@ -544,9 +538,7 @@ GET /steady/checksquare/item-detail?itemId=1812345678901234568&detailType=HARMON
POST /steady/checksquare/restart?taskId=1812345678901234567
```
### 8.5 响应说明
响应 `data``/create``SteadyChecksquareTaskVO` 字段一致。
### 8.5 响应示例
```json
{
@@ -555,7 +547,6 @@ POST /steady/checksquare/restart?taskId=1812345678901234567
"data": {
"taskId": "1812345678901234567",
"taskNo": "CS202606180001",
"lineId": "LINE_001",
"lineIds": ["LINE_001", "LINE_002"],
"lineName": "1号监测点,2号监测点",
"timeStart": "2026-06-18 00:00:00",
@@ -607,15 +598,15 @@ Content-Type: application/json
}
```
## 10. 常见调试注意事项
## 10. 调试注意事项
- `/create` 返回的是任务列表行信息,不是详情页完整数据
- `/create` 如果命中已存在任务,会直接返回该任务,不会生成重复任务
- 新标准推荐传 `lineIds``lineId` 仅作为单监测点兼容字段保留
- `/query``lineId` 会匹配任务内的 `lineIds`,可用于筛选多监测点任务
- `/create` 只接收 `lineIds`,不再接收任务级 `lineId`
- 单监测点也传 `lineIds` 单元素数组
- `/create` 如果命中已存在任务,会直接返回该任务,不生成重复任务
- `/query``lineId` 是筛选条件,会匹配任务内的 `lineIds`
- 任务级返回字段使用 `lineIds`;检测项级 `items[].lineId` 表示该检测项实际所属监测点。
- `/detail` 需要使用 `/create``/restart``/query` 返回的 `taskId`
- `/detail` 的任务级 `lineId` 是首个监测点,检测项级 `items[].lineId` 才是该检测项实际所属监测点。
- `/item-detail` 需要使用 `/detail` 返回的 `items[].itemId`
- `/item-detail` 只有 `pageNum``pageSize` 同时为正数时才分页;否则返回全部匹配明细,分页字段为空。
- `/item-detail` 只有 `pageNum``pageSize` 同时为正数时才分页;否则返回全部匹配明细,分页字段为空。
- `/restart` 只允许重启失败任务,成功或执行中的任务调用会返回业务失败。
- `dataIntegrity` 是 0 到 1 的小数,展示百分比优先使用 `dataIntegrityText`

View File

@@ -78,8 +78,8 @@ const checks = [
const typeBlock =
read(files.apiTypes).match(/interface SteadyChecksquareCreateParams\s*\{[\s\S]*?\n {4}\}/)?.[0] || ''
return (
/lineId\?: string/.test(typeBlock) &&
/lineIds\?: string\[\]/.test(typeBlock) &&
!/lineId\?: string/.test(typeBlock) &&
/lineIds: string\[\]/.test(typeBlock) &&
/indicatorCodes: string\[\]/.test(typeBlock) &&
/timeStart: string/.test(typeBlock) &&
/timeEnd: string/.test(typeBlock) &&
@@ -206,7 +206,7 @@ const checks = [
return (
/buildSteadyChecksquareCreatePayload\s*=\s*\(\s*lineIds:\s*string\[\]/.test(payload) &&
/lineIds,/.test(payload) &&
/lineId:\s*lineIds\[0\]/.test(payload) &&
!/lineId:\s*lineIds\[0\]/.test(payload) &&
!/lineIds\.length > 1/.test(payload) &&
/buildSteadyChecksquareCreatePayload\(lineIds\.value,\s*selectedIndicators\.value,\s*formState\.value\)/.test(
page

View File

@@ -46,7 +46,6 @@ export const buildSteadyChecksquareCreatePayload = (
formState: ChecksquareFormState
): SteadyDataView.SteadyChecksquareCreateParams => {
return {
lineId: lineIds[0],
lineIds,
indicatorCodes: collectChecksquareIndicatorCodes(indicators),
timeStart: (formState.timeRange[0] || '').replace(/\.[^.]+$/, ''),

View File

@@ -30,7 +30,7 @@ const router = useRouter()
const openDiskMonitor = async () => {
//
await router.push('/systemMonitor/diskMonitor')
await router.push('/system-monitor/diskMonitor')
}
</script>

View File

@@ -91,6 +91,7 @@
@confirm-icd-consistency-problems="handleConfirmIcdConsistencyProblemsEvent"
@remove-icd-consistency-problem="handleRemoveIcdConsistencyProblemEvent"
@update-mapping-json="handleUpdateMappingJsonEvent"
@update-xml-mapping="handleUpdateXmlMappingEvent"
@update:sequence-dialog-visible="sequenceDialogVisible = $event"
@sequence-config-complete="handleSequenceConfigComplete"
@save-icd-check-result="handleSaveIcdCheckResult"
@@ -201,6 +202,7 @@ const {
handleConfirmIcdConsistencyProblems,
handleRemoveIcdConsistencyProblem,
handleUpdateMappingJson,
handleUpdateXmlMapping,
handleSequenceConfigComplete,
handleSaveIcdCheckResult,
handleConfirmIndexSelection
@@ -249,6 +251,13 @@ const handleUpdateMappingJsonEvent = (...args: unknown[]) => {
handleUpdateMappingJson(mappingJson)
}
const handleUpdateXmlMappingEvent = (...args: unknown[]) => {
const [xmlMapping] = args
if (typeof xmlMapping !== 'string') return
handleUpdateXmlMapping(xmlMapping)
}
type StepStatus = 'success' | 'wait' | 'process' | 'finish' | 'error'
interface IcdCheckFlowStep {

View File

@@ -46,23 +46,18 @@
/>
</el-select>
</el-form-item>
<el-form-item label="角度">
<el-input-number
v-model="formModel.angle"
:min="-360"
:max="360"
controls-position="right"
/>
</el-form-item>
<el-form-item label="相位索引">
<el-switch
v-model="formModel.usePhaseIndex"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="关闭"
/>
</el-form-item>
<div class="icd-path-form-dialog__switch-row">
<el-form-item label="支持相角">
<el-switch v-model="formModel.angle" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="使用相别指标">
<el-switch
v-model="formModel.usePhaseIndex"
:active-value="1"
:inactive-value="0"
/>
</el-form-item>
</div>
</el-form>
</div>
</div>
@@ -128,9 +123,11 @@ const icdTypeOptions = [
{ label: '上游解析传递的标准 ICD', value: 3 },
{ label: '上游解析传递的非标准 ICD', value: 4 }
]
const isStandardIcdType = (type?: number) => type === 1 || type === 3
const isReferenceIcdRequired = computed(() => !isStandardIcdType(formModel.type))
const formRules = computed<FormRules<IcdPathFormModel>>(() => ({
name: [{ required: true, message: '请输入 ICD 名称', trigger: 'blur' }],
referenceIcdId: [{ required: true, message: '请选择标准 ICD 引用', trigger: 'change' }]
referenceIcdId: [{ required: isReferenceIcdRequired.value, message: '请选择标准 ICD 引用', trigger: 'change' }]
}))
const dialogTitle = computed(() => (props.mode === 'create' ? '新增ICD记录' : '编辑ICD记录'))
@@ -179,12 +176,23 @@ watch(
}
)
watch(
() => formModel.type,
type => {
// 标准 ICD 的引用就是当前记录本身,切换类型时清掉旧的外部引用。
if (isStandardIcdType(type)) {
formModel.referenceIcdId = ''
formRef.value?.clearValidate('referenceIcdId')
}
}
)
const buildSavePayload = (): MmsMapping.CreateIcdPathRequest => ({
name: formModel.name.trim(),
angle: formModel.angle,
usePhaseIndex: formModel.usePhaseIndex,
type: formModel.type,
referenceIcdId: formModel.referenceIcdId
referenceIcdId: isStandardIcdType(formModel.type) ? '' : formModel.referenceIcdId
})
const handleClose = () => {
@@ -262,6 +270,12 @@ const handleSave = async () => {
padding: 4px 0 0;
}
.icd-path-form-dialog__switch-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 24px;
}
:deep(.icd-check-dialog .el-dialog__body) {
padding: 16px 20px 4px;
}

View File

@@ -4,13 +4,51 @@
<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>
<template v-if="editable">
<el-button v-if="!isEditing" type="primary" plain size="small" :icon="Edit" @click="startEdit">
编辑
</el-button>
<template v-else>
<el-button type="primary" plain size="small" :icon="Check" @click="applyEdit">完成</el-button>
<el-button plain size="small" :icon="Close" @click="cancelEdit">取消</el-button>
</template>
</template>
<el-tooltip content="全部展开" placement="top">
<el-button
type="primary"
plain
size="small"
:icon="Expand"
:disabled="!rootNode"
aria-label="全部展开"
@click="expandAll"
/>
</el-tooltip>
<el-tooltip content="全部收起" placement="top">
<el-button
plain
size="small"
:icon="Fold"
:disabled="!rootNode"
aria-label="全部收起"
@click="collapseAll"
/>
</el-tooltip>
<slot name="trailing-actions" />
</div>
</div>
<div v-if="rootNode" class="json-tree-body">
<div v-if="isEditing" class="json-editor-wrap">
<el-input
v-model="editableSource"
type="textarea"
resize="none"
spellcheck="false"
class="json-editor-input"
/>
<div v-if="editError" class="json-editor-error">{{ editError }}</div>
</div>
<div v-else-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>
@@ -18,6 +56,7 @@
</template>
<script setup lang="ts">
import { Check, Close, Edit, Expand, Fold } from '@element-plus/icons-vue'
import { computed, ref, watch } from 'vue'
import JsonTreeNode, { type JsonTreeNodeModel, type JsonValueType } from './JsonMappingTreeNode.vue'
@@ -26,12 +65,22 @@ defineOptions({
})
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
const props = defineProps<{
const props = withDefaults(defineProps<{
source: string
metaText?: string
editable?: boolean
}>(), {
editable: false
})
const emit = defineEmits<{
(event: 'update:source', value: string): void
}>()
const expandedKeys = ref<Set<string>>(new Set())
const isEditing = ref(false)
const editableSource = ref('')
const editError = ref('')
const parsedJson = computed<{ valid: true; value: JsonValue } | { valid: false }>(() => {
try {
@@ -60,6 +109,16 @@ watch(
{ immediate: true }
)
watch(
() => props.source,
source => {
if (isEditing.value) return
editableSource.value = source
editError.value = ''
},
{ immediate: true }
)
function buildJsonNode(keyName: string | undefined, source: JsonValue, path: string): JsonTreeNodeModel {
if (Array.isArray(source)) {
return {
@@ -137,6 +196,30 @@ function expandAll() {
function collapseAll() {
expandedKeys.value = new Set()
}
function startEdit() {
editableSource.value = props.source
editError.value = ''
isEditing.value = true
}
function cancelEdit() {
editableSource.value = props.source
editError.value = ''
isEditing.value = false
}
function applyEdit() {
try {
const parsedValue = JSON.parse(editableSource.value)
emit('update:source', JSON.stringify(parsedValue, null, 4))
editError.value = ''
isEditing.value = false
} catch (error) {
editError.value = error instanceof Error ? error.message : 'JSON 格式不正确'
}
}
</script>
<style scoped lang="scss">
@@ -178,7 +261,8 @@ function collapseAll() {
}
.json-tree-body,
.mapping-json-text {
.mapping-json-text,
.json-editor-wrap {
flex: 1;
min-height: 0;
margin: 0;
@@ -193,6 +277,38 @@ function collapseAll() {
color: #172033;
}
.json-editor-wrap {
display: flex;
flex-direction: column;
padding: 0;
}
.json-editor-input {
flex: 1;
min-height: 0;
}
.json-editor-input :deep(.el-textarea__inner) {
height: 100%;
min-height: 100%;
border: 0;
border-radius: 10px;
box-shadow: none;
font-family: Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre;
}
.json-editor-error {
padding: 8px 12px;
border-top: 1px solid #f3d19e;
color: #b45309;
font-size: 12px;
line-height: 1.5;
background: #fff7ed;
}
.mapping-json-text {
white-space: pre-wrap;
word-break: break-word;

View File

@@ -0,0 +1,49 @@
/* 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 configPanelFile = path.resolve(rootDir, 'views/tools/mmsMapping/components/MappingConfigPanel.vue')
const resultPanelFile = path.resolve(rootDir, 'views/tools/mmsMapping/components/MappingResultPanel.vue')
const configPanelSource = fs.readFileSync(configPanelFile, 'utf8')
const resultPanelSource = fs.readFileSync(resultPanelFile, 'utf8')
const checks = [
[
'config panel exposes fullscreen action for index selection content',
() =>
/:icon="FullScreen"/.test(configPanelSource) &&
/@click="indexSelectionFullscreenVisible = true"/.test(configPanelSource) &&
/title="索引配置全屏查看"/.test(configPanelSource) &&
/class="index-selection-fullscreen-input"/.test(configPanelSource)
],
[
'result panel exposes fullscreen action for JSON mapping content',
() =>
/@click="jsonMappingFullscreenVisible = true"/.test(resultPanelSource) &&
/title="JSON映射全屏查看"/.test(resultPanelSource) &&
/class="mapping-fullscreen-dialog mapping-fullscreen-dialog--json"/.test(resultPanelSource)
],
[
'result panel exposes fullscreen action for XML mapping content',
() =>
/@click="xmlMappingFullscreenVisible = true"/.test(resultPanelSource) &&
/title="XML映射全屏查看"/.test(resultPanelSource) &&
/class="mapping-fullscreen-dialog mapping-fullscreen-dialog--xml"/.test(resultPanelSource)
]
]
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
if (failures.length) {
console.error('mmsMapping fullscreen actions contract failed:')
for (const failure of failures) {
console.error(`- ${failure}`)
}
process.exit(1)
}
console.log('mmsMapping fullscreen actions contract passed')

View File

@@ -104,7 +104,14 @@ const checks = [
['ICD JSON consistency API uses backend comparison endpoint', () => /checkIcdJsonConsistencyApi/.test(apiSource) && /\/api\/mms-mapping\/check-icd-json-consistency/.test(apiSource) && /interface\s+IcdJsonConsistencyCheckRequest/.test(typeSource) && /interface\s+IcdJsonConsistencyCheckResponse/.test(typeSource)],
['ICD path types are defined', () => /interface\s+IcdPathRecord/.test(typeSource) && /interface\s+CreateIcdPathRequest/.test(typeSource) && /interface\s+UpdateIcdPathRequest/.test(typeSource)],
['ICD path reference option type is defined', () => /interface\s+IcdPathReferenceOption[\s\S]*id\?:\s*string[\s\S]*name\?:\s*string/.test(typeSource)],
['ICD path save requests include standard reference ID', () => /CreateIcdPathRequest[\s\S]*referenceIcdId\?:\s*string/.test(typeSource) && /buildSavePayload[\s\S]*referenceIcdId:\s*formModel\.referenceIcdId/.test(formDialogSource)],
[
'ICD path save requests include standard reference ID',
() =>
/CreateIcdPathRequest[\s\S]*referenceIcdId\?:\s*string/.test(typeSource) &&
/buildSavePayload[\s\S]*referenceIcdId:\s*isStandardIcdType\(formModel\.type\)\s*\?\s*''\s*:\s*formModel\.referenceIcdId/.test(
formDialogSource
)
],
['ICD path mapping detail response type is defined', () => /interface\s+IcdPathMappingDetailResponse[\s\S]*id\?:\s*string[\s\S]*name\?:\s*string[\s\S]*jsonStr\?:\s*string[\s\S]*xmlStr\?:\s*string[\s\S]*icdText\?:\s*string/.test(typeSource)],
[
'ICD path type options cover manual and upstream standard states',
@@ -119,7 +126,30 @@ const checks = [
['ICD path type renders readable text in table', () => /getIcdTypeText\(scope\.row\.type\)/.test(pageSource)],
['ICD path type search uses select options', () => /prop:\s*'type'[\s\S]*enum:\s*icdTypeOptions[\s\S]*search:\s*\{[\s\S]*el:\s*'select'/.test(pageSource)],
['ICD path form uses select for type', () => /<el-select\s+v-model="formModel\.type"[\s\S]*<el-option[\s\S]*v-for="option in icdTypeOptions"/.test(formDialogSource)],
['ICD path form requires standard reference select after ICD type', () => /<el-form-item label="ICD类型"[\s\S]*<\/el-form-item>\s*<el-form-item label="标准ICD引用" prop="referenceIcdId">[\s\S]*<el-select\s+v-model="formModel\.referenceIcdId"[\s\S]*v-for="option in referenceOptions"[\s\S]*:label="option\.name"[\s\S]*:value="option\.id"/.test(formDialogSource) && /referenceIcdId:\s*\[\{\s*required:\s*true,\s*message:\s*'请选择标准 ICD 引用'/.test(formDialogSource)],
[
'ICD path form shows support angle and phase metric switches on one row',
() =>
/class="icd-path-form-dialog__switch-row"[\s\S]*<el-form-item label="支持相角"[\s\S]*v-model="formModel\.angle"[\s\S]*<el-form-item label="使用相别指标"[\s\S]*v-model="formModel\.usePhaseIndex"/.test(
formDialogSource
)
],
[
'ICD path form requires standard reference only for non-standard ICD types',
() =>
/<el-form-item label="ICD类型"[\s\S]*<\/el-form-item>\s*<el-form-item label="标准ICD引用" prop="referenceIcdId">[\s\S]*<el-select\s+v-model="formModel\.referenceIcdId"[\s\S]*v-for="option in referenceOptions"[\s\S]*:label="option\.name"[\s\S]*:value="option\.id"/.test(
formDialogSource
) &&
/isStandardIcdType\s*=\s*\(type\?:\s*number\)\s*=>\s*type\s*===\s*1\s*\|\|\s*type\s*===\s*3/.test(
formDialogSource
) &&
/isReferenceIcdRequired\s*=\s*computed\(\(\)\s*=>\s*!isStandardIcdType\(formModel\.type\)\)/.test(
formDialogSource
) &&
/referenceIcdId:\s*\[\s*\{\s*required:\s*isReferenceIcdRequired\.value/.test(formDialogSource) &&
/watch\(\s*\(\)\s*=>\s*formModel\.type[\s\S]*if\s*\(isStandardIcdType\(type\)\)\s*\{[\s\S]*formModel\.referenceIcdId\s*=\s*''/.test(
formDialogSource
)
],
['ICD path form loads and backfills standard reference ID', () => /listIcdPathReferencesApi/.test(formDialogSource) && /loadReferenceOptions/.test(formDialogSource) && /formModel\.referenceIcdId\s*=\s*record\.referenceIcdId\s*\|\|\s*''/.test(formDialogSource)],
['ICD path create and edit form uses single column layout', () => !/<el-col\s+:span="12"/.test(formDialogSource)],
['new ICD path defaults to upstream parsed non-standard type', () => /type:\s*4/.test(formDialogSource) && /formModel\.type\s*=\s*4/.test(formDialogSource)],
@@ -313,6 +343,29 @@ const checks = [
/icdDocument:\s*parsedIcdDocument\.value\s*\|\|\s*responsePayload\.value\?\.icdDocument/.test(flowSource)
],
['mapping result panel has no mojibake text', () => !/[鏄搴褰鍙纭鏍閰獙][\s\S]*[犲垪撳栨疆]/.test(resultPanelSource)],
[
'mapping result panel supports editable JSON and XML mapping content',
() =>
/update-mapping-json/.test(resultPanelSource) &&
/update-xml-mapping/.test(resultPanelSource) &&
/editable/.test(resultPanelSource) &&
!/<pre class="xml-file-content">/.test(resultPanelSource) &&
/<el-input[\s\S]*type="textarea"[\s\S]*xmlMappingDraft/.test(resultPanelSource)
],
[
'JSON mapping expand and collapse actions are icon-only tooltips',
() => {
const jsonTreeFile = path.resolve(rootDir, 'views/tools/mmsMapping/components/JsonMappingTree.vue')
const jsonTreeSource = exists(jsonTreeFile) ? read(jsonTreeFile) : ''
return (
/<el-tooltip[\s\S]*content="全部展开"[\s\S]*<el-button[\s\S]*:icon="Expand"/.test(jsonTreeSource) &&
/<el-tooltip[\s\S]*content="全部收起"[\s\S]*<el-button[\s\S]*:icon="Fold"/.test(jsonTreeSource) &&
!/>全部展开<\/el-button>/.test(jsonTreeSource) &&
!/>全部收起<\/el-button>/.test(jsonTreeSource)
)
}
],
['flow supports injected ICD check save handler', () => /saveIcdCheckResult\?:/.test(flowSource) && /options\.saveIcdCheckResult/.test(flowSource)],
['form dialog follows ICD check dialog visual shell', () => /class="icd-check-dialog"/.test(formDialogSource) && /class="icd-check-dialog__body"/.test(formDialogSource)],
['check dialog reuses shared MMS mapping flow panels', () => /<MappingRequestPanel/.test(checkDialogSource) && /<MappingConfigPanel/.test(checkDialogSource) && /<MappingResultPanel/.test(checkDialogSource) && /<MappingConfirmDialog/.test(checkDialogSource)]

View File

@@ -19,6 +19,12 @@ const checks = [
'ICD type column is widened to avoid text clipping',
() => /prop: 'type'[\s\S]*?label: 'ICD类型'[\s\S]*?width: 220/.test(columnsSource)
],
[
'Angle and phase metric columns use requested labels',
() =>
/prop: 'angle'[\s\S]*?label: '支持相角'[\s\S]*?width: 90/.test(columnsSource) &&
/prop: 'usePhaseIndex'[\s\S]*?label: '使用相别指标'[\s\S]*?width: 130/.test(columnsSource)
],
[
'Mapping detail column uses width 150',
() => /prop: 'mappingDetail'[\s\S]*?label: '映射文件详情'[\s\S]*?width: 150/.test(columnsSource)

View File

@@ -10,6 +10,12 @@ const source = fs.readFileSync(panelFile, 'utf8')
const checks = [
['JSON problem button uses dedicated JSON mapping issue text', () => /problemButtonText/.test(source)],
['JSON result label uses updated JSON match text', () => source.includes('JSON匹配结果')],
['JSON export button uses shortened download text', () => source.includes('下载JSON')],
['Old JSON mapping issue list text is removed', () => !source.includes('JSON映射问题列表')],
['XML match result label uses updated XML match text', () => source.includes('XML匹配结果')],
['XML export button uses shortened download text', () => source.includes('下载XML')],
['Old XML mapping match result list text is removed', () => !source.includes('XML映射匹配结果列表')],
['Old generic problem dialog title is removed', () => !source.includes('title="问题列表"')],
['XML match result action opens dedicated XML match dialog', () => /@click="matchResultDialogVisible = true"/.test(source)],
['Old XML match result display text is removed', () => !source.includes('匹配结果展示')],

View File

@@ -496,11 +496,11 @@ const columns = reactive<ColumnProps<MmsMapping.IcdPathRecord>[]>([
}
}
},
{ prop: 'angle', label: '角', width: 90 },
{ prop: 'angle', label: '支持相角', width: 90 },
{
prop: 'usePhaseIndex',
label: '相位索引',
width: 110,
label: '使用相别指标',
width: 130,
render: scope => getUsePhaseIndexText(scope.row.usePhaseIndex)
},
{

View File

@@ -817,10 +817,33 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
reviewedIcdConsistencyProblemList.value = []
hasConfirmedIcdConsistencyProblems.value = false
shouldOpenIcdConsistencyProblems.value = 0
hasSequenceConfigured.value = false
activeResultTab.value = 'json'
}
const handleUpdateXmlMapping = (xml: string) => {
if (!xmlResponsePayload.value) return
const xmlFile = xmlResponsePayload.value.xmlFile
// 关键业务节点XML 结果允许人工修正,保存和下载必须使用编辑后的内容。
xmlResponsePayload.value = {
...xmlResponsePayload.value,
xmlFile: xmlFile
? {
...xmlFile,
content: xml
}
: xmlResponsePayload.value.xmlFile,
mappingXml: xml,
xmlContent: xml,
xmlText: xml
}
lastIcdConsistencyCheckResult.value = null
reviewedIcdConsistencyProblemList.value = []
hasConfirmedIcdConsistencyProblems.value = false
shouldOpenIcdConsistencyProblems.value = 0
}
const handleSequenceConfigComplete = () => {
hasSequenceConfigured.value = true
}
@@ -894,6 +917,7 @@ export const useMmsMappingFlow = (options: UseMmsMappingFlowOptions = {}) => {
handleConfirmIcdConsistencyProblems,
handleRemoveIcdConsistencyProblem,
handleUpdateMappingJson,
handleUpdateXmlMapping,
handleSequenceConfigComplete,
handleSaveIcdCheckResult,
handleConfirmIndexSelection

View File

@@ -27,6 +27,9 @@
>
JSON映射
</el-button>
<el-button type="primary" plain :icon="FullScreen" @click="indexSelectionFullscreenVisible = true">
全屏查看
</el-button>
</div>
</div>
@@ -48,11 +51,34 @@
<el-empty v-if="!hasDefaultJson" :description="emptyDescription" />
</div>
<el-dialog
v-model="indexSelectionFullscreenVisible"
class="mapping-fullscreen-dialog mapping-fullscreen-dialog--index"
title="索引配置全屏查看"
fullscreen
append-to-body
destroy-on-close
>
<div class="mapping-fullscreen-body">
<el-alert v-if="jsonError" :title="jsonError" type="error" :closable="false" class="json-alert" />
<el-input
type="textarea"
class="index-selection-fullscreen-input"
:model-value="indexSelectionJson"
:disabled="isSubmitting"
resize="none"
placeholder="索引配置完成后这里会自动回填索引配置仍可继续直接编辑"
@update:model-value="value => emit('update:indexSelectionJson', String(value || ''))"
/>
</div>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { Connection, EditPen } from '@element-plus/icons-vue'
import { Connection, EditPen, FullScreen } from '@element-plus/icons-vue'
import { ref } from 'vue'
defineOptions({
name: 'MappingConfigPanel'
@@ -80,6 +106,8 @@ const emit = defineEmits<{
(event: 'confirm-config'): void
(event: 'generate'): void
}>()
const indexSelectionFullscreenVisible = ref(false)
</script>
<style scoped lang="scss">
@@ -202,6 +230,37 @@ const emit = defineEmits<{
margin: 16px 0 0;
}
:global(.mapping-fullscreen-dialog .el-dialog__body) {
height: calc(100vh - 54px);
padding: 16px 20px 20px;
}
.mapping-fullscreen-body {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.index-selection-fullscreen-input {
flex: 1;
min-height: 0;
margin-top: 0;
}
.index-selection-fullscreen-input :deep(.el-textarea) {
width: 100%;
height: 100%;
}
.index-selection-fullscreen-input :deep(.el-textarea__inner) {
height: 100%;
min-height: 100%;
font-family: Consolas, 'Courier New', monospace;
line-height: 1.6;
overflow-y: auto;
}
@media (max-width: 768px) {
.mapping-panel {
padding: 20px;

View File

@@ -57,7 +57,7 @@
:content="
icdConsistencyStatus === 'success'
? 'ICD校验通过'
: `ICD校验不通过点击查看JSON映射问题列表${icdConsistencyProblemCount}条)`
: `ICD校验不通过点击查看JSON匹配结果${icdConsistencyProblemCount}条)`
"
placement="top"
>
@@ -101,6 +101,8 @@
v-if="mappingJsonPreview"
:source="mappingJsonPreview"
:meta-text="mappingMetaText"
editable
@update:source="emit('update-mapping-json', $event)"
>
<template #actions>
<el-button
@@ -124,6 +126,16 @@
</el-button>
</template>
<template #trailing-actions>
<el-button
type="primary"
plain
size="small"
:icon="FullScreen"
:disabled="!mappingJsonPreview"
@click="jsonMappingFullscreenVisible = true"
>
全屏查看
</el-button>
<el-button
type="primary"
plain
@@ -132,7 +144,7 @@
:disabled="!canExportJsonMapping"
@click="emit('export-mapping', 'json')"
>
下载JSON映射
下载JSON
</el-button>
</template>
</JsonMappingTree>
@@ -145,6 +157,16 @@
<div class="xml-preview-toolbar">
<div class="xml-preview-meta">{{ xmlMetaText }}</div>
<div class="xml-preview-actions">
<el-button
type="primary"
plain
size="small"
:icon="FullScreen"
:disabled="!xmlMappingPreview"
@click="xmlMappingFullscreenVisible = true"
>
全屏查看
</el-button>
<el-button
type="primary"
plain
@@ -153,7 +175,7 @@
:disabled="!methodDescribe"
@click="matchResultDialogVisible = true"
>
XML映射匹配结果列表
XML匹配结果
</el-button>
<el-button
type="primary"
@@ -163,11 +185,18 @@
:disabled="!canExportXmlMapping"
@click="emit('export-mapping', 'xml')"
>
下载XML映射
下载XML
</el-button>
</div>
</div>
<pre class="xml-file-content">{{ xmlMappingPreview }}</pre>
<el-input
v-model="xmlMappingDraft"
type="textarea"
resize="none"
spellcheck="false"
class="xml-file-content"
@input="handleXmlMappingInput"
/>
</div>
<el-empty v-else :description="xmlEmptyText" />
</div>
@@ -178,7 +207,7 @@
<el-dialog
v-model="problemDialogVisible"
title="JSON映射问题列表"
title="JSON匹配结果"
width="720px"
destroy-on-close
top="8vh"
@@ -229,7 +258,7 @@
<el-dialog
v-model="matchResultDialogVisible"
title="XML映射匹配结果列表"
title="XML匹配结果"
width="640px"
destroy-on-close
top="12vh"
@@ -239,6 +268,108 @@
</div>
</el-dialog>
<el-dialog
v-model="jsonMappingFullscreenVisible"
class="mapping-fullscreen-dialog mapping-fullscreen-dialog--json"
title="JSON映射全屏查看"
fullscreen
append-to-body
destroy-on-close
>
<div class="mapping-fullscreen-body">
<JsonMappingTree
v-if="mappingJsonPreview"
:source="mappingJsonPreview"
:meta-text="mappingMetaText"
editable
@update:source="emit('update-mapping-json', $event)"
>
<template #actions>
<el-button
type="primary"
plain
size="small"
:icon="Warning"
@click="problemDialogVisible = true"
>
{{ problemButtonText }}
</el-button>
<el-button
v-if="icdConsistencyStatus"
type="primary"
plain
size="small"
:icon="WarningFilled"
@click="openIcdConsistencyProblems"
>
{{ icdConsistencyProblemButtonText }}
</el-button>
</template>
<template #trailing-actions>
<el-button
type="primary"
plain
size="small"
:icon="Download"
:disabled="!canExportJsonMapping"
@click="emit('export-mapping', 'json')"
>
下载JSON
</el-button>
</template>
</JsonMappingTree>
<el-empty v-else description="当前返回未包含 mappingJson" />
</div>
</el-dialog>
<el-dialog
v-model="xmlMappingFullscreenVisible"
class="mapping-fullscreen-dialog mapping-fullscreen-dialog--xml"
title="XML映射全屏查看"
fullscreen
append-to-body
destroy-on-close
>
<div class="mapping-fullscreen-body">
<div v-if="xmlMappingPreview" class="xml-preview-viewer">
<div class="xml-preview-toolbar">
<div class="xml-preview-meta">{{ xmlMetaText }}</div>
<div class="xml-preview-actions">
<el-button
type="primary"
plain
size="small"
:icon="Document"
:disabled="!methodDescribe"
@click="matchResultDialogVisible = true"
>
XML匹配结果
</el-button>
<el-button
type="primary"
plain
size="small"
:icon="Download"
:disabled="!canExportXmlMapping"
@click="emit('export-mapping', 'xml')"
>
下载XML
</el-button>
</div>
</div>
<el-input
v-model="xmlMappingDraft"
type="textarea"
resize="none"
spellcheck="false"
class="xml-file-content"
@input="handleXmlMappingInput"
/>
</div>
<el-empty v-else :description="xmlEmptyText" />
</div>
</el-dialog>
<el-dialog
v-model="xmlDataTypeDialogVisible"
title="XML映射类型"
@@ -345,6 +476,7 @@ import {
Document,
Download,
Finished,
FullScreen,
Search,
Setting,
Warning,
@@ -436,6 +568,7 @@ const emit = defineEmits<{
(event: 'export-mapping', value: ExportMappingType): void
(event: 'generate-xml-mapping', value: number): void
(event: 'update-mapping-json', value: string): void
(event: 'update-xml-mapping', value: string): void
(event: 'update:sequenceDialogVisible', value: boolean): void
(event: 'sequence-config-complete'): void
(event: 'icd-check'): void
@@ -449,7 +582,7 @@ const activeTabProxy = computed({
set: value => emit('update:activeResultTab', value)
})
const JSON_MAPPING_PROBLEM_LABEL = 'JSON\u6620\u5c04\u95ee\u9898\u5217\u8868'
const JSON_MAPPING_PROBLEM_LABEL = 'JSON\u5339\u914d\u7ed3\u679c'
const ICD_CONSISTENCY_PROBLEM_LABEL = 'ICD\u6821\u9a8c\u95ee\u9898\u5217\u8868'
const problemButtonText = computed(() =>
props.problemList.length ? `${JSON_MAPPING_PROBLEM_LABEL} (${props.problemList.length})` : JSON_MAPPING_PROBLEM_LABEL
@@ -463,10 +596,13 @@ const icdConsistencyProblemCount = computed(() => props.icdConsistencyProblemLis
const problemDialogVisible = ref(false)
const icdConsistencyProblemDialogVisible = ref(false)
const matchResultDialogVisible = ref(false)
const jsonMappingFullscreenVisible = ref(false)
const xmlMappingFullscreenVisible = ref(false)
const xmlDataTypeDialogVisible = ref(false)
const xmlDataType = ref<number>(2)
const sequenceConfigRows = ref<SequenceConfigRow[]>([])
const sequenceSearchKeyword = ref('')
const xmlMappingDraft = ref('')
const sequenceDialogVisible = computed({
get: () => props.sequenceDialogVisible,
set: value => emit('update:sequenceDialogVisible', value)
@@ -671,6 +807,18 @@ watch(
}
)
watch(
() => props.xmlMappingPreview,
value => {
xmlMappingDraft.value = value
},
{ immediate: true }
)
const handleXmlMappingInput = (value: string | number) => {
emit('update-xml-mapping', String(value))
}
const confirmSequenceConfig = () => {
try {
const nextJson = JSON.parse(props.mappingJsonPreview) as unknown
@@ -918,14 +1066,22 @@ const confirmSequenceConfig = () => {
}
.xml-file-content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.xml-file-content :deep(.el-textarea__inner) {
height: 100%;
min-height: 100%;
margin: 0;
padding: 16px;
border: 1px solid #dbe3f0;
border-radius: 10px;
background: #ffffff;
overflow: auto;
box-shadow: none;
font-family: Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
@@ -1181,6 +1337,18 @@ const confirmSequenceConfig = () => {
line-height: 18px;
}
:global(.mapping-fullscreen-dialog .el-dialog__body) {
height: calc(100vh - 54px);
padding: 16px 20px 20px;
}
.mapping-fullscreen-body {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
@media (max-width: 768px) {
.mapping-panel {
padding: 20px;