设备管理按多对多关系展示关联测试
检测柜和采集器列表及编辑摘要统一显示关联测试,支持单项跳转、多项展开状态和绑定通道,移除汇总测试状态列及筛选。
类型检查、构建和两类设备的桌面及窄屏浏览器验证通过,规范与需求审查通过。
| | |
| | | CabinetOnlineStatus, |
| | | CabinetPayload, |
| | | CabinetStatus, |
| | | CabinetTestStatus, |
| | | DeviceCabinetRecord, |
| | | } from '@/stores/deviceCabinets' |
| | | import { request, type ApiResponse } from './request' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | import type { DeviceRelatedTest } from '@/stores/deviceRelatedTests' |
| | | |
| | | export interface DeviceCabinetListParams { |
| | | pageNum: number |
| | |
| | | beginCreateTime?: string |
| | | endCreateTime?: string |
| | | onlineStatus?: string |
| | | testStatus?: string |
| | | status?: string |
| | | } |
| | | |
| | |
| | | capacity: number | string | null |
| | | channelCount: number | null |
| | | onlineStatus: string | null |
| | | testStatus: string | null |
| | | currentTest: string | null |
| | | relatedTests: DeviceRelatedTest[] |
| | | status: string | null |
| | | remark: string | null |
| | | createTime: number | null |
| | |
| | | model: string | null |
| | | channelCount: number |
| | | onlineStatus: CabinetOnlineStatus |
| | | testStatus: CabinetTestStatus |
| | | currentTest: string |
| | | } |
| | | |
| | | export interface DiscoveredCabinetListResult { |
| | |
| | | } |
| | | |
| | | const onlineStatusMap: Record<string, CabinetOnlineStatus> = { '0': '在线', '1': '离线' } |
| | | const testStatusMap: Record<string, CabinetTestStatus> = { '0': '空闲', '1': '测试中' } |
| | | const statusMap: Record<string, CabinetStatus> = { '0': '启用', '1': '禁用' } |
| | | |
| | | function mapCabinet(item: DetectionCabinetDto): DeviceCabinetRecord { |
| | |
| | | specs: Number(item.capacity) || 0, |
| | | remark: item.remark || '', |
| | | onlineStatus: onlineStatusMap[item.onlineStatus || ''] || '离线', |
| | | testStatus: testStatusMap[item.testStatus || ''] || '空闲', |
| | | currentTest: item.currentTest || '', |
| | | relatedTests: item.relatedTests || [], |
| | | status: statusMap[item.status || ''] || '禁用', |
| | | createdAt: formatBusinessTime(item.createTime), |
| | | } |
| | |
| | | import type { |
| | | CollectorOnlineStatus, |
| | | CollectorStatus, |
| | | CollectorTestStatus, |
| | | CollectorPayload, |
| | | DeviceCollectorRecord, |
| | | } from '@/stores/deviceCollectors' |
| | | import { request, type ApiResponse } from './request' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | import type { DeviceRelatedTest } from '@/stores/deviceRelatedTests' |
| | | |
| | | export interface DeviceCollectorListParams { |
| | | pageNum: number |
| | |
| | | beginCreateTime?: string |
| | | endCreateTime?: string |
| | | onlineStatus?: string |
| | | testStatus?: string |
| | | status?: string |
| | | } |
| | | |
| | |
| | | model: string | null |
| | | channelCount: number | null |
| | | onlineStatus: string | null |
| | | testStatus: string | null |
| | | currentTest: string | null |
| | | relatedTests: DeviceRelatedTest[] |
| | | status: string | null |
| | | remark: string | null |
| | | createTime: number | null |
| | |
| | | model: string |
| | | channelCount: number |
| | | onlineStatus: CollectorOnlineStatus |
| | | testStatus: CollectorTestStatus |
| | | currentTest: string |
| | | } |
| | | |
| | | interface DiscoveredCollectorListResponse extends ApiResponse { |
| | |
| | | } |
| | | |
| | | const onlineStatusMap: Record<string, CollectorOnlineStatus> = { '0': '离线', '1': '在线' } |
| | | const testStatusMap: Record<string, CollectorTestStatus> = { '0': '空闲', '1': '测试中' } |
| | | const statusMap: Record<string, CollectorStatus> = { '0': '启用', '1': '禁用' } |
| | | |
| | | function mapCollector(item: CollectorDto): DeviceCollectorRecord { |
| | |
| | | channelCount: item.channelCount ?? 0, |
| | | remark: item.remark || '', |
| | | onlineStatus: onlineStatusMap[item.onlineStatus || ''] || '离线', |
| | | testStatus: testStatusMap[item.testStatus || ''] || '空闲', |
| | | currentTest: item.currentTest || '', |
| | | relatedTests: item.relatedTests || [], |
| | | status: statusMap[item.status || ''] || '禁用', |
| | | createdAt: formatBusinessTime(item.createTime), |
| | | } |
| | |
| | | model: item.model || '', |
| | | channelCount: item.channelCount || 1, |
| | | onlineStatus: item.onlineStatus || '离线', |
| | | testStatus: item.testStatus || '空闲', |
| | | currentTest: item.currentTest || '-', |
| | | relatedTests: [], |
| | | status: '启用', |
| | | remark: '', |
| | | })), |
| | |
| | | interface TestOptionsResponse extends ApiResponse { data: TestConfigurationOptions } |
| | | export interface TestListResult { total: number; rows: TestRecord[] } |
| | | |
| | | const statusMap: Record<string, TestStatus> = { |
| | | export const testStatusLabels: Record<string, TestStatus> = { |
| | | WAITING: '待测试', RUNNING: '检测中', PAUSED: '暂停', COMPLETED: '检测完成', |
| | | // 历史数据只作兼容展示,不会由新流程写入。 |
| | | RUNNING_ABNORMAL: '检测中', COMPLETION_PENDING: '检测完成', TERMINATED: '检测完成', |
| | |
| | | NOT_STARTED: '未开始接收', RECEIVING: '接收中', PAUSED: '暂停接收', ENDED: '已结束接收', |
| | | } |
| | | function mapTest(item: TestDto): TestRecord { |
| | | const status = item.testStatus ? statusMap[item.testStatus] : '未知' |
| | | const status = item.testStatus ? testStatusLabels[item.testStatus] : '未知' |
| | | const receptionStatus = item.receptionStatus ? receptionStatusMap[item.receptionStatus] : '未知' |
| | | return { |
| | | id: item.testId, name: item.testName || '-', type: item.testTypeName || '-', batteryNo: item.batteryNo || '-', batteryGroup: '-', |
| New file |
| | |
| | | <script setup lang="ts"> |
| | | import { ref } from 'vue' |
| | | import { testStatusLabels } from '@/api/tests' |
| | | import type { DeviceRelatedTest } from '@/stores/deviceRelatedTests' |
| | | |
| | | defineProps<{ tests: DeviceRelatedTest[] }>() |
| | | const expanded = ref(false) |
| | | </script> |
| | | |
| | | <template> |
| | | <span v-if="tests.length === 0">-</span> |
| | | <router-link v-else-if="tests.length === 1" class="related-test-link" |
| | | :to="{ name: 'test-console', params: { id: tests[0]!.testId } }" |
| | | :title="`${tests[0]!.testName} · ${testStatusLabels[tests[0]!.testStatus] || '未知'} · ${tests[0]!.channels.join('、')}`"> |
| | | {{ tests[0]!.testName }} |
| | | </router-link> |
| | | <button v-else class="related-tests-button" type="button" @click="expanded = true">{{ tests.length }} 项测试</button> |
| | | |
| | | <el-dialog v-model="expanded" title="关联测试" width="560px" style="max-width: calc(100% - 24px)" append-to-body> |
| | | <table class="related-tests-table"> |
| | | <thead><tr><th>测试名称</th><th>测试状态</th><th>绑定通道</th></tr></thead> |
| | | <tbody> |
| | | <tr v-for="test in tests" :key="test.testId"> |
| | | <td><router-link :to="{ name: 'test-console', params: { id: test.testId } }" @click="expanded = false">{{ test.testName }}</router-link></td> |
| | | <td>{{ testStatusLabels[test.testStatus] || '未知' }}</td> |
| | | <td>{{ test.channels.join('、') || '-' }}</td> |
| | | </tr> |
| | | </tbody> |
| | | </table> |
| | | </el-dialog> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .related-test-link { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| | | .related-test-link, .related-tests-button, .related-tests-table a { color: var(--blue); text-decoration: none; } |
| | | .related-tests-button { padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer; } |
| | | .related-test-link:hover, .related-tests-button:hover, .related-tests-table a:hover { text-decoration: underline; } |
| | | .related-tests-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 13px; } |
| | | .related-tests-table th, .related-tests-table td { padding: 10px 8px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; white-space: normal; } |
| | | .related-tests-table th { background: var(--surface-soft); } |
| | | .related-tests-table th:nth-child(2) { width: 72px; } |
| | | </style> |
| | |
| | | import type { DeviceRelatedTest } from './deviceRelatedTests' |
| | | |
| | | export type CabinetOnlineStatus = '在线' | '离线' |
| | | export type CabinetTestStatus = '待检测' | '测试中' | '空闲' |
| | | export type CabinetStatus = '启用' | '禁用' |
| | | |
| | | export interface DeviceCabinetRecord { |
| | |
| | | specs: number |
| | | remark: string |
| | | onlineStatus: CabinetOnlineStatus |
| | | testStatus: CabinetTestStatus |
| | | currentTest: string |
| | | relatedTests: DeviceRelatedTest[] |
| | | status: CabinetStatus |
| | | createdAt: string |
| | | } |
| | | |
| | | export type CabinetPayload = Pick<DeviceCabinetRecord, 'deviceCode' | 'ip' | 'mac' | 'name' | 'manufacturer' | 'model' | 'channelCount' | 'specs' | 'remark' | 'onlineStatus' | 'testStatus' | 'currentTest' | 'status'> |
| | | export type CabinetPayload = Pick<DeviceCabinetRecord, 'deviceCode' | 'ip' | 'mac' | 'name' | 'manufacturer' | 'model' | 'channelCount' | 'specs' | 'remark' | 'onlineStatus' | 'relatedTests' | 'status'> |
| | |
| | | import type { DeviceRelatedTest } from './deviceRelatedTests' |
| | | |
| | | export type CollectorOnlineStatus = '在线' | '离线' |
| | | export type CollectorTestStatus = '测试中' | '空闲' |
| | | export type CollectorStatus = '启用' | '禁用' |
| | | |
| | | export interface DeviceCollectorRecord { |
| | |
| | | channelCount: number |
| | | remark: string |
| | | onlineStatus: CollectorOnlineStatus |
| | | testStatus: CollectorTestStatus |
| | | currentTest: string |
| | | relatedTests: DeviceRelatedTest[] |
| | | status: CollectorStatus |
| | | createdAt: string |
| | | } |
| | | |
| | | export type CollectorPayload = Pick<DeviceCollectorRecord, 'deviceCode' | 'ip' | 'mac' | 'name' | 'manufacturer' | 'model' | 'channelCount' | 'remark' | 'onlineStatus' | 'testStatus' | 'currentTest' | 'status'> |
| | | export type CollectorPayload = Pick<DeviceCollectorRecord, 'deviceCode' | 'ip' | 'mac' | 'name' | 'manufacturer' | 'model' | 'channelCount' | 'remark' | 'onlineStatus' | 'relatedTests' | 'status'> |
| New file |
| | |
| | | export interface DeviceRelatedTest { |
| | | testId: number |
| | | testName: string |
| | | testStatus: string |
| | | channels: string[] |
| | | } |
| | |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { useRouter } from 'vue-router' |
| | | import { changeDeviceCabinetStatus, deleteDeviceCabinets, getDeviceCabinetList } from '@/api/deviceCabinets' |
| | | import type { CabinetOnlineStatus, CabinetStatus, CabinetTestStatus, DeviceCabinetRecord } from '@/stores/deviceCabinets' |
| | | import type { CabinetOnlineStatus, CabinetStatus, DeviceCabinetRecord } from '@/stores/deviceCabinets' |
| | | import DeviceRelatedTests from '@/components/DeviceRelatedTests.vue' |
| | | |
| | | interface FilterState { |
| | | device: string |
| | | dateRange: [string, string] | null |
| | | onlineStatus: string |
| | | testStatus: string |
| | | status: string |
| | | } |
| | | |
| | | const router = useRouter() |
| | | const onlineStatuses: CabinetOnlineStatus[] = ['在线', '离线'] |
| | | const testStatuses: CabinetTestStatus[] = ['测试中', '空闲'] |
| | | const statuses: CabinetStatus[] = ['启用', '禁用'] |
| | | const filters = reactive<FilterState>({ device: '', dateRange: null, onlineStatus: '', testStatus: '', status: '' }) |
| | | const filters = reactive<FilterState>({ device: '', dateRange: null, onlineStatus: '', status: '' }) |
| | | const records = ref<DeviceCabinetRecord[]>([]) |
| | | const total = ref(0) |
| | | const loading = ref(false) |
| | |
| | | const pageSize = ref(10) |
| | | |
| | | const onlineStatusValues: Record<CabinetOnlineStatus, string> = { 在线: '0', 离线: '1' } |
| | | const testStatusValues: Record<CabinetTestStatus, string> = { 空闲: '0', 测试中: '1', 待检测: '0' } |
| | | const statusValues: Record<CabinetStatus, string> = { 启用: '0', 禁用: '1' } |
| | | |
| | | async function loadRecords(showResult = false) { |
| | |
| | | beginCreateTime: filters.dateRange?.[0] ? `${filters.dateRange[0]} 00:00:00` : undefined, |
| | | endCreateTime: filters.dateRange?.[1] ? `${filters.dateRange[1]} 23:59:59` : undefined, |
| | | onlineStatus: filters.onlineStatus && filters.onlineStatus !== '全部' ? onlineStatusValues[filters.onlineStatus as CabinetOnlineStatus] : undefined, |
| | | testStatus: filters.testStatus && filters.testStatus !== '全部' ? testStatusValues[filters.testStatus as CabinetTestStatus] : undefined, |
| | | status: filters.status && filters.status !== '全部' ? statusValues[filters.status as CabinetStatus] : undefined, |
| | | }) |
| | | records.value = result.rows |
| | |
| | | } |
| | | |
| | | function resetSearch() { |
| | | Object.assign(filters, { device: '', dateRange: null, onlineStatus: '', testStatus: '', status: '' }) |
| | | Object.assign(filters, { device: '', dateRange: null, onlineStatus: '', status: '' }) |
| | | currentPage.value = 1 |
| | | loadRecords() |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | function testStatusClass(status: CabinetTestStatus) { |
| | | return { '待检测': 'pending', '测试中': 'running', '空闲': 'idle' }[status] |
| | | } |
| | | |
| | | onMounted(() => loadRecords()) |
| | | </script> |
| | | |
| | |
| | | <label><span>设备</span><el-input v-model="filters.device" clearable placeholder="输入IP、设备编码、MAC或名称" @keyup.enter="applySearch" /></label> |
| | | <label class="date-filter"><span>创建时间</span><el-date-picker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" unlink-panels /></label> |
| | | <label><span>在线状态</span><el-select v-model="filters.onlineStatus" clearable placeholder="全部状态"><el-option label="全部" value="全部" /><el-option v-for="item in onlineStatuses" :key="item" :label="item" :value="item" /></el-select></label> |
| | | <label><span>测试状态</span><el-select v-model="filters.testStatus" clearable placeholder="全部状态"><el-option label="全部" value="全部" /><el-option v-for="item in testStatuses" :key="item" :label="item" :value="item" /></el-select></label> |
| | | <label><span>状态</span><el-select v-model="filters.status" clearable placeholder="全部状态"><el-option label="全部" value="全部" /><el-option v-for="item in statuses" :key="item" :label="item" :value="item" /></el-select></label> |
| | | <div class="filter-actions"><button class="primary-button" type="button" @click="applySearch"><el-icon><Search /></el-icon>查询</button><button class="secondary-button" type="button" @click="resetSearch"><el-icon><RefreshLeft /></el-icon>重置</button></div> |
| | | </div> |
| | |
| | | <div class="result-toolbar"><div><i></i><strong>检测柜列表</strong><span>{{ total }} RESULTS · 按创建时间倒序</span></div><button class="primary-button" type="button" @click="createCabinet"><el-icon><Plus /></el-icon>手动添加</button></div> |
| | | <div v-loading="loading" class="table-wrap"> |
| | | <table> |
| | | <thead><tr><th>设备编码</th><th>IP</th><th>MAC</th><th>名称</th><th>制造商</th><th>型号</th><th>通道数</th><th>规格/容量</th><th>备注</th><th>在线状态</th><th>测试状态</th><th>当前测试</th><th class="action-column">操作</th></tr></thead> |
| | | <thead><tr><th>设备编码</th><th>IP</th><th>MAC</th><th>名称</th><th>制造商</th><th>型号</th><th>通道数</th><th>规格/容量</th><th>备注</th><th>在线状态</th><th>关联测试</th><th class="action-column">操作</th></tr></thead> |
| | | <tbody> |
| | | <tr v-for="item in records" :key="item.id"> |
| | | <td class="code-cell">{{ item.deviceCode }}</td><td class="code-cell">{{ item.ip }}</td><td class="code-cell">{{ item.mac }}</td><td><strong>{{ item.name }}</strong></td><td>{{ item.manufacturer }}</td><td>{{ item.model }}</td><td>{{ item.channelCount }}</td><td :title="String(item.specs)">{{ item.specs }}</td><td :title="item.remark">{{ item.remark || '-' }}</td> |
| | | <td><span class="online-state" :class="item.onlineStatus === '在线' ? 'online' : 'offline'"><i></i>{{ item.onlineStatus }}</span></td> |
| | | <td><span class="test-state" :class="testStatusClass(item.testStatus)">{{ item.testStatus }}</span></td> |
| | | <td :title="item.currentTest">{{ item.currentTest || '-' }}</td> |
| | | <td><DeviceRelatedTests :tests="item.relatedTests" /></td> |
| | | <td class="action-column"><div class="row-actions"><button type="button" :disabled="submittingId !== null" :class="item.status === '启用' ? 'disable-action' : 'enable-action'" @click="changeStatus(item)">{{ submittingId === item.id ? '处理中' : item.status === '启用' ? '禁用' : '启用' }}</button><button type="button" :disabled="submittingId !== null" @click="editCabinet(item.id)">编辑</button><button class="delete-action" type="button" :disabled="submittingId !== null" @click="deleteCabinet(item)">删除</button></div></td> |
| | | </tr> |
| | | <tr v-if="!loading && !records.length"><td colspan="13" class="empty-cell">暂无符合条件的检测柜</td></tr> |
| | | <tr v-if="!loading && !records.length"><td colspan="12" class="empty-cell">暂无符合条件的检测柜</td></tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | |
| | | .filter-actions { grid-column: -2 / -1; min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 8px; } |
| | | .result-toolbar { height: 50px; padding: 0 16px; } |
| | | .table-wrap { overflow-x: auto; } |
| | | table { width: 100%; min-width: 1740px; table-layout: fixed; border-collapse: collapse; color: #52606d; font-size: 13px; } |
| | | table { width: 100%; min-width: 1640px; table-layout: fixed; border-collapse: collapse; color: #52606d; font-size: 13px; } |
| | | th, td { height: 50px; padding: 0 12px; border-right: 1px solid #dde7f0; border-bottom: 1px solid #dde7f0; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| | | th { height: 40px; color: #52606d; background: #eaf4ff; border-color: #c7e0ff; font-size: 12px; font-weight: 600; } |
| | | th:nth-child(1) { width: 115px; } th:nth-child(2) { width: 120px; } th:nth-child(3) { width: 130px; } th:nth-child(4) { width: 130px; } |
| | | th:nth-child(5), th:nth-child(6) { width: 100px; } th:nth-child(7) { width: 80px; } th:nth-child(8) { width: 145px; } th:nth-child(9) { width: 145px; } |
| | | th:nth-child(10), th:nth-child(11) { width: 100px; } th:nth-child(12) { width: 180px; } th:last-child { width: 180px; } |
| | | th:nth-child(10) { width: 100px; } th:nth-child(11) { width: 180px; } th:last-child { width: 180px; } |
| | | th:last-child, td:last-child { border-right: 0; text-align: center; } |
| | | .action-column { position: sticky; right: 0; z-index: 2; background: #fff; overflow: visible; border-left: 0; box-shadow: inset 1px 0 0 #b4c7d9, -8px 0 14px rgba(23, 49, 74, .12); } |
| | | th.action-column { z-index: 3; background: #eaf4ff; } |
| | |
| | | .online-state i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } |
| | | .online-state.online { color: #23865e; } |
| | | .online-state.offline { color: #a64a4a; } |
| | | .test-state { display: inline-flex; padding: 4px 7px; border: 1px solid; font-size: 11px; line-height: 1; } |
| | | .test-state.pending { color: #a96b16; background: #fff8e9; border-color: #efd59e; } |
| | | .test-state.running { color: #176fcb; background: #edf6ff; border-color: #b8d9fb; } |
| | | .test-state.idle { color: #697682; background: #f2f4f6; border-color: #ccd4da; } |
| | | .row-actions { display: flex; align-items: center; justify-content: center; gap: 12px; } |
| | | .row-actions button { border: 0; color: var(--blue); background: transparent; font-size: 12px; font-weight: 500; cursor: pointer; } |
| | | .row-actions button:disabled { opacity: .55; cursor: not-allowed; } |
| | |
| | | type DiscoveredCabinet, |
| | | } from '@/api/deviceCabinets' |
| | | import type { CabinetPayload } from '@/stores/deviceCabinets' |
| | | import DeviceRelatedTests from '@/components/DeviceRelatedTests.vue' |
| | | |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | |
| | | |
| | | const form = reactive<CabinetPayload>({ |
| | | deviceCode: '', ip: '', mac: '', name: '', manufacturer: '', model: '', channelCount: 1, |
| | | specs: 0, remark: '', onlineStatus: '离线', testStatus: '空闲', currentTest: '-', status: '启用', |
| | | specs: 0, remark: '', onlineStatus: '离线', relatedTests: [], status: '启用', |
| | | }) |
| | | |
| | | async function loadEditingCabinet() { |
| | |
| | | model: device.model || '', |
| | | channelCount: device.channelCount, |
| | | onlineStatus: device.onlineStatus, |
| | | testStatus: device.testStatus, |
| | | currentTest: device.currentTest, |
| | | relatedTests: [], |
| | | }) |
| | | deviceDialogVisible.value = false |
| | | ElMessage.success(`已选择设备 ${device.deviceCode}`) |
| | |
| | | <div><span>IP</span><strong class="mono-value">{{ form.ip || '-' }}</strong></div> |
| | | <div><span>MAC</span><strong class="mono-value">{{ form.mac || '-' }}</strong></div> |
| | | <div><span>在线状态</span><strong :class="form.onlineStatus === '在线' ? 'online-value' : 'offline-value'">{{ form.deviceCode ? form.onlineStatus : '-' }}</strong></div> |
| | | <div><span>测试状态</span><strong>{{ form.deviceCode ? form.testStatus : '-' }}</strong></div> |
| | | <div><span>当前测试</span><strong :title="form.currentTest">{{ form.deviceCode ? form.currentTest : '-' }}</strong></div> |
| | | <div><span>关联测试</span><DeviceRelatedTests :tests="form.relatedTests" /></div> |
| | | </div> |
| | | |
| | | <div class="detail-form"> |
| | |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { useRouter } from 'vue-router' |
| | | import { changeDeviceCollectorStatus, deleteDeviceCollectors, getDeviceCollectorList } from '@/api/deviceCollectors' |
| | | import type { CollectorOnlineStatus, CollectorStatus, CollectorTestStatus, DeviceCollectorRecord } from '@/stores/deviceCollectors' |
| | | import type { CollectorOnlineStatus, CollectorStatus, DeviceCollectorRecord } from '@/stores/deviceCollectors' |
| | | import DeviceRelatedTests from '@/components/DeviceRelatedTests.vue' |
| | | |
| | | interface FilterState { |
| | | device: string |
| | | dateRange: [string, string] | null |
| | | onlineStatus: string |
| | | testStatus: string |
| | | status: string |
| | | } |
| | | |
| | | const router = useRouter() |
| | | const onlineStatuses: CollectorOnlineStatus[] = ['在线', '离线'] |
| | | const testStatuses: CollectorTestStatus[] = ['空闲', '测试中'] |
| | | const statuses: CollectorStatus[] = ['启用', '禁用'] |
| | | const filters = reactive<FilterState>({ device: '', dateRange: null, onlineStatus: '', testStatus: '', status: '' }) |
| | | const filters = reactive<FilterState>({ device: '', dateRange: null, onlineStatus: '', status: '' }) |
| | | const records = ref<DeviceCollectorRecord[]>([]) |
| | | const total = ref(0) |
| | | const currentPage = ref(1) |
| | |
| | | const submittingId = ref<number | null>(null) |
| | | |
| | | const onlineStatusValues: Record<CollectorOnlineStatus, string> = { '离线': '0', '在线': '1' } |
| | | const testStatusValues: Record<CollectorTestStatus, string> = { '空闲': '0', '测试中': '1' } |
| | | const statusValues: Record<CollectorStatus, string> = { '启用': '0', '禁用': '1' } |
| | | |
| | | async function loadCollectors(showResult = false) { |
| | |
| | | endCreateTime: filters.dateRange?.[1], |
| | | onlineStatus: filters.onlineStatus && filters.onlineStatus !== '全部' |
| | | ? onlineStatusValues[filters.onlineStatus as CollectorOnlineStatus] |
| | | : undefined, |
| | | testStatus: filters.testStatus && filters.testStatus !== '全部' |
| | | ? testStatusValues[filters.testStatus as CollectorTestStatus] |
| | | : undefined, |
| | | status: filters.status && filters.status !== '全部' |
| | | ? statusValues[filters.status as CollectorStatus] |
| | |
| | | } |
| | | |
| | | function resetSearch() { |
| | | Object.assign(filters, { device: '', dateRange: null, onlineStatus: '', testStatus: '', status: '' }) |
| | | Object.assign(filters, { device: '', dateRange: null, onlineStatus: '', status: '' }) |
| | | currentPage.value = 1 |
| | | loadCollectors() |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | function testStatusClass(status: CollectorTestStatus) { |
| | | return { '测试中': 'running', '空闲': 'idle' }[status] |
| | | } |
| | | |
| | | onMounted(loadCollectors) |
| | | </script> |
| | | |
| | |
| | | <label><span>设备</span><el-input v-model="filters.device" clearable placeholder="输入IP、设备编码、MAC或名称" @keyup.enter="applySearch" /></label> |
| | | <label class="date-filter"><span>创建时间</span><el-date-picker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" unlink-panels /></label> |
| | | <label><span>在线状态</span><el-select v-model="filters.onlineStatus" clearable placeholder="全部状态"><el-option label="全部" value="全部" /><el-option v-for="item in onlineStatuses" :key="item" :label="item" :value="item" /></el-select></label> |
| | | <label><span>测试状态</span><el-select v-model="filters.testStatus" clearable placeholder="全部状态"><el-option label="全部" value="全部" /><el-option v-for="item in testStatuses" :key="item" :label="item" :value="item" /></el-select></label> |
| | | <label><span>状态</span><el-select v-model="filters.status" clearable placeholder="全部状态"><el-option label="全部" value="全部" /><el-option v-for="item in statuses" :key="item" :label="item" :value="item" /></el-select></label> |
| | | <div class="filter-actions"><button class="primary-button" type="button" :disabled="loading" @click="applySearch"><el-icon><Search /></el-icon>查询</button><button class="secondary-button" type="button" :disabled="loading" @click="resetSearch"><el-icon><RefreshLeft /></el-icon>重置</button></div> |
| | | </div> |
| | |
| | | <div class="result-toolbar"><div><i></i><strong>采集器列表</strong><span>{{ total }} RESULTS · 按创建时间倒序</span></div><button class="primary-button" type="button" @click="createCollector"><el-icon><Plus /></el-icon>手动添加</button></div> |
| | | <div v-loading="loading" class="table-wrap"> |
| | | <table> |
| | | <thead><tr><th>设备编码</th><th>IP</th><th>MAC</th><th>名称</th><th>制造商</th><th>型号</th><th>通道数</th><th>备注</th><th>在线状态</th><th>测试状态</th><th>当前测试</th><th class="action-column">操作</th></tr></thead> |
| | | <thead><tr><th>设备编码</th><th>IP</th><th>MAC</th><th>名称</th><th>制造商</th><th>型号</th><th>通道数</th><th>备注</th><th>在线状态</th><th>关联测试</th><th class="action-column">操作</th></tr></thead> |
| | | <tbody> |
| | | <tr v-for="item in records" :key="item.id"> |
| | | <td class="code-cell">{{ item.deviceCode }}</td><td class="code-cell">{{ item.ip }}</td><td class="code-cell">{{ item.mac }}</td><td><strong>{{ item.name }}</strong></td><td>{{ item.manufacturer }}</td><td>{{ item.model }}</td><td>{{ item.channelCount }}</td><td :title="item.remark">{{ item.remark || '-' }}</td> |
| | | <td><span class="online-state" :class="item.onlineStatus === '在线' ? 'online' : 'offline'"><i></i>{{ item.onlineStatus }}</span></td> |
| | | <td><span class="test-state" :class="testStatusClass(item.testStatus)">{{ item.testStatus }}</span></td> |
| | | <td :title="item.currentTest">{{ item.currentTest || '-' }}</td> |
| | | <td><DeviceRelatedTests :tests="item.relatedTests" /></td> |
| | | <td class="action-column"><div class="row-actions"><button type="button" :disabled="submittingId !== null" :class="item.status === '启用' ? 'disable-action' : 'enable-action'" @click="changeStatus(item)">{{ submittingId === item.id ? '处理中' : item.status === '启用' ? '禁用' : '启用' }}</button><button type="button" :disabled="submittingId !== null" @click="editCollector(item)">编辑</button><button class="delete-action" type="button" :disabled="submittingId !== null" @click="deleteCollector(item)">删除</button></div></td> |
| | | </tr> |
| | | <tr v-if="!loading && !records.length"><td colspan="12" class="empty-cell">暂无符合条件的采集器</td></tr> |
| | | <tr v-if="!loading && !records.length"><td colspan="11" class="empty-cell">暂无符合条件的采集器</td></tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | |
| | | .filter-actions { grid-column: -2 / -1; min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 8px; } |
| | | .result-toolbar { height: 50px; padding: 0 16px; } |
| | | .table-wrap { overflow-x: auto; } |
| | | table { width: 100%; min-width: 1580px; table-layout: fixed; border-collapse: collapse; color: #52606d; font-size: 13px; } |
| | | table { width: 100%; min-width: 1480px; table-layout: fixed; border-collapse: collapse; color: #52606d; font-size: 13px; } |
| | | th, td { height: 50px; padding: 0 12px; border-right: 1px solid #dde7f0; border-bottom: 1px solid #dde7f0; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| | | th { height: 40px; color: #52606d; background: #eaf4ff; border-color: #c7e0ff; font-size: 12px; font-weight: 600; } |
| | | th:nth-child(1) { width: 115px; } th:nth-child(2) { width: 120px; } th:nth-child(3) { width: 130px; } th:nth-child(4) { width: 130px; } |
| | | th:nth-child(5), th:nth-child(6) { width: 100px; } th:nth-child(7) { width: 80px; } th:nth-child(8) { width: 145px; } th:nth-child(9), th:nth-child(10) { width: 100px; } th:nth-child(11) { width: 180px; } th:last-child { width: 180px; } |
| | | th:nth-child(5), th:nth-child(6) { width: 100px; } th:nth-child(7) { width: 80px; } th:nth-child(8) { width: 145px; } th:nth-child(9) { width: 100px; } th:nth-child(10) { width: 180px; } th:last-child { width: 180px; } |
| | | th:last-child, td:last-child { border-right: 0; text-align: center; } |
| | | .action-column { position: sticky; right: 0; z-index: 2; background: #fff; overflow: visible; border-left: 0; box-shadow: inset 1px 0 0 #b4c7d9, -8px 0 14px rgba(23, 49, 74, .12); } |
| | | th.action-column { z-index: 3; background: #eaf4ff; } |
| | |
| | | .online-state i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } |
| | | .online-state.online { color: #23865e; } |
| | | .online-state.offline { color: #a64a4a; } |
| | | .test-state { display: inline-flex; padding: 4px 7px; border: 1px solid; font-size: 11px; line-height: 1; } |
| | | .test-state.running { color: #176fcb; background: #edf6ff; border-color: #b8d9fb; } |
| | | .test-state.idle { color: #697682; background: #f2f4f6; border-color: #ccd4da; } |
| | | .row-actions { display: flex; align-items: center; justify-content: center; gap: 12px; } |
| | | .row-actions button { border: 0; color: var(--blue); background: transparent; font-size: 12px; font-weight: 500; cursor: pointer; } |
| | | .row-actions .disable-action, .row-actions .delete-action { color: #ad4a4a; } |
| | |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { createDeviceCollector, getDeviceCollector, getDiscoveredCollectorList, updateDeviceCollector } from '@/api/deviceCollectors' |
| | | import type { CollectorPayload } from '@/stores/deviceCollectors' |
| | | import DeviceRelatedTests from '@/components/DeviceRelatedTests.vue' |
| | | |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | |
| | | |
| | | const form = reactive<CollectorPayload>({ |
| | | deviceCode: '', ip: '', mac: '', name: '', manufacturer: '', model: '', channelCount: 1, |
| | | remark: '', onlineStatus: '离线', testStatus: '空闲', currentTest: '-', status: '启用', |
| | | remark: '', onlineStatus: '离线', relatedTests: [], status: '启用', |
| | | }) |
| | | |
| | | async function loadEditingCollector() { |
| | |
| | | model: device.model, |
| | | channelCount: device.channelCount, |
| | | onlineStatus: device.onlineStatus, |
| | | testStatus: device.testStatus, |
| | | currentTest: device.currentTest, |
| | | relatedTests: [], |
| | | }) |
| | | deviceDialogVisible.value = false |
| | | ElMessage.success(`已选择设备 ${device.deviceCode}`) |
| | |
| | | <div><span>IP</span><strong class="mono-value">{{ form.ip || '-' }}</strong></div> |
| | | <div><span>MAC</span><strong class="mono-value">{{ form.mac || '-' }}</strong></div> |
| | | <div><span>在线状态</span><strong :class="form.onlineStatus === '在线' ? 'online-value' : 'offline-value'">{{ form.deviceCode ? form.onlineStatus : '-' }}</strong></div> |
| | | <div><span>测试状态</span><strong>{{ form.deviceCode ? form.testStatus : '-' }}</strong></div> |
| | | <div><span>当前测试</span><strong :title="form.currentTest">{{ form.deviceCode ? form.currentTest : '-' }}</strong></div> |
| | | <div><span>关联测试</span><DeviceRelatedTests :tests="form.relatedTests" /></div> |
| | | </div> |
| | | |
| | | <div class="detail-form"> |
| New file |
| | |
| | | import assert from 'node:assert/strict' |
| | | import { readFile, mkdir } from 'node:fs/promises' |
| | | import { resolve } from 'node:path' |
| | | import { chromium, expect } from '@playwright/test' |
| | | |
| | | // 读取真实构建产物并拦截 API,不启动服务或写入开发数据库。 |
| | | const output = '../.scratch/simulator-and-data-import-v1/related-tests-browser' |
| | | await mkdir(output, { recursive: true }) |
| | | const origin = 'http://device-relations.test' |
| | | const browser = await chromium.launch({ channel: 'chrome', headless: true }) |
| | | const relatedTests = [ |
| | | { testId: 1, testName: '低温循环测试', testStatus: 'WAITING', channels: ['1-1', '1-2'] }, |
| | | { testId: 2, testName: '正在接收的测试', testStatus: 'RUNNING', channels: ['1-3'] }, |
| | | { testId: 3, testName: '暂停接收的测试', testStatus: 'PAUSED', channels: ['1-4'] }, |
| | | { testId: 4, testName: '已结束的长名称测试用于验证弹窗内容换行和窄屏布局', testStatus: 'COMPLETED', channels: ['2-1', '2-2', '2-3'] }, |
| | | ] |
| | | const devices = (kind) => [[], [relatedTests[0]], relatedTests].map((tests, index) => ({ |
| | | [`${kind}Id`]: index + 1, [`${kind}Code`]: `${kind}-${index + 1}`, [`${kind}Name`]: `设备 ${index + 1}`, |
| | | ipAddress: '127.0.0.2', macAddress: '02:44:00:10:00:01', manufacturer: '开发模拟器', model: 'Virtual', |
| | | channelCount: 8, capacity: 1, onlineStatus: kind === 'cabinet' ? '0' : '1', status: '0', |
| | | relatedTests: kind === 'cabinet' ? tests : tests.map(test => ({ ...test, channels: ['S-01', 'S-02'] })), |
| | | })) |
| | | |
| | | try { |
| | | const page = await browser.newPage({ viewport: { width: 1920, height: 1000 } }) |
| | | const errors = [] |
| | | let responseMode = 'normal' |
| | | page.on('pageerror', error => errors.push(error.message)) |
| | | await page.addInitScript(() => localStorage.setItem('battery_detection_auth_token', 'browser-test')) |
| | | await page.route(`${origin}/**`, async route => { |
| | | const url = new URL(route.request().url()) |
| | | const path = url.pathname |
| | | if (path.startsWith('/assets/')) { |
| | | const body = await readFile(resolve('dist', path.slice(1))) |
| | | return route.fulfill({ body, contentType: path.endsWith('.css') ? 'text/css' : 'application/javascript' }) |
| | | } |
| | | if (path.includes('/system/')) { |
| | | if (path.includes('/system/test/')) return route.fulfill({ json: { code: 404, msg: '测试控制台响应未纳入设备页面夹具' } }) |
| | | const kind = path.includes('/detectionCabinet') ? 'cabinet' : 'collector' |
| | | if (responseMode === 'error') return route.fulfill({ status: 500, json: { code: 500, msg: '设备查询失败' } }) |
| | | if (responseMode === 'forbidden') return route.fulfill({ json: { code: 403, msg: '没有设备查询权限' } }) |
| | | if (path.endsWith('/list')) { |
| | | assert.equal(url.searchParams.has('testStatus'), false) |
| | | const rows = responseMode === 'empty' ? [] : devices(kind) |
| | | return route.fulfill({ json: { code: 200, rows, total: rows.length } }) |
| | | } |
| | | return route.fulfill({ json: { code: 200, data: devices(kind)[2] } }) |
| | | } |
| | | if (path.includes('/getInfo')) return route.fulfill({ json: { code: 200, user: { userName: 'admin' }, roles: ['admin'] } }) |
| | | return route.fulfill({ contentType: 'text/html', body: await readFile('dist/index.html', 'utf8') }) |
| | | }) |
| | | const kinds = process.argv[2] ? [process.argv[2]] : ['cabinet', 'collector'] |
| | | for (const kind of kinds) { |
| | | const listUrl = `${origin}/device-${kind}` |
| | | await page.goto(listUrl) |
| | | const table = page.locator('.result-panel table') |
| | | await expect(table.locator('tbody tr')).toHaveCount(3) |
| | | await expect(table.getByRole('columnheader', { name: '关联测试' })).toBeVisible() |
| | | await expect(table.getByRole('columnheader', { name: '测试状态', exact: true })).toHaveCount(0) |
| | | await expect(page.getByText('当前测试', { exact: true })).toHaveCount(0) |
| | | const cellIndex = kind === 'cabinet' ? 10 : 9 |
| | | await expect(table.locator('tbody tr').nth(0).locator('td').nth(cellIndex)).toHaveText('-') |
| | | await expect(table.getByRole('link', { name: '低温循环测试' })).toHaveAttribute('href', '/test-list/1/console') |
| | | await page.screenshot({ path: `${output}/${kind}-list.png`, fullPage: true }) |
| | | for (const width of [1920, 768, 390]) { |
| | | await page.setViewportSize({ width, height: 900 }) |
| | | await table.getByRole('button', { name: '4 项测试' }).click() |
| | | const dialog = page.getByRole('dialog', { name: '关联测试' }) |
| | | await expect(dialog).toBeVisible() |
| | | await expect(dialog).toHaveCSS('opacity', '1') |
| | | await expect(dialog).toHaveCSS('transform', 'none') |
| | | await expect(dialog.locator('tbody tr')).toHaveCount(4) |
| | | for (const state of ['待测试', '检测中', '暂停', '检测完成']) await expect(dialog.getByRole('cell', { name: state, exact: true })).toBeVisible() |
| | | await expect(dialog.locator('tbody tr').first()).toContainText(kind === 'cabinet' ? '1-1、1-2' : 'S-01、S-02') |
| | | const box = await dialog.boundingBox() |
| | | assert.ok(box && box.x >= 0 && box.x + box.width <= width && box.y >= 0 && box.y + box.height <= 900, JSON.stringify({ width, box })) |
| | | assert.equal(await dialog.evaluate(element => element.scrollWidth > element.clientWidth), false) |
| | | await page.screenshot({ path: `${output}/${kind}-${width}.png`, fullPage: true, animations: 'disabled' }) |
| | | await dialog.getByRole('button', { name: /关闭|Close/ }).click() |
| | | await expect(dialog).not.toBeVisible() |
| | | } |
| | | await page.setViewportSize({ width: 1920, height: 1000 }) |
| | | await table.getByRole('link', { name: '低温循环测试' }).click() |
| | | await expect(page).toHaveURL(`${origin}/test-list/1/console`) |
| | | await page.goto(listUrl) |
| | | await table.getByRole('button', { name: '4 项测试' }).click() |
| | | await page.getByRole('dialog', { name: '关联测试' }).getByRole('link', { name: '暂停接收的测试' }).click() |
| | | await expect(page).toHaveURL(`${origin}/test-list/3/console`) |
| | | await page.goto(listUrl) |
| | | await page.reload() |
| | | await expect(table.getByRole('button', { name: '4 项测试' })).toBeVisible() |
| | | await page.goto(`${listUrl}/3/edit`) |
| | | await expect(page.locator('.device-summary').getByRole('button', { name: '4 项测试' })).toBeVisible() |
| | | await expect(page.locator('.device-summary').getByText('测试状态', { exact: true })).toHaveCount(0) |
| | | for (const mode of ['empty', 'error', 'forbidden']) { |
| | | responseMode = mode |
| | | await page.goto(listUrl) |
| | | await expect(table).not.toContainText('4 项测试') |
| | | await expect(table).toContainText('暂无符合条件') |
| | | if (mode === 'error') await expect(page.getByText('设备查询失败', { exact: true })).toBeVisible() |
| | | if (mode === 'forbidden') await expect(page.getByText('没有设备查询权限', { exact: true })).toBeVisible() |
| | | } |
| | | responseMode = 'normal' |
| | | } |
| | | assert.deepEqual(errors, []) |
| | | console.log(`关联测试浏览器验证通过:${kinds.join('、')};空值、单项链接、多项展开、状态、通道、刷新、编辑、错误及 1920/768/390 布局。`) |
| | | } finally { |
| | | await browser.close() |
| | | } |