LIMSVerify.vue
12.8 KB
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
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
<template>
<div class="lims-page">
<div class="page-header">
<h1>样本核对接收</h1>
</div>
<el-tabs v-model="activeTab">
<!-- 样本核对 -->
<el-tab-pane label="物理样本核对" name="verify">
<div class="tab-card">
<!-- 扫码区 -->
<div class="scan-area">
<div class="scan-label">扫描样本条码或输入家系编号</div>
<div class="scan-input">
<el-input
v-model="scanInput"
placeholder="扫描条码 或 输入申请单号(如 BC2026030001 或 JB2026030001)"
style="width:500px"
size="large"
@keyup.enter="doScan"
ref="scanRef"
>
<template #prefix>
<span style="font-size:18px">📱</span>
</template>
</el-input>
<el-button type="primary" size="large" @click="doScan">确认</el-button>
</div>
<div class="scan-tip">支持扫码枪连续扫描,每次回车确认</div>
</div>
<!-- 核对区 -->
<div v-if="currentOrder" class="verify-area">
<div class="verify-header">
<div class="order-info-badge">
<span class="badge-label">申请单</span>
<span class="badge-value">{{ currentOrder.orderNo }}</span>
</div>
<div class="order-info-badge">
<span class="badge-label">患者</span>
<span class="badge-value">{{ currentOrder.patientName }}</span>
</div>
<div class="order-info-badge">
<span class="badge-label">项目</span>
<span class="badge-value">{{ currentOrder.productType }}</span>
</div>
<div class="order-info-badge">
<span class="badge-label">医院</span>
<span class="badge-value">{{ currentOrder.hospitalName }}</span>
</div>
<el-tag v-if="currentOrder.urgency" type="danger" effect="dark">加急</el-tag>
</div>
<div class="verify-table">
<div class="section-title">样本核对列表(共 {{ currentOrder.samples.length }} 个)</div>
<table class="check-table">
<thead>
<tr>
<th>样本编号</th>
<th>亲缘关系</th>
<th>样本类型</th>
<th>条码</th>
<th>外观检查</th>
<th>核对结果</th>
</tr>
</thead>
<tbody>
<tr v-for="(s, idx) in currentOrder.samples" :key="s.id">
<td>{{ s.sampleNo }}</td>
<td>{{ s.relation }}</td>
<td>{{ s.sampleType }}</td>
<td class="barcode-cell">{{ s.barcode }}</td>
<td>
<el-input v-model="sampleChecks[idx].appearance" placeholder="外观描述" size="small" style="width:150px" />
</td>
<td>
<el-radio-group v-model="sampleChecks[idx].result" size="small">
<el-radio-button label="consistent">✓ 一致</el-radio-button>
<el-radio-button label="inconsistent">✗ 不一致</el-radio-button>
<el-radio-button label="missing">△ 缺失</el-radio-button>
</el-radio-group>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="hasInconsistency" class="anomaly-section">
<el-form-item label="异常描述">
<el-input v-model="anomalyDesc" type="textarea" :rows="2" placeholder="请详细描述样本异常情况" />
</el-form-item>
</div>
<div class="verify-actions">
<el-button
type="primary"
:disabled="!canReceive"
size="large"
@click="confirmReceive"
>
✅ 确认接收(全部一致)
</el-button>
<el-button
type="warning"
:disabled="!hasInconsistency"
size="large"
@click="markAnomaly"
>
⚠ 标记异常并推送通知
</el-button>
<el-button size="large" @click="clearVerify">清除</el-button>
</div>
</div>
<div v-else-if="scanned" class="not-found-tip">
<el-empty description="未找到对应的申请单,请检查条码/编号是否正确" />
</div>
</div>
</el-tab-pane>
<!-- 待处理异常 -->
<el-tab-pane name="anomaly">
<template #label>
待处理异常 <el-badge :value="anomalyList.length" :hidden="!anomalyList.length" type="danger" />
</template>
<div class="tab-card">
<div v-if="anomalyList.length === 0" class="empty-tip">暂无待处理异常</div>
<div v-else>
<div v-for="item in anomalyList" :key="item.id" class="anomaly-card">
<div class="ac-header">
<span class="ac-order">{{ item.orderNo }}</span>
<el-tag type="danger" size="small">待处理</el-tag>
<span class="ac-time">{{ item.time }}</span>
</div>
<div class="ac-body">
<div class="ac-info">患者:{{ item.patientName }} · {{ item.productType }} · {{ item.hospital }}</div>
<div class="ac-reason">异常:{{ item.reason }}</div>
</div>
<div class="ac-actions">
<el-button size="small" type="primary" @click="notifyCustomer(item)">通知客户补充</el-button>
<el-button size="small" type="success" plain @click="resolveAnomaly(item)">标记已处理</el-button>
</div>
</div>
</div>
</div>
</el-tab-pane>
<!-- 接收记录 -->
<el-tab-pane label="接收记录" name="records">
<div class="tab-card">
<el-table :data="receiveRecords" border>
<el-table-column label="申请单号" prop="orderNo" width="160" />
<el-table-column label="患者" prop="patientName" width="90" />
<el-table-column label="项目" prop="productType" width="90" />
<el-table-column label="接收时间" prop="receiveTime" width="160" />
<el-table-column label="操作人" prop="operator" width="90" />
<el-table-column label="样本数" prop="sampleCount" width="80" />
<el-table-column label="状态">
<template #default>
<el-tag type="success" size="small" effect="light">已接收</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { usePGXStore, type Order } from '@/stores/pgx'
import { ElMessage, ElMessageBox } from 'element-plus'
const pgxStore = usePGXStore()
const activeTab = ref('verify')
const scanInput = ref('')
const scanned = ref(false)
const currentOrder = ref<Order | null>(null)
const sampleChecks = ref<{ result: string; appearance: string }[]>([])
function doScan() {
if (!scanInput.value) return
scanned.value = true
const input = scanInput.value.trim()
// 先按申请单号查
let found = pgxStore.orders.find(o => o.orderNo === input)
// 再按条码查
if (!found) {
found = pgxStore.orders.find(o => o.samples.some(s => s.barcode === input))
}
if (found) {
currentOrder.value = found
sampleChecks.value = found.samples.map(() => ({ result: 'consistent', appearance: '正常' }))
} else {
currentOrder.value = null
}
scanInput.value = ''
}
const hasInconsistency = computed(() =>
sampleChecks.value.some(s => s.result !== 'consistent')
)
const canReceive = computed(() =>
sampleChecks.value.length > 0 && sampleChecks.value.every(s => s.result === 'consistent')
)
const anomalyDesc = ref('')
async function confirmReceive() {
if (!currentOrder.value) return
await ElMessageBox.confirm(`确认接收申请单 ${currentOrder.value.orderNo},共 ${currentOrder.value.samples.length} 个样本?`, '确认接收', { type: 'success' })
pgxStore.receiveOrder(currentOrder.value.id)
receiveRecords.value.unshift({
orderNo: currentOrder.value.orderNo,
patientName: currentOrder.value.patientName,
productType: currentOrder.value.productType,
receiveTime: new Date().toLocaleString('zh-CN'),
operator: 'checkPointer',
sampleCount: currentOrder.value.samples.length,
})
ElMessage.success(`申请单 ${currentOrder.value.orderNo} 已确认接收,状态已更新`)
clearVerify()
}
function markAnomaly() {
if (!currentOrder.value) return
if (!anomalyDesc.value) { ElMessage.warning('请填写异常描述'); return }
anomalyList.value.unshift({
id: Date.now().toString(),
orderNo: currentOrder.value.orderNo,
patientName: currentOrder.value.patientName,
productType: currentOrder.value.productType,
hospital: currentOrder.value.hospitalName,
reason: anomalyDesc.value,
time: new Date().toLocaleString('zh-CN'),
})
ElMessage.warning('已标记异常,已推送通知至生信部和客户端')
clearVerify()
}
function clearVerify() {
currentOrder.value = null
scanned.value = false
sampleChecks.value = []
anomalyDesc.value = ''
}
const anomalyList = ref([
{ id: 'a1', orderNo: 'JB2026030008', patientName: '陈小华', productType: 'PGT-M', hospital: '北京大学人民医院', reason: '胚胎2样本条码不匹配,申请单标记BC999但实物条码为BC998', time: '2026-03-29 14:32' },
])
function notifyCustomer(item: any) {
ElMessage.success(`已推送异常通知至 ${item.patientName} 的送检医生`)
}
function resolveAnomaly(item: any) {
const idx = anomalyList.value.findIndex(a => a.id === item.id)
if (idx !== -1) anomalyList.value.splice(idx, 1)
ElMessage.success('已标记为处理完成')
}
const receiveRecords = ref([
{ orderNo: 'JB2026030001', patientName: '张晓华', productType: 'PGT-M', receiveTime: '2026-03-03 09:15', operator: 'checkPointer', sampleCount: 4 },
{ orderNo: 'JB2026030003', patientName: '刘婷婷', productType: 'ECS', receiveTime: '2026-03-12 10:30', operator: 'checkPointer', sampleCount: 1 },
])
</script>
<style scoped>
.lims-page { padding: 24px; }
.page-header { display: flex; align-items: center; margin-bottom: 20px; }
.page-header h1 { font-size: 20px; font-weight: 600; margin: 0; color: #1a1f2e; }
.tab-card { background: #fff; border-radius: 8px; padding: 24px; }
.scan-area { background: #f8faff; border: 2px dashed #c0d9ff; border-radius: 10px; padding: 28px; text-align: center; margin-bottom: 24px; }
.scan-label { font-size: 16px; font-weight: 600; color: #334155; margin-bottom: 16px; }
.scan-input { display: flex; gap: 12px; align-items: center; justify-content: center; }
.scan-tip { font-size: 12px; color: #aaa; margin-top: 10px; }
.verify-area { border: 1px solid #e8ecf0; border-radius: 8px; padding: 16px; }
.verify-header { display: flex; align-items: center; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; padding-bottom: 14px; border-bottom: 1px solid #f0f2f5; }
.order-info-badge { display: flex; align-items: center; gap: 4px; }
.badge-label { font-size: 12px; color: #888; }
.badge-value { font-size: 14px; font-weight: 600; color: #1a1f2e; }
.section-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: #334155; }
.check-table { width: 100%; border-collapse: collapse; }
.check-table th { background: #f8faff; padding: 10px 12px; text-align: left; font-size: 13px; color: #666; border: 1px solid #e8ecf0; }
.check-table td { padding: 10px 12px; font-size: 13px; border: 1px solid #e8ecf0; }
.barcode-cell { font-family: monospace; color: #334155; }
.anomaly-section { margin: 12px 0; padding: 12px; background: #fff1f0; border-radius: 6px; }
.verify-actions { margin-top: 16px; display: flex; gap: 12px; }
.not-found-tip { padding: 40px; }
.empty-tip { padding: 60px; text-align: center; color: #aaa; }
.anomaly-card { border: 1px solid #ffa39e; border-left: 4px solid #ff4d4f; border-radius: 8px; padding: 14px; margin-bottom: 12px; }
.ac-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.ac-order { font-size: 14px; font-weight: 600; color: #1a1f2e; }
.ac-time { font-size: 12px; color: #aaa; margin-left: auto; }
.ac-info { font-size: 13px; color: #666; margin-bottom: 6px; }
.ac-reason { font-size: 13px; color: #cf1322; background: #fff1f0; padding: 6px 10px; border-radius: 4px; margin-bottom: 10px; }
.ac-actions { display: flex; gap: 10px; }
</style>