LIMSVerify.vue 12.8 KB
<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>