<script setup lang="ts">
|
import { computed, reactive, ref, onMounted } from 'vue'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { useExceptions, type ExceptionRecord, type ExceptionStatus } from '@/stores/exceptions'
|
|
interface FilterState {
|
id: string
|
testName: string
|
dateRange: [string, string] | null
|
status: string
|
processor: string
|
}
|
|
const { records, handleServerRecord, loadFromServer } = useExceptions()
|
onMounted(async () => { if (!await loadFromServer()) ElMessage.error('异常记录加载失败,请检查后端服务') })
|
const statuses: ExceptionStatus[] = ['待处理', '已处理']
|
const processors = ['张工', '李工', '周工', '王工', '赵工', '陈工', 'Admin']
|
const filters = reactive<FilterState>({ id: '', testName: '', dateRange: null, status: '', processor: '' })
|
const appliedFilters = ref<FilterState>({ id: '', testName: '', dateRange: null, status: '', processor: '' })
|
const currentPage = ref(1)
|
const pageSize = ref(10)
|
|
const filteredRecords = computed(() => {
|
const query = appliedFilters.value
|
return records.value
|
.filter(item => !query.id || String(item.id).includes(query.id.trim()))
|
.filter(item => !query.testName || item.testName.toLowerCase().includes(query.testName.trim().toLowerCase()))
|
.filter(item => !query.status || query.status === '全部' || item.status === query.status)
|
.filter(item => !query.processor || query.processor === '全部' || item.processor === query.processor)
|
.filter(item => {
|
if (!query.dateRange) return true
|
const generatedDate = item.generatedAt.slice(0, 10)
|
return generatedDate >= query.dateRange[0] && generatedDate <= query.dateRange[1]
|
})
|
.sort((a, b) => b.generatedAt.localeCompare(a.generatedAt))
|
})
|
|
const pagedRecords = computed(() => {
|
const start = (currentPage.value - 1) * pageSize.value
|
return filteredRecords.value.slice(start, start + pageSize.value)
|
})
|
|
function applySearch() {
|
appliedFilters.value = { ...filters, dateRange: filters.dateRange ? [...filters.dateRange] as [string, string] : null }
|
currentPage.value = 1
|
ElMessage.success(`已找到 ${filteredRecords.value.length} 条异常记录`)
|
}
|
|
function resetSearch() {
|
Object.assign(filters, { id: '', testName: '', dateRange: null, status: '', processor: '' })
|
appliedFilters.value = { id: '', testName: '', dateRange: null, status: '', processor: '' }
|
currentPage.value = 1
|
}
|
|
async function handleException(item: ExceptionRecord) {
|
if (item.status === '已处理') return
|
|
try {
|
const result = await ElMessageBox.prompt(`处理“${item.exceptionItem}”异常,填写本次处理说明。`, '处理异常记录', {
|
confirmButtonText: '完成处理',
|
cancelButtonText: '取消',
|
inputPlaceholder: '输入处理说明',
|
inputType: 'textarea',
|
inputValidator: value => value.trim() ? true : '请输入处理说明',
|
inputErrorMessage: '请输入处理说明',
|
})
|
try {
|
await handleServerRecord(item.id, result.value.trim())
|
ElMessage.success('异常记录已处理')
|
} catch {
|
ElMessage.error('异常处理失败,请刷新后重试')
|
}
|
} catch (reason) {
|
if (reason !== 'cancel' && reason !== 'close') ElMessage.error('异常处理窗口打开失败')
|
}
|
}
|
|
function escapeCsv(value: string | number) {
|
return `"${String(value).replaceAll('"', '""')}"`
|
}
|
|
function downloadExceptions() {
|
if (!filteredRecords.value.length) {
|
ElMessage.warning('当前筛选条件下没有可下载的数据')
|
return
|
}
|
|
const headers = ['异常记录ID', '测试ID', '测试名称', '测试时间', '来源', '异常项', '异常值', '正常值', '异常生成时间', '处理人', '处理时间', '状态', '处理说明']
|
const rows = filteredRecords.value.map(item => [
|
item.id, item.testId, item.testName, item.testTime, item.source, item.exceptionItem, item.abnormalValue,
|
item.normalValue, item.generatedAt, item.processor || '-', item.handledAt, item.status, item.handleRemark || '-',
|
])
|
const csv = `\uFEFF${[headers, ...rows].map(row => row.map(escapeCsv).join(',')).join('\n')}`
|
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }))
|
const anchor = document.createElement('a')
|
anchor.href = url
|
anchor.download = `异常管理_${new Date().toISOString().slice(0, 10)}.csv`
|
anchor.style.display = 'none'
|
document.body.appendChild(anchor)
|
anchor.click()
|
anchor.remove()
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
ElMessage.success(`已导出 ${filteredRecords.value.length} 条异常记录`)
|
}
|
</script>
|
|
<template>
|
<div class="exception-page">
|
<section class="filter-panel" aria-label="异常查询条件">
|
<header class="section-heading">
|
<div><i></i><strong>查询条件</strong><span>FILTER CONDITIONS</span></div>
|
<small>共 {{ records.length }} 条记录</small>
|
</header>
|
<div class="filter-grid">
|
<label><span>异常记录ID</span><el-input v-model="filters.id" clearable placeholder="输入异常记录ID" @keyup.enter="applySearch" /></label>
|
<label><span>测试名称</span><el-input v-model="filters.testName" clearable placeholder="输入测试名称" @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.status" clearable placeholder="全部状态"><el-option label="全部" value="全部" /><el-option v-for="item in statuses" :key="item" :label="item" :value="item" /></el-select></label>
|
<label><span>处理人</span><el-select v-model="filters.processor" clearable placeholder="全部人员"><el-option label="全部" value="全部" /><el-option v-for="item in processors" :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>
|
</section>
|
|
<section class="result-panel">
|
<div class="result-toolbar">
|
<div><i></i><strong>异常记录</strong><span>{{ filteredRecords.length }} RESULTS · 按异常生成时间倒序</span></div>
|
<button class="secondary-button" type="button" @click="downloadExceptions"><el-icon><Download /></el-icon>下载结果</button>
|
</div>
|
<div class="table-wrap">
|
<table>
|
<thead><tr><th>异常记录ID</th><th>测试ID</th><th>测试名称</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>
|
<tbody>
|
<tr v-for="item in pagedRecords" :key="item.id">
|
<td class="id-cell">#{{ item.id }}</td>
|
<td class="code-cell">#{{ item.testId }}</td>
|
<td><strong>{{ item.testName }}</strong></td>
|
<td>{{ item.testTime }}</td><td :title="item.source">{{ item.source }}</td>
|
<td>{{ item.exceptionItem }}</td>
|
<td class="abnormal-value">{{ item.abnormalValue }}</td>
|
<td>{{ item.normalValue }}</td>
|
<td>{{ item.generatedAt }}</td>
|
<td>{{ item.processor || '-' }}</td>
|
<td>{{ item.handledAt }}</td>
|
<td><span class="status-tag" :class="item.status === '待处理' ? 'pending' : 'handled'"><i></i>{{ item.status }}</span></td>
|
<td class="action-column"><div class="row-actions"><button v-if="item.status === '待处理'" type="button" @click="handleException(item)">处理</button></div></td>
|
</tr>
|
<tr v-if="!pagedRecords.length"><td colspan="13" class="empty-cell">暂无符合条件的异常记录</td></tr>
|
</tbody>
|
</table>
|
</div>
|
<footer class="pagination-bar"><span>显示 {{ pagedRecords.length }} 条,共 {{ filteredRecords.length }} 条</span><el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]" :total="filteredRecords.length" layout="sizes, prev, pager, next" background /></footer>
|
</section>
|
</div>
|
</template>
|
|
<style scoped>
|
.exception-page { width: 100%; }
|
.primary-button, .secondary-button { height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 13px; border: 1px solid transparent; border-radius: 3px; font-size: 13px; font-weight: 500; cursor: pointer; white-space: nowrap; }
|
.primary-button { color: #fff; background: var(--blue); border-color: var(--blue); }
|
.primary-button:hover { background: #176fcb; border-color: #176fcb; }
|
.secondary-button { color: #52606d; background: #fff; border-color: var(--line-strong); }
|
.secondary-button:hover { color: var(--blue); border-color: #9dc8f5; }
|
.filter-panel, .result-panel { background: #fff; border: 1px solid #d8e1e9; }
|
.filter-panel { margin-bottom: 16px; }
|
.section-heading, .result-toolbar { display: flex; align-items: center; justify-content: space-between; background: var(--surface-soft); border-bottom: 1px solid var(--line); }
|
.section-heading { height: 44px; padding: 0 16px; }
|
.section-heading > div, .result-toolbar > div { display: flex; align-items: center; gap: 8px; }
|
.section-heading > div > i, .result-toolbar > div:first-child > i { width: 3px; height: 16px; background: #2b78a3; }
|
.section-heading strong, .result-toolbar strong { color: var(--ink); font-size: 15px; font-weight: 600; }
|
.section-heading span, .result-toolbar span { color: #81909d; font-size: 10px; }
|
.section-heading small { color: #647380; font-size: 11px; }
|
.filter-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px 18px; padding: 16px; }
|
.filter-grid label { width: 100%; min-width: 0; display: grid; grid-template-columns: 72px minmax(0, 1fr); align-items: center; gap: 8px; }
|
.filter-grid label > span { color: #52606d; font-size: 12px; font-weight: 500; }
|
.filter-grid :deep(.el-select), .filter-grid :deep(.el-date-editor) { width: 100%; }
|
.filter-grid :deep(.el-input__wrapper), .filter-grid :deep(.el-select__wrapper) { min-height: 34px; border-radius: 3px; box-shadow: 0 0 0 1px #d8e1e9 inset; background: #f8fafc; }
|
.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: 1500px; 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: 125px; } th:nth-child(2) { width: 82px; } th:nth-child(3) { width: 180px; } th:nth-child(4) { width: 150px; }
|
th:nth-child(5) { width: 120px; } th:nth-child(6), th:nth-child(7) { width: 105px; } th:nth-child(8), th:nth-child(9), th:nth-child(10) { width: 150px; }
|
th:nth-child(11) { width: 90px; } th:last-child { width: 130px; }
|
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; }
|
tbody tr:hover td.action-column { background: #f7fbff; }
|
tbody tr:hover { background: #f7fbff; }
|
tbody tr:last-child td { border-bottom: 0; }
|
td strong { color: #26333f; font-weight: 600; }
|
.id-cell, .code-cell { color: #647380; font-family: 'Cascadia Mono', Consolas, monospace; font-size: 12px; }
|
.abnormal-value { color: #b34f4f; font-weight: 600; }
|
.status-tag { display: inline-flex; align-items: center; gap: 6px; padding: 4px 7px; border: 1px solid; font-size: 11px; line-height: 1; }
|
.status-tag i { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
.status-tag.pending { color: #b06a18; background: #fff8e9; border-color: #efd59e; }
|
.status-tag.handled { color: #23865e; background: #edf9f3; border-color: #b8dfcd; }
|
.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 .delete-action { color: #ad4a4a; }
|
.empty-cell { height: 160px; color: #8795a3; text-align: center; }
|
.pagination-bar { min-height: 58px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 14px 8px 16px; border-top: 1px solid var(--line); }
|
.pagination-bar > span { color: #7b8794; font-size: 11px; }
|
.pagination-bar :deep(.el-pagination) { --el-pagination-button-bg-color: #edf4fa; --el-pagination-hover-color: var(--blue); }
|
@media (max-width: 1320px) { .filter-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
@media (max-width: 980px) { .filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
@media (max-width: 680px) {
|
.filter-grid { grid-template-columns: minmax(0, 1fr); gap: 12px; padding: 14px 12px; }
|
.filter-actions { justify-content: stretch; }
|
.filter-actions button { flex: 1; }
|
.section-heading, .result-toolbar { padding-left: 12px; }
|
.section-heading span, .result-toolbar span { display: none; }
|
.pagination-bar { align-items: flex-start; flex-direction: column; overflow-x: auto; }
|
}
|
</style>
|