Files
zkxw/web-admin/src/views/pilot-service/pilot-application/PilotApplicationTab.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

695 lines
23 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 -->
<template>
<div class="pilot-application-tab">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item v-if="!isClinical" label="项目组"
><ProjectSelect filter
team
v-model="teamFilter"
:biz-type="
props.bizType === 'basic_experiment'
? 'basic_experiment'
: 'pilot_service'
"
/></el-form-item>
<el-form-item v-if="!isClinical" label="项目"
><ProjectSelect filter
v-model="projectFilter"
:biz-type="
props.bizType === 'basic_experiment'
? 'basic_experiment'
: 'pilot_service'
"
/></el-form-item>
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" :placeholder="keywordPlaceholder" clearable />
</el-form-item>
<el-form-item v-if="bizConfig.showChannel" label="渠道">
<el-select
v-model="searchForm.channelId"
placeholder="全部"
clearable
filterable
:loading="channelLoading"
@focus="onChannelSelectFocus"
>
<el-option
v-for="ch in channelOptions"
:key="ch.id"
:label="ch.channelName || ch.name"
:value="ch.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.applicationStatus" placeholder="全部" clearable>
<el-option label="待开始" value="dks" />
<el-option label="进行中" value="jxz" />
<el-option label="已完成" value="ywc" />
<el-option label="已作废" value="yzf" />
</el-select>
</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">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新增
</el-button>
<el-button
class="btn-ghost"
:disabled="!canBatchStart"
:loading="batchActionLoading === 'start'"
@click="handleBatchStart"
>
<el-icon><VideoPlay /></el-icon>
开始
</el-button>
<el-button
class="btn-ghost"
:disabled="!canBatchComplete"
:loading="batchActionLoading === 'complete'"
@click="handleBatchComplete"
>
<el-icon><CircleCheck /></el-icon>
完成
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="!canBatchVoid"
:loading="batchActionLoading === 'void'"
@click="handleBatchVoid"
>
<el-icon><CircleClose /></el-icon>
作废
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
ref="tableRef"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column v-if="!isClinical" prop="projectName" label="所属项目" min-width="160" />
<el-table-column v-if="!isClinical" prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="applicationNo"
label="申请单号"
min-width="180"
show-overflow-tooltip
>
<template #default="{ row }">
<el-button link type="primary" @click="handleView(row)">
{{ row.applicationNo || '-' }}
</el-button>
</template>
</el-table-column>
<el-table-column
v-if="isBasicExperiment"
prop="projectTitle"
label="课题名称"
min-width="160"
show-overflow-tooltip
/>
<el-table-column
v-if="isBasicExperiment"
prop="customerName"
label="客户名称"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
v-if="bizConfig.showChannel"
prop="channelName"
label="渠道名称"
min-width="150"
show-overflow-tooltip
/>
<el-table-column
v-if="!isClinical && !isBasicExperiment"
label="服务订单名称"
min-width="180"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="row.serviceOrderNo">{{ row.serviceOrderNo }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column
v-if="bizConfig.showPatient"
prop="patientName"
label="患者姓名"
min-width="100"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.patientName || '-' }}</template>
</el-table-column>
<el-table-column
prop="infusionScheduleCount"
:label="`${bizConfig.scheduleLabel}数`"
min-width="100"
align="center"
>
<template #default="{ row }">{{ row.infusionScheduleCount ?? 0 }}</template>
</el-table-column>
<el-table-column prop="applicationStatus" label="状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="applicationStatusTagType(row.applicationStatus)" size="small">
{{ applicationStatusLabel(row.applicationStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="160" align="center" >
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="260"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<template v-if="hasPrimaryStatusActions(row)">
<el-button
v-if="row.applicationStatus === 'dks'"
class="btn-op-item primary"
type="primary"
@click="handleStart(row)"
>
<el-icon><VideoPlay /></el-icon>开始
</el-button>
<el-button
v-if="row.applicationStatus === 'jxz'"
class="btn-op-item primary"
type="primary"
@click="handleComplete(row)"
>
<el-icon><CircleCheck /></el-icon>完成
</el-button>
<el-button
v-if="canVoidApplication(row.applicationStatus)"
class="btn-op-item op-warn"
type="danger"
@click="handleVoid(row)"
>
<el-icon><CircleClose /></el-icon>作废
</el-button>
</template>
<el-button v-else class="btn-op-item primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown
v-if="hasMoreActions(row)"
trigger="hover"
@command="(cmd) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="hasPrimaryStatusActions(row)" command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item
v-if="canEditApplication(row.applicationStatus)"
command="edit"
>
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item
v-if="row.applicationStatus === 'dks'"
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, 100]"
layout="total, sizes, prev, pager, next, jumper"
@update:current-page="(val) => (pagination.page = val)"
@update:page-size="(val) => (pagination.pageSize = val)"
@size-change="loadData"
@current-change="loadData"
/>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<ApplicationFormDialog
v-model="formDialogVisible"
:edit-id="formDialogEditId"
:biz-type="bizType"
@saved="onFormSaved"
/>
<!-- 查看详情弹窗 -->
<ApplicationViewDialog v-model="viewDialogVisible" :application-id="viewApplicationId" />
</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 { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Search,
RefreshLeft,
View,
Edit,
Delete,
More,
VideoPlay,
CircleCheck,
CircleClose
} from '@element-plus/icons-vue'
import ApplicationFormDialog from './ApplicationFormDialog.vue'
import ApplicationViewDialog from './ApplicationViewDialog.vue'
import {
fetchPilotApplicationList,
deletePilotApplication,
startPilotApplication,
completePilotApplication,
voidPilotApplication,
fetchChannelList
} from '@/api/admin'
import { applicationStatusLabel, applicationStatusTagType } from '@/utils/status-tag-registry'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import { getPilotBizConfig, resolvePilotBizType } from '@/config/pilot-biz-type'
defineOptions({ name: 'PilotApplicationTab' })
const props = defineProps<{ bizType?: string }>()
const bizConfig = computed(() => getPilotBizConfig(props.bizType))
const isClinical = computed(() => props.bizType === 'clinical_application')
const isBasicExperiment = computed(() => props.bizType === 'basic_experiment')
const keywordPlaceholder = computed(() =>
isBasicExperiment.value ? '申请单号/课题名称/客户名称' : '申请单号/订单名称/患者姓名'
)
// ── 搜索 ──────────────────────────────────────────────────────────────
const searchForm = reactive({
keyword: '',
channelId: '',
applicationStatus: ''
})
// ── 搜索区域渠道下拉 ──────────────────────────────────────────────────
const channelOptions = ref<any[]>([])
const channelLoading = ref(false)
const channelLoaded = ref(false)
const onChannelSelectFocus = () => {
if (!channelLoaded.value) {
loadChannelOptions()
}
}
const loadChannelOptions = async () => {
channelLoading.value = true
try {
channelOptions.value = await fetchAllPagedRecords((page, pageSize) =>
fetchChannelList({ page, pageSize })
)
channelLoaded.value = true
} catch {
channelOptions.value = []
} finally {
channelLoading.value = false
}
}
// ── 表格 ──────────────────────────────────────────────────────────────
const tableRef = ref()
const tableData = ref<any[]>([])
const selectedRows = ref<any[]>([])
const loading = ref(false)
const batchActionLoading = ref<'start' | 'complete' | 'void' | ''>('')
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
const clearTableSelection = () => {
tableRef.value?.clearSelection?.()
selectedRows.value = []
}
const batchStartTargets = computed(() =>
selectedRows.value.filter((row) => row.applicationStatus === 'dks')
)
const batchCompleteTargets = computed(() =>
selectedRows.value.filter((row) => row.applicationStatus === 'jxz')
)
const batchVoidTargets = computed(() =>
selectedRows.value.filter((row) => ['dks', 'jxz'].includes(row.applicationStatus))
)
const canBatchStart = computed(() => batchStartTargets.value.length > 0)
const canBatchComplete = computed(() => batchCompleteTargets.value.length > 0)
const canBatchVoid = computed(() => batchVoidTargets.value.length > 0)
const loadData = async () => {
loading.value = true
try {
const params: Record<string, any> = {
page: pagination.page,
pageSize: pagination.pageSize
}
if (searchForm.keyword) params.keyword = searchForm.keyword
if (searchForm.channelId) params.channelId = searchForm.channelId
if (searchForm.applicationStatus) params.applicationStatus = searchForm.applicationStatus
params.projectId = projectFilter.value || undefined
params.teamId = teamFilter.value || undefined
params.bizType = resolvePilotBizType(props.bizType)
const res: any = await fetchPilotApplicationList(params)
tableData.value = res?.list ?? res?.records ?? []
pagination.total = res?.pagination?.total ?? res?.total ?? 0
} catch (e) {
console.error('加载申请单列表失败:', e)
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
loadData()
}
const handleReset = () => {
projectFilter.value = ''
teamFilter.value = ''
searchForm.keyword = ''
searchForm.channelId = ''
searchForm.applicationStatus = ''
pagination.page = 1
loadData()
}
// ── 查看详情弹窗 ──────────────────────────────────────────────────────
const viewDialogVisible = ref(false)
const viewApplicationId = ref('')
const handleView = (row: any) => {
viewApplicationId.value = row.id
viewDialogVisible.value = true
}
// ── 新增/编辑弹窗 ──────────────────────────────────────────────────────
const formDialogVisible = ref(false)
const formDialogEditId = ref<string | null>(null)
const handleCreate = () => {
formDialogEditId.value = null
formDialogVisible.value = true
}
/** 待开始、进行中可编辑 */
const canEditApplication = (status: string) => ['dks', 'jxz'].includes(status)
/** 待开始、进行中可作废 */
const canVoidApplication = (status: string) => ['dks', 'jxz'].includes(status)
/** 是否存在开始/完成/作废等主操作 */
const hasPrimaryStatusActions = (row: any) =>
row.applicationStatus === 'dks' || row.applicationStatus === 'jxz'
/** 更多菜单是否展示(有主操作时,查看/编辑/删除收入更多) */
const hasMoreActions = (row: any) => hasPrimaryStatusActions(row)
const handleDropdownCommand = (command: string, row: any) => {
switch (command) {
case 'view':
handleView(row)
break
case 'edit':
handleEdit(row)
break
case 'delete':
handleDelete(row)
break
}
}
const handleEdit = (row: any) => {
formDialogEditId.value = row.id
formDialogVisible.value = true
}
const onFormSaved = () => {
loadData()
}
// ── 删除 ──────────────────────────────────────────────────────────────
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定删除该申请单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await deletePilotApplication({ id: row.id })
ElMessage.success('删除成功')
loadData()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.message || '删除失败')
}
}
}
// ── 状态操作 ──────────────────────────────────────────────────────────
type BatchStatusAction = 'start' | 'complete' | 'void'
const runBatchStatusAction = async (options: {
action: BatchStatusAction
targets: any[]
actionLabel: string
confirmMessage: string
confirmType?: 'info' | 'warning'
apiFn: (payload: { id: string }) => Promise<unknown>
}) => {
const { action, targets, actionLabel, confirmMessage, confirmType = 'info', apiFn } = options
if (!selectedRows.value.length) {
ElMessage.warning('请先选择申请单')
return
}
if (!targets.length) {
ElMessage.warning(`所选申请单中没有可${actionLabel}的记录`)
return
}
const skipped = selectedRows.value.length - targets.length
let message = confirmMessage
if (skipped > 0) {
message += `\n共选中 ${selectedRows.value.length} 条,其中 ${targets.length} 条将执行${actionLabel},${skipped} 条因状态不符将跳过。`
} else {
message += `\n共 ${targets.length} 条申请单。`
}
try {
await ElMessageBox.confirm(message, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: confirmType
})
} catch {
return
}
batchActionLoading.value = action
let successCount = 0
let failCount = 0
let firstError = ''
for (const row of targets) {
try {
await apiFn({ id: row.id })
successCount += 1
} catch (e: any) {
failCount += 1
if (!firstError) {
firstError = e?.message || `${actionLabel}失败`
}
}
}
batchActionLoading.value = ''
clearTableSelection()
loadData()
if (failCount === 0) {
ElMessage.success(`已成功${actionLabel} ${successCount} 条申请单`)
} else if (successCount === 0) {
ElMessage.error(firstError || `${actionLabel}失败`)
} else {
ElMessage.warning(
`${actionLabel}完成:成功 ${successCount} 条,失败 ${failCount} 条。${firstError}`
)
}
}
const handleBatchStart = () =>
runBatchStatusAction({
action: 'start',
targets: batchStartTargets.value,
actionLabel: '开始',
confirmMessage: '确定将所选待开始申请单变更为进行中吗?',
apiFn: startPilotApplication
})
const handleBatchComplete = () =>
runBatchStatusAction({
action: 'complete',
targets: batchCompleteTargets.value,
actionLabel: '完成',
confirmMessage: '确定将所选进行中申请单变更为已完成吗?',
apiFn: completePilotApplication
})
const handleBatchVoid = () =>
runBatchStatusAction({
action: 'void',
targets: batchVoidTargets.value,
actionLabel: '作废',
confirmMessage: '确定作废所选申请单吗?作废后不可恢复。',
confirmType: 'warning',
apiFn: voidPilotApplication
})
const handleStart = async (row: any) => {
try {
await ElMessageBox.confirm('确定开始该申请单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
await startPilotApplication({ id: row.id })
ElMessage.success('操作成功')
loadData()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.message || '操作失败')
}
}
}
const handleComplete = async (row: any) => {
try {
await ElMessageBox.confirm('确定完成该申请单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
await completePilotApplication({ id: row.id })
ElMessage.success('操作成功')
loadData()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.message || '操作失败')
}
}
}
const handleVoid = async (row: any) => {
try {
await ElMessageBox.confirm('确定作废该申请单吗?作废后不可恢复。', '提示', {
confirmButtonText: '确定作废',
cancelButtonText: '取消',
type: 'warning'
})
await voidPilotApplication({ id: row.id })
ElMessage.success('操作成功')
loadData()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e?.message || '操作失败')
}
}
}
// 检索条件变更时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
// ── 初始化 ────────────────────────────────────────────────────────────
watch([projectFilter, teamFilter], () => {
pagination.page = 1
loadData()
})
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.status-filter-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
</style>