This commit is contained in:
zlwu committed 2026-05-19 12:03:08 +08:00
1 parent f269a994f4
commit 93cd506a90
71 files changed
+8297 -2207

No files matched your search

+7
View File
@@ -132,6 +132,13 @@ export function fetchUploadAttachment(data: FormData) {
})
}
export function fetchAttachmentDownloadUrl(data: { fileUrl: string; fileName?: string }) {
return request.post<{ downloadUrl: string }>({
url: '/web/admin/attachment/download-url',
data
})
}
export function fetchUploadProductImage(data: FormData) {
return request.post({
url: '/web/admin/products/upload-image',
+1
View File
@@ -21,6 +21,7 @@ export type QcTask = {
feeAmount?: number
taskStatus: string
thirdPartyAttachments?: string
attachmentFiles?: Array<{ id?: string; fileUrl: string; fileName?: string; fileType?: string }>
completedTime?: string
completedBy?: string
remark?: string
+1 -1
View File
@@ -100,7 +100,7 @@ export function fetchSubmitDonationAudit(
// 状态变更
export function fetchDonationStatusChange(
donationId: string,
data: { donationStatus: string; desc?: string; time?: string }
data: { newStatus: string; desc?: string; timeVal?: string }
) {
return request.post({
url: `/web/admin/research-donations/${donationId}/status-change`,
@@ -87,6 +87,7 @@
VideoPlay
} from '@element-plus/icons-vue'
import { fetchUploadAttachment } from '@/api/business'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import type { FinanceAttachmentItem } from './finance-attachments'
const props = withDefaults(
@@ -135,13 +136,7 @@
}
function downloadFile(file: FinanceAttachmentItem) {
if (!file.fileUrl) return
const a = document.createElement('a')
a.href = fullFileUrl(file.fileUrl)
a.download = file.fileName || 'download'
a.target = '_blank'
a.rel = 'noopener'
a.click()
downloadAttachmentFile(file, { fullFileUrl })
}
function removeAttachment(index: number) {
@@ -37,6 +37,7 @@
</div>
</dl>
</div>
<slot name="append" />
</div>
<template #footer>
+24 -31
View File
@@ -142,13 +142,6 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
path: 'exhibition-appointment',
component: '/operations/exhibition-appointment/index',
meta: { title: '展馆预约', keepAlive: true }
},
{
id: '60000000000000000000000000000005',
name: 'OperationsDemo',
path: 'demo',
component: '/operations/demo/index',
meta: { title: 'demo', keepAlive: true }
}
]
},
@@ -218,7 +211,14 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
name: 'CellStorageProtocol',
path: 'protocol',
component: '/cell-storage/protocol/index',
meta: { title: '采集协议单', keepAlive: true }
meta: { title: '采集协议单录入', keepAlive: true }
},
{
id: '50000000000000000000000000000003',
name: 'CellStorageProtocolAudit',
path: 'protocol-audit',
component: '/cell-storage/protocol-audit/index',
meta: { title: '采集协议单管理', keepAlive: true }
},
{
id: '50000000000000000000000000000007',
@@ -227,13 +227,6 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
component: '/cell-storage/payment-info/index',
meta: { title: '缴费信息', keepAlive: true }
},
{
id: '50000000000000000000000000000003',
name: 'CellStorageProtocolAudit',
path: 'protocol-audit',
component: '/cell-storage/protocol-audit/index',
meta: { title: '采集协议单审核', keepAlive: true }
},
{
id: '50000000000000000000000000000004',
name: 'CellStoragePaymentConfirm',
@@ -267,14 +260,14 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
name: 'ResearchDonationList',
path: 'list',
component: '/research-donation/list/index',
meta: { title: '捐赠协议单', keepAlive: true }
meta: { title: '捐赠协议单录入', keepAlive: true }
},
{
id: '60000000000000000000000000000003',
name: 'ResearchDonationAudit',
path: 'audit',
component: '/research-donation/audit/index',
meta: { title: '捐赠协议审核', keepAlive: true }
meta: { title: '捐赠协议单管理', keepAlive: true }
}
]
},
@@ -422,6 +415,13 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
component: '/production/consumable/index',
meta: { title: '耗材管理', keepAlive: true, menuCode: 'production:consumable:list' }
},
{
id: '90000000000000000000000000000060',
name: 'ProductionConsumablePack',
path: 'consumable-pack',
component: '/production/consumable-pack/index',
meta: { title: '耗材包管理', keepAlive: true, menuCode: 'production:consumable-pack:list' }
},
{
id: '90000000000000000000000000000020',
name: 'ProductionReagent',
@@ -429,13 +429,6 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
component: '/production/reagent/index',
meta: { title: '试剂管理', keepAlive: true, menuCode: 'production:reagent:list' }
},
{
id: '90000000000000000000000000000030',
name: 'ProductionSop',
path: 'sop',
component: '/production/sop/index',
meta: { title: 'SOP文档', keepAlive: true, menuCode: 'production:sop:list' }
},
{
id: '90000000000000000000000000000040',
name: 'ProductionProcessCategory',
@@ -450,19 +443,19 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
component: '/production/output-type/index',
meta: { title: '工艺产出物类型', keepAlive: true, menuCode: 'production:output-type:list' }
},
{
id: '90000000000000000000000000000060',
name: 'ProductionConsumablePack',
path: 'consumable-pack',
component: '/production/consumable-pack/index',
meta: { title: '耗材包管理', keepAlive: true, menuCode: 'production:consumable-pack:list' }
},
{
id: '90000000000000000000000000000070',
name: 'ProductionProcess',
path: 'process',
component: '/production/process/index',
meta: { title: '工艺库管理', keepAlive: true, menuCode: 'production:process:list' }
},
{
id: '90000000000000000000000000000030',
name: 'ProductionSop',
path: 'sop',
component: '/production/sop/index',
meta: { title: 'SOP文档', keepAlive: true, menuCode: 'production:sop:list' }
}
]
},
+2 -2
View File
@@ -542,7 +542,7 @@ declare namespace Api {
id: string
protocolNo: string
issued: number
/** 是否已在采集协议单主表占用(与号池「是否下发」无关) */
/** 是否已在采集协议单或科研捐赠单中占用(与号池「是否下发」无关) */
usedInProtocol?: boolean
issuedUserId: string
issuedUserName: string
@@ -561,7 +561,7 @@ declare namespace Api {
sampleCode: string
sampleCodeType: string
issued: number
/** 是否已在协议单样本表中占用(与号池「是否下发」无关) */
/** 是否已在采集协议单或科研捐赠单样本表中占用(与号池「是否下发」无关) */
usedInProtocol?: boolean
issuedUserId: string
issuedUserName: string
@@ -0,0 +1,45 @@
import { ElMessage } from 'element-plus'
import { fetchAttachmentDownloadUrl } from '@/api/business'
export type AttachmentDownloadItem = {
fileUrl?: string
fileName?: string
}
function triggerAnchorDownload(href: string, fileName: string) {
const a = document.createElement('a')
a.href = href
a.download = fileName
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
/** 附件下载:优先走后端预签名 URL,保证跨域场景下保留原始文件名与后缀。 */
export async function downloadAttachmentFile(
file: AttachmentDownloadItem,
options?: { fullFileUrl?: (url: string) => string }
) {
const fileUrl = String(file.fileUrl || '').trim()
if (!fileUrl) return
const fileName = String(file.fileName || '').trim() || fileUrl.split('/').pop() || 'download'
try {
const res = await fetchAttachmentDownloadUrl({ fileUrl, fileName })
if (res?.downloadUrl) {
triggerAnchorDownload(res.downloadUrl, fileName)
return
}
} catch {
// 预签名失败时回退直连
}
const resolveUrl = options?.fullFileUrl || ((url: string) => url)
const href = resolveUrl(fileUrl)
if (!href) {
ElMessage.error('无法下载附件')
return
}
triggerAnchorDownload(href, fileName)
}
+4 -1
View File
@@ -22,5 +22,8 @@ export const fullscreenLoadingText = {
save: '正在保存,请稍候…',
delete: '正在删除,请稍候…',
batchDelete: '正在批量删除,请稍候…',
batchAudit: '正在提交审核,请稍候…'
batchAudit: '正在提交审核,请稍候…',
activate: '正在生效,请稍候…',
voidAgreement: '正在作废,请稍候…',
addItems: '正在添加,请稍候…'
} as const
@@ -242,6 +242,7 @@
type FinanceAttachmentItem
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
import { maskIdCard } from '@/utils/mask-sensitive'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import {
formatPaymentAmount,
formatPaymentDueDate,
@@ -304,14 +305,7 @@
}
function handleAttachmentDownload(file: Record<string, unknown>) {
const url = String(file.fileUrl || '')
if (!url) return
const a = document.createElement('a')
a.href = fullFileUrl(url)
a.download = String(file.fileName || 'download')
a.target = '_blank'
a.rel = 'noopener'
a.click()
downloadAttachmentFile(file, { fullFileUrl })
}
async function loadData() {
@@ -1,10 +1,10 @@
<!-- 采集协议单审核 -->
<!-- 采集协议单管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>采集协议单审核</h1>
<p class="page-desc">查看与管理采集协议单审核数据。</p>
<h1>采集协议单管理</h1>
<p class="page-desc">查看与管理采集协议单及审核数据。</p>
</header>
<section class="panel">
@@ -738,7 +738,7 @@
<!-- Tab 5: 缴费信息 -->
<div id="form-section-payment" class="form-tab-pane">
<div class="section-card section-card--gray">
<div class="section-card">
<div class="section-title">
<div class="section-title-left"><h3>缴费信息</h3></div>
</div>
@@ -1194,6 +1194,7 @@
type CollectionProtocolRow
} from '@/api/collection-protocol'
import { fetchUploadAttachment } from '@/api/business'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import {
fetchCustomerList,
fetchUserList,
@@ -2056,7 +2057,7 @@
if (!isEdit.value && formData.protocolNoId) {
const picked = protocolNoOptions.value.find((x) => x.id === formData.protocolNoId)
if (picked?.usedInProtocol) {
ElMessage.warning(`协议号「${formData.protocolNo}」已存在协议单,请更换其他协议号`)
ElMessage.warning(`协议号「${formData.protocolNo}」已被占用,请更换其他协议号`)
return
}
}
@@ -2066,7 +2067,7 @@
const sc = sampleCodeOptions.value.find((x) => x.id === sampleRow.sampleCodeId)
if (sc?.usedInProtocol) {
ElMessage.warning(
`样本编码「${sampleRow.sampleCode || sc.sampleCode}」已被其他协议单使用,请更换`
`样本编码「${sampleRow.sampleCode || sc.sampleCode}」已被占用,请更换`
)
return
}
@@ -2660,7 +2661,7 @@
/* ====== 协议号下拉检索 ====== */
function protocolNoOptionLabel(p: Api.BizCollectionProtocolNo.ProtocolNoItem) {
if (p.usedInProtocol) return `${p.protocolNo}(已建单)`
if (p.usedInProtocol) return `${p.protocolNo}(已占用)`
if (p.issued) return `${p.protocolNo}(已下发)`
return p.protocolNo
}
@@ -2706,7 +2707,7 @@
/* ====== 样本编码选择 ====== */
function sampleCodeOptionLabel(sc: Api.BizSampleCode.SampleCodeItem) {
if (sc.usedInProtocol) return `${sc.sampleCode}(已建单)`
if (sc.usedInProtocol) return `${sc.sampleCode}(已占用)`
if (sc.issued) return `${sc.sampleCode}(已下发)`
return sc.sampleCode
}
@@ -2894,15 +2895,7 @@
}
function handleDownloadAttachment(file: any) {
const url = fullFileUrl(file.fileUrl)
if (!url) return
const a = document.createElement('a')
a.href = url
a.download = file.fileName || ''
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
downloadAttachmentFile(file, { fullFileUrl })
}
/* ====== 弹窗打开时自动初始化 ====== */
@@ -2998,10 +2991,6 @@
&--green {
background: #f0fdf4;
}
&--gray {
background: #f8fafc;
}
}
.section-title {
@@ -173,7 +173,7 @@
if (props.actionType === 'toResearch') {
try {
await ElMessageBox.confirm(
'确定将该协议转为科研捐赠吗?转科研后将创建新的科研捐赠记录。',
'确定将该协议转为科研捐赠吗?将创建新的科研捐赠记录,初始协议号与样本编码与源单一致。',
'转科研确认',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
@@ -291,7 +291,7 @@
</div>
<!-- 缴费信息 -->
<div id="view-section-payment" class="section-card section-card--gray">
<div id="view-section-payment" class="section-card">
<div class="section-title"><h3>缴费信息</h3></div>
<!-- 缴费主表指标卡 -->
<div class="payment-summary">
@@ -664,6 +664,7 @@
fetchHospitalList,
fetchUserList
} from '@/api/admin'
import { downloadAttachmentFile } from '@/utils/download-attachment'
const props = withDefaults(
defineProps<{
@@ -1343,15 +1344,7 @@
}
function downloadFile(file: Record<string, any>) {
const url = fullFileUrl(file.fileUrl)
if (!url) return
const a = document.createElement('a')
a.href = url
a.download = file.fileName || ''
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
downloadAttachmentFile(file, { fullFileUrl })
}
async function loadLookups(data: Record<string, any>) {
@@ -1606,10 +1599,6 @@
&--green {
background: #f0fdf4;
}
&--gray {
background: #f8fafc;
}
}
.section-title {
@@ -1,10 +1,10 @@
<!-- 采集协议单 -->
<!-- 采集协议单录入 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>采集协议单</h1>
<p class="page-desc">查看与管理采集协议单主数据。</p>
<h1>采集协议单录入</h1>
<p class="page-desc">录入与管理采集协议单主数据。</p>
</header>
<section class="panel">
@@ -259,6 +259,7 @@
type FinanceAttachmentItem
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
import { maskIdCard } from '@/utils/mask-sensitive'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import {
formatPaymentAmount,
formatPaymentDueDate,
@@ -321,14 +322,7 @@
}
function handleAttachmentDownload(file: Record<string, unknown>) {
const url = String(file.fileUrl || '')
if (!url) return
const a = document.createElement('a')
a.href = fullFileUrl(url)
a.download = String(file.fileName || 'download')
a.target = '_blank'
a.rel = 'noopener'
a.click()
downloadAttachmentFile(file, { fullFileUrl })
}
async function loadData() {
@@ -425,10 +425,16 @@
// --- Delete ---
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该价格记录吗?', '提示', { type: 'warning' })
await fetchDeleteCellProductPrice(row.id)
ElMessage.success('删除成功')
await loadData()
try {
await ElMessageBox.confirm('确定要删除该价格记录吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteCellProductPrice(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
// --- Batch delete ---
@@ -293,10 +293,16 @@
}
async function handleDelete(row: Api.CellProductSpec.CellProductSpecItem) {
await ElMessageBox.confirm('确定要删除该规格吗?', '提示', { type: 'warning' })
await fetchDeleteCellProductSpec(row.id)
ElMessage.success('删除成功')
await loadData()
try {
await ElMessageBox.confirm('确定要删除该规格吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteCellProductSpec(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
async function handleBatchDelete() {
@@ -293,10 +293,16 @@
}
async function handleDelete(row: Api.CellProductType.CellProductTypeItem) {
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
await fetchDeleteCellProductType(row.id)
ElMessage.success('删除成功')
await loadData()
try {
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteCellProductType(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
async function handleBatchDelete() {
@@ -157,7 +157,7 @@
<el-empty v-else description="暂无邮寄地址" :image-size="48" />
</div>
<div id="view-section-agreement" class="section-card section-card--gray">
<div id="view-section-agreement" class="section-card">
<div class="section-title"><h3>协议信息</h3></div>
<div v-if="agreementList.length" class="agreement-cards">
<div
@@ -235,6 +235,7 @@
import { computed, nextTick, ref, watch } from 'vue'
import { Close } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import {
fetchChannelInvoices,
fetchChannelMailAddresses,
@@ -301,7 +302,12 @@
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const protocolAttachmentFiles = computed(() => {
const urls = String(channel.value?.protocolAttachments || '').trim()
const row = channel.value
if (!row) return []
if (Array.isArray(row.attachmentFiles) && row.attachmentFiles.length) {
return row.attachmentFiles as Array<Record<string, any>>
}
const urls = String(row.protocolAttachments || '').trim()
if (!urls) return []
return urls
.split(',')
@@ -414,15 +420,7 @@
}
function downloadFile(file: Record<string, any>) {
const url = fullFileUrl(file.fileUrl)
if (!url) return
const a = document.createElement('a')
a.href = url
a.download = file.fileName || ''
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
downloadAttachmentFile(file, { fullFileUrl })
}
async function loadSubTables() {
@@ -586,10 +584,6 @@
&--green {
background: #f0fdf4;
}
&--gray {
background: #f8fafc;
}
}
.section-title {
@@ -314,13 +314,28 @@
新增
</el-button>
</div>
<div v-if="invoiceList.length" class="sub-cards">
<div
v-if="invoiceList.length"
class="sub-cards"
v-loading="!!deletingInvoiceId"
element-loading-text="删除中…"
>
<div v-for="(row, $index) in invoiceList" :key="row.id || $index" class="sub-card">
<div class="sub-card-header">
<h4>{{ row.companyName || '未命名' }}</h4>
<div class="sub-card-actions">
<el-button class="btn-op-item primary" link size="small" @click="handleEditInvoice(row)">编辑</el-button>
<el-button class="btn-op-item" type="danger" link size="small" @click="handleDeleteInvoice(row)">删除</el-button>
<el-button
class="btn-op-item"
type="danger"
link
size="small"
:loading="deletingInvoiceId === row.id"
:disabled="!!deletingInvoiceId"
@click="handleDeleteInvoice(row)"
>
删除
</el-button>
</div>
</div>
<div class="sub-card-fields">
@@ -366,13 +381,28 @@
新增
</el-button>
</div>
<div v-if="mailList.length" class="sub-cards">
<div
v-if="mailList.length"
class="sub-cards"
v-loading="!!deletingMailId"
element-loading-text="删除中…"
>
<div v-for="(row, $index) in mailList" :key="row.id || $index" class="sub-card">
<div class="sub-card-header">
<h4>{{ row.mailAddress || '未命名' }}</h4>
<div class="sub-card-actions">
<el-button class="btn-op-item primary" link size="small" @click="handleEditMail(row)">编辑</el-button>
<el-button class="btn-op-item" type="danger" link size="small" @click="handleDeleteMail(row)">删除</el-button>
<el-button
class="btn-op-item"
type="danger"
link
size="small"
:loading="deletingMailId === row.id"
:disabled="!!deletingMailId"
@click="handleDeleteMail(row)"
>
删除
</el-button>
</div>
</div>
<div class="sub-card-fields">
@@ -524,7 +554,12 @@
新增协议
</el-button>
</div>
<div v-if="agreementList.length" class="protocol-cards">
<div
v-if="agreementList.length"
class="protocol-cards"
v-loading="!!agreementOperatingId"
element-loading-text="处理中…"
>
<div
v-for="(agr, $index) in agreementList"
:key="agr.id || $index"
@@ -550,26 +585,36 @@
<el-button
v-if="agr.status === 'dsx'"
class="btn-op-item primary"
:loading="agreementOperatingId === agr.id && agreementOperatingAction === 'activate'"
:disabled="!!agreementOperatingId"
@click="handleActivateAgreement(agr)"
>
<el-icon><Check /></el-icon>生效
<el-icon v-if="!(agreementOperatingId === agr.id && agreementOperatingAction === 'activate')"><Check /></el-icon>生效
</el-button>
<el-button
v-if="agr.status === 'dsx' || agr.status === 'sxz'"
class="btn-op-item"
:loading="agreementOperatingId === agr.id && agreementOperatingAction === 'void'"
:disabled="!!agreementOperatingId"
@click="handleVoidAgreement(agr)"
>
<el-icon><CircleClose /></el-icon>作废
<el-icon v-if="!(agreementOperatingId === agr.id && agreementOperatingAction === 'void')"><CircleClose /></el-icon>作废
</el-button>
<el-button
class="btn-op-item"
:class="{ primary: agr.status !== 'dsx' }"
:disabled="!!agreementOperatingId"
@click="handleEditAgreement(agr)"
>
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button class="btn-op-item" @click="handleDeleteAgreement(agr)">
<el-icon><Delete /></el-icon>删除
<el-button
class="btn-op-item"
:loading="agreementOperatingId === agr.id && agreementOperatingAction === 'delete'"
:disabled="!!agreementOperatingId"
@click="handleDeleteAgreement(agr)"
>
<el-icon v-if="!(agreementOperatingId === agr.id && agreementOperatingAction === 'delete')"><Delete /></el-icon>删除
</el-button>
</div>
</div>
@@ -632,6 +677,8 @@
class="list-body list-wrap protocol-price-list-wrap"
>
<el-table
v-loading="isAgreementPriceTableLoading(agr.id)"
element-loading-text="删除中…"
:data="agreementPricesMap[agr.id] || []"
class="list-table protocol-card-price-table"
stripe
@@ -727,8 +774,14 @@
header-align="center"
>
<template #default="{ row: priceRow }">
<el-button class="btn-op-item" @click="handleDeletePriceInline(priceRow, agr)">
<el-icon><Delete /></el-icon>删除
<el-button
class="btn-op-item"
:loading="deletingAgreementPriceId === priceRow.id"
:disabled="!!deletingAgreementPriceId"
@click="handleDeletePriceInline(priceRow, agr)"
>
<el-icon v-if="deletingAgreementPriceId !== priceRow.id"><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
@@ -861,23 +914,22 @@
append-to-body
:close-on-click-modal="false"
>
<el-form v-if="priceSelectorMode === 'project'" label-position="top" class="price-selector-filter">
<el-form-item label="选择项目价格体系">
<el-select
v-model="selectedProjectSystemId"
placeholder="请选择项目价格体系"
style="width: 100%"
@change="handleProjectSystemChange"
>
<el-option
v-for="sys in projectSystemList"
:key="sys.id"
:label="sys.systemName"
:value="sys.id"
/>
</el-select>
</el-form-item>
</el-form>
<div v-if="priceSelectorMode === 'project'" class="price-selector-filter price-selector-system-row">
<span class="price-selector-system-label">选择项目价格体系</span>
<el-select
v-model="selectedProjectSystemId"
class="price-selector-system-select"
placeholder="请选择项目价格体系"
@change="handleProjectSystemChange"
>
<el-option
v-for="sys in projectSystemList"
:key="sys.id"
:label="sys.systemName"
:value="sys.id"
/>
</el-select>
</div>
<div class="price-selector-filter">
<el-input
v-model="priceSelectorKeyword"
@@ -896,12 +948,17 @@
v-loading="priceSelectorLoading"
:data="filteredPriceSelectorList"
border
size="small"
class="list-table"
max-height="480"
@selection-change="handlePriceSelectorChange"
>
<el-table-column type="selection" width="45" align="center" header-align="center" />
<el-table-column
type="selection"
width="45"
align="center"
header-align="center"
:selectable="priceSelectorRowSelectable"
/>
<el-table-column prop="projectName" label="项目名称" min-width="120" align="center" header-align="center" />
<el-table-column prop="projectCode" label="项目编码" min-width="100" align="center" header-align="center" />
<el-table-column prop="cellTypeName" label="细胞类型" min-width="100" align="center" header-align="center" />
@@ -921,10 +978,11 @@
<el-button
class="btn-main"
type="primary"
:disabled="priceSelectorSelected.length === 0"
:loading="priceConfirmLoading"
:disabled="priceSelectorSelected.length === 0 || priceConfirmLoading"
@click="handleConfirmPriceSelector"
>
<el-icon><Check /></el-icon>
<el-icon v-if="!priceConfirmLoading"><Check /></el-icon>
确认添加({{ priceSelectorSelected.length }})
</el-button>
</template>
@@ -988,6 +1046,7 @@
import ChannelViewDialog from './ChannelViewDialog.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import { fetchUploadAttachment } from '@/api/business'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import {
fetchChannelList,
fetchCreateChannel,
@@ -1119,6 +1178,25 @@
}>
}
function resolveChannelAttachments(row: any) {
if (Array.isArray(row?.attachmentFiles) && row.attachmentFiles.length) {
return row.attachmentFiles.map((a: any, i: number) => ({
id: a.id || `att_${i}`,
fileName: a.fileName || '',
fileUrl: a.fileUrl || '',
fileSize: a.fileSize ?? 0,
fileType: a.fileType || ''
}))
}
if (Array.isArray(row?.attachments) && row.attachments.length) {
return row.attachments.map((a: any) => ({ ...a }))
}
if (Array.isArray(row?.attachmentList) && row.attachmentList.length) {
return row.attachmentList.map((a: any) => ({ ...a }))
}
return protocolUrlsToAttachments(String(row?.protocolAttachments || ''))
}
const agreementTypeTagMap: Record<string, { label: string; type: string }> = {
kyxy: { label: '框架协议', type: 'primary' },
zsxy: { label: '中试协议', type: 'success' },
@@ -1248,6 +1326,11 @@
const agreementFormStatus = ref('dsx')
const agreementSubmitLoading = ref(false)
const agreementActivateLoading = ref(false)
const agreementOperatingId = ref<string | null>(null)
const agreementOperatingAction = ref<'activate' | 'void' | 'delete' | null>(null)
const deletingInvoiceId = ref<string | null>(null)
const deletingMailId = ref<string | null>(null)
const priceConfirmLoading = ref(false)
const agreementFormRef = ref()
const agreementFormData = reactive({
agreementType: '' as string,
@@ -1278,6 +1361,48 @@
const priceSelectorTitle = ref('从价格体系添加')
const priceSelectorKeyword = ref('')
const priceSelectorLoading = ref(false)
const deletingAgreementPriceId = ref<string | null>(null)
const deletingAgreementPriceAgreementId = ref<string | null>(null)
function isAgreementPriceTableLoading(agreementId: string) {
return (
deletingAgreementPriceAgreementId.value === agreementId &&
deletingAgreementPriceId.value != null
)
}
function removeAgreementPriceFromMap(agreementId: string, priceId: string) {
const list = agreementPricesMap.value[agreementId] || []
agreementPricesMap.value = {
...agreementPricesMap.value,
[agreementId]: list.filter((item) => item.id !== priceId)
}
}
/** 协议项目信息唯一键:项目编码 + 细胞类型 + 规格 */
function buildAgreementPriceItemKey(item: Record<string, any>) {
const code = String(item.projectCode || item.projectName || '')
.trim()
.toLowerCase()
const cellType = String(item.cellTypeCode || item.cellTypeName || '')
.trim()
.toLowerCase()
const spec = String(item.specText || '')
.trim()
.toLowerCase()
return `${code}|${cellType}|${spec}`
}
function getAgreementPriceKeySet(agreementId: string | null) {
if (!agreementId) return new Set<string>()
const list = agreementPricesMap.value[agreementId] || []
return new Set(list.map((item: any) => buildAgreementPriceItemKey(item)))
}
function priceSelectorRowSelectable(row: any) {
if (!currentAgreementId.value) return true
return !getAgreementPriceKeySet(currentAgreementId.value).has(buildAgreementPriceItemKey(row))
}
const filteredPriceSelectorList = computed(() => {
const kw = priceSelectorKeyword.value.trim().toLowerCase()
@@ -1380,13 +1505,7 @@
formData.contactName = row.contactName || ''
formData.contactPhone = row.contactPhone || ''
formData.contactEmail = row.contactEmail || ''
if (Array.isArray(row.attachments) && row.attachments.length) {
formData.attachments = row.attachments.map((a: any) => ({ ...a }))
} else if (Array.isArray(row.attachmentList) && row.attachmentList.length) {
formData.attachments = row.attachmentList.map((a: any) => ({ ...a }))
} else {
formData.attachments = protocolUrlsToAttachments(String(row.protocolAttachments || ''))
}
formData.attachments = resolveChannelAttachments(row)
formData.remark = row.remark || ''
activeTab.value = 'basic'
dialogVisible.value = true
@@ -1468,10 +1587,16 @@
// 主表删除
// ---------------------------------------------------------------------------
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该渠道吗?关联的子表数据也将清除。', '提示', { type: 'warning' })
await fetchDeleteChannel(row.id)
ElMessage.success('删除成功')
await loadData()
try {
await ElMessageBox.confirm('确定要删除该渠道吗?关联的子表数据也将清除。', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteChannel(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
async function handleBatchDelete() {
@@ -1580,15 +1705,25 @@
}
async function handleDeleteInvoice(row: any) {
await ElMessageBox.confirm('确定删除该开票信息吗?', '提示', { type: 'warning' })
if (editingId.value) {
try {
await ElMessageBox.confirm('确定删除该开票信息吗?', '提示', { type: 'warning' })
} catch {
return
}
if (!editingId.value) {
const idx = invoiceList.value.findIndex((r: any) => r.id === row.id)
if (idx !== -1) invoiceList.value.splice(idx, 1)
return
}
deletingInvoiceId.value = row.id
try {
await fetchDeleteChannelInvoice(row.id)
ElMessage.success('删除成功')
await loadSubTables(editingId.value)
} else {
// 本地模式:从本地数组中移除
const idx = invoiceList.value.findIndex((r: any) => r.id === row.id)
if (idx !== -1) invoiceList.value.splice(idx, 1)
} catch {
/* 全局已提示 */
} finally {
deletingInvoiceId.value = null
}
}
@@ -1663,14 +1798,25 @@
}
async function handleDeleteMail(row: any) {
await ElMessageBox.confirm('确定删除该邮寄地址吗?', '提示', { type: 'warning' })
if (editingId.value) {
try {
await ElMessageBox.confirm('确定删除该邮寄地址吗?', '提示', { type: 'warning' })
} catch {
return
}
if (!editingId.value) {
const idx = mailList.value.findIndex((r: any) => r.id === row.id)
if (idx !== -1) mailList.value.splice(idx, 1)
return
}
deletingMailId.value = row.id
try {
await fetchDeleteChannelMailAddress(row.id)
ElMessage.success('删除成功')
await loadSubTables(editingId.value)
} else {
const idx = mailList.value.findIndex((r: any) => r.id === row.id)
if (idx !== -1) mailList.value.splice(idx, 1)
} catch {
/* 全局已提示 */
} finally {
deletingMailId.value = null
}
}
@@ -1786,17 +1932,43 @@
}
async function handleDeleteAgreement(row: any) {
await ElMessageBox.confirm('确定删除该协议吗?', '提示', { type: 'warning' })
await fetchDeleteChannelAgreement(row.id)
ElMessage.success('删除成功')
await loadAgreementsForDialog(agreementChannelId.value!)
try {
await ElMessageBox.confirm('确定删除该协议吗?', '提示', { type: 'warning' })
} catch {
return
}
agreementOperatingId.value = row.id
agreementOperatingAction.value = 'delete'
try {
await fetchDeleteChannelAgreement(row.id)
ElMessage.success('删除成功')
await loadAgreementsForDialog(agreementChannelId.value!)
} catch {
/* 全局已提示 */
} finally {
agreementOperatingId.value = null
agreementOperatingAction.value = null
}
}
async function handleActivateAgreement(row: any) {
await ElMessageBox.confirm('确定将该协议设为生效中吗?', '协议生效', { type: 'info' })
await fetchActivateChannelAgreement(row.id)
ElMessage.success('生效成功')
await loadAgreementsForDialog(agreementChannelId.value!)
try {
await ElMessageBox.confirm('确定将该协议设为生效中吗?', '协议生效', { type: 'info' })
} catch {
return
}
agreementOperatingId.value = row.id
agreementOperatingAction.value = 'activate'
try {
await fetchActivateChannelAgreement(row.id)
ElMessage.success('生效成功')
await loadAgreementsForDialog(agreementChannelId.value!)
} catch {
/* 全局已提示 */
} finally {
agreementOperatingId.value = null
agreementOperatingAction.value = null
}
}
async function handleActivateAgreementFromForm() {
@@ -1825,10 +1997,23 @@
}
async function handleVoidAgreement(row: any) {
await ElMessageBox.confirm('确定作废该协议吗?作废后不可恢复。', '提示', { type: 'warning' })
await fetchVoidChannelAgreement(row.id)
ElMessage.success('操作成功')
await loadAgreementsForDialog(agreementChannelId.value!)
try {
await ElMessageBox.confirm('确定作废该协议吗?作废后不可恢复。', '提示', { type: 'warning' })
} catch {
return
}
agreementOperatingId.value = row.id
agreementOperatingAction.value = 'void'
try {
await fetchVoidChannelAgreement(row.id)
ElMessage.success('操作成功')
await loadAgreementsForDialog(agreementChannelId.value!)
} catch {
/* 全局已提示 */
} finally {
agreementOperatingId.value = null
agreementOperatingAction.value = null
}
}
// ===========================================================================
@@ -1867,10 +2052,25 @@
}
async function handleDeletePriceInline(priceRow: any, agreement: any) {
await ElMessageBox.confirm('确定删除该价格明细吗?', '提示', { type: 'warning' })
await fetchDeleteChannelAgreementPrice(priceRow.id)
ElMessage.success('删除成功')
await refreshAgreementPrices(agreement.id)
try {
await ElMessageBox.confirm('确定删除该价格明细吗?', '提示', { type: 'warning' })
} catch {
return
}
const agreementId = agreement.id
const priceId = priceRow.id
deletingAgreementPriceId.value = priceId
deletingAgreementPriceAgreementId.value = agreementId
try {
await fetchDeleteChannelAgreementPrice(priceId)
removeAgreementPriceFromMap(agreementId, priceId)
ElMessage.success('删除成功')
} catch {
/* 全局已提示 */
} finally {
deletingAgreementPriceId.value = null
deletingAgreementPriceAgreementId.value = null
}
}
function handleAddPriceCommand(command: string) {
@@ -1881,7 +2081,6 @@
} else if (command === 'fromProjectSystem') {
priceSelectorMode.value = 'project'
priceSelectorTitle.value = '从项目价格体系添加'
selectedProjectSystemId.value = ''
openProjectPriceSelector()
}
}
@@ -1918,6 +2117,11 @@
try {
const res = await fetchProjectSystemList({ page: 1, pageSize: 100 })
projectSystemList.value = (res as any)?.list || []
const first = projectSystemList.value[0]
if (first?.id) {
selectedProjectSystemId.value = first.id
await handleProjectSystemChange(first.id)
}
} catch {
projectSystemList.value = []
}
@@ -1953,37 +2157,85 @@
async function handleConfirmPriceSelector() {
if (!currentAgreementId.value) return
const existingList = agreementPricesMap.value[currentAgreementId.value] || []
const existingIds = new Set(existingList.map((item: any) => String(item.sourcePriceId || '')).filter(Boolean))
const existingKeys = getAgreementPriceKeySet(currentAgreementId.value)
const batchKeys = new Set<string>()
const toAdd: any[] = []
let skippedCount = 0
for (const row of priceSelectorSelected.value) {
if (existingIds.has(row.id)) continue
try {
await fetchCreateChannelAgreementPrice(currentAgreementId.value, {
projectName: row.projectName || '',
projectCode: row.projectCode || '',
cellTypeCode: row.cellTypeCode || undefined,
cellTypeName: row.cellTypeName || '',
specText: row.specText || '',
originalPrice: row.unitPrice ?? null,
agreementPrice: null,
remark: row.remark || undefined
})
} catch {
/* */
const key = buildAgreementPriceItemKey(row)
if (existingKeys.has(key) || batchKeys.has(key)) {
skippedCount += 1
continue
}
batchKeys.add(key)
toAdd.push(row)
}
priceSelectorVisible.value = false
if (currentAgreementId.value) {
await refreshAgreementPrices(currentAgreementId.value)
if (!toAdd.length) {
ElMessage.warning(
skippedCount > 0 ? '所选项目均已存在,不可重复添加' : '请选择要添加的项目'
)
return
}
priceConfirmLoading.value = true
let successCount = 0
try {
for (const row of toAdd) {
try {
await fetchCreateChannelAgreementPrice(currentAgreementId.value, {
projectName: row.projectName || '',
projectCode: row.projectCode || '',
cellTypeCode: row.cellTypeCode || undefined,
cellTypeName: row.cellTypeName || '',
specText: row.specText || '',
originalPrice: row.unitPrice ?? null,
agreementPrice: null,
remark: row.remark || undefined
})
successCount += 1
} catch {
/* */
}
}
if (successCount > 0) {
ElMessage.success(`成功添加 ${successCount} 条项目`)
}
if (skippedCount > 0) {
ElMessage.warning(`已跳过 ${skippedCount} 条重复项目`)
}
priceSelectorVisible.value = false
if (currentAgreementId.value) {
await refreshAgreementPrices(currentAgreementId.value)
}
} finally {
priceConfirmLoading.value = false
}
}
async function handleDeletePrice(row: any) {
await ElMessageBox.confirm('确定删除该价格明细吗?', '提示', { type: 'warning' })
await fetchDeleteChannelAgreementPrice(row.id)
ElMessage.success('删除成功')
if (currentAgreementId.value) {
await refreshAgreementPrices(currentAgreementId.value)
try {
await ElMessageBox.confirm('确定删除该价格明细吗?', '提示', { type: 'warning' })
} catch {
return
}
const agreementId = currentAgreementId.value
if (!agreementId) return
const priceId = row.id
deletingAgreementPriceId.value = priceId
deletingAgreementPriceAgreementId.value = agreementId
try {
await fetchDeleteChannelAgreementPrice(priceId)
removeAgreementPriceFromMap(agreementId, priceId)
priceList.value = priceList.value.filter((item) => item.id !== priceId)
ElMessage.success('删除成功')
} catch {
/* 全局已提示 */
} finally {
deletingAgreementPriceId.value = null
deletingAgreementPriceAgreementId.value = null
}
}
@@ -2083,15 +2335,7 @@
}
function handleDownloadAttachment(file: any) {
const url = fullFileUrl(file.fileUrl)
if (!url) return
const a = document.createElement('a')
a.href = url
a.download = file.fileName || ''
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
downloadAttachmentFile(file, { fullFileUrl })
}
// ---------------------------------------------------------------------------
@@ -2648,6 +2892,24 @@
}
}
.price-selector-system-row {
align-items: center;
.price-selector-system-label {
flex-shrink: 0;
font-size: 14px;
font-weight: 500;
color: var(--mint-text, #334155);
line-height: 36px;
white-space: nowrap;
}
.price-selector-system-select {
width: 280px;
flex-shrink: 0;
}
}
@media (max-width: 900px) {
.form-grid {
grid-template-columns: repeat(2, 1fr);
@@ -227,6 +227,7 @@
fetchDeleteCheckupInstitutionPackage,
fetchCheckupInstitutionProjects
} from '@/api/admin'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
const props = defineProps<{
institutionId: string
@@ -374,10 +375,16 @@
}
async function handleDelete(row: any) {
await ElMessageBox.confirm(`确定删除套餐「${row.packageName}」?`, '提示', { type: 'warning' })
await fetchDeleteCheckupInstitutionPackage(row.id)
ElMessage.success('已删除')
await loadPackages()
try {
await ElMessageBox.confirm(`确定删除套餐「${row.packageName}」?`, '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteCheckupInstitutionPackage(row.id)
ElMessage.success('已删除')
await loadPackages()
}, fullscreenLoadingText.delete)
}
async function openProjectSelector() {
@@ -332,6 +332,7 @@
fetchUpdateCheckupInstitutionProject,
fetchDeleteCheckupInstitutionProject
} from '@/api/admin'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
defineOptions({ name: 'PilotCheckupInstitution' })
@@ -555,12 +556,18 @@
}
async function handleDelete(row: any) {
await ElMessageBox.confirm(`确定删除「${row.institutionName}」?其下属项目价格将一并删除。`, '提示', {
type: 'warning'
})
await fetchDeleteCheckupInstitution(row.id)
ElMessage.success('已删除')
loadData()
try {
await ElMessageBox.confirm(`确定删除「${row.institutionName}」?其下属项目价格将一并删除。`, '提示', {
type: 'warning'
})
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteCheckupInstitution(row.id)
ElMessage.success('已删除')
await loadData()
}, fullscreenLoadingText.delete)
}
// ---------------------------------------------------------------------------
@@ -693,10 +700,16 @@
}
async function handleDeleteItem(row: any) {
await ElMessageBox.confirm(`确定删除「${row.projectName}」?`, '提示', { type: 'warning' })
await fetchDeleteCheckupInstitutionProject(row.id)
ElMessage.success('已删除')
loadItems()
try {
await ElMessageBox.confirm(`确定删除「${row.projectName}」?`, '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteCheckupInstitutionProject(row.id)
ElMessage.success('已删除')
await loadItems()
}, fullscreenLoadingText.delete)
}
onMounted(() => loadData())
@@ -523,10 +523,16 @@
// --- Delete ---
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该体系吗?删除后关联的价格明细也将清除。', '提示', { type: 'warning' })
await fetchDeleteProjectSystem(row.id)
ElMessage.success('删除成功')
await loadData()
try {
await ElMessageBox.confirm('确定要删除该体系吗?删除后关联的价格明细也将清除。', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteProjectSystem(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
// --- Batch delete ---
@@ -61,6 +61,7 @@
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" width="55" align="center" header-align="center" />
<el-table-column
@@ -125,8 +126,11 @@
align="center"
header-align="center"
/>
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
<el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
</template>
@@ -145,11 +149,90 @@
</div>
</div>
<!-- 查看弹窗 -->
<el-dialog
v-model="viewVisible"
title="查看耗材包"
width="960px"
class="dialog-form-layout pack-view-dialog"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<div v-loading="viewLoading" class="pack-view-body">
<template v-if="viewDetail">
<div class="form-section">
<div class="section-title">基本信息</div>
<div class="pack-view-kv-grid">
<div class="pack-view-kv">
<span class="pack-view-label">编号</span>
<span class="pack-view-value">{{ viewDetail.packCode || '—' }}</span>
</div>
<div class="pack-view-kv">
<span class="pack-view-label">名称</span>
<span class="pack-view-value">{{ viewDetail.packName || '—' }}</span>
</div>
<div class="pack-view-kv">
<span class="pack-view-label">使用场景</span>
<span class="pack-view-value">{{ viewDetail.usageScene || '—' }}</span>
</div>
<div class="pack-view-kv">
<span class="pack-view-label">类型</span>
<span class="pack-view-value">{{ packTypeLabel(viewDetail.packType) }}</span>
</div>
<div class="pack-view-kv">
<span class="pack-view-label">状态</span>
<span class="pack-view-value">
<el-tag :type="viewDetail.status === 1 ? 'success' : 'danger'" size="small">
{{ viewDetail.status === 1 ? '启用' : '停用' }}
</el-tag>
</span>
</div>
<div class="pack-view-kv">
<span class="pack-view-label">备注</span>
<span class="pack-view-value">{{ viewDetail.remark || '—' }}</span>
</div>
</div>
</div>
<div class="form-section">
<div class="section-title">耗材明细</div>
<el-table :data="viewDetail.items || []" stripe class="list-table inner-table">
<el-table-column
type="index"
label="序号"
width="56"
align="center"
header-align="center"
:index="(index) => index + 1"
/>
<el-table-column prop="consumableCode" label="耗材编码" min-width="120" align="center" header-align="center" />
<el-table-column prop="consumableName" label="耗材名称" min-width="120" align="center" header-align="center" />
<el-table-column prop="specModel" label="规格型号" min-width="120" align="center" header-align="center" />
<el-table-column prop="standardUsage" label="标准用量" min-width="100" align="center" header-align="center" />
<el-table-column prop="unit" label="单位" min-width="80" align="center" header-align="center" />
<el-table-column prop="remark" label="备注" min-width="140" align="center" header-align="center" />
</el-table>
<el-empty
v-if="!(viewDetail.items || []).length"
description="暂无耗材明细"
:image-size="48"
/>
</div>
</template>
</div>
<template #footer>
<el-button class="btn-ghost" @click="viewVisible = false">
<el-icon><Close /></el-icon>
关闭
</el-button>
</template>
</el-dialog>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="800px"
width="960px"
class="dialog-form-layout"
align-center
destroy-on-close
@@ -167,7 +250,7 @@
<!-- 基本信息 -->
<div class="form-section">
<div class="section-title">基本信息</div>
<div class="form-grid-2">
<div class="form-grid-3">
<el-form-item label="编号" prop="packCode">
<el-input v-model="formData.packCode" placeholder="请输入编号" />
</el-form-item>
@@ -201,16 +284,33 @@
添加耗材
</el-button>
</div>
<el-table :data="formData.items" stripe size="small" class="inner-table">
<el-table :data="formData.items" stripe class="inner-table">
<el-table-column
type="index"
label="序号"
width="56"
align="center"
header-align="center"
:index="(index) => index + 1"
/>
<el-table-column prop="consumableCode" label="耗材编码" min-width="120" align="center" header-align="center" />
<el-table-column prop="consumableName" label="耗材名称" min-width="120" align="center" header-align="center" />
<el-table-column prop="specModel" label="规格型号" min-width="120" align="center" header-align="center" />
<el-table-column label="标准用量" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.standardUsage" placeholder="用量" size="small" />
<el-input
v-model="row.standardUsage"
placeholder="用量"
class="inner-cell-input inner-cell-input--usage"
/>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" min-width="80" align="center" header-align="center" />
<el-table-column label="备注" min-width="140" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" align="center" header-align="center" />
<el-table-column label="操作" width="80" align="center" header-align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="formData.items.splice($index, 1)">
@@ -244,29 +344,42 @@
destroy-on-close
:close-on-click-modal="false"
>
<el-input
v-model="consumableKeyword"
placeholder="搜索耗材编码/名称"
clearable
style="margin-bottom: 12px"
@input="filterConsumables"
/>
<div class="consumable-select-toolbar">
<el-input
v-model="consumableKeyword"
placeholder="搜索耗材编码/名称"
clearable
class="consumable-select-search"
@input="filterConsumables"
/>
<span class="consumable-select-count">
已选 <span class="sel-num">{{ selectedConsumables.length }}</span> 项
</span>
</div>
<el-table
ref="consumableSelectTableRef"
class="list-table consumable-select-table"
:data="filteredConsumableList"
stripe
size="small"
height="360"
highlight-current-row
@current-change="handleConsumableSelect"
row-key="id"
@selection-change="handleConsumableSelectionChange"
>
<el-table-column type="selection" width="48" align="center" header-align="center" :reserve-selection="true" />
<el-table-column prop="consumableCode" label="耗材编码" min-width="120" align="center" header-align="center" />
<el-table-column prop="consumableName" label="耗材名称" min-width="140" align="center" header-align="center" />
<el-table-column prop="specModel" label="规格型号" min-width="120" align="center" header-align="center" />
<el-table-column prop="unit" label="单位" min-width="80" align="center" header-align="center" />
</el-table>
<template #footer>
<el-button class="btn-ghost" @click="selectConsumableVisible = false">取消</el-button>
<el-button class="btn-main" type="primary" :disabled="!selectedConsumable" @click="confirmAddConsumable">
确定
<el-button
class="btn-main"
type="primary"
:disabled="selectedConsumables.length === 0"
@click="confirmAddConsumable"
>
确定添加
</el-button>
</template>
</el-dialog>
@@ -275,7 +388,7 @@
</template>
<script setup lang="ts">
import { Plus, Close, Check } from '@element-plus/icons-vue'
import { Plus, Close, Check, View } from '@element-plus/icons-vue'
import {
fetchConsumablePackList,
fetchConsumablePackDetail,
@@ -284,6 +397,7 @@
fetchDeleteConsumablePack,
fetchAllConsumables
} from '@/api/production'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
defineOptions({ name: 'ProductionConsumablePack' })
@@ -298,6 +412,15 @@
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
const viewVisible = ref(false)
const viewLoading = ref(false)
const viewDetail = ref<any>(null)
function packTypeLabel(packType?: string) {
if (packType === 'bzhcb') return '标准耗材包'
if (packType === 'zdyhcb') return '自定义耗材包'
return packType || '—'
}
const formData = reactive({
packCode: '',
@@ -320,7 +443,8 @@
const allConsumableList = ref<any[]>([])
const filteredConsumableList = ref<any[]>([])
const consumableKeyword = ref('')
const selectedConsumable = ref<any>(null)
const consumableSelectTableRef = ref()
const selectedConsumables = ref<any[]>([])
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
@@ -371,13 +495,28 @@
nextTick(() => formRef.value?.clearValidate?.())
}
async function handleView(row: any) {
viewVisible.value = true
viewLoading.value = true
viewDetail.value = null
try {
viewDetail.value = await fetchConsumablePackDetail(row.id)
} catch {
ElMessage.error('加载详情失败')
viewVisible.value = false
} finally {
viewLoading.value = false
}
}
async function handleEdit(row: any) {
isEdit.value = true
dialogTitle.value = '编辑耗材包'
editingId.value = row.id
const packId = row.id
resetForm()
editingId.value = packId
try {
const detail = await fetchConsumablePackDetail(row.id)
const detail = await fetchConsumablePackDetail(packId)
formData.packCode = detail.packCode || ''
formData.packName = detail.packName || ''
formData.usageScene = detail.usageScene || ''
@@ -409,6 +548,7 @@
consumableName: item.consumableName,
specModel: item.specModel,
standardUsage: item.standardUsage,
unit: item.unit || undefined,
remark: item.remark || ''
}))
}
@@ -428,27 +568,39 @@
}
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该耗材包吗?', '提示', { type: 'warning' })
await fetchDeleteConsumablePack(row.id)
ElMessage.success('删除成功')
await loadData()
try {
await ElMessageBox.confirm('确定要删除该耗材包吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteConsumablePack(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个耗材包吗?`, '提示', { type: 'warning' })
for (const id of ids) {
await fetchDeleteConsumablePack(id)
try {
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个耗材包吗?`, '提示', { type: 'warning' })
} catch {
return
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteConsumablePack(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// 选择耗材弹窗
async function openAddConsumableDialog() {
selectedConsumable.value = null
selectedConsumables.value = []
consumableKeyword.value = ''
if (allConsumableList.value.length === 0) {
try {
@@ -459,6 +611,7 @@
}
filteredConsumableList.value = [...allConsumableList.value]
selectConsumableVisible.value = true
nextTick(() => consumableSelectTableRef.value?.clearSelection?.())
}
function filterConsumables() {
@@ -474,30 +627,41 @@
)
}
function handleConsumableSelect(row: any) {
selectedConsumable.value = row
function handleConsumableSelectionChange(rows: any[]) {
selectedConsumables.value = rows
}
function confirmAddConsumable() {
if (!selectedConsumable.value) return
const c = selectedConsumable.value
// 检查是否已添加
const exists = formData.items.some(
(item) => item.consumableId === c.id || item.consumableCode === c.consumableCode
)
if (exists) {
ElMessage.warning('该耗材已在明细中')
return
if (!selectedConsumables.value.length) return
let added = 0
let skipped = 0
for (const c of selectedConsumables.value) {
const exists = formData.items.some(
(item) => item.consumableId === c.id || item.consumableCode === c.consumableCode
)
if (exists) {
skipped++
continue
}
formData.items.push({
consumableId: c.id,
consumableCode: c.consumableCode || '',
consumableName: c.consumableName || '',
specModel: c.specModel || '',
standardUsage: c.standardUsage || '',
unit: c.unit || '',
remark: ''
})
added++
}
if (added > 0) {
ElMessage.success(
skipped > 0 ? `已添加 ${added} 项,${skipped} 项重复已跳过` : `已添加 ${added} 项耗材`
)
selectConsumableVisible.value = false
} else {
ElMessage.warning('所选耗材均已在明细中')
}
formData.items.push({
consumableId: c.id,
consumableCode: c.consumableCode || '',
consumableName: c.consumableName || '',
specModel: c.specModel || '',
standardUsage: '',
remark: ''
})
selectConsumableVisible.value = false
}
onMounted(() => {
@@ -533,5 +697,80 @@
.inner-table {
width: 100%;
:deep(td.el-table__cell) {
padding-top: 8px;
padding-bottom: 8px;
}
:deep(.inner-cell-input) {
.el-input__wrapper {
min-height: 36px;
padding-top: 2px;
padding-bottom: 2px;
}
.el-input__inner {
height: 32px;
line-height: 32px;
}
}
:deep(.inner-cell-input--usage .el-input__inner) {
text-align: center;
}
}
.consumable-select-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.consumable-select-search {
flex: 1;
}
.consumable-select-count {
flex-shrink: 0;
font-size: 13px;
color: var(--mint-text-muted, #64748b);
white-space: nowrap;
.sel-num {
color: var(--primary, #10b981);
font-weight: 600;
}
}
.pack-view-body {
min-height: 120px;
}
.pack-view-kv-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px 24px;
}
.pack-view-kv {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.pack-view-label {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
}
.pack-view-value {
font-size: 14px;
color: var(--mint-text, #1e293b);
line-height: 1.5;
word-break: break-word;
}
</style>
@@ -4,7 +4,7 @@
<div class="page-container">
<header class="page-head">
<h1>耗材管理</h1>
<p class="page-desc">维护生产耗材的编码、名称、规格及标准用量等信息。</p>
<p class="page-desc">维护生产耗材的编码、名称、规格、标准用量及单位等信息。</p>
</header>
<section class="panel">
@@ -72,6 +72,7 @@
<el-table-column prop="consumableName" label="耗材名称" min-width="150" align="center" header-align="center" />
<el-table-column prop="specModel" label="规格型号" min-width="140" align="center" header-align="center" />
<el-table-column prop="standardUsage" label="标准用量" min-width="110" align="center" header-align="center" />
<el-table-column prop="unit" label="单位" min-width="90" align="center" header-align="center" />
<el-table-column label="描述" min-width="160" align="center" header-align="center">
<template #default="{ row }">
{{ truncateText(row.description, 20) }}
@@ -132,6 +133,16 @@
<el-form-item label="标准用量">
<el-input v-model="formData.standardUsage" placeholder="请输入标准用量" />
</el-form-item>
<el-form-item label="单位">
<el-select v-model="formData.unit" placeholder="请选择单位" clearable filterable>
<el-option
v-for="item in unitOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="描述" class="form-col-span-2">
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="请输入描述" />
</el-form-item>
@@ -175,9 +186,12 @@
fetchUpdateConsumable,
fetchDeleteConsumable
} from '@/api/production'
import { fetchItemUnitList } from '@/api/admin'
defineOptions({ name: 'ProductionConsumable' })
const unitOptions = ref<{ label: string; value: string }[]>([])
function truncateText(text: string, max: number) {
if (!text) return ''
return text.length > max ? text.slice(0, max) + '…' : text
@@ -202,6 +216,7 @@
consumableName: '',
specModel: '',
standardUsage: '',
unit: '',
description: '',
remark: ''
})
@@ -226,6 +241,7 @@
{ label: '耗材名称', value: row.consumableName },
{ label: '规格型号', value: row.specModel },
{ label: '标准用量', value: row.standardUsage },
{ label: '单位', value: row.unit },
{ label: '描述', value: row.description, span: true },
{ label: '备注', value: row.remark, span: true }
]
@@ -264,6 +280,7 @@
formData.consumableName = ''
formData.specModel = ''
formData.standardUsage = ''
formData.unit = ''
formData.description = ''
formData.remark = ''
editingId.value = null
@@ -285,6 +302,7 @@
formData.consumableName = row.consumableName || ''
formData.specModel = row.specModel || ''
formData.standardUsage = row.standardUsage ?? ''
formData.unit = row.unit || ''
formData.description = row.description || ''
formData.remark = row.remark || ''
dialogVisible.value = true
@@ -300,6 +318,7 @@
consumableName: formData.consumableName.trim(),
specModel: formData.specModel?.trim() || undefined,
standardUsage: formData.standardUsage ?? undefined,
unit: formData.unit?.trim() || undefined,
description: formData.description?.trim() || undefined,
remark: formData.remark?.trim() || undefined
}
@@ -324,10 +343,16 @@
}
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该耗材吗?', '提示', { type: 'warning' })
await fetchDeleteConsumable(row.id)
ElMessage.success('删除成功')
await loadData()
try {
await ElMessageBox.confirm('确定要删除该耗材吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteConsumable(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
async function handleBatchDelete() {
@@ -344,8 +369,10 @@
}, fullscreenLoadingText.batchDelete)
}
onMounted(() => {
onMounted(async () => {
loadData()
const res = await fetchItemUnitList({ page: 1, pageSize: 100 })
unitOptions.value = (res.list || []).map((u: any) => ({ label: u.unitName, value: u.unitName }))
})
</script>
@@ -288,10 +288,16 @@
}
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
await fetchDeleteOutputType(row.id)
ElMessage.success('删除成功')
await loadData()
try {
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteOutputType(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
async function handleBatchDelete() {
@@ -288,10 +288,16 @@
}
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该分类吗?', '提示', { type: 'warning' })
await fetchDeleteProcessCategory(row.id)
ElMessage.success('删除成功')
await loadData()
try {
await ElMessageBox.confirm('确定要删除该分类吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteProcessCategory(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
async function handleBatchDelete() {
File diff suppressed because it is too large. Load diff
@@ -343,10 +343,16 @@
}
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该试剂吗?', '提示', { type: 'warning' })
await fetchDeleteReagent(row.id)
ElMessage.success('删除成功')
await loadData()
try {
await ElMessageBox.confirm('确定要删除该试剂吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteReagent(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
async function handleBatchDelete() {
+485 -112
View File
@@ -129,29 +129,75 @@
<el-input v-model="formData.versionNo" placeholder="请输入版本号" />
</el-form-item>
<el-form-item label="附件" class="form-col-span-2">
<div class="upload-card-row">
<div
v-for="(url, idx) in attachmentList"
:key="idx"
class="upload-card-item"
>
<el-icon class="upload-card-icon"><Document /></el-icon>
<span class="upload-card-name" :title="getFileName(url)">{{ getFileName(url) }}</span>
<el-icon class="upload-card-remove" @click="removeAttachment(idx)"><Close /></el-icon>
</div>
<el-upload
:auto-upload="false"
:show-file-list="false"
@change="handleFileChange"
accept="*"
>
<div class="upload-card-trigger">
<el-icon><Plus /></el-icon>
<span>上传</span>
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
ref="attachmentUploadRef"
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
@change="handleAttachmentChange"
>
<div class="upload-card">
<el-icon :size="28" color="#10b981"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
<div
v-for="(file, index) in formData.attachments"
:key="file.id || index"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div
class="attachment-preview"
@click="
isImageFile(file) && file.fileUrl
? handlePreviewAttachment(file)
: undefined
"
>
<template v-if="isImageFile(file) && file.fileUrl">
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else-if="isImageFile(file) && !file.fileUrl">
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
</template>
<template v-else-if="isVideoFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--video">
<el-icon :size="28"><VideoPlay /></el-icon>
</div>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
</div>
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon
class="action-btn"
title="下载"
@click="handleDownloadAttachment(file)"
><Download
/></el-icon>
<el-icon
class="action-btn action-btn--danger"
title="删除"
@click="removeAttachment(index)"
><Delete
/></el-icon>
</div>
</div>
</div>
</el-upload>
</div>
<span class="upload-hint"
>支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
>
</div>
<div class="upload-card-hint">支持上传多个附件文件。</div>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" placeholder="可选" />
@@ -170,21 +216,104 @@
</template>
</el-dialog>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
<img
:src="previewUrl"
alt="预览"
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
/>
</div>
<div v-else class="preview-container">
<video
:src="previewUrl"
controls
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
/>
</div>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.sopName || 'SOP详情'"
:icon="ScaleToOriginal"
:meta="viewRow?.createTime || ''"
:sections="viewSections"
/>
>
<template #append>
<div v-if="viewRow" class="view-section sop-view-attachment-section">
<h3>附件</h3>
<div v-if="viewAttachments.length" class="attachment-list">
<div
v-for="(file, index) in viewAttachments"
:key="file.id || index"
class="attachment-item attachment-item--view"
>
<div
class="attachment-preview"
@click="
isImageFile(file) || isVideoFile(file)
? handlePreviewAttachment(file)
: undefined
"
>
<template v-if="isImageFile(file)">
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else-if="isVideoFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--video">
<el-icon :size="28"><VideoPlay /></el-icon>
</div>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
</div>
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon
class="action-btn"
title="下载"
@click.stop="handleDownloadAttachment(file)"
>
<Download />
</el-icon>
</div>
</div>
</div>
</div>
<p v-else class="sop-view-attachment-empty">暂无附件</p>
</div>
</template>
</SystemViewDialog>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check, View, ScaleToOriginal, Document } from '@element-plus/icons-vue'
import {
Plus,
Close,
Check,
View,
ScaleToOriginal,
Document,
Download,
Delete,
VideoPlay,
Loading
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
@@ -194,9 +323,18 @@
fetchDeleteSop
} from '@/api/production'
import { fetchUploadAttachment } from '@/api/business'
import { downloadAttachmentFile } from '@/utils/download-attachment'
defineOptions({ name: 'ProductionSop' })
type SopAttachment = {
id: string
fileName: string
fileUrl: string
fileType?: string
_uploading?: boolean
}
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<any[]>([])
const selectedRows = ref<any[]>([])
@@ -210,18 +348,19 @@
const editingId = ref<string | null>(null)
const viewVisible = ref(false)
const viewRow = ref<any>(null)
const attachmentUploadRef = ref()
const previewVisible = ref(false)
const previewUrl = ref('')
const previewType = ref<'image' | 'video'>('image')
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const formData = reactive({
sopCode: '',
sopName: '',
versionNo: '',
sopAttachments: '',
remark: ''
})
const attachmentList = computed(() => {
if (!formData.sopAttachments) return []
return formData.sopAttachments.split(',').filter(Boolean)
remark: '',
attachments: [] as SopAttachment[]
})
const formRules = {
@@ -229,27 +368,89 @@
sopName: [{ required: true, message: '请输入SOP文档名称', trigger: 'blur' }]
}
function fullFileUrl(suffix: string) {
if (!suffix) return ''
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/'))
return suffix
return `${minioBaseUrl}/${suffix}`
}
function getFileName(url: string) {
if (!url) return ''
const parts = url.split('/')
return decodeURIComponent(parts[parts.length - 1] || '')
}
function attachmentsFromUrls(urls: string): SopAttachment[] {
if (!urls) return []
return urls
.split(',')
.filter(Boolean)
.map((url, i) => ({
id: `existing_${i}`,
fileName: getFileName(url),
fileUrl: url,
fileType: ''
}))
}
function attachmentsFromApiFiles(files: any[]): SopAttachment[] {
if (!files?.length) return []
return files.map((f, i) => ({
id: f.id || `existing_${i}`,
fileName: f.fileName || getFileName(f.fileUrl || ''),
fileUrl: f.fileUrl || '',
fileType: f.fileType || ''
}))
}
function resolveAttachments(row: any): SopAttachment[] {
if (row?.attachmentFiles?.length) {
return attachmentsFromApiFiles(row.attachmentFiles)
}
return attachmentsFromUrls(row?.sopAttachments || '')
}
function syncSopAttachmentsFromList() {
formData.sopAttachments = formData.attachments
.filter((a) => a.fileUrl && !a._uploading)
.map((a) => a.fileUrl)
.join(',')
}
function isImageFile(file: SopAttachment) {
const url = (file.fileUrl || '').toLowerCase()
const name = (file.fileName || '').toLowerCase()
return (
/\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
(file.fileType || '').startsWith('image/')
)
}
function isVideoFile(file: SopAttachment) {
const url = (file.fileUrl || '').toLowerCase()
const name = (file.fileName || '').toLowerCase()
return (
/\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
/\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
(file.fileType || '').startsWith('video/')
)
}
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
const viewAttachments = computed(() => {
if (!viewRow.value) return []
return resolveAttachments(viewRow.value)
})
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
const attachCount = row.sopAttachments ? row.sopAttachments.split(',').filter(Boolean).length : 0
const attachLinks = row.sopAttachments
? row.sopAttachments
.split(',')
.filter(Boolean)
.map((url: string) => getFileName(url))
.join('、')
: '无'
return [
{
title: '基本信息',
@@ -258,16 +459,33 @@
{ label: 'SOP文档名称', value: row.sopName },
{ label: '版本号', value: row.versionNo },
{ label: '附件数', value: String(attachCount) },
{ label: '附件列表', value: attachLinks, span: true },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
async function handleFileChange(uploadFile: any) {
const file = uploadFile?.raw
async function handleAttachmentChange(uploadFile: any) {
const file = uploadFile?.raw || uploadFile
if (!file) return
const maxSizeMb = 20
if (file.size / 1024 / 1024 > maxSizeMb) {
ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
attachmentUploadRef.value?.clearFiles()
return
}
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
formData.attachments.push({
id: tempId,
fileName: file.name,
fileUrl: '',
fileType: file.type || '',
_uploading: true
})
const idx = formData.attachments.length - 1
try {
const fd = new FormData()
fd.append('business_type', 'sop')
@@ -275,19 +493,40 @@
fd.append('file', file)
const res: any = await fetchUploadAttachment(fd)
if (res?.fileUrl) {
const current = formData.sopAttachments ? formData.sopAttachments.split(',').filter(Boolean) : []
current.push(res.fileUrl)
formData.sopAttachments = current.join(',')
formData.attachments[idx] = {
...formData.attachments[idx],
id: res.id || tempId,
fileName: file.name || res.fileName,
fileUrl: res.fileUrl,
fileType: res.fileType || file.type || '',
_uploading: false
}
syncSopAttachmentsFromList()
} else {
formData.attachments.splice(idx, 1)
}
} catch {
ElMessage.error('文件上传失败')
formData.attachments.splice(idx, 1)
} finally {
nextTick(() => attachmentUploadRef.value?.clearFiles())
}
}
function removeAttachment(idx: number) {
const current = formData.sopAttachments.split(',').filter(Boolean)
current.splice(idx, 1)
formData.sopAttachments = current.join(',')
function removeAttachment(index: number) {
formData.attachments.splice(index, 1)
syncSopAttachmentsFromList()
}
function handlePreviewAttachment(file: SopAttachment) {
if (file._uploading) return
previewUrl.value = fullFileUrl(file.fileUrl)
previewType.value = isVideoFile(file) ? 'video' : 'image'
previewVisible.value = true
}
function handleDownloadAttachment(file: SopAttachment) {
downloadAttachmentFile(file, { fullFileUrl })
}
async function loadData() {
@@ -322,6 +561,7 @@
formData.versionNo = ''
formData.sopAttachments = ''
formData.remark = ''
formData.attachments = []
editingId.value = null
}
@@ -341,6 +581,7 @@
formData.sopName = row.sopName || ''
formData.versionNo = row.versionNo || ''
formData.sopAttachments = row.sopAttachments || ''
formData.attachments = resolveAttachments(row)
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
@@ -348,6 +589,7 @@
async function handleSubmit() {
await formRef.value?.validate?.()
syncSopAttachmentsFromList()
submitLoading.value = true
try {
const payload = {
@@ -378,10 +620,16 @@
}
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该SOP文档吗?', '提示', { type: 'warning' })
await fetchDeleteSop(row.id)
ElMessage.success('删除成功')
await loadData()
try {
await ElMessageBox.confirm('确定要删除该SOP文档吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteSop(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
async function handleBatchDelete() {
@@ -410,83 +658,208 @@
}
}
.upload-card-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
.attachment-upload {
width: 100%;
}
.upload-card-item {
width: 120px;
height: 96px;
border: 1px solid #d1d5db;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
padding: 8px;
background: #f9fafb;
}
.upload-card-icon {
font-size: 24px;
color: #6b7280;
margin-bottom: 4px;
}
.upload-card-name {
font-size: 12px;
color: #374151;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.upload-card-remove {
position: absolute;
top: 4px;
right: 4px;
font-size: 14px;
color: #ef4444;
cursor: pointer;
}
.upload-card-trigger {
width: 120px;
.upload-card {
width: 140px;
height: 96px;
border: 1px dashed #a7f3d0;
border-radius: 6px;
border-radius: 8px;
background: #f0fdf4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
color: #6b7280;
transition: border-color 0.2s;
transition:
border-color 0.15s,
background 0.15s;
&:hover {
border-color: #10b981;
color: #10b981;
}
.el-icon {
font-size: 20px;
margin-bottom: 4px;
}
span {
font-size: 12px;
border-style: solid;
background: #ecfdf5;
}
}
.upload-card-hint {
.upload-card-text {
font-size: 12px;
color: #9ca3af;
color: #10b981;
font-weight: 500;
line-height: 1;
}
.upload-hint {
display: block;
font-size: 11px;
color: #94a3b8;
line-height: 1;
margin-top: 8px;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.attachment-item {
width: 140px;
display: flex;
flex-direction: column;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #10b981;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
}
&.is-uploading {
opacity: 0.6;
pointer-events: none;
}
}
.attachment-preview {
width: 100%;
height: 96px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
overflow: hidden;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.attachment-preview__icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
&--video {
background: #ede9fe;
color: #7c3aed;
}
&--doc {
background: #e0f2fe;
color: #0284c7;
}
}
.attachment-footer {
display: flex;
flex-direction: column;
flex: 1;
padding: 6px 8px;
}
.attachment-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
color: #334155;
line-height: 1.4;
word-break: break-all;
min-height: calc(11px * 1.4 * 2);
}
.attachment-actions {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 4px;
flex-shrink: 0;
transition: opacity 0.15s;
}
.action-btn {
width: 22px;
height: 22px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #64748b;
cursor: pointer;
background: #f1f5f9;
transition:
background 0.15s,
color 0.15s;
&:hover {
background: #10b981;
color: #fff;
}
&--danger:hover {
background: #ef4444;
color: #fff;
}
}
.preview-container {
text-align: center;
}
.sop-view-attachment-section {
padding: 18px 22px;
border-bottom: none;
h3 {
margin: 0 0 14px;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
border-left: 3px solid #10b981;
line-height: 1;
}
.attachment-list {
margin-top: 4px;
}
}
.sop-view-attachment-empty {
margin: 0;
font-size: 13px;
color: #94a3b8;
}
.attachment-item--view {
.attachment-preview {
cursor: default;
}
.attachment-preview:has(img),
.attachment-preview:has(.attachment-preview__icon--video) {
cursor: pointer;
}
}
</style>
@@ -148,7 +148,21 @@
:icon="Tickets"
:meta="viewDetail?.taskNo || ''"
:sections="viewSections"
/>
>
<template #append>
<div
v-if="viewDetail?.attachmentFiles?.length"
class="view-section qc-task-view-attachments"
>
<h3>检测材料</h3>
<ProtocolAttachmentReadonly
:files="viewDetail.attachmentFiles"
@preview="handleViewAttachmentPreview"
@download="handleViewAttachmentDownload"
/>
</div>
</template>
</SystemViewDialog>
</section>
</div>
</template>
@@ -158,6 +172,7 @@
import { ElMessage } from 'element-plus'
import { Close, Check, Search, RefreshLeft, VideoPlay, Upload, View, Tickets, EditPen } from '@element-plus/icons-vue'
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import {
@@ -175,6 +190,7 @@
} from '@/api/qc-task'
import { fetchQcTaskResults, fetchCompleteTaskWithResult, type QcTaskResultItem } from '@/api/qc-task-result'
import { taskTypeLabel, taskStatusLabel } from '@/utils/qc-labels'
import { downloadAttachmentFile } from '@/utils/download-attachment'
defineOptions({ name: 'QualityInspectionQcTask' })
@@ -199,6 +215,35 @@
const viewVisible = ref(false)
const viewDetail = ref<QcTaskDetail | null>(null)
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function apiFilesToFinanceItems(
files?: Array<{ id?: string; fileUrl: string; fileName?: string; fileType?: string }>
): FinanceAttachmentItem[] {
if (!files?.length) return []
return files.map((f, i) => ({
id: f.id || `att_${i}`,
fileName: f.fileName || '',
fileUrl: f.fileUrl || '',
fileType: f.fileType || ''
}))
}
function attachmentFullUrl(url: string) {
const u = String(url || '').trim()
if (!u) return ''
if (/^https?:\/\//i.test(u)) return u
return minioBaseUrl ? `${minioBaseUrl}/${u.replace(/^\//, '')}` : u
}
function handleViewAttachmentPreview(file: Record<string, unknown>) {
const url = attachmentFullUrl(String(file.fileUrl || ''))
if (url) window.open(url, '_blank')
}
function handleViewAttachmentDownload(file: Record<string, unknown>) {
downloadAttachmentFile(file, { fullFileUrl: attachmentFullUrl })
}
const viewSections = computed<ViewSection[]>(() => {
const d = viewDetail.value
@@ -272,11 +317,19 @@
loadData()
}
function openAttach(row: QcTask) {
async function openAttach(row: QcTask) {
attachTaskId.value = row.id
attachRemark.value = ''
attachments.value = []
attachRemark.value = row.remark || ''
attachments.value = apiFilesToFinanceItems(row.attachmentFiles)
attachVisible.value = true
if (attachments.value.length) return
try {
const detail = await fetchQcTaskDetail(row.id)
attachRemark.value = detail.remark || ''
attachments.value = apiFilesToFinanceItems(detail.attachmentFiles)
} catch {
/* 列表未带附件时拉详情 */
}
}
async function saveAttachments() {
@@ -313,10 +366,15 @@
async function openResult(row: QcTask) {
resultTaskId.value = row.id
resultTaskType.value = row.taskType
resultRemark.value = ''
resultAttachments.value = []
resultRemark.value = row.remark || ''
resultAttachments.value = apiFilesToFinanceItems(row.attachmentFiles)
resultLoading.value = true
try {
if (row.taskType === 'dsfjc' && !resultAttachments.value.length) {
const detail = await fetchQcTaskDetail(row.id)
resultRemark.value = detail.remark || ''
resultAttachments.value = apiFilesToFinanceItems(detail.attachmentFiles)
}
const results = await fetchQcTaskResults(row.id)
if (results && results.length > 0) {
resultParams.value = results
@@ -379,4 +437,19 @@
margin-bottom: 0;
}
}
.qc-task-view-attachments {
padding: 18px 22px;
border-bottom: none;
h3 {
margin: 0 0 14px;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
border-left: 3px solid #10b981;
line-height: 1;
}
}
</style>
@@ -493,6 +493,7 @@
type FinanceAttachmentItem
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
import { useUserStore } from '@/store/modules/user'
import { downloadAttachmentFile } from '@/utils/download-attachment'
defineOptions({ name: 'QualityInspectionSampleReception' })
@@ -620,14 +621,7 @@
}
function handleAttachmentDownload(file: Record<string, unknown>) {
const url = String(file.fileUrl || '')
if (!url) return
const a = document.createElement('a')
a.href = fullFileUrl(url)
a.download = String(file.fileName || 'download')
a.target = '_blank'
a.rel = 'noopener'
a.click()
downloadAttachmentFile(file, { fullFileUrl })
}
function getCurrentUserHandler() {
@@ -1,4 +1,4 @@
<!-- 捐赠协议单审核 -->
<!-- 捐赠协议审核 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
@@ -9,24 +9,27 @@
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">捐赠协议审核</h2>
<h2 class="title-bar-heading">捐赠协议单列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="捐赠编号/母亲姓名" clearable />
<el-input v-model="searchForm.keyword" placeholder="捐赠编号/备注" clearable />
</el-form-item>
<el-form-item label="审核状态">
<el-form-item label="协议状态">
<el-select
v-model="searchForm.auditStatus"
v-model="searchForm.donationStatus"
clearable
placeholder="全部"
style="width: 100%"
>
<el-option label="审核通过" value="shtg" />
<el-option label="审核驳回" value="shth" />
<el-option label="待生效" value="dsx" />
<el-option label="生效中" value="sxz" />
<el-option label="已过期" value="ygq" />
<el-option label="已作废" value="yzf" />
<el-option label="转细胞存储" value="zckc" />
</el-select>
</el-form-item>
</div>
@@ -49,19 +52,45 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchAudit('shtg')">
<el-button type="primary" class="btn-main" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="openAuditDialog()">
<el-icon><CircleCheck /></el-icon>
批量审核通过
审核
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchAudit('shth')">
<el-icon><CircleClose /></el-icon>
批量审核驳回
<el-button
class="btn-ghost"
:disabled="!selectedRows.length"
@click="openStatusChangeDialog('activate')"
>
协议生效
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchStatusChange('activate')">
批量生效
<el-button
class="btn-ghost"
:disabled="!selectedRows.length"
@click="openStatusChangeDialog('expire')"
>
协议过期
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchStatusChange('void')">
批量作废
<el-button
class="btn-ghost"
:disabled="!selectedRows.length"
@click="openStatusChangeDialog('void')"
>
协议作废
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedRows.length"
@click="openStatusChangeDialog('toStorage')"
>
转细胞存储
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
@@ -92,6 +121,24 @@
align="center"
header-align="center"
/>
<el-table-column
prop="donationStatus"
label="状态"
width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{
donationStatusLabel(String(row.donationStatus || ''))
}}</template>
</el-table-column>
<el-table-column
prop="donationDate"
label="捐赠日期"
width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="motherName"
label="母亲姓名"
@@ -100,39 +147,100 @@
header-align="center"
/>
<el-table-column
prop="auditStatus"
label="审核状态"
width="120"
prop="motherPhone"
label="母亲电话"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column
prop="motherIdCard"
label="母亲身份证号"
min-width="190"
align="center"
header-align="center"
/>
<el-table-column
prop="fatherName"
label="父亲姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="newbornSex"
label="新生儿性别"
width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="row.auditStatus === 'shtg' ? 'success' : row.auditStatus === 'shth' ? 'danger' : 'info'"
size="small"
>
{{ auditStatusLabel(String(row.auditStatus || '')) }}
</el-tag>
</template>
<template #default="{ row }">{{
newbornSexLabel(String(row.newbornSex || ''))
}}</template>
</el-table-column>
<el-table-column
prop="auditRemark"
label="审核备注"
prop="deliveryMode"
label="分娩方式"
width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{
deliveryModeLabel(String(row.deliveryMode || ''))
}}</template>
</el-table-column>
<el-table-column
prop="newbornWeightGram"
label="新生儿体重(克)"
width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="regionName"
label="采集地区"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="hospitalName"
label="采集医院"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="collectorUserName"
label="采集专员"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="salesUserName"
label="销售人员"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="sampleCollectTime"
label="样本采集时间"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="auditTime"
label="审核时间"
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="260"
min-width="380"
fixed="right"
align="center"
header-align="center"
@@ -144,6 +252,12 @@
<el-button class="btn-op-item" @click="openAuditDialog(row)"
><el-icon><CircleCheck /></el-icon>审核</el-button
>
<el-button class="btn-op-item primary" @click="handleEdit(row)"
><el-icon><Edit /></el-icon>编辑</el-button
>
<el-button class="btn-op-item" @click="handleDelete(row)"
><el-icon><Delete /></el-icon>删除</el-button
>
</template>
</el-table-column>
</el-table>
@@ -160,6 +274,29 @@
</div>
</div>
<!-- 新增/编辑弹窗 -->
<DonationFormDialog
v-model="formDialogVisible"
:edit-id="formDialogEditId"
:list-row="formDialogRow"
@saved="onFormSaved"
/>
<!-- 审核弹窗 -->
<DonationAuditDialog
v-model="auditDialogVisible"
:targets="auditTargets"
@submitted="onAuditSubmitted"
/>
<!-- 状态变更弹窗(生效/过期/作废/转细胞存储) -->
<DonationStatusChangeDialog
v-model="statusChangeDialogVisible"
:action-type="statusChangeAction"
:targets="statusChangeTargets"
@submitted="onStatusChangeSubmitted"
/>
<!-- 查看弹窗 -->
<DonationViewDialog
v-model="viewVisible"
@@ -167,14 +304,6 @@
:list-row="viewRow"
show-audit-history
/>
<!-- 审核弹窗 -->
<DonationAuditDialog
v-model="auditDialogVisible"
:donation-id="auditDonationId"
:list-row="auditRow"
@saved="onAuditSubmitted"
/>
</section>
</div>
</template>
@@ -182,55 +311,76 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
View,
Edit,
Delete,
Search,
RefreshLeft,
CircleCheck,
CircleClose
CircleCheck
} from '@element-plus/icons-vue'
import DonationFormDialog from '../list/DonationFormDialog.vue'
import DonationViewDialog from '../list/DonationViewDialog.vue'
import DonationAuditDialog from '../list/DonationAuditDialog.vue'
import DonationAuditDialog, { type DonationAuditTarget } from '../list/DonationAuditDialog.vue'
import DonationStatusChangeDialog, {
type DonationStatusChangeAction,
type DonationStatusChangeTarget
} from '../list/DonationStatusChangeDialog.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchResearchDonationAuditList,
fetchSubmitDonationAudit,
fetchDonationStatusChange,
fetchResearchDonationList,
fetchDeleteResearchDonation,
type ResearchDonationRow
} from '@/api/research-donation'
defineOptions({ name: 'ResearchDonationAudit' })
/* ====== 标签映射 ====== */
function auditStatusLabel(s: string) {
if (s === 'shtg') return '审核通过'
if (s === 'shth') return '审核驳回'
return s || '—'
function donationStatusLabel(s: string) {
const m: Record<string, string> = {
dsx: '待生效',
sxz: '生效中',
ygq: '已过期',
yzf: '已作废',
zckc: '转细胞存储'
}
return m[s] || s || '-'
}
function newbornSexLabel(s: string) {
if (s === '1') return '男'
if (s === '0') return '女'
return s || '-'
}
function deliveryModeLabel(s: string) {
if (s === 'sc') return '顺产'
if (s === 'pgc') return '剖宫产'
return s || '-'
}
/* ====== 列表 ====== */
type AuditRow = Record<string, unknown>
const searchForm = reactive<{ keyword: string; auditStatus: string }>({
const searchForm = reactive<{ keyword: string; donationStatus: string }>({
keyword: '',
auditStatus: ''
donationStatus: ''
})
const tableData = ref<AuditRow[]>([])
const selectedRows = ref<AuditRow[]>([])
const tableData = ref<ResearchDonationRow[]>([])
const selectedRows = ref<ResearchDonationRow[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const handleSelectionChange = (rows: AuditRow[]) => {
const handleSelectionChange = (rows: ResearchDonationRow[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchResearchDonationAuditList({
const res = await fetchResearchDonationList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
auditStatus: searchForm.auditStatus || undefined
donationStatus: searchForm.donationStatus || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
@@ -246,7 +396,7 @@
function handleReset() {
searchForm.keyword = ''
searchForm.auditStatus = ''
searchForm.donationStatus = ''
pagination.page = 1
loadData()
}
@@ -254,22 +404,49 @@
/* ====== 查看弹窗 ====== */
const viewVisible = ref(false)
const viewDonationId = ref<string | null>(null)
const viewRow = ref<AuditRow | null>(null)
const viewRow = ref<ResearchDonationRow | null>(null)
function handleView(row: AuditRow) {
function handleView(row: ResearchDonationRow) {
viewDonationId.value = String(row.id)
viewRow.value = row
viewVisible.value = true
}
/* ====== 新增/编辑弹窗 ====== */
const formDialogVisible = ref(false)
const formDialogEditId = ref<string | null>(null)
const formDialogRow = ref<ResearchDonationRow | null>(null)
function handleAdd() {
formDialogEditId.value = null
formDialogRow.value = null
formDialogVisible.value = true
}
function handleEdit(row: ResearchDonationRow) {
formDialogEditId.value = row.id
formDialogRow.value = row
formDialogVisible.value = true
}
function onFormSaved() {
loadData()
}
/* ====== 审核 ====== */
const auditDialogVisible = ref(false)
const auditDonationId = ref<string | null>(null)
const auditRow = ref<AuditRow | null>(null)
const auditTargets = ref<DonationAuditTarget[]>([])
function openAuditDialog(row: AuditRow) {
auditDonationId.value = String(row.id)
auditRow.value = row
function openAuditDialog(row?: ResearchDonationRow) {
const rows = row ? [row] : selectedRows.value
if (!rows.length) {
ElMessage.warning('请先选择要审核的协议单')
return
}
auditTargets.value = rows.map((r) => ({
id: String(r.id),
donationNo: r.donationNo != null ? String(r.donationNo) : undefined
}))
auditDialogVisible.value = true
}
@@ -278,59 +455,60 @@
loadData()
}
/* ====== 批量审核 ====== */
async function handleBatchAudit(status: string) {
if (!selectedRows.value.length) {
ElMessage.warning('请先选择要操作的记录')
/* ====== 状态变更(生效/过期/作废/转细胞存储) ====== */
const statusChangeDialogVisible = ref(false)
const statusChangeAction = ref<DonationStatusChangeAction>('activate')
const statusChangeTargets = ref<DonationStatusChangeTarget[]>([])
function openStatusChangeDialog(action: DonationStatusChangeAction, row?: ResearchDonationRow) {
const rows = row ? [row] : selectedRows.value
if (!rows.length) {
ElMessage.warning(`请先选择要操作的协议单`)
return
}
const label = status === 'shtg' ? '审核通过' : '审核驳回'
await ElMessageBox.confirm(
`确认将选中的 ${selectedRows.value.length} 条记录标记为「${label}」?`,
'批量审核',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const rows = [...selectedRows.value]
await withFullscreenLoading(async () => {
for (const row of rows) {
await fetchSubmitDonationAudit(String(row.id), { auditStatus: status })
}
ElMessage.success(`批量${label}成功`)
selectedRows.value = []
loadData()
}, fullscreenLoadingText.batchAudit)
statusChangeAction.value = action
statusChangeTargets.value = rows.map((r) => ({
id: String(r.id),
donationNo: r.donationNo != null ? String(r.donationNo) : undefined
}))
statusChangeDialogVisible.value = true
}
/* ====== 批量状态变更 ====== */
async function handleBatchStatusChange(action: 'activate' | 'void') {
if (!selectedRows.value.length) {
ElMessage.warning('请先选择要操作的记录')
return
}
const label = action === 'activate' ? '生效' : '作废'
const targetStatus = action === 'activate' ? 'sxz' : 'yzf'
await ElMessageBox.confirm(
`确认将选中的 ${selectedRows.value.length} 条记录标记为「${label}」?`,
`批量${label}`,
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
function onStatusChangeSubmitted() {
selectedRows.value = []
loadData()
}
/* ====== 删除 ====== */
function handleDelete(row: ResearchDonationRow) {
ElMessageBox.confirm(`确认删除捐赠编号「${row.donationNo}」?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await fetchDeleteResearchDonation(String(row.id))
loadData()
})
.catch(() => {})
}
async function handleBatchDelete() {
if (!selectedRows.value.length) return
await ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录?`, '批量删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const rows = [...selectedRows.value]
await withFullscreenLoading(async () => {
for (const row of rows) {
await fetchDonationStatusChange(String(row.id), { donationStatus: targetStatus })
await fetchDeleteResearchDonation(String(row.id))
}
ElMessage.success(`批量${label}成功`)
ElMessage.success('批量删除成功')
selectedRows.value = []
loadData()
}, fullscreenLoadingText.save)
}, fullscreenLoadingText.batchDelete)
}
onMounted(() => {
@@ -1,4 +1,4 @@
<!-- 捐赠协议单 — 审核弹窗 -->
<!-- 捐赠协议单 — 提交审核(支持批量,写入 biz_donation_audit) -->
<template>
<el-dialog
:model-value="modelValue"
@@ -8,12 +8,15 @@
align-center
destroy-on-close
:close-on-click-modal="false"
style="backdrop-filter: none"
@update:model-value="emit('update:modelValue', $event)"
@closed="resetForm"
>
<p class="audit-target-hint">
捐赠编号:<strong>{{ listRow?.donationNo || '—' }}</strong>
<p v-if="targets.length > 1" class="audit-target-hint">
将对已选
<strong>{{ targets.length }}</strong> 条捐赠协议单提交相同审核结果,每条各记一条审核记录。
</p>
<p v-else-if="targets.length === 1" class="audit-target-hint">
捐赠编号:<strong>{{ targets[0].donationNo || '—' }}</strong>
</p>
<el-form
@@ -27,7 +30,7 @@
<el-form-item label="审核状态" prop="auditStatus">
<el-radio-group v-model="form.auditStatus">
<el-radio value="shtg">审核通过</el-radio>
<el-radio value="shth">审核驳回</el-radio>
<el-radio value="shth">审核退回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审核备注">
@@ -57,21 +60,26 @@
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import { Check, Close } from '@element-plus/icons-vue'
import { fetchSubmitDonationAudit } from '@/api/research-donation'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
export type DonationAuditTarget = {
id: string
donationNo?: string
}
defineOptions({ name: 'DonationAuditDialog' })
const props = defineProps<{
modelValue: boolean
donationId: string | null
listRow?: Record<string, unknown> | null
targets: DonationAuditTarget[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
(e: 'submitted'): void
}>()
const formRef = ref<FormInstance>()
@@ -85,7 +93,9 @@
auditStatus: [{ required: true, message: '请选择审核状态', trigger: 'change' }]
}
const dialogTitle = computed(() => '捐赠协议单审核')
const dialogTitle = computed(() =>
props.targets.length > 1 ? `批量审核(${props.targets.length} 条)` : '捐赠协议单审核'
)
function resetForm() {
form.auditStatus = 'shtg'
@@ -95,22 +105,44 @@
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || !props.donationId) return
if (!valid || !props.targets.length) return
const payload = {
auditStatus: form.auditStatus,
auditRemark: form.auditRemark.trim() || undefined
}
const rows = [...props.targets]
const isBatch = rows.length > 1
submitting.value = true
try {
await withFullscreenLoading(
async () => {
await fetchSubmitDonationAudit(props.donationId!, payload)
emit('update:modelValue', false)
emit('saved')
let ok = 0
const failed: string[] = []
for (const row of rows) {
try {
await fetchSubmitDonationAudit(String(row.id), payload, {
showSuccessMessage: !isBatch
})
ok += 1
} catch {
failed.push(row.donationNo || String(row.id))
}
}
if (isBatch) {
if (failed.length) {
ElMessage.warning(`成功 ${ok} 条,失败 ${failed.length} 条`)
} else {
ElMessage.success(`批量审核成功,共 ${ok} 条`)
}
}
if (ok > 0) {
emit('update:modelValue', false)
emit('submitted')
}
},
fullscreenLoadingText.save
isBatch ? fullscreenLoadingText.batchAudit : fullscreenLoadingText.save
)
} finally {
submitting.value = false
File diff suppressed because it is too large. Load diff
@@ -8,12 +8,16 @@
align-center
destroy-on-close
:close-on-click-modal="false"
style="backdrop-filter: none"
@update:model-value="emit('update:modelValue', $event)"
@closed="resetForm"
>
<p class="status-change-hint">
捐赠编号:<strong>{{ listRow?.donationNo || '—' }}</strong>
<p v-if="targets.length > 1" class="status-change-hint">
将对已选
<strong>{{ targets.length }}</strong> 条捐赠协议单执行<strong>{{ actionLabel }}</strong
>操作,每条各记一次变更。
</p>
<p v-else-if="targets.length === 1" class="status-change-hint">
捐赠编号:<strong>{{ targets[0].donationNo || '—' }}</strong>
</p>
<el-form
@@ -24,27 +28,6 @@
class="biz-dialog-form"
@submit.prevent
>
<el-form-item label="变更操作" prop="operationType">
<el-radio-group v-model="form.operationType" @change="handleOperationChange">
<el-radio
v-for="op in availableOperations"
:key="op.value"
:value="op.value"
>
{{ op.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-alert
v-if="form.operationType === 'zckc'"
type="warning"
:closable="false"
class="convert-alert"
>
转为细胞存储后将创建新的细胞存储协议,需补填缴费信息。
</el-alert>
<el-form-item :label="timeLabel" prop="changeTime">
<el-date-picker
v-model="form.changeTime"
@@ -54,7 +37,7 @@
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item label="描述">
<el-form-item :label="descLabel">
<el-input
v-model="form.changeDesc"
type="textarea"
@@ -81,102 +64,97 @@
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Close } from '@element-plus/icons-vue'
import { fetchDonationStatusChange } from '@/api/research-donation'
import { fetchDonationStatusChange, fetchConvertToStorage } from '@/api/research-donation'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
type OperationType = 'activate' | 'expire' | 'void' | 'zckc'
export type DonationStatusChangeAction = 'activate' | 'expire' | 'void' | 'toStorage'
export type DonationStatusChangeTarget = {
id: string
donationNo?: string
}
defineOptions({ name: 'DonationStatusChangeDialog' })
const props = defineProps<{
modelValue: boolean
donationId: string | null
listRow?: Record<string, unknown> | null
actionType: DonationStatusChangeAction
targets: DonationStatusChangeTarget[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
(e: 'submitted'): void
}>()
const OPERATION_CONFIG: Record<
OperationType,
const ACTION_CONFIG: Record<
DonationStatusChangeAction,
{
label: string
timeLabel: string
targetStatus: string
descLabel: string
status: string
timeKey: string
descKey: string
}
> = {
activate: {
label: '生效',
timeLabel: '生效时间',
targetStatus: 'sxz'
label: '协议生效',
timeLabel: '协议生效时间',
descLabel: '协议生效描述',
status: 'sxz',
timeKey: 'effectiveTime',
descKey: 'effectiveDesc'
},
expire: {
label: '过期',
timeLabel: '过期时间',
targetStatus: 'ygq'
label: '协议过期',
timeLabel: '协议过期时间',
descLabel: '协议过期描述',
status: 'ygq',
timeKey: 'expireTime',
descKey: 'expireDesc'
},
void: {
label: '作废',
timeLabel: '作废时间',
targetStatus: 'yzf'
label: '协议作废',
timeLabel: '协议作废时间',
descLabel: '协议作废描述',
status: 'yzf',
timeKey: 'voidTime',
descKey: 'voidDesc'
},
zckc: {
toStorage: {
label: '转细胞存储',
timeLabel: '转换时间',
targetStatus: 'zckc'
timeLabel: '转细胞存储时间',
descLabel: '转细胞存储描述',
status: 'zckc',
timeKey: 'convertTime',
descKey: 'convertDesc'
}
}
const currentStatus = computed(() => String(props.listRow?.donationStatus || ''))
const config = computed(() => ACTION_CONFIG[props.actionType])
const actionLabel = computed(() => config.value.label)
const timeLabel = computed(() => config.value.timeLabel)
const descLabel = computed(() => config.value.descLabel)
const availableOperations = computed(() => {
const ops: Array<{ value: OperationType; label: string }> = []
const s = currentStatus.value
if (s === 'dsx') {
ops.push({ value: 'activate', label: '生效' })
ops.push({ value: 'void', label: '作废' })
} else if (s === 'sxz') {
ops.push({ value: 'expire', label: '过期' })
ops.push({ value: 'void', label: '作废' })
ops.push({ value: 'zckc', label: '转细胞存储' })
}
return ops
const dialogTitle = computed(() => {
const label = config.value.label
return props.targets.length > 1 ? `批量${label}(${props.targets.length} 条)` : label
})
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
operationType: '' as OperationType | '',
changeTime: '',
changeDesc: ''
})
const rules: FormRules = {
operationType: [{ required: true, message: '请选择变更操作', trigger: 'change' }],
changeTime: [{ required: true, message: '请选择时间', trigger: 'change' }]
}
const config = computed(() => {
if (!form.operationType) return null
return OPERATION_CONFIG[form.operationType]
})
const timeLabel = computed(() => config.value?.timeLabel || '时间')
const dialogTitle = computed(() => '捐赠协议单 — 状态变更')
function handleOperationChange() {
form.changeTime = ''
form.changeDesc = ''
}
function resetForm() {
form.operationType = ''
form.changeTime = ''
form.changeDesc = ''
formRef.value?.clearValidate()
@@ -184,12 +162,60 @@
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || !props.donationId || !form.operationType) return
if (!valid || !props.targets.length) return
const cfg = config.value
const rows = [...props.targets]
const isBatch = rows.length > 1
// 转细胞存储:确认 + 调用专用接口
if (props.actionType === 'toStorage') {
try {
await ElMessageBox.confirm(
'确定将该捐赠协议转为细胞存储吗?将创建新的细胞存储协议,初始协议号与样本编码与源单一致。',
'转细胞存储确认',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
submitting.value = true
try {
await withFullscreenLoading(
async () => {
let ok = 0
const failed: string[] = []
for (const row of rows) {
try {
await fetchConvertToStorage(String(row.id))
ok += 1
} catch {
failed.push(row.donationNo || String(row.id))
}
}
if (isBatch) {
if (failed.length) {
ElMessage.warning(`${cfg.label}成功 ${ok} 条,失败 ${failed.length} 条`)
} else {
ElMessage.success(`批量${cfg.label}成功,共 ${ok} 条`)
}
}
if (ok > 0) {
emit('update:modelValue', false)
emit('submitted')
}
},
isBatch ? fullscreenLoadingText.batchAudit : fullscreenLoadingText.save
)
} finally {
submitting.value = false
}
return
}
const cfg = OPERATION_CONFIG[form.operationType]
const payload = {
donationStatus: cfg.targetStatus,
time: form.changeTime,
newStatus: cfg.status,
timeVal: form.changeTime,
desc: form.changeDesc.trim() || undefined
}
@@ -197,11 +223,29 @@
try {
await withFullscreenLoading(
async () => {
await fetchDonationStatusChange(props.donationId!, payload)
emit('update:modelValue', false)
emit('saved')
let ok = 0
const failed: string[] = []
for (const row of rows) {
try {
await fetchDonationStatusChange(String(row.id), payload)
ok += 1
} catch {
failed.push(row.donationNo || String(row.id))
}
}
if (isBatch) {
if (failed.length) {
ElMessage.warning(`${cfg.label}成功 ${ok} 条,失败 ${failed.length} 条`)
} else {
ElMessage.success(`批量${cfg.label}成功,共 ${ok} 条`)
}
}
if (ok > 0) {
emit('update:modelValue', false)
emit('submitted')
}
},
fullscreenLoadingText.save
isBatch ? fullscreenLoadingText.batchAudit : fullscreenLoadingText.save
)
} finally {
submitting.value = false
@@ -221,8 +265,4 @@
font-weight: 600;
}
}
.convert-alert {
margin-bottom: 16px;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -18,7 +18,7 @@
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="捐赠编号/备注" clearable />
</el-form-item>
<el-form-item label="状态">
<el-form-item label="协议状态">
<el-select
v-model="searchForm.donationStatus"
clearable
@@ -89,6 +89,17 @@
align="center"
header-align="center"
/>
<el-table-column
prop="donationStatus"
label="状态"
width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{
donationStatusLabel(String(row.donationStatus || ''))
}}</template>
</el-table-column>
<el-table-column
prop="donationDate"
label="捐赠日期"
@@ -104,29 +115,90 @@
header-align="center"
/>
<el-table-column
prop="donationStatus"
label="状态"
width="120"
prop="motherPhone"
label="母亲电话"
min-width="130"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="statusTagType(String(row.donationStatus || ''))" disable-transitions>
{{ donationStatusLabel(String(row.donationStatus || '')) }}
</el-tag>
</template>
</el-table-column>
/>
<el-table-column
prop="sourceType"
label="来源类型"
width="120"
prop="motherIdCard"
label="母亲身份证号"
min-width="190"
align="center"
header-align="center"
/>
<el-table-column
prop="fatherName"
label="父亲姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="newbornSex"
label="新生儿性别"
width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{
sourceTypeLabel(String(row.sourceType || ''))
newbornSexLabel(String(row.newbornSex || ''))
}}</template>
</el-table-column>
<el-table-column
prop="deliveryMode"
label="分娩方式"
width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{
deliveryModeLabel(String(row.deliveryMode || ''))
}}</template>
</el-table-column>
<el-table-column
prop="newbornWeightGram"
label="新生儿体重(克)"
width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="regionName"
label="采集地区"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="hospitalName"
label="采集医院"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="collectorUserName"
label="采集专员"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="salesUserName"
label="销售人员"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="sampleCollectTime"
label="样本采集时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
@@ -148,12 +220,6 @@
<el-button class="btn-op-item primary" @click="handleEdit(row)"
><el-icon><Edit /></el-icon>编辑</el-button
>
<el-button class="btn-op-item" @click="handleAudit(row)"
><el-icon><CircleCheck /></el-icon>审核</el-button
>
<el-button class="btn-op-item" @click="handleStatusChange(row)"
><el-icon><Switch /></el-icon>状态变更</el-button
>
<el-button class="btn-op-item" @click="handleDelete(row)"
><el-icon><Delete /></el-icon>删除</el-button
>
@@ -188,42 +254,15 @@
:list-row="viewRow"
show-audit-history
/>
<!-- 审核弹窗 -->
<DonationAuditDialog
v-model="auditVisible"
:donation-id="auditDonationId"
:list-row="auditRow"
@saved="onAuditSaved"
/>
<!-- 状态变更弹窗 -->
<DonationStatusChangeDialog
v-model="statusChangeVisible"
:donation-id="statusChangeDonationId"
:list-row="statusChangeRow"
@saved="onStatusChangeSaved"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
View,
Edit,
Delete,
Search,
RefreshLeft,
CircleCheck,
Switch
} from '@element-plus/icons-vue'
import { Plus, View, Edit, Delete, Search, RefreshLeft } from '@element-plus/icons-vue'
import DonationFormDialog from './DonationFormDialog.vue'
import DonationViewDialog from './DonationViewDialog.vue'
import DonationAuditDialog from './DonationAuditDialog.vue'
import DonationStatusChangeDialog from './DonationStatusChangeDialog.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchResearchDonationList,
@@ -245,20 +284,15 @@
return m[s] || s || '-'
}
function statusTagType(s: string) {
const m: Record<string, string> = {
dsx: 'info',
sxz: 'success',
ygq: 'warning',
yzf: 'danger',
zckc: 'primary'
}
return m[s] || 'info'
function newbornSexLabel(s: string) {
if (s === '1') return '男'
if (s === '0') return '女'
return s || '-'
}
function sourceTypeLabel(s: string) {
if (s === 'direct') return '独立新建'
if (s === 'from_storage') return '从存储转来'
function deliveryModeLabel(s: string) {
if (s === 'sc') return '顺产'
if (s === 'pgc') return '剖宫产'
return s || '-'
}
@@ -327,7 +361,7 @@
}
function handleEdit(row: ResearchDonationRow) {
formDialogEditId.value = row.id as string
formDialogEditId.value = row.id
formDialogRow.value = row
formDialogVisible.value = true
}
@@ -336,36 +370,6 @@
loadData()
}
/* ====== 审核弹窗 ====== */
const auditVisible = ref(false)
const auditDonationId = ref<string | null>(null)
const auditRow = ref<ResearchDonationRow | null>(null)
function handleAudit(row: ResearchDonationRow) {
auditDonationId.value = String(row.id)
auditRow.value = row
auditVisible.value = true
}
function onAuditSaved() {
loadData()
}
/* ====== 状态变更弹窗 ====== */
const statusChangeVisible = ref(false)
const statusChangeDonationId = ref<string | null>(null)
const statusChangeRow = ref<ResearchDonationRow | null>(null)
function handleStatusChange(row: ResearchDonationRow) {
statusChangeDonationId.value = String(row.id)
statusChangeRow.value = row
statusChangeVisible.value = true
}
function onStatusChangeSaved() {
loadData()
}
/* ====== 删除 ====== */
function handleDelete(row: ResearchDonationRow) {
ElMessageBox.confirm(`确认删除捐赠编号「${row.donationNo}」?`, '删除确认', {