Files
zkxw/web-admin/src/views/cell-storage/protocol-audit/ProtocolAuditTab.vue
T
zlwu f1b4a5ce26 fix(web-admin): place project columns after index in lists
Move 所属项目/项目组 after selection and serial-number columns so multi-select and index stay leftmost across affected business lists.
2026-11-05 10:26:59 +08:00

682 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 采集协议单管理 · 协议单 Tab(扁平行列表) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="protocol-audit-tab">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="项目组"
><ProjectSelect filter team v-model="teamFilter" biz-type="cell_storage"
/></el-form-item>
<el-form-item label="项目"
><ProjectSelect filter v-model="projectFilter" biz-type="cell_storage"
/></el-form-item>
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="姓名/身份证号/电话/入库编码"
clearable
/>
</el-form-item>
<el-form-item label="协议状态">
<el-select
v-model="searchForm.protocolStatus"
clearable
placeholder="全部"
style="width: 100%"
>
<el-option label="待生效" value="dsx" />
<el-option label="生效中" value="sxz" />
<el-option label="已过期" value="ygq" />
<el-option label="已作废" value="yzf" />
<el-option label="属地废弃" value="sdfq" />
<el-option label="转科研" value="zky" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="createTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="样本采集时间">
<el-date-picker
v-model="sampleCollectTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedProtocolCount }}</span> 项
</div>
<el-button type="primary" class="btn-main" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedProtocolCount !== 1"
@click="openRefundDialog"
>
<el-icon><Coin /></el-icon>
退费登记
</el-button>
<el-button class="btn-ghost" :disabled="!selectedProtocolCount" @click="openAuditDialog()">
<el-icon><CircleCheck /></el-icon>
审核
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedProtocolCount"
@click="openStatusChangeDialog('activate')"
>
<el-icon><Select /></el-icon>
协议生效
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedProtocolCount"
@click="openStatusChangeDialog('expire')"
>
<el-icon><Timer /></el-icon>
协议过期
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedProtocolCount"
@click="openStatusChangeDialog('void')"
>
<el-icon><CircleClose /></el-icon>
协议作废
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedProtocolCount"
@click="openStatusChangeDialog('toResearch')"
>
<el-icon><Switch /></el-icon>
协议转科研
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedProtocolCount"
@click="handleBatchDelete"
>
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
v-loading="loading"
:data="flatRows"
row-key="rowKey"
stripe
@selection-change="handleSelectionChange"
@row-dblclick="handleRowDblClick"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column label="序号" min-width="64" align="center" header-align="center">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column prop="projectName" label="所属项目" min-width="160" />
<el-table-column prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="collectDate"
label="采集日期"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.collectDate || '—' }}</template>
</el-table-column>
<el-table-column
prop="labStorageCode"
label="实验室流转编号"
min-width="130"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span class="mono">{{ row.labStorageCode || '—' }}</span>
</template>
</el-table-column>
<el-table-column
prop="motherName"
label="母亲姓名"
min-width="100"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.motherName || '—' }}</template>
</el-table-column>
<el-table-column
prop="sampleCode"
label="样本编码"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span class="mono">{{ row.sampleCode || '—' }}</span>
</template>
</el-table-column>
<el-table-column
prop="sampleTypeName"
label="样本类型"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.sampleTypeName || '—' }}</template>
</el-table-column>
<el-table-column label="是否同存" min-width="90" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="row.isCoStored ? 'warning' : 'info'" size="small">
{{ row.isCoStored ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="regionName"
label="采集地区"
min-width="100"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.regionName || '—' }}</template>
</el-table-column>
<el-table-column
prop="hospitalName"
label="采集医院"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.hospitalName || '—' }}</template>
</el-table-column>
<el-table-column
prop="sampleCollectTime"
label="采集时间"
min-width="160"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatDisplayOrDash(row.sampleCollectTime) }}</template>
</el-table-column>
<el-table-column label="缴费方式" min-width="90" align="center" header-align="center">
<template #default="{ row }">
<el-tag
v-if="row.paymentSummary?.payMode"
:type="payModeTagType(String(row.paymentSummary.payMode))"
size="small"
>
{{
row.paymentSummary.payModeLabel ||
payModeLabel(String(row.paymentSummary.payMode)) ||
'—'
}}
</el-tag>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="首期缴费时间" min-width="160" align="center" header-align="center">
<template #default="{ row }">
<template v-if="payLines(row).length">
<div class="pay-time-stack">
<div v-for="(t, i) in payLines(row)" :key="i">{{ formatDisplayOrDash(t) }}</div>
</div>
</template>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column
prop="salesUserName"
label="市场专员"
min-width="100"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.salesUserName || '—' }}</template>
</el-table-column>
<el-table-column
prop="regionManagerUserName"
label="地区经理"
min-width="100"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.regionManagerUserName || '—' }}</template>
</el-table-column>
<el-table-column label="运输方式" min-width="100" align="center" header-align="center">
<template #default="{ row }">
{{ row.transportType ? transportTypeLabel(row.transportType) : '—' }}
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="200"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div class="op-row">
<el-button class="btn-op-item" @click.stop="openAuditDialog(row.protocol)">
<el-icon><CircleCheck /></el-icon>审核
</el-button>
<el-button class="btn-op-item" @click.stop="handleEdit(row.protocol)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-dropdown
trigger="hover"
@command="(cmd) => handleDropdownCommand(String(cmd), row)"
>
<el-button class="btn-op-item" @click.stop>
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<ProtocolFormDialog
v-model="formDialogVisible"
:edit-id="formDialogEditId"
:list-row="formDialogRow"
@saved="onFormSaved"
/>
<ProtocolAuditDialog
v-model="auditDialogVisible"
:targets="auditTargets"
@submitted="onAuditSubmitted"
/>
<ProtocolStatusChangeDialog
v-model="statusChangeDialogVisible"
:action-type="statusChangeAction"
:targets="statusChangeTargets"
@submitted="onStatusChangeSubmitted"
/>
<ProtocolViewDialog
v-model="viewVisible"
:protocol-id="viewProtocolId"
:list-row="viewRow"
show-audit-history
show-storage-cert
show-fullmoon-insurance
/>
<ProtocolRefundRecordDialog
v-model="refundDialogVisible"
:protocol-id="refundProtocolId"
:protocol-label="refundProtocolLabel"
/>
</div>
</template>
<script setup lang="ts">
import ProjectSelect from '@/components/business/ProjectSelect.vue'
const projectFilter = ref('')
const teamFilter = ref('')
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
View,
Edit,
Delete,
Search,
RefreshLeft,
CircleCheck,
CircleClose,
Select,
Switch,
Timer,
Coin,
More
} from '@element-plus/icons-vue'
import ProtocolFormDialog from '../protocol/ProtocolFormDialog.vue'
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
import ProtocolRefundRecordDialog from '../protocol/ProtocolRefundRecordDialog.vue'
import ProtocolAuditDialog, {
type ProtocolAuditTarget
} from '../protocol/ProtocolAuditDialog.vue'
import ProtocolStatusChangeDialog, {
type StatusChangeAction,
type StatusChangeTarget
} from '../protocol/ProtocolStatusChangeDialog.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCollectionProtocolList,
fetchDeleteCollectionProtocol,
type CollectionProtocolRow
} from '@/api/collection-protocol'
import {
formatProtocolDocDeleteConfirm,
formatProtocolDocBatchDeleteConfirm
} from '@/utils/protocol-doc-label'
import {
payModeLabel,
payModeTagType,
transportTypeLabel
} from '@/utils/status-tag-registry'
import { loadVoidTypeDict } from '@/utils/void-type-dict'
import {
flattenProtocolAuditRows,
uniqueProtocolsFromFlatRows,
firstPayTimeLines,
type ProtocolAuditFlatRow
} from '@/utils/protocol-audit-flatten'
defineOptions({ name: 'CellStorageProtocolAuditTab' })
const searchForm = reactive<{ keyword: string; protocolStatus: string }>({
keyword: '',
protocolStatus: ''
})
const createTimeRange = ref<[string, string] | null>(null)
const sampleCollectTimeRange = ref<[string, string] | null>(null)
const tableData = ref<CollectionProtocolRow[]>([])
const selectedFlatRows = ref<ProtocolAuditFlatRow[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const flatRows = computed(() => flattenProtocolAuditRows(tableData.value))
const selectedProtocolCount = computed(
() => uniqueProtocolsFromFlatRows(selectedFlatRows.value).length
)
function selectedProtocols(): CollectionProtocolRow[] {
return uniqueProtocolsFromFlatRows(selectedFlatRows.value)
}
function handleSelectionChange(rows: ProtocolAuditFlatRow[]) {
selectedFlatRows.value = rows || []
}
function handleRowDblClick(row: ProtocolAuditFlatRow) {
handleView(row.protocol)
}
function handleDropdownCommand(cmd: string, row: ProtocolAuditFlatRow) {
if (cmd === 'view') handleView(row.protocol)
else if (cmd === 'delete') handleDelete(row.protocol)
}
function payLines(row: ProtocolAuditFlatRow) {
return firstPayTimeLines(row.paymentSummary)
}
async function loadData() {
loading.value = true
try {
const res = await fetchCollectionProtocolList({
page: pagination.page,
pageSize: pagination.pageSize,
projectId: projectFilter.value || undefined,
teamId: teamFilter.value || undefined,
keyword: searchForm.keyword || undefined,
protocolStatus: searchForm.protocolStatus || undefined,
createTimeStart: createTimeRange.value?.[0] || undefined,
createTimeEnd: createTimeRange.value?.[1] || undefined,
sampleCollectTimeStart: sampleCollectTimeRange.value?.[0] || undefined,
sampleCollectTimeEnd: sampleCollectTimeRange.value?.[1] || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
const validIds = new Set(tableData.value.map((r) => String(r.id)))
selectedFlatRows.value = selectedFlatRows.value.filter((r) => validIds.has(r.protocolId))
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
projectFilter.value = ''
teamFilter.value = ''
searchForm.keyword = ''
searchForm.protocolStatus = ''
createTimeRange.value = null
sampleCollectTimeRange.value = null
pagination.page = 1
loadData()
}
const viewVisible = ref(false)
const viewProtocolId = ref<string | null>(null)
const viewRow = ref<CollectionProtocolRow | null>(null)
function handleView(row: CollectionProtocolRow) {
viewProtocolId.value = String(row.id)
viewRow.value = row
viewVisible.value = true
}
const formDialogVisible = ref(false)
const formDialogEditId = ref<string | null>(null)
const formDialogRow = ref<CollectionProtocolRow | null>(null)
function handleAdd() {
formDialogEditId.value = null
formDialogRow.value = null
formDialogVisible.value = true
}
function handleEdit(row: CollectionProtocolRow) {
formDialogEditId.value = row.id as string
formDialogRow.value = row
formDialogVisible.value = true
}
function onFormSaved() {
loadData()
}
const refundDialogVisible = ref(false)
const refundProtocolId = ref('')
const refundProtocolLabel = ref('')
function openRefundDialog() {
const rows = selectedProtocols()
if (rows.length !== 1) {
ElMessage.warning('请先勾选一条协议单再登记退费')
return
}
const row = rows[0]
refundProtocolId.value = String(row.id || '')
refundProtocolLabel.value = String(row.motherName || '').trim()
refundDialogVisible.value = true
}
const auditDialogVisible = ref(false)
const auditTargets = ref<ProtocolAuditTarget[]>([])
function openAuditDialog(row?: CollectionProtocolRow) {
const rows = row ? [row] : selectedProtocols()
if (!rows.length) {
ElMessage.warning('请先选择要审核的协议单')
return
}
auditTargets.value = rows.map((r) => ({
id: String(r.id),
motherName: r.motherName != null ? String(r.motherName) : undefined
}))
auditDialogVisible.value = true
}
function onAuditSubmitted() {
selectedFlatRows.value = []
loadData()
}
const statusChangeDialogVisible = ref(false)
const statusChangeAction = ref<StatusChangeAction>('activate')
const statusChangeTargets = ref<StatusChangeTarget[]>([])
function openStatusChangeDialog(action: StatusChangeAction, row?: CollectionProtocolRow) {
const rows = row ? [row] : selectedProtocols()
if (!rows.length) {
ElMessage.warning('请先选择要操作的协议单')
return
}
statusChangeAction.value = action
statusChangeTargets.value = rows.map((r) => ({
id: String(r.id),
motherName: r.motherName != null ? String(r.motherName) : undefined
}))
statusChangeDialogVisible.value = true
}
function onStatusChangeSubmitted() {
selectedFlatRows.value = []
loadData()
}
function handleDelete(row: CollectionProtocolRow) {
ElMessageBox.confirm(formatProtocolDocDeleteConfirm('cell_storage', row), '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await fetchDeleteCollectionProtocol(String(row.id))
loadData()
})
.catch(() => {})
}
async function handleBatchDelete() {
const rows = selectedProtocols()
if (!rows.length) return
await ElMessageBox.confirm(
formatProtocolDocBatchDeleteConfirm('cell_storage', rows.length),
'批量删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await withFullscreenLoading(async () => {
for (const row of rows) {
await fetchDeleteCollectionProtocol(String(row.id))
}
ElMessage.success('批量删除成功')
selectedFlatRows.value = []
loadData()
}, fullscreenLoadingText.batchDelete)
}
watchDebounced(
[searchForm, createTimeRange, sampleCollectTimeRange],
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
watch([projectFilter, teamFilter], () => {
pagination.page = 1
loadData()
})
onMounted(async () => {
await loadVoidTypeDict()
loadData()
})
</script>
<style scoped lang="scss">
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.muted {
color: var(--el-text-color-secondary);
}
.pay-time-stack {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.35;
}
.op-row {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
align-items: center;
}
</style>