yeg
2026-09-06 e6a282cb844d7f5f7ec6c0fb18b643fd6f3d557e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
<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>