Bioinformatics.vue 15.2 KB
<template>
  <div class="bio-page">
    <div class="page-header">
      <h1>生信分析平台</h1>
      <el-button type="primary" @click="showCreateDialog = true">+ 新建分析</el-button>
    </div>

    <!-- 应用类型 Tab -->
    <div class="app-tabs">
      <div
        v-for="app in appTypes"
        :key="app.type"
        class="app-tab"
        :class="{ active: activeApp === app.type }"
        @click="activeApp = app.type"
      >
        <span class="app-icon">{{ app.icon }}</span>
        <span class="app-name">{{ app.type }}</span>
        <span class="app-count">{{ getRunCount(app.type) }}</span>
      </div>
    </div>

    <!-- 分析列表 -->
    <div class="run-list">
      <div v-if="filteredRuns.length === 0" class="empty-tip">暂无分析记录</div>
      <div v-for="run in filteredRuns" :key="run.id" class="run-card">
        <div class="run-header">
          <div class="run-name">{{ run.groupName }}</div>
          <div class="run-meta">
            <el-tag size="small" effect="light">{{ run.appType }}</el-tag>
            <span class="run-date">创建:{{ run.createDate }}</span>
          </div>
        </div>
        <div class="run-body">
          <div class="run-status">
            <el-tag :type="statusType(run.status)" :effect="run.status === 'running' ? 'dark' : 'light'" size="large">
              <span v-if="run.status === 'running'" class="running-dot"></span>
              {{ statusLabel(run.status) }}
            </el-tag>
            <el-progress v-if="run.status === 'running'" :percentage="runProgress[run.id] || 30" :stroke-width="6" style="width:200px" />
          </div>
          <div v-if="run.results" class="run-metrics">
            <span v-if="run.results.q30">Q30: <b :style="{color: run.results.q30 >= 85 ? '#52c41a' : '#ff4d4f'}">{{ run.results.q30 }}%</b></span>
            <span v-if="run.results.clusterPf">Cluster PF: <b>{{ run.results.clusterPf }}%</b></span>
            <span v-if="run.results.duplicate">Dup: <b>{{ run.results.duplicate }}%</b></span>
          </div>
        </div>
        <div class="run-actions">
          <el-button v-if="run.status === 'ready'" size="small" type="primary" @click="startRun(run)">▶ 运行应用</el-button>
          <el-button v-if="run.status === 'success'" size="small" type="success" plain @click="viewReport(run)">查看报告</el-button>
          <el-button v-if="run.status === 'success' && !run.notifyLims" size="small" plain @click="pushToLims(run)">推送至LIMS</el-button>
          <el-tag v-if="run.notifyLims" type="success" size="small" effect="plain">✓ 已推送LIMS</el-tag>
          <el-button v-if="run.status === 'failed'" size="small" type="warning" @click="retryRun(run)">重新运行</el-button>
          <el-button size="small" text type="danger" @click="deleteRun(run)">删除</el-button>
        </div>
      </div>
    </div>

    <!-- 质控看板 -->
    <div class="qc-panel-card">
      <div class="qc-header">质控监测看板(实时)</div>
      <div class="qc-grid">
        <div v-for="m in qcMetrics" :key="m.label" class="qc-item">
          <div class="qc-label">{{ m.label }}</div>
          <div class="qc-value" :style="{ color: m.ok ? '#52c41a' : '#ff4d4f' }">{{ m.value }}{{ m.unit }}</div>
          <el-progress :percentage="m.percentage" :color="m.ok ? '#52c41a' : '#ff4d4f'" :stroke-width="10" :show-text="false" />
          <div class="qc-threshold">阈值: {{ m.threshold }}</div>
        </div>
      </div>
    </div>

    <!-- 新建分析弹窗 -->
    <el-dialog v-model="showCreateDialog" title="新建分析分组" width="580px" destroy-on-close>
      <el-form :model="newForm" label-width="110px" size="small">
        <el-form-item label="分析类型">
          <el-radio-group v-model="newForm.appType">
            <el-radio-button v-for="a in appTypes" :key="a.type" :label="a.type">{{ a.type }}</el-radio-button>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="分组名称" :rules="[{required:true}]">
          <el-input v-model="newForm.groupName" placeholder="如:患者姓名-检测类型-日期" />
        </el-form-item>
        <el-form-item label="应用版本">
          <el-select v-model="newForm.version" style="width:200px">
            <el-option label="v2.5.1(推荐)" value="v2.5.1" />
            <el-option label="v2.4.0" value="v2.4.0" />
            <el-option label="v2.3.2" value="v2.3.2" />
          </el-select>
        </el-form-item>
        <el-form-item label="关联申请单">
          <el-select v-model="newForm.orderId" filterable clearable style="width:100%">
            <el-option
              v-for="o in pgxStore.orders"
              :key="o.id"
              :label="o.orderNo + ' - ' + o.patientName"
              :value="o.id"
            />
          </el-select>
        </el-form-item>
        <el-form-item label="上传文件">
          <el-upload
            drag
            action="#"
            :auto-upload="false"
            accept=".fastq,.gz,.bam,.idat"
            multiple
            class="upload-area"
          >
            <div class="upload-inner">
              <span style="font-size:30px">📂</span>
              <div>拖拽文件到此处,或 <em>点击上传</em></div>
              <div class="upload-tip">支持 FASTQ / BAM / IDAT 格式</div>
            </div>
          </el-upload>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="showCreateDialog = false">取消</el-button>
        <el-button type="primary" @click="createRun">创建分析</el-button>
      </template>
    </el-dialog>

    <!-- 查看报告弹窗 -->
    <el-dialog v-model="showReportDialog" :title="'分析报告:' + (selectedRun?.groupName || '')" width="800px">
      <div v-if="selectedRun?.results" class="report-view">
        <div class="report-section">
          <div class="rs-title">检测结论</div>
          <el-descriptions :column="2" border size="small">
            <el-descriptions-item label="CNV结论">{{ selectedRun.results.cnvConclusion || '整倍体' }}</el-descriptions-item>
            <el-descriptions-item label="染色体状态">{{ selectedRun.results.chromosome || '正常' }}</el-descriptions-item>
            <el-descriptions-item label="非整倍体结论">{{ selectedRun.results.aneuploidyConclusion || '未检测到异常' }}</el-descriptions-item>
          </el-descriptions>
        </div>
        <div class="report-section">
          <div class="rs-title">质量指标</div>
          <el-descriptions :column="3" border size="small">
            <el-descriptions-item label="Q30 (%)">
              <span :style="{ color: (selectedRun.results.q30||0) >= 85 ? '#52c41a' : '#ff4d4f' }">{{ selectedRun.results.q30 || '—' }}</span>
            </el-descriptions-item>
            <el-descriptions-item label="Cluster PF (%)">{{ selectedRun.results.clusterPf || '—' }}</el-descriptions-item>
            <el-descriptions-item label="Duplicate (%)">{{ selectedRun.results.duplicate || '—' }}</el-descriptions-item>
          </el-descriptions>
        </div>
        <div class="report-section">
          <div class="rs-title">染色体拷贝数图(示意)</div>
          <div class="chr-chart">
            <div v-for="n in 24" :key="n" class="chr-bar">
              <div class="chr-line" :style="{ height: (40 + Math.random() * 30) + 'px', background: n === 13 || n === 21 ? '#52c41a' : '#409eff' }"></div>
              <div class="chr-label">{{ n === 23 ? 'X' : n === 24 ? 'Y' : n }}</div>
            </div>
          </div>
        </div>
      </div>
      <template #footer>
        <el-button @click="ElMessage.success('PDF下载中...')">下载PDF</el-button>
        <el-button type="primary" @click="showReportDialog = false">关闭</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { usePGXStore, type BioinfoRun } from '@/stores/pgx'
import { ElMessage, ElMessageBox } from 'element-plus'

const pgxStore = usePGXStore()

const activeApp = ref('PGS')
const showCreateDialog = ref(false)
const showReportDialog = ref(false)
const selectedRun = ref<BioinfoRun | null>(null)

const appTypes = [
  { type: 'PGS', icon: '🧬' },
  { type: 'PGD', icon: '🔬' },
  { type: 'AZF', icon: '🔍' },
  { type: 'ECS', icon: '📊' },
  { type: 'Array-PGT', icon: '🧪' },
]

function getRunCount(type: string) {
  return pgxStore.bioinfoRuns.filter(r => r.appType === type).length
}

const filteredRuns = computed(() => pgxStore.bioinfoRuns.filter(r => r.appType === activeApp.value))

function statusLabel(s: string) {
  return { ready: '准备运行', running: '正在运行', success: '运行成功', failed: '运行失败' }[s] || s
}
function statusType(s: string): any {
  return { ready: 'info', running: 'primary', success: 'success', failed: 'danger' }[s] || ''
}

const runProgress = ref<Record<string, number>>({})
let progressTimers: Record<string, any> = {}

async function startRun(run: BioinfoRun) {
  pgxStore.updateBioRun(run.id, { status: 'running' })
  runProgress.value[run.id] = 5
  progressTimers[run.id] = setInterval(() => {
    const p = runProgress.value[run.id] || 0
    if (p >= 95) {
      clearInterval(progressTimers[run.id])
      pgxStore.updateBioRun(run.id, {
        status: 'success',
        results: { q30: +(85 + Math.random() * 8).toFixed(1), clusterPf: +(92 + Math.random() * 5).toFixed(1), duplicate: +(10 + Math.random() * 10).toFixed(1) }
      })
      ElMessage.success(`分析完成:${run.groupName}`)
    } else {
      runProgress.value[run.id] = p + Math.floor(Math.random() * 15 + 5)
    }
  }, 800)
}

async function retryRun(run: BioinfoRun) {
  pgxStore.updateBioRun(run.id, { status: 'ready' })
  ElMessage.info('已重置为准备运行状态')
}

function viewReport(run: BioinfoRun) {
  selectedRun.value = run
  showReportDialog.value = true
}

function pushToLims(run: BioinfoRun) {
  pgxStore.updateBioRun(run.id, { notifyLims: true })
  ElMessage.success('已推送至LIMS系统')
}

async function deleteRun(run: BioinfoRun) {
  await ElMessageBox.confirm(`确定删除分析分组「${run.groupName}」?`, '删除确认', { type: 'warning' })
  const idx = pgxStore.bioinfoRuns.findIndex(r => r.id === run.id)
  if (idx !== -1) pgxStore.bioinfoRuns.splice(idx, 1)
}

const newForm = ref({ appType: 'PGS', groupName: '', version: 'v2.5.1', orderId: '' })

function createRun() {
  pgxStore.addBioRun({
    groupName: newForm.value.groupName || '新分析组',
    appType: newForm.value.appType as any,
    status: 'ready',
    createDate: new Date().toISOString().split('T')[0],
    orderId: newForm.value.orderId,
  })
  activeApp.value = newForm.value.appType
  showCreateDialog.value = false
  ElMessage.success('分析分组已创建,文件上传后可运行')
}

const qcMetrics = ref([
  { label: 'Q30 平均', value: 91.2, unit: '%', percentage: 91, threshold: '≥85%', ok: true },
  { label: 'Cluster PF', value: 94.8, unit: '%', percentage: 95, threshold: '≥90%', ok: true },
  { label: 'Duplicate', value: 13.2, unit: '%', percentage: 13, threshold: '≤25%', ok: true },
  { label: 'Map Ratio', value: 98.1, unit: '%', percentage: 98, threshold: '≥95%', ok: true },
  { label: '低质量样本', value: 1, unit: '个', percentage: 5, threshold: '0', ok: true },
  { label: '运行失败', value: 1, unit: '次', percentage: 10, threshold: '0', ok: false },
])

let qcTimer: any
onMounted(() => {
  qcTimer = setInterval(() => {
    qcMetrics.value[0].value = +(88 + Math.random() * 6).toFixed(1)
    qcMetrics.value[0].percentage = qcMetrics.value[0].value
    qcMetrics.value[0].ok = qcMetrics.value[0].value >= 85
    qcMetrics.value[1].value = +(92 + Math.random() * 5).toFixed(1)
    qcMetrics.value[1].percentage = qcMetrics.value[1].value
    qcMetrics.value[2].value = +(10 + Math.random() * 8).toFixed(1)
    qcMetrics.value[2].percentage = qcMetrics.value[2].value
    qcMetrics.value[2].ok = qcMetrics.value[2].value <= 25
  }, 3000)
})
onUnmounted(() => {
  clearInterval(qcTimer)
  Object.values(progressTimers).forEach(t => clearInterval(t))
})
</script>

<style scoped>
.bio-page { padding: 24px; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.page-header h1 { font-size: 20px; font-weight: 600; margin: 0; color: #1a1f2e; }

.app-tabs {
  display: flex;
  gap: 8px;
  margin-bottom: 20px;
  background: #fff;
  padding: 8px;
  border-radius: 10px;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
.app-tab {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 16px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
  color: #666;
  transition: all 0.15s;
}
.app-tab:hover { background: #f0f7ff; color: #409eff; }
.app-tab.active { background: #409eff; color: #fff; }
.app-icon { font-size: 16px; }
.app-count { background: rgba(255,255,255,0.3); border-radius: 10px; padding: 0 6px; font-size: 12px; }
.app-tab.active .app-count { background: rgba(255,255,255,0.3); }
.app-tab:not(.active) .app-count { background: #f0f2f5; color: #888; }

.run-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; }
.empty-tip { background: #fff; border-radius: 8px; padding: 60px; text-align: center; color: #aaa; }

.run-card {
  background: #fff;
  border-radius: 10px;
  padding: 16px 20px;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);
  border-left: 4px solid #409eff;
}
.run-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.run-name { font-size: 15px; font-weight: 600; color: #1a1f2e; }
.run-meta { display: flex; align-items: center; gap: 10px; }
.run-date { font-size: 12px; color: #888; }
.run-body { display: flex; align-items: center; gap: 20px; margin-bottom: 12px; }
.run-status { display: flex; align-items: center; gap: 12px; }
.run-metrics { display: flex; gap: 16px; font-size: 13px; color: #666; }
.run-actions { display: flex; gap: 10px; align-items: center; }

.running-dot {
  display: inline-block;
  width: 8px; height: 8px;
  background: #fff;
  border-radius: 50%;
  margin-right: 4px;
  animation: pulse 1s infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }

.qc-panel-card { background: #fff; border-radius: 10px; padding: 16px 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.qc-header { font-size: 15px; font-weight: 600; color: #1a1f2e; margin-bottom: 14px; }
.qc-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.qc-item { }
.qc-label { font-size: 12px; color: #888; margin-bottom: 4px; }
.qc-value { font-size: 20px; font-weight: 700; margin-bottom: 6px; }
.qc-threshold { font-size: 11px; color: #aaa; margin-top: 4px; }

.upload-area { width: 100%; }
.upload-inner { padding: 20px; text-align: center; font-size: 13px; color: #888; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.upload-tip { font-size: 11px; color: #bbb; }

.report-view {}
.report-section { margin-bottom: 20px; }
.rs-title { font-size: 14px; font-weight: 600; color: #334155; margin-bottom: 10px; padding-left: 8px; border-left: 3px solid #409eff; }
.chr-chart { display: flex; gap: 4px; align-items: flex-end; background: #f8faff; padding: 12px; border-radius: 6px; height: 100px; }
.chr-bar { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; }
.chr-line { width: 12px; border-radius: 2px 2px 0 0; }
.chr-label { font-size: 9px; color: #888; }
</style>