提交“

This commit is contained in:
zlwu committed 2026-05-30 14:03:11 +08:00
1 parent 69ee0f505c
commit 9b88581450
614 files changed
+90318 -689022

No files matched your search

+24 -1738
View File
File diff suppressed because it is too large. Load diff
-238
View File
@@ -1,238 +0,0 @@
import request from '@/utils/http'
/**
* 订单与订单页依赖的少量接口(其余业务 API 已移除,需要时再加回)。
*/
export function fetchOrderList(params: {
page?: number
pageSize?: number
keyword?: string
status?: string
orderStatus?: string
deliveryStatus?: string
payStatus?: string
paymentMethod?: string
regionId?: string
clientId?: string
startDate?: string
endDate?: string
}) {
return request.get<{
list: Api.Order.OrderItem[]
total: number
page: number
pageSize: number
totalPages: number
}>({
url: '/web/admin/orders',
params
})
}
export function fetchOrderDetail(id: string) {
return request.get<Api.Order.OrderDetail>({
url: `/web/admin/orders/${id}`
})
}
export function fetchAssignDelivery(data: {
orderId: string
deliverId: string
deliveryDeadlineAt: string
dispatchRemark?: string
}) {
return request.post({
url: `/web/admin/orders/${data.orderId}/assign-deliverer`,
params: {
deliverId: data.deliverId,
deliveryDeadlineAt: data.deliveryDeadlineAt,
dispatchRemark: data.dispatchRemark || ''
}
})
}
export function fetchReviewCancel(data: { orderId: string; approved: boolean; note?: string }) {
return request.post({
url: `/web/admin/orders/${data.orderId}/review-cancel`,
params: { approved: data.approved, note: data.note }
})
}
export function fetchCancelOrder(data: {
orderId: string
reason: string
note?: string
evidenceImageUrls?: string[]
}) {
return request.post({
url: `/web/admin/orders/${data.orderId}/cancel`,
params: {
reason: data.reason,
note: data.note,
evidenceImageUrls: data.evidenceImageUrls || []
}
})
}
export function fetchOrderAdjusts(orderId: string) {
return request.get({
url: `/web/admin/orders/${orderId}/adjusts`
})
}
export function fetchAdjustOrder(data: {
orderId: string
adjustments?: Array<{
orderItemId: string
adjustType: 'reduce' | 'delete' | 'substitute' | 'increase'
newQuantity?: number
newProductId?: string
reason: string
}>
addressUpdate?: {
deliveryAddress?: string
contactPerson?: string
contactPhone?: string
reason?: string
}
}) {
return request.post({
url: `/web/admin/orders/${data.orderId}/adjust`,
params: {
adjustments: data.adjustments || [],
addressUpdate: data.addressUpdate || undefined
}
})
}
export function fetchOrderItems(orderId: string) {
return request.get({
url: `/web/admin/orders/${orderId}/items`
})
}
export function fetchPriceVersionDetail(id: string) {
return request.get({
url: `/web/admin/price-versions/${id}`
})
}
export function fetchUploadAttachment(data: FormData) {
return request.post<{
id: string
fileName: string
fileUrl: string
fileSize: number
fileSizeDisplay?: string
fileType?: string
}>({
url: '/web/admin/attachment/upload',
data
})
}
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',
data
})
}
export function fetchSystemConfig() {
return request.get({
url: '/web/admin/system-config/settings'
})
}
// ─── 临床应用缴费 ─────────────────────────────────────
export function fetchClinicalPayments(params: any) {
return request.get({
url: '/web/admin/clinical-payments',
params
})
}
export function fetchClinicalPaymentDetail(id: string) {
return request.get({
url: `/web/admin/clinical-payments/${id}`
})
}
export function submitClinicalReceipt(id: string, data?: Record<string, unknown>) {
return request.post({
url: `/web/admin/clinical-payments/${id}/submit-receipt`,
params: data
})
}
export function batchSubmitClinicalReceipt(ids: string[], data?: Record<string, unknown>) {
return request.post({
url: '/web/admin/clinical-payments/submit-receipt-batch',
params: { paymentIds: ids, ...data }
})
}
export function confirmClinicalReceipt(id: string, data: any) {
return request.post({
url: `/web/admin/clinical-payments/${id}/confirm-receipt`,
params: data
})
}
export function submitClinicalRefund(id: string, data: any) {
return request.post({
url: `/web/admin/clinical-payments/${id}/submit-refund`,
params: data
})
}
export function batchSubmitClinicalRefund(ids: string[], data: any) {
return request.post({
url: '/web/admin/clinical-payments/submit-refund-batch',
params: { ...data, paymentIds: ids }
})
}
export function confirmClinicalRefund(id: string, data: any) {
return request.post({
url: `/web/admin/clinical-payments/${id}/confirm-refund`,
params: data
})
}
export function fetchClinicalReceiptPending(params: any) {
return request.get({
url: '/web/admin/clinical-payments/receipt-pending',
params
})
}
export function fetchClinicalReceiptConfirmed(params: any) {
return request.get({
url: '/web/admin/clinical-payments/receipt-confirmed',
params
})
}
export function fetchClinicalRefundPending(params: any) {
return request.get({
url: '/web/admin/clinical-payments/refund-pending',
params
})
}
export function fetchClinicalRefundConfirmed(params: any) {
return request.get({
url: '/web/admin/clinical-payments/refund-confirmed',
params
})
}
-45
View File
@@ -1,45 +0,0 @@
import request from '@/utils/http'
export function fetchCellProductList(params?: {
page?: number
pageSize?: number
keyword?: string
productStatus?: string
passage?: string
sourceType?: string
}) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
totalPages: number
}>({ url: '/web/admin/cell-products', params })
}
export function fetchCellProductDetail(id: string) {
return request.get<any>({ url: `/web/admin/cell-products/${id}` })
}
export function fetchCreateCellProduct(data: Record<string, any>) {
return request.post({ url: '/web/admin/cell-products', params: data })
}
export function fetchUpdateCellProduct(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/cell-products/${id}`, params: data })
}
export function fetchDeleteCellProduct(id: string) {
return request.del({ url: `/web/admin/cell-products/${id}` })
}
export function fetchNextCellProductCode(count = 1) {
return request.get<{ code: string; codes?: string[] }>({
url: '/web/admin/cell-products/next-code',
params: count > 1 ? { count } : undefined
})
}
export function fetchCellProductChain(productId: string) {
return request.get<any>({ url: `/web/admin/cell-products/${productId}/chain` })
}
-15
View File
@@ -1,15 +0,0 @@
import request from '@/utils/http'
/** 与小程序端一致:value=区划代码,label=名称,children=下级 */
export interface ChinaRegionTreeNode {
value: string
label: string
children?: ChinaRegionTreeNode[]
}
/** 运营端:数据源 bas_china_region */
export function fetchChinaRegionTree() {
return request.get<{ tree: ChinaRegionTreeNode[] }>({
url: '/web/admin/china-region-tree'
})
}
-222
View File
@@ -1,222 +0,0 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
/** 采集协议单列表项(camelCase,与后端一致) */
export type CollectionProtocolRow = Record<string, unknown>
export function fetchCollectionProtocolList(params?: {
page?: number
pageSize?: number
keyword?: string
protocolStatus?: string
createTimeStart?: string
createTimeEnd?: string
sampleCollectTimeStart?: string
sampleCollectTimeEnd?: string
}) {
return request.get<PageList<CollectionProtocolRow>>({
url: '/web/admin/collection-protocols',
params
})
}
export function fetchCollectionProtocolDetail(id: string) {
return request.get<{
protocol: Record<string, unknown>
partyCustomer: Record<string, unknown> | null
collectionInfo: Record<string, unknown> | null
samples: Array<Record<string, unknown>> | null
payment: Record<string, unknown> | null
paymentDetails: Array<Record<string, unknown>> | null
attachments?: Array<Record<string, unknown>>
storageCert?: Record<string, unknown> | null
}>({
url: `/web/admin/collection-protocols/${id}`
})
}
export function fetchCollectionProtocolAuditList(params?: {
page?: number
pageSize?: number
keyword?: string
auditStatus?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/collection-protocol-audits',
params
})
}
export function fetchSubmitProtocolAudit(
protocolId: string,
data: { auditStatus: string; auditRemark?: string },
options?: { showSuccessMessage?: boolean }
) {
return request.post({
url: `/web/admin/collection-protocols/${protocolId}/audit`,
data,
showSuccessMessage: options?.showSuccessMessage ?? true
})
}
export function fetchReceiptConfirmList(params?: {
page?: number
pageSize?: number
keyword?: string
installmentDueDateStart?: string
installmentDueDateEnd?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/protocol-payment-details/receipt-confirm',
params
})
}
export function fetchReceiptConfirmedList(params?: {
page?: number
pageSize?: number
keyword?: string
installmentDueDateStart?: string
installmentDueDateEnd?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/protocol-payment-details/receipt-confirmed',
params
})
}
export function fetchReceiptConfirmSubmit(
detailId: string,
data?: { financeConfirmAttachments?: string; financeRemark?: string }
) {
return request.post({
url: `/web/admin/protocol-payment-details/${detailId}/receipt-confirm`,
data: data || {},
showSuccessMessage: true
})
}
export function fetchRefundConfirmList(params?: {
page?: number
pageSize?: number
keyword?: string
installmentDueDateStart?: string
installmentDueDateEnd?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/protocol-payment-details/refund-confirm',
params
})
}
export function fetchRefundConfirmedList(params?: {
page?: number
pageSize?: number
keyword?: string
installmentDueDateStart?: string
installmentDueDateEnd?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/protocol-payment-details/refund-confirmed',
params
})
}
export function fetchRefundConfirmSubmit(
detailId: string,
data?: { refundFinanceConfirmAttachments?: string; refundFinanceRemark?: string }
) {
return request.post({
url: `/web/admin/protocol-payment-details/${detailId}/refund-confirm`,
data: data || {},
showSuccessMessage: true
})
}
export function fetchSubmitReceiptFinance(detailId: string) {
return request.post({
url: `/web/admin/protocol-payment-details/${detailId}/submit-receipt-finance`,
showSuccessMessage: true
})
}
export function fetchSubmitReceiptFinanceBatch(detailIds: string[]) {
return request.post({
url: '/web/admin/protocol-payment-details/submit-receipt-finance-batch',
data: { detailIds },
showSuccessMessage: true
})
}
export function fetchSubmitRefundFinance(
detailId: string,
data: {
refundAmount: number
refundRemark?: string
refundAttachments?: string
}
) {
return request.post({
url: `/web/admin/protocol-payment-details/${detailId}/submit-refund-finance`,
data,
showSuccessMessage: true
})
}
export function fetchSubmitRefundFinanceBatch(data: {
detailIds: string[]
refundAmount: number
refundRemark?: string
refundAttachments?: string
}) {
return request.post({
url: '/web/admin/protocol-payment-details/submit-refund-finance-batch',
data,
showSuccessMessage: true
})
}
export function fetchPaymentDetailList(params?: {
page?: number
pageSize?: number
payStatus?: string
keyword?: string
transferType?: string
installmentDueDateStart?: string
installmentDueDateEnd?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/protocol-payment-details',
params
})
}
export function fetchCreateCollectionProtocol(data: Record<string, unknown>) {
return request.post({
url: '/web/admin/collection-protocols',
data,
showSuccessMessage: true
})
}
export function fetchUpdateCollectionProtocol(id: string, data: Record<string, unknown>) {
return request.put({
url: `/web/admin/collection-protocols/${id}`,
data,
showSuccessMessage: true
})
}
export function fetchDeleteCollectionProtocol(id: string) {
return request.del({
url: `/web/admin/collection-protocols/${id}`,
showSuccessMessage: true
})
}
-15
View File
@@ -1,15 +0,0 @@
import request from '@/utils/http'
export type EthnicOption = {
id: string
ethnicName: string
ethnicCode: string
orderNum?: number | null
}
export function fetchEthnicList(params?: { keyword?: string; limit?: number }) {
return request.get<{ list: EthnicOption[] }>({
url: '/web/admin/ethnics',
params
})
}
@@ -1,60 +0,0 @@
import request from '@/utils/http'
export type OutboundHandoverInfo = {
handoverUserId?: string
handoverPerson?: string
handoverTime?: string
handoverLocation?: string
receiverPerson?: string
remark?: string
}
export type OutboundHandoverDetail = {
scheduleSummary?: {
applicationNo?: string
patientName?: string
infusionTime?: string
}
outboundMaterial?: {
remark?: string
cells?: Array<{ cellProductId?: string; remark?: string }>
qcMaterials?: Array<{ qcMaterialId?: string; qty?: number; remark?: string }>
paperMaterials?: Array<{
paperName?: string
copies?: number
attachmentUrl?: string
remark?: string
}>
}
handover?: OutboundHandoverInfo
}
export function fetchInfusionScheduleOutboundHandover(scheduleId: string) {
return request.get<OutboundHandoverDetail>({
url: `/web/admin/pilot-infusion-schedule-mgmt/${scheduleId}/outbound-handover`
})
}
export function saveInfusionScheduleOutboundHandover(
scheduleId: string,
data: {
outboundMaterial: {
remark?: string
cells: { cellProductId: string; remark?: string }[]
qcMaterials: { materialId: string; qty: number; remark?: string }[]
paperMaterials: { paperName: string; copies: number; attachmentUrl: string; remark?: string }[]
}
handover: {
handoverUserId: string
handoverTime: string
handoverLocation?: string
receiverPerson: string
remark?: string
}
}
) {
return request.put<OutboundHandoverDetail>({
url: `/web/admin/pilot-infusion-schedule-mgmt/${scheduleId}/outbound-handover`,
data
})
}
-6
View File
@@ -1,6 +1,5 @@
import request from '@/utils/http'
/** 请假申请列表 */
export function fetchLeaveList(params?: {
page?: number
pageSize?: number
@@ -19,7 +18,6 @@ export function fetchLeaveList(params?: {
})
}
/** 创建请假申请 */
export function fetchCreateLeave(data: {
leaveType: string
startDate: string
@@ -35,7 +33,6 @@ export function fetchCreateLeave(data: {
})
}
/** 编辑请假申请 */
export function fetchUpdateLeave(
id: string,
data: Partial<{
@@ -54,7 +51,6 @@ export function fetchUpdateLeave(
})
}
/** 删除请假申请 */
export function fetchDeleteLeave(id: string) {
return request.del({
url: `/web/admin/leave/${id}`,
@@ -62,7 +58,6 @@ export function fetchDeleteLeave(id: string) {
})
}
/** 提交请假申请并发起审批 */
export function fetchSubmitLeave(id: string) {
return request.post<{ instanceId: string; status: string }>({
url: `/web/admin/leave/${id}/submit`,
@@ -70,7 +65,6 @@ export function fetchSubmitLeave(id: string) {
})
}
/** 请假详情 */
export function fetchLeaveDetail(id: string) {
return request.get<Api.Leave.LeaveItem>({
url: `/web/admin/leave/${id}`
-144
View File
@@ -1,144 +0,0 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type Material = {
id: string
materialCode: string
materialName: string
category: string
specModel: string
unit: string
stockQty: number
warningQty: number
status: string
imageUrls: string
tenantNo: string
orderNum: number
remark: string
createBy: string
createTime: string
updateTime: string
}
export type MaterialStockLog = {
id: string
materialId: string
materialCode: string
materialName: string
changeType: string
changeQty: number
stockBefore: number
stockAfter: number
receiver: string
purpose: string
remark: string
createBy: string
createByName?: string
createTime: string
updateTime: string
}
export function fetchMaterialList(params?: {
page?: number
pageSize?: number
keyword?: string
category?: string
status?: string
}) {
return request.get<PageList<Material>>({
url: '/web/admin/materials',
params
})
}
export function fetchMaterialOptions() {
return request.get<{ id: string; label: string; materialCode: string }[]>({
url: '/web/admin/materials/options'
})
}
export function fetchCreateMaterial(data: Record<string, any>) {
return request.post<Material>({
url: '/web/admin/materials',
data
})
}
export function fetchUpdateMaterial(id: string, data: Record<string, any>) {
return request.put<Material>({
url: `/web/admin/materials/${id}`,
data
})
}
export function fetchDeleteMaterial(id: string) {
return request.delete<void>({
url: `/web/admin/materials/${id}`
})
}
export function fetchStockInMaterial(
id: string,
data: { qty: number; remark?: string; stockInTime?: string }
) {
return request.post<Material>({
url: `/web/admin/materials/${id}/stock-in`,
data
})
}
export function fetchStockOutMaterial(
id: string,
data: { qty: number; receiver?: string; purpose?: string; remark?: string; stockOutTime?: string }
) {
return request.post<Material>({
url: `/web/admin/materials/${id}/stock-out`,
data
})
}
export function fetchBatchStockInMaterial(data: {
items: { materialId: string; qty: number; remark?: string }[]
remark?: string
stockInTime?: string
}) {
return request.post<{ count: number }>({
url: '/web/admin/materials/batch-stock-in',
data
})
}
export function fetchBatchStockOutMaterial(data: {
items: { materialId: string; qty: number; remark?: string }[]
receiver?: string
purpose?: string
remark?: string
stockOutTime?: string
}) {
return request.post<{ count: number }>({
url: '/web/admin/materials/batch-stock-out',
data
})
}
export function fetchMaterialStockLogList(params?: {
page?: number
pageSize?: number
materialId?: string
changeType?: string
startTime?: string
endTime?: string
keyword?: string
}) {
return request.get<PageList<MaterialStockLog>>({
url: '/web/admin/material-stock-logs',
params
})
}
@@ -1,242 +0,0 @@
import request from '@/utils/http'
import type {
OutboundQcDetailApplication,
OutboundQcDetailPatient,
OutboundQcDetailInfusionPlan,
OutboundQcDetailInfusionSchedule,
OutboundQcDetailProduct,
} from '@/api/qc-work-order'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type OutboundReleaseReview = {
id: string
scheduleId?: string
/** pilot_service=中试服务 clinical_application=临床应用 */
bizType?: string
bizTypeLabel?: string
applicationNo?: string
patientName?: string
cellType?: string
specText?: string
infusionTime?: string
scheduleStatus?: string
scheduleStatusLabel?: string
qcStatus?: string
qcStatusLabel?: string
reviewConclusion?: string
reviewConclusionLabel?: string
reviewRemark?: string
reviewAttachments?: string
reviewByName?: string
reviewTime?: string
createTime?: string
updateTime?: string
}
export type {
OutboundQcDetailApplication,
OutboundQcDetailPatient,
OutboundQcDetailInfusionPlan,
OutboundQcDetailInfusionSchedule,
OutboundQcDetailProduct,
}
export type OutboundReviewInfo = {
id?: string
scheduleId?: string
reviewConclusion?: string
reviewRemark?: string
reviewAttachments?: string
reviewByName?: string
reviewTime?: string
}
export type OutboundProductionTask = {
id?: string
taskNo?: string
taskName?: string
taskStatus?: string
taskStatusLabel?: string
conclusion?: string
conclusionLabel?: string
startedTime?: string
completedTime?: string
}
export type OutboundProductionInfo = {
workOrderNo?: string
workOrderStatus?: string
workOrderStatusLabel?: string
tasks?: OutboundProductionTask[]
}
export type OutboundQcTask = {
id?: string
taskType?: string
taskTypeLabel?: string
taskStatus?: string
taskStatusLabel?: string
conclusion?: string
conclusionLabel?: string
}
export type OutboundQcInfo = {
workOrderNo?: string
workOrderStatus?: string
workOrderStatusLabel?: string
tasks?: OutboundQcTask[]
}
export type OutboundReleaseReviewDetail = {
application?: OutboundQcDetailApplication
patient?: OutboundQcDetailPatient
infusionPlan?: OutboundQcDetailInfusionPlan
infusionSchedule?: OutboundQcDetailInfusionSchedule
products?: OutboundQcDetailProduct[]
productionInfo?: OutboundProductionInfo
qcInfo?: OutboundQcInfo
reviewInfo?: OutboundReviewInfo
outboundMaterial?: OutboundReleaseMaterialDetail
}
export type OutboundReleaseCellRow = {
id?: string
cellProductId?: string
productCode?: string
productName?: string
productTypeName?: string
specName?: string
passage?: string
batchNo?: string
storageSlotName?: string
productStatus?: string
remark?: string
}
export type OutboundReleaseQcMaterialRow = {
id?: string
qcMaterialId?: string
qty?: number
materialCode?: string
materialName?: string
specModel?: string
unit?: string
remark?: string
}
export type OutboundReleasePaperRow = {
id?: string
paperName?: string
copies?: number
attachmentUrl?: string
remark?: string
}
export type OutboundReleaseMaterialDetail = {
id?: string
infusionScheduleId?: string
paperAttachments?: string
paperMaterials?: OutboundReleasePaperRow[]
remark?: string
cells?: OutboundReleaseCellRow[]
qcMaterials?: OutboundReleaseQcMaterialRow[]
createTime?: string
updateTime?: string
}
export function fetchOutboundReleaseReviewList(params?: {
page?: number
pageSize?: number
scheduleStatus?: string
reviewConclusion?: string
qcStatus?: string
keyword?: string
/** pilot_service | clinical_application */
bizType?: string
infusionTimeStart?: string
infusionTimeEnd?: string
}) {
return request.get<PageList<OutboundReleaseReview>>({
url: '/web/admin/pilot-release-reviews',
params,
})
}
export function fetchOutboundReleaseReviewDetail(scheduleId: string) {
return request.get<OutboundReleaseReviewDetail>({
url: `/web/admin/pilot-release-reviews/${scheduleId}/detail`,
})
}
export function submitOutboundReleaseReview(
scheduleId: string,
data: {
reviewConclusion: string
reviewRemark?: string
reviewAttachments?: string
}
) {
return request.put<OutboundReleaseReview>({
url: `/web/admin/pilot-release-reviews/${scheduleId}/review`,
data,
})
}
export function batchReviewOutboundRelease(data: {
scheduleIds: string[]
reviewConclusion: string
reviewRemark?: string
reviewAttachments?: string
}) {
return request.put<void>({
url: '/web/admin/pilot-release-reviews/batch-review',
data,
})
}
export function fetchOutboundReleaseMaterial(scheduleId: string) {
return request.get<OutboundReleaseMaterialDetail>({
url: `/web/admin/pilot-release-reviews/${scheduleId}/outbound-materials`,
})
}
export function fetchReleaseReviewList(params: { scheduleId: string }) {
return request.get<any>({
url: `/web/admin/pilot-release-reviews/${params.scheduleId}/detail`,
})
}
export function updateReleaseReview(data: { id: string; reviewConclusion: string }) {
return request.put<any>({
url: `/web/admin/pilot-release-reviews/${data.id}/review`,
data: { reviewConclusion: data.reviewConclusion },
})
}
export function deleteReleaseReview(data: { id: string }) {
return request.delete<any>({
url: `/web/admin/pilot-release-reviews/${data.id}`,
})
}
export function saveOutboundReleaseMaterial(
scheduleId: string,
data: {
paperMaterials?: { paperName: string; copies: number; attachmentUrl: string; remark?: string }[]
remark?: string
cells: { cellProductId: string; remark?: string }[]
qcMaterials: { materialId: string; qty: number; remark?: string }[]
}
) {
return request.put<OutboundReleaseMaterialDetail>({
url: `/web/admin/pilot-release-reviews/${scheduleId}/outbound-materials`,
data,
})
}
-98
View File
@@ -1,98 +0,0 @@
import request from '@/utils/http'
export function fetchCreateProductionTask(data: Record<string, any>) {
return request.post({ url: '/web/admin/production-tasks', params: data })
}
export function fetchProductionTaskList(params?: {
page?: number
pageSize?: number
keyword?: string
taskStatus?: string
workOrderId?: string
startedTimeStart?: string
startedTimeEnd?: string
}) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
totalPages: number
}>({ url: '/web/admin/production-tasks', params })
}
export function fetchProductionTaskDetail(id: string) {
return request.get<any>({ url: `/web/admin/production-tasks/${id}` })
}
export function fetchUpdateProductionTask(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/production-tasks/${id}`, params: data })
}
export function fetchUpdateProductionTaskStatus(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/production-tasks/${id}/status`, params: data })
}
export function fetchDeleteProductionTask(id: string) {
return request.del({ url: `/web/admin/production-tasks/${id}` })
}
export function fetchAddTaskStep(taskId: string, data: Record<string, any>) {
return request.post({ url: `/web/admin/production-tasks/${taskId}/steps`, params: data })
}
export function fetchUpdateTaskStep(taskId: string, stepId: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/production-tasks/${taskId}/steps/${stepId}`, params: data })
}
export function fetchDeleteTaskStep(taskId: string, stepId: string) {
return request.del({ url: `/web/admin/production-tasks/${taskId}/steps/${stepId}` })
}
export function fetchReplaceStepReagents(taskId: string, stepId: string, data: any[]) {
return request.put({
url: `/web/admin/production-tasks/${taskId}/steps/${stepId}/reagents`,
params: data
})
}
export function fetchReplaceStepConsumables(taskId: string, stepId: string, data: any[]) {
return request.put({
url: `/web/admin/production-tasks/${taskId}/steps/${stepId}/consumables`,
params: data
})
}
export function fetchReplaceStepDataCollection(taskId: string, stepId: string, data: any[]) {
return request.put({
url: `/web/admin/production-tasks/${taskId}/steps/${stepId}/data-collection`,
params: data
})
}
export function fetchSetTaskInputs(taskId: string, data: any[] | Record<string, any>) {
return request.post({ url: `/web/admin/production-tasks/${taskId}/inputs`, params: data })
}
export function fetchSetTaskOutputs(taskId: string, data: any[] | Record<string, any>) {
return request.post({ url: `/web/admin/production-tasks/${taskId}/outputs`, params: data })
}
export function fetchReorderTaskSteps(data: { taskId: string; stepIds: string[] }) {
return request.put({ url: '/web/admin/production-tasks/step/reorder', params: data })
}
export function fetchCopyTaskStep(data: { taskId: string; stepId: string }) {
return request.post({
url: `/web/admin/production-tasks/${data.taskId}/steps/${data.stepId}/copy`,
params: data
})
}
export function fetchReplaceStepOutputs(taskId: string, stepId: string, data: any[]) {
return request.put({
url: `/web/admin/production-tasks/${taskId}/steps/${stepId}/outputs`,
params: data
})
}
-129
View File
@@ -1,129 +0,0 @@
import request from '@/utils/http'
import type {
QcWorkOrderReceptionDetail,
OutboundQcDetailApplication,
OutboundQcDetailPatient,
OutboundQcDetailInfusionPlan,
OutboundQcDetailInfusionSchedule,
OutboundQcDetailProduct
} from '@/api/qc-work-order'
export interface ProductionWorkOrderItem {
id: string
workOrderNo: string
workOrderType?: string
protocolNo?: string
sampleCode?: string
receptionSource?: string
receptionSourceLabel?: string
sampleTypeName?: string
handlerName?: string
receptionTime?: string
workOrderStatus: string
createTime?: string
remark?: string
/** 出库工单:关联申请单号 */
applicationNo?: string
/** 出库工单:患者/用户姓名 */
patientName?: string
/** 出库工单:当前为第几次输注计划 */
sortNo?: number | null
/** 出库工单:输注计划总次数 */
totalCourseCount?: number | null
infusionTime?: string
}
export type ProductionWorkOrderDetail = ProductionWorkOrderItem & {
reception?: QcWorkOrderReceptionDetail
bizType?: string
bizTypeLabel?: string
/** 临床应用出库:简要申请单 */
pilotApplication?: {
applicationNo?: string
applicationName?: string
applicationStatus?: string
applicationStatusLabel?: string
}
/** 临床应用出库:简要输注计划 */
infusionSchedule?: {
cellType?: string
specText?: string
infusionTime?: string
infusionLocation?: string
infusionMethod?: string
scheduleStatus?: string
}
/** 中试服务出库:与出库质检工单查看弹窗一致 */
application?: OutboundQcDetailApplication
patient?: OutboundQcDetailPatient
infusionPlan?: OutboundQcDetailInfusionPlan
products?: OutboundQcDetailProduct[]
}
export function fetchProductionWorkOrderList(params?: {
page?: number
pageSize?: number
keyword?: string
workOrderStatus?: string
workOrderType?: string
createTimeStart?: string
createTimeEnd?: string
infusionTimeStart?: string
infusionTimeEnd?: string
}) {
return request.get<{
list: ProductionWorkOrderItem[]
total: number
page: number
pageSize: number
totalPages: number
}>({ url: '/web/admin/production-work-orders', params })
}
export function fetchProductionWorkOrderDetail(id: string) {
return request.get<ProductionWorkOrderDetail>({ url: `/web/admin/production-work-orders/${id}` })
}
export function fetchCompleteProductionWorkOrder(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/production-work-orders/${id}/complete`, params: data })
}
export function fetchCreateProductionWorkOrder(data: {
workOrderType?: string
protocolNo?: string
sampleCode?: string
receptionSource?: string
remark?: string
}) {
return request.post<ProductionWorkOrderItem>({
url: '/web/admin/production-work-orders',
data
})
}
export function fetchConfigureProductionWorkOrder(
id: string,
data: {
taskName?: string
taskType?: string
routeId?: string
description?: string
remark?: string
}
) {
return request.post<ProductionWorkOrderItem>({
url: `/web/admin/production-work-orders/${id}/configure`,
data
})
}
export function fetchUpdateProductionWorkOrder(id: string, data: { remark?: string }) {
return request.put<ProductionWorkOrderItem>({
url: `/web/admin/production-work-orders/${id}`,
data
})
}
export function fetchDeleteProductionWorkOrder(id: string) {
return request.del({ url: `/web/admin/production-work-orders/${id}` })
}
-435
View File
@@ -1,435 +0,0 @@
import request from '@/utils/http'
// ---------------------------------------------------------------------------
// 耗材管理
// ---------------------------------------------------------------------------
export function fetchConsumableList(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
totalPages: number
}>({ url: '/web/admin/consumables', params })
}
export function fetchAllConsumables() {
return request.get<any[]>({ url: '/web/admin/consumables/all' })
}
export function fetchCreateConsumable(data: Record<string, any>) {
return request.post({ url: '/web/admin/consumables', params: data })
}
export function fetchUpdateConsumable(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/consumables/${id}`, params: data })
}
export function fetchDeleteConsumable(id: string) {
return request.del({ url: `/web/admin/consumables/${id}` })
}
// ---------------------------------------------------------------------------
// 试剂管理
// ---------------------------------------------------------------------------
export function fetchReagentList(params?: { page?: number; pageSize?: number; keyword?: string }) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
totalPages: number
}>({ url: '/web/admin/reagents', params })
}
export function fetchAllReagents() {
return request.get<any[]>({ url: '/web/admin/reagents/all' })
}
export function fetchCreateReagent(data: Record<string, any>) {
return request.post({ url: '/web/admin/reagents', params: data })
}
export function fetchUpdateReagent(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/reagents/${id}`, params: data })
}
export function fetchDeleteReagent(id: string) {
return request.del({ url: `/web/admin/reagents/${id}` })
}
// ---------------------------------------------------------------------------
// SOP文档
// ---------------------------------------------------------------------------
export function fetchSopList(params?: { page?: number; pageSize?: number; keyword?: string }) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
totalPages: number
}>({ url: '/web/admin/sop-documents', params })
}
export function fetchAllSopDocuments() {
return request.get<any[]>({ url: '/web/admin/sop-documents/all' })
}
export function fetchCreateSop(data: Record<string, any>) {
return request.post({ url: '/web/admin/sop-documents', params: data })
}
export function fetchUpdateSop(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/sop-documents/${id}`, params: data })
}
export function fetchDeleteSop(id: string) {
return request.del({ url: `/web/admin/sop-documents/${id}` })
}
// ---------------------------------------------------------------------------
// 工艺工序分类
// ---------------------------------------------------------------------------
export function fetchProcessCategoryList(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
totalPages: number
}>({ url: '/web/admin/process-categories', params })
}
export function fetchAllProcessCategories() {
return request.get<any[]>({ url: '/web/admin/process-categories/all' })
}
export function fetchCreateProcessCategory(data: Record<string, any>) {
return request.post({ url: '/web/admin/process-categories', params: data })
}
export function fetchUpdateProcessCategory(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/process-categories/${id}`, params: data })
}
export function fetchDeleteProcessCategory(id: string) {
return request.del({ url: `/web/admin/process-categories/${id}` })
}
// ---------------------------------------------------------------------------
// 工艺产出物类型
// ---------------------------------------------------------------------------
export function fetchOutputTypeList(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
totalPages: number
}>({ url: '/web/admin/output-types', params })
}
export function fetchAllOutputTypes() {
return request.get<any[]>({ url: '/web/admin/output-types/all' })
}
export function fetchCreateOutputType(data: Record<string, any>) {
return request.post({ url: '/web/admin/output-types', params: data })
}
export function fetchUpdateOutputType(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/output-types/${id}`, params: data })
}
export function fetchDeleteOutputType(id: string) {
return request.del({ url: `/web/admin/output-types/${id}` })
}
// ---------------------------------------------------------------------------
// 耗材包管理
// ---------------------------------------------------------------------------
export function fetchConsumablePackList(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
totalPages: number
}>({ url: '/web/admin/consumable-packs', params })
}
export function fetchAllConsumablePacks() {
return request.get<any[]>({ url: '/web/admin/consumable-packs/all' })
}
export function fetchCreateConsumablePack(data: Record<string, any>) {
return request.post({ url: '/web/admin/consumable-packs', params: data })
}
export function fetchUpdateConsumablePack(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/consumable-packs/${id}`, params: data })
}
export function fetchDeleteConsumablePack(id: string) {
return request.del({ url: `/web/admin/consumable-packs/${id}` })
}
export function fetchConsumablePackDetail(id: string) {
return request.get<any>({ url: `/web/admin/consumable-packs/${id}` })
}
// ---------------------------------------------------------------------------
// 工艺库管理
// ---------------------------------------------------------------------------
export function fetchProcessList(params?: { page?: number; pageSize?: number; keyword?: string }) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
totalPages: number
}>({ url: '/web/admin/processes', params })
}
export function fetchAllProcesses() {
return request.get<any[]>({ url: '/web/admin/processes/all' })
}
export function fetchCreateProcess(data: Record<string, any>) {
return request.post({ url: '/web/admin/processes', params: data })
}
export function fetchUpdateProcess(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/processes/${id}`, params: data })
}
export function fetchDeleteProcess(id: string) {
return request.del({ url: `/web/admin/processes/${id}` })
}
export function fetchProcessDetail(id: string) {
return request.get<any>({ url: `/web/admin/processes/${id}` })
}
// ---------------------------------------------------------------------------
// 工艺路线管理
// ---------------------------------------------------------------------------
export function fetchProcessRouteList(params?: {
page?: number
pageSize?: number
routeName?: string
routeCode?: string
productTypeId?: string
}) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
totalPages: number
}>({ url: '/web/admin/process-routes', params })
}
export function fetchProcessRouteDetail(id: string) {
return request.get<any>({ url: `/web/admin/process-routes/${id}` })
}
export function fetchCreateProcessRoute(data: Record<string, any>) {
return request.post({ url: '/web/admin/process-routes', params: data })
}
export function fetchUpdateProcessRoute(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/process-routes/${id}`, params: data })
}
export function fetchSetCurrentProcessRoute(routeId: string) {
return request.put({ url: '/web/admin/process-routes/set-current', params: { routeId } })
}
export function fetchDeleteProcessRoute(id: string) {
return request.del({ url: `/web/admin/process-routes/${id}` })
}
// 步骤操作
export function fetchAddRouteStep(data: {
routeId: string
processId: string
stepOrder?: number
}) {
return request.post({ url: '/web/admin/process-routes/step/add', params: data })
}
export function fetchCopyRouteStep(data: { stepId: string }) {
return request.post({ url: '/web/admin/process-routes/step/copy', params: data })
}
export function fetchUpdateRouteStep(data: Record<string, any>) {
return request.put({ url: '/web/admin/process-routes/step/update', params: data })
}
export function fetchReorderRouteSteps(data: { routeId: string; stepIds: string[] }) {
return request.put({ url: '/web/admin/process-routes/step/reorder', params: data })
}
export function fetchDeleteRouteStep(stepId: string) {
return request.del({ url: `/web/admin/process-routes/step/${stepId}` })
}
// 步骤子表
export function fetchUpdateStepReagents(data: { stepId: string; items: any[] }) {
return request.put({ url: '/web/admin/process-routes/step/reagent/update', params: data })
}
export function fetchUpdateStepConsumables(data: { stepId: string; items: any[] }) {
return request.put({ url: '/web/admin/process-routes/step/consumable/update', params: data })
}
export function fetchUpdateStepOutputs(data: { stepId: string; items: any[] }) {
return request.put({ url: '/web/admin/process-routes/step/output/update', params: data })
}
export function fetchUpdateStepDataCollections(data: { stepId: string; items: any[] }) {
return request.put({ url: '/web/admin/process-routes/step/data-collection/update', params: data })
}
// ---------------------------------------------------------------------------
// 试剂库存操作
// ---------------------------------------------------------------------------
export function fetchReagentStockIn(id: string, data: { qty: number; remark?: string }) {
return request.post({ url: `/web/admin/reagents/${id}/stock-in`, data })
}
export function fetchReagentStockOut(
id: string,
data: { qty: number; receiver: string; purpose?: string; remark?: string }
) {
return request.post({ url: `/web/admin/reagents/${id}/stock-out`, data })
}
export function fetchReagentBatchStockIn(data: {
items: Array<{ materialId: string; qty: number; remark?: string }>
remark?: string
}) {
return request.post<{ count: number }>({ url: '/web/admin/reagents/batch-stock-in', data })
}
export function fetchReagentBatchStockOut(data: {
items: Array<{ materialId: string; qty: number }>
receiver: string
purpose?: string
remark?: string
}) {
return request.post<{ count: number }>({ url: '/web/admin/reagents/batch-stock-out', data })
}
export function fetchReagentStockLogs(
id: string,
params: { page: number; pageSize: number; changeType?: string }
) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
}>({ url: `/web/admin/reagents/${id}/stock-logs`, params })
}
export function fetchAllReagentStockLogs(params: {
page: number
pageSize: number
keyword?: string
changeType?: string
startTime?: string
endTime?: string
}) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
}>({ url: '/web/admin/reagents/stock-logs', params })
}
// ---------------------------------------------------------------------------
// 耗材库存操作
// ---------------------------------------------------------------------------
export function fetchConsumableStockIn(id: string, data: { qty: number; remark?: string }) {
return request.post({ url: `/web/admin/consumables/${id}/stock-in`, data })
}
export function fetchConsumableStockOut(
id: string,
data: { qty: number; receiver?: string; purpose?: string; remark?: string }
) {
return request.post({ url: `/web/admin/consumables/${id}/stock-out`, data })
}
export function fetchConsumableBatchStockIn(data: {
items: { materialId: string; qty: number; remark?: string }[]
remark?: string
}) {
return request.post<{ count: number }>({ url: '/web/admin/consumables/batch-stock-in', data })
}
export function fetchConsumableBatchStockOut(data: {
items: { materialId: string; qty: number }[]
receiver?: string
purpose?: string
remark?: string
}) {
return request.post<{ count: number }>({ url: '/web/admin/consumables/batch-stock-out', data })
}
export function fetchConsumableStockLogs(
id: string,
params: { page: number; pageSize: number; changeType?: string }
) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
}>({ url: `/web/admin/consumables/${id}/stock-logs`, params })
}
export function fetchAllConsumableStockLogs(params: {
page: number
pageSize: number
keyword?: string
changeType?: string
startTime?: string
endTime?: string
}) {
return request.get<{
list: any[]
total: number
page: number
pageSize: number
}>({ url: '/web/admin/consumables/stock-logs', params })
}
-26
View File
@@ -1,26 +0,0 @@
import request from '@/utils/http'
export type QcItemParam = {
id?: string
paramName: string
paramCode: string
paramUnit?: string
paramType: string
requiredFlag: string
orderNum?: number
}
export function fetchQcItemParams(itemSource: string, itemId: string) {
return request.get<QcItemParam[]>({
url: '/web/admin/qc-item-params',
params: { itemSource, itemId }
})
}
export function fetchSaveQcItemParams(itemSource: string, itemId: string, params: QcItemParam[]) {
return request.put<QcItemParam[]>({
url: '/web/admin/qc-item-params',
params: { itemSource, itemId },
data: { params }
})
}
-74
View File
@@ -1,74 +0,0 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type QcLabTestItem = {
id: string
itemCode: string
itemName: string
description?: string
status: string
orderNum: number
remark?: string
createTime?: string
updateTime?: string
}
export function fetchQcLabTestItemList(params?: {
page?: number
pageSize?: number
keyword?: string
status?: string
}) {
return request.get<PageList<QcLabTestItem>>({
url: '/web/admin/qc-lab-test-items',
params
})
}
export function fetchQcLabTestItemsActive() {
return request.get<QcLabTestItem[]>({
url: '/web/admin/qc-lab-test-items/active'
})
}
export function fetchCreateQcLabTestItem(data: {
itemCode: string
itemName: string
description?: string
status?: string
orderNum?: number
remark?: string
}) {
return request.post<QcLabTestItem>({
url: '/web/admin/qc-lab-test-items',
data
})
}
export function fetchUpdateQcLabTestItem(
id: string,
data: Partial<{
itemCode: string
itemName: string
description: string
status: string
orderNum: number
remark: string
}>
) {
return request.put<QcLabTestItem>({
url: `/web/admin/qc-lab-test-items/${id}`,
data
})
}
export function fetchDeleteQcLabTestItem(id: string) {
return request.del({ url: `/web/admin/qc-lab-test-items/${id}` })
}
-43
View File
@@ -1,43 +0,0 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type QcMaterialUsage = {
id: string
materialId: string
materialCode: string
materialName: string
changeType: string
changeQty: number
stockAfter: number
qcTaskId: string
qcTaskNo: string
receiver?: string
purpose?: string
useTime?: string
sourceType: string
remark: string
createBy: string
createByName?: string
createTime: string
}
export function fetchQcMaterialUsageList(params?: {
page?: number
pageSize?: number
materialId?: string
changeType?: string
startTime?: string
endTime?: string
}) {
return request.get<PageList<QcMaterialUsage>>({
url: '/web/admin/qc-material-usages',
params
})
}
-124
View File
@@ -1,124 +0,0 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type QcMaterial = {
id: string
materialCode: string
materialName: string
specModel: string
unit: string
stockQty: number
warningQty: number
status: string
tenantNo: string
orderNum: number
remark: string
createBy: string
createTime: string
updateTime: string
}
export type QcMaterialOption = {
id: string
label: string
materialCode: string
materialName: string
specModel: string
unit: string
stockQty: number
}
export function fetchQcMaterialList(params?: {
page?: number
pageSize?: number
keyword?: string
status?: string
}) {
return request.get<PageList<QcMaterial>>({
url: '/web/admin/qc-materials',
params
})
}
export function fetchQcMaterialOptions() {
return request.get<QcMaterialOption[]>({
url: '/web/admin/qc-materials/options'
})
}
export function fetchCreateQcMaterial(data: Record<string, any>) {
return request.post<QcMaterial>({
url: '/web/admin/qc-materials',
data
})
}
export function fetchUpdateQcMaterial(id: string, data: Record<string, any>) {
return request.put<QcMaterial>({
url: `/web/admin/qc-materials/${id}`,
data
})
}
export function fetchDeleteQcMaterial(id: string) {
return request.delete<void>({
url: `/web/admin/qc-materials/${id}`
})
}
export function fetchStockInQcMaterial(
id: string,
data: { qty: number; remark?: string; stockInTime?: string }
) {
return request.post<QcMaterial>({
url: `/web/admin/qc-materials/${id}/stock-in`,
data
})
}
export function fetchStockOutQcMaterial(
id: string,
data: {
qty: number
receiver?: string
purpose?: string
remark?: string
stockOutTime?: string
}
) {
return request.post<QcMaterial>({
url: `/web/admin/qc-materials/${id}/stock-out`,
data
})
}
export function fetchBatchStockInQcMaterial(data: {
items: { materialId: string; qty: number; remark?: string }[]
remark?: string
stockInTime?: string
}) {
return request.post<{ count: number }>({
url: '/web/admin/qc-materials/batch-stock-in',
data
})
}
export function fetchBatchStockOutQcMaterial(data: {
items: { materialId: string; qty: number; remark?: string }[]
receiver?: string
purpose?: string
remark?: string
stockOutTime?: string
}) {
return request.post<{ count: number }>({
url: '/web/admin/qc-materials/batch-stock-out',
data
})
}
-46
View File
@@ -1,46 +0,0 @@
import request from '@/utils/http'
export type QcTaskResultItem = {
paramId?: string
paramName: string
paramCode?: string
paramUnit?: string
paramType: string
paramValue: string
}
export function fetchQcTaskResults(taskId: string) {
return request.get<QcTaskResultItem[]>({
url: `/web/admin/qc-tasks/${taskId}/results`
})
}
/** 任务已保存的物料使用(用于填写结果弹窗回显) */
export type QcTaskMaterialUsageItem = {
materialId: string
materialName: string
changeQty: number
remark?: string
}
export function fetchQcTaskMaterialUsages(taskId: string) {
return request.get<QcTaskMaterialUsageItem[]>({
url: `/web/admin/qc-tasks/${taskId}/material-usages`
})
}
export function fetchCompleteTaskWithResult(
taskId: string,
data: {
conclusion: string
results: QcTaskResultItem[]
remark?: string
thirdPartyAttachments?: string
materialUsages?: Array<{ materialId: string; qty: number; remark?: string }>
}
) {
return request.post({
url: `/web/admin/qc-tasks/${taskId}/complete-with-result`,
data
})
}
-152
View File
@@ -1,152 +0,0 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type QcTaskReceptionDetail = {
protocolNo?: string
sampleCode?: string
sampleTypeName?: string
sourceType?: string
sourceTypeLabel?: string
receptionTime?: string
handlerName?: string
receiveTemperature?: string
volumeMl?: number
netWeightG?: number
acceptConclusion?: string
acceptConclusionLabel?: string
remark?: string
transportType?: string
expressCompanyName?: string
waybillNo?: string
carrierName?: string
carrierPhone?: string
etaTime?: string
checkResults?: Array<{ itemName?: string; checked?: boolean }>
receiveAttachments?: string
receiveAttachmentFiles?: Array<{
id?: string
fileUrl?: string
fileName?: string
fileType?: string
}>
}
export type QcTask = {
id: string
workOrderId: string
workOrderNo?: string
orderSource?: string
taskNo: string
taskType: string
itemCode: string
itemName: string
companyId?: string
companyName?: string
feeAmount?: number
taskStatus: string
conclusion?: string
conclusionLabel?: string
qcBizType?: string
qcBizTypeLabel?: string
receptionSource?: string
receptionSourceLabel?: string
protocolNo?: string
sampleCode?: string
sampleTypeName?: string
receptionTime?: string
handlerName?: string
workOrderStatus?: string
cellProductId?: string
cellProductCode?: string
cellProductName?: string
cellProductTypeName?: string
cellProductPassage?: string
cellProductSpecName?: string
thirdPartyAttachments?: string
attachmentFiles?: Array<{ id?: string; fileUrl: string; fileName?: string; fileType?: string }>
completedTime?: string
completedBy?: string
remark?: string
createTime?: string
updateTime?: string
}
export type QcTaskDetail = QcTask & {
reception?: QcTaskReceptionDetail
attachmentFiles?: Array<{ id?: string; fileUrl: string; fileName?: string }>
results?: Array<{
paramName: string
paramUnit?: string
paramType: string
paramValue: string
}>
}
export function fetchQcTaskList(params?: {
page?: number
pageSize?: number
keyword?: string
taskType?: string
taskStatus?: string
workOrderId?: string
qcBizType?: string
}) {
return request.get<PageList<QcTask>>({
url: '/web/admin/qc-tasks',
params
})
}
export function fetchQcTaskDetail(id: string) {
return request.get<QcTaskDetail>({
url: `/web/admin/qc-tasks/${id}`
})
}
export function fetchStartQcTask(id: string) {
return request.post<QcTask>({
url: `/web/admin/qc-tasks/${id}/start`
})
}
export function fetchCompleteQcTask(id: string) {
return request.post<QcTask>({
url: `/web/admin/qc-tasks/${id}/complete`
})
}
export function fetchUpdateQcTaskAttachments(id: string, thirdPartyAttachments: string) {
return request.put<QcTask>({
url: `/web/admin/qc-tasks/${id}/attachments`,
data: { thirdPartyAttachments }
})
}
export function fetchCreateQcTask(data: {
taskType: string
itemCode: string
itemName: string
companyId?: string
companyName?: string
feeAmount?: number
sampleCode?: string
qcBizType?: string
workOrderId?: string
remark?: string
}) {
return request.post<QcTask>({
url: '/web/admin/qc-tasks',
params: data
})
}
export function fetchDeleteQcTask(id: string) {
return request.del({ url: `/web/admin/qc-tasks/${id}` })
}
@@ -1,92 +0,0 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type QcThirdPartyCompany = {
id: string
companyCode: string
companyName: string
contactName?: string
contactPhone?: string
address?: string
invoiceTitle?: string
taxId?: string
bankName?: string
bankAccount?: string
status: string
orderNum: number
remark?: string
createTime?: string
updateTime?: string
}
export function fetchQcThirdPartyCompanyList(params?: {
page?: number
pageSize?: number
keyword?: string
status?: string
}) {
return request.get<PageList<QcThirdPartyCompany>>({
url: '/web/admin/qc-third-party-companies',
params
})
}
export function fetchQcThirdPartyCompanyOptions() {
return request.get<Array<{ id: string; companyCode: string; companyName: string }>>({
url: '/web/admin/qc-third-party-companies/options'
})
}
export function fetchCreateQcThirdPartyCompany(data: {
companyCode: string
companyName: string
contactName?: string
contactPhone?: string
address?: string
invoiceTitle?: string
taxId?: string
bankName?: string
bankAccount?: string
status?: string
orderNum?: number
remark?: string
}) {
return request.post<QcThirdPartyCompany>({
url: '/web/admin/qc-third-party-companies',
data
})
}
export function fetchUpdateQcThirdPartyCompany(
id: string,
data: Partial<{
companyCode: string
companyName: string
contactName: string
contactPhone: string
address: string
invoiceTitle: string
taxId: string
bankName: string
bankAccount: string
status: string
orderNum: number
remark: string
}>
) {
return request.put<QcThirdPartyCompany>({
url: `/web/admin/qc-third-party-companies/${id}`,
data
})
}
export function fetchDeleteQcThirdPartyCompany(id: string) {
return request.del({ url: `/web/admin/qc-third-party-companies/${id}` })
}
-80
View File
@@ -1,80 +0,0 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type QcThirdPartyItem = {
id: string
companyId: string
companyName?: string
itemCode: string
itemName: string
feeAmount: number
status: string
orderNum: number
remark?: string
createTime?: string
updateTime?: string
}
export function fetchQcThirdPartyItemList(params?: {
page?: number
pageSize?: number
keyword?: string
status?: string
companyId?: string
}) {
return request.get<PageList<QcThirdPartyItem>>({
url: '/web/admin/qc-third-party-items',
params
})
}
export function fetchQcThirdPartyItemsActive(companyId?: string) {
return request.get<QcThirdPartyItem[]>({
url: '/web/admin/qc-third-party-items/active',
params: companyId ? { companyId } : undefined
})
}
export function fetchCreateQcThirdPartyItem(data: {
companyId: string
itemCode: string
itemName: string
feeAmount: number
status?: string
orderNum?: number
remark?: string
}) {
return request.post<QcThirdPartyItem>({
url: '/web/admin/qc-third-party-items',
data
})
}
export function fetchUpdateQcThirdPartyItem(
id: string,
data: Partial<{
companyId: string
itemCode: string
itemName: string
feeAmount: number
status: string
orderNum: number
remark: string
}>
) {
return request.put<QcThirdPartyItem>({
url: `/web/admin/qc-third-party-items/${id}`,
data
})
}
export function fetchDeleteQcThirdPartyItem(id: string) {
return request.del({ url: `/web/admin/qc-third-party-items/${id}` })
}
-257
View File
@@ -1,257 +0,0 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type SampleReceptionCheckResult = {
itemCode: string
itemName: string
checked: boolean
checkItemId?: string
}
export type QcWorkOrderReceptionDetail = {
protocolNo?: string
sampleCode?: string
sampleTypeName?: string
sourceType?: string
sourceTypeLabel?: string
receptionTime?: string
handlerName?: string
receiveTemperature?: string
volumeMl?: number
netWeightG?: number
acceptConclusion?: string
acceptConclusionLabel?: string
remark?: string
etaTime?: string
transportType?: string
expressCompanyName?: string
waybillNo?: string
carrierName?: string
carrierPhone?: string
checkResults?: SampleReceptionCheckResult[]
receiveAttachments?: string
receiveAttachmentFiles?: Array<{
id?: string
fileUrl?: string
fileName?: string
fileType?: string
fileSize?: number
fileSizeDisplay?: string
}>
}
export type QcWorkOrder = {
id: string
workOrderNo: string
workOrderType?: string
receptionId: string
protocolSampleId: string
collectionProtocolId: string
protocolNo: string
sampleCode: string
receptionSource?: string
receptionSourceLabel?: string
qcBizType?: string
qcBizTypeLabel?: string
cellProductId?: string
cellProductCode?: string
cellProductName?: string
cellProductTypeName?: string
cellProductPassage?: string
cellProductSpecName?: string
sampleTypeName?: string
receptionTime?: string
handlerName?: string
workOrderStatus: string
hasLab: boolean
hasThirdParty: boolean
configuredTime?: string
configuredBy?: string
configuredByName?: string
thirdPartyFeeTotal?: number
remark?: string
createTime?: string
updateTime?: string
bizType?: string
bizTypeLabel?: string
applicationNo?: string
applicationName?: string
channelName?: string
/** 出库工单:患者/用户姓名 */
patientName?: string
/** 出库工单:当前为第几次输注计划 */
sortNo?: number | null
/** 出库工单:输注计划总次数 */
totalCourseCount?: number | null
infusionTime?: string
}
export type QcWorkOrderDetail = QcWorkOrder & {
reception?: QcWorkOrderReceptionDetail
labItems?: Array<{ id: string; labTestItemId: string; itemCode: string; itemName: string }>
thirdPartyItems?: Array<{
id: string
thirdPartyItemId: string
companyId: string
companyName: string
itemCode: string
itemName: string
feeAmount: number
}>
tasks?: Array<{
id: string
taskNo: string
taskType: string
itemCode?: string
itemName?: string
companyId?: string
companyName?: string
feeAmount?: number
taskStatus: string
conclusion?: string
conclusionLabel?: string
remark?: string
completedTime?: string
attachmentFiles?: Array<{ id?: string; fileUrl?: string; fileName?: string }>
}>
}
export function fetchQcWorkOrderList(params?: {
page?: number
pageSize?: number
keyword?: string
workOrderStatus?: string
workOrderType?: string
bizType?: string
createTimeStart?: string
createTimeEnd?: string
infusionTimeStart?: string
infusionTimeEnd?: string
}) {
return request.get<PageList<QcWorkOrder>>({
url: '/web/admin/qc-work-orders',
params
})
}
export function fetchQcWorkOrderDetail(id: string) {
return request.get<QcWorkOrderDetail>({
url: `/web/admin/qc-work-orders/${id}`
})
}
export function fetchConfigureQcWorkOrder(
id: string,
data: {
hasLab: boolean
hasThirdParty: boolean
labItemIds: string[]
thirdPartyItemIds: string[]
}
) {
return request.post<QcWorkOrder>({
url: `/web/admin/qc-work-orders/${id}/configure`,
data
})
}
export function fetchCreateQcWorkOrder(data: {
workOrderType?: string
protocolNo?: string
sampleCode?: string
receptionSource?: string
qcBizType?: string
cellProductId?: string
remark?: string
}) {
return request.post<QcWorkOrder>({
url: '/web/admin/qc-work-orders',
data
})
}
export function fetchUpdateQcWorkOrder(
id: string,
data: { cellProductId?: string; workOrderStatus?: string; remark?: string }
) {
return request.put<QcWorkOrder>({
url: `/web/admin/qc-work-orders/${id}`,
data
})
}
export function fetchDeleteQcWorkOrder(id: string) {
return request.del({ url: `/web/admin/qc-work-orders/${id}` })
}
export type OutboundQcDetailApplication = {
applicationNo?: string
applicationName?: string
applicationStatus?: string
channelName?: string
}
export type OutboundQcDetailPatient = {
patientName?: string
sex?: string
age?: string
phoneNo?: string
idCardNo?: string
emergencyContact?: string
emergencyPhone?: string
clinicalDiagnosis?: string
diagnosisAttachments?: string
}
export type OutboundQcDetailInfusionPlan = {
cellType?: string
specText?: string
totalCourseCount?: number
courseInterval?: string
infusionRoute?: string
infusionStartDate?: string
}
export type OutboundQcDetailInfusionSchedule = {
infusionTime?: string
infusionLocation?: string
infusionMethod?: string
scheduleStatus?: string
infusionStatus?: string
qcStatus?: string
cancelReason?: string
}
export type OutboundQcDetailProduct = {
projectCode?: string
projectName?: string
cellTypeName?: string
specText?: string
agreementPrice?: number
actualPrice?: number
quantity?: number
productStatus?: string
}
export type OutboundQcDetail = {
workOrder: QcWorkOrderDetail
application?: OutboundQcDetailApplication
patient?: OutboundQcDetailPatient
infusionPlan?: OutboundQcDetailInfusionPlan
infusionSchedule?: OutboundQcDetailInfusionSchedule
products?: OutboundQcDetailProduct[]
tasks?: QcWorkOrderDetail['tasks']
}
export function fetchOutboundQcDetail(id: string) {
return request.get<OutboundQcDetail>({
url: `/web/admin/qc-work-orders/${id}/outbound-detail`
})
}
-132
View File
@@ -1,132 +0,0 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type ResearchDonationRow = Record<string, unknown>
// 列表
export function fetchResearchDonationList(params?: {
page?: number
pageSize?: number
keyword?: string
donationStatus?: string
createTimeStart?: string
createTimeEnd?: string
sampleCollectTimeStart?: string
sampleCollectTimeEnd?: string
}) {
return request.get<PageList<ResearchDonationRow>>({
url: '/web/admin/research-donations',
params
})
}
// 详情
export function fetchResearchDonationDetail(id: string) {
return request.get<{
donation: Record<string, unknown>
party: Record<string, unknown> | null
collection: Record<string, unknown> | null
samples: Array<Record<string, unknown>> | null
attachments?: Array<Record<string, unknown>>
}>({
url: `/web/admin/research-donations/${id}`
})
}
// 创建
export function fetchCreateResearchDonation(data: Record<string, unknown>) {
return request.post({
url: '/web/admin/research-donations',
data,
showSuccessMessage: true
})
}
// 更新
export function fetchUpdateResearchDonation(id: string, data: Record<string, unknown>) {
return request.put({
url: `/web/admin/research-donations/${id}`,
data,
showSuccessMessage: true
})
}
// 删除
export function fetchDeleteResearchDonation(id: string) {
return request.del({
url: `/web/admin/research-donations/${id}`,
showSuccessMessage: true
})
}
// 批量删除
export function fetchBatchDeleteResearchDonation(ids: string[]) {
return request.post({
url: '/web/admin/research-donations/batch-delete',
data: { ids },
showSuccessMessage: true
})
}
// 审核列表
export function fetchResearchDonationAuditList(params?: {
page?: number
pageSize?: number
keyword?: string
auditStatus?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/research-donations/audits',
params
})
}
// 提交审核
export function fetchSubmitDonationAudit(
donationId: string,
data: { auditStatus: string; auditRemark?: string },
options?: { showSuccessMessage?: boolean }
) {
return request.post({
url: `/web/admin/research-donations/${donationId}/audit`,
data,
showSuccessMessage: options?.showSuccessMessage ?? true
})
}
// 状态变更
export function fetchDonationStatusChange(
donationId: string,
data: { newStatus: string; desc?: string; timeVal?: string }
) {
return request.post({
url: `/web/admin/research-donations/${donationId}/status-change`,
data,
showSuccessMessage: true
})
}
// 从细胞存储转化
export function fetchConvertFromStorage(protocolId: string) {
return request.post({
url: '/web/admin/research-donations/convert-from-storage',
data: { protocolId },
showSuccessMessage: true
})
}
// 转为细胞存储
export function fetchConvertToStorage(donationId: string, data?: { desc?: string }) {
return request.post({
url: `/web/admin/research-donations/${donationId}/convert-to-storage`,
data: data || {},
showSuccessMessage: true
})
}
@@ -1,73 +0,0 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type SampleReceptionCheckItem = {
id: string
itemCode: string
itemName: string
status: string
orderNum: number
remark?: string
createTime?: string
updateTime?: string
}
export function fetchSampleReceptionCheckItemList(params?: {
page?: number
pageSize?: number
keyword?: string
status?: string
}) {
return request.get<PageList<SampleReceptionCheckItem>>({
url: '/web/admin/sample-reception-check-items',
params
})
}
export function fetchSampleReceptionCheckItemsActive() {
return request.get<SampleReceptionCheckItem[]>({
url: '/web/admin/sample-reception-check-items/active'
})
}
export function fetchCreateSampleReceptionCheckItem(data: {
itemCode: string
itemName: string
status?: string
orderNum?: number
remark?: string
}) {
return request.post<SampleReceptionCheckItem>({
url: '/web/admin/sample-reception-check-items',
data
})
}
export function fetchUpdateSampleReceptionCheckItem(
id: string,
data: {
itemCode?: string
itemName?: string
status?: string
orderNum?: number
remark?: string
}
) {
return request.put<SampleReceptionCheckItem>({
url: `/web/admin/sample-reception-check-items/${id}`,
data
})
}
export function fetchDeleteSampleReceptionCheckItem(id: string) {
return request.del({
url: `/web/admin/sample-reception-check-items/${id}`
})
}
-94
View File
@@ -1,94 +0,0 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export function fetchSampleReceptionPendingList(params?: {
page?: number
pageSize?: number
keyword?: string
sourceType?: string
etaTimeStart?: string
etaTimeEnd?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/sample-receptions/pending',
params
})
}
export function fetchSampleReceptionReceivedList(params?: {
page?: number
pageSize?: number
keyword?: string
sourceType?: string
receptionTimeStart?: string
receptionTimeEnd?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/sample-receptions/received',
params
})
}
export function fetchSampleReceptionDetail(receptionId: string) {
return request.get<Record<string, unknown>>({
url: `/web/admin/sample-receptions/${receptionId}`
})
}
export type SampleReceptionCheckResult = {
id?: string
checkItemId?: string
itemCode: string
itemName: string
checked: boolean
orderNum?: number
}
export function fetchSampleReceptionUpdate(
receptionId: string,
data: {
receptionTime: string
handlerUserId?: string
handlerName?: string
receiveTemperature?: string
volumeMl?: number
netWeightG?: number
checks: Array<{ checkItemId: string; checked: boolean }>
acceptConclusion: 'js' | 'bj'
receiveAttachments?: string
remark?: string
}
) {
return request.put({
url: `/web/admin/sample-receptions/${receptionId}`,
data
})
}
export function fetchSampleReceptionSubmit(data: {
protocolSampleId?: string
sourceType?: 'cell_storage' | 'research_donation'
donationSampleId?: string
receptionTime: string
handlerUserId?: string
handlerName?: string
receiveTemperature?: string
volumeMl?: number
netWeightG?: number
checks: Array<{ checkItemId: string; checked: boolean }>
acceptConclusion: 'js' | 'bj'
receiveAttachments?: string
remark?: string
}) {
return request.post({
url: '/web/admin/sample-receptions',
data
})
}
@@ -1,44 +0,0 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
/** 查询列表(联查 sample_reception + sms_notification) */
export function fetchSampleSmsNotificationList(params?: {
page?: number
pageSize?: number
keyword?: string
acceptConclusion?: string
smsNotified?: string
receptionTimeStart?: string
receptionTimeEnd?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/sample-sms-notifications/list',
params
})
}
/** 切换通知状态 */
export function fetchToggleSmsNotified(
id: string,
data: { smsNotified: number; sampleReceptionId?: string }
) {
return request.put({
url: `/web/admin/sample-sms-notifications/${id}/toggle-status`,
data
})
}
/** 生成短信内容 */
export function fetchGenerateSms(id: string, sampleReceptionId?: string) {
return request.post<{ smsContent: string }>({
url: `/web/admin/sample-sms-notifications/${id}/generate-sms`,
params: sampleReceptionId ? { sampleReceptionId } : undefined
})
}
@@ -1,44 +0,0 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
/** 查询列表(联查 storage_release_review + sms_notification) */
export function fetchStorageEntrySmsList(params?: {
page?: number
pageSize?: number
keyword?: string
storageStatus?: string
smsNotified?: string
storageTimeStart?: string
storageTimeEnd?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/storage-entry-sms-notifications/list',
params
})
}
/** 切换通知状态 */
export function fetchToggleStorageEntrySmsNotified(
id: string,
data: { smsNotified: number; storageReviewId?: string }
) {
return request.put({
url: `/web/admin/storage-entry-sms-notifications/${id}/toggle-status`,
data
})
}
/** 生成短信内容 */
export function fetchGenerateStorageEntrySms(id: string, storageReviewId?: string) {
return request.post<{ smsContent: string }>({
url: `/web/admin/storage-entry-sms-notifications/${id}/generate-sms`,
params: storageReviewId ? { storageReviewId } : undefined
})
}
-177
View File
@@ -1,177 +0,0 @@
import request from '@/utils/http'
import type { QcWorkOrderReceptionDetail } from '@/api/qc-work-order'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type StorageReleaseReview = {
id: string
sampleCode?: string
sampleName?: string
protocolNo?: string
receptionSource?: string
receptionSourceLabel?: string
reviewConclusion?: string
reviewConclusionLabel?: string
reviewRemark?: string
reviewAttachments?: string
reviewBy?: string
reviewByName?: string
reviewTime?: string
storageStatus?: string
storageStatusLabel?: string
sampleTypeName?: string
handlerName?: string
receptionTime?: string
motherName?: string
protocolStatus?: string
protocolStatusLabel?: string
storageBy?: string
storageByName?: string
storageTime?: string
remark?: string
createTime?: string
updateTime?: string
}
export type ProtocolInfo = {
protocolNo?: string
protocolStatus?: string
motherName?: string
motherPhone?: string
fatherName?: string
fatherPhone?: string
newbornName?: string
sampleCode?: string
sampleTypeName?: string
collectAmount?: number | null
storageYears?: number | null
}
export type TransportInfo = {
transportType?: string
waybillNo?: string
expressFee?: number | null
etaTime?: string
carrierName?: string
carrierPhone?: string
}
export type ReceptionInfo = {
receptionTime?: string
handlerName?: string
receiveTemperature?: string
volumeMl?: number | null
netWeightG?: number | null
acceptConclusion?: string
}
export type QcInfoTask = {
id?: string
taskType?: string
taskTypeLabel?: string
taskStatus?: string
taskStatusLabel?: string
conclusion?: string
conclusionLabel?: string
}
export type QcInfo = {
workOrderNo?: string
workOrderStatus?: string
workOrderStatusLabel?: string
tasks?: QcInfoTask[]
}
export type ProductionInfoTask = {
id?: string
taskNo?: string
taskName?: string
taskStatus?: string
taskStatusLabel?: string
conclusion?: string
conclusionLabel?: string
startedTime?: string
completedTime?: string
}
export type ProductionInfo = {
workOrderNo?: string
workOrderStatus?: string
workOrderStatusLabel?: string
tasks?: ProductionInfoTask[]
}
export type StorageReleaseReviewDetail = {
review: StorageReleaseReview
protocolInfo?: ProtocolInfo
transportInfo?: TransportInfo
receptionInfo?: ReceptionInfo
reception?: QcWorkOrderReceptionDetail
qcInfo?: QcInfo
productionInfo?: ProductionInfo
}
export function fetchStorageReleaseReviewList(params?: {
page?: number
pageSize?: number
receptionSource?: string
reviewConclusion?: string
storageStatus?: string
keyword?: string
}) {
return request.get<PageList<StorageReleaseReview>>({
url: '/web/admin/storage-release-reviews',
params
})
}
export function fetchStorageReleaseReviewDetail(id: string) {
return request.get<StorageReleaseReviewDetail>({
url: `/web/admin/storage-release-reviews/${id}`
})
}
export function submitStorageReleaseReview(
id: string,
data: {
reviewConclusion: string
reviewRemark?: string
reviewAttachments?: string
}
) {
return request.put<StorageReleaseReview>({
url: `/web/admin/storage-release-reviews/${id}/review`,
data
})
}
export function batchReviewStorageRelease(data: {
ids: string[]
reviewConclusion: string
reviewRemark?: string
reviewAttachments?: string
}) {
return request.put<void>({
url: '/web/admin/storage-release-reviews/batch-review',
data
})
}
export function storeSample(id: string) {
return request.put<StorageReleaseReview>({
url: `/web/admin/storage-release-reviews/${id}/store`
})
}
export function batchStoreSample(data: { ids: string[] }) {
return request.put<void>({
url: '/web/admin/storage-release-reviews/batch-store',
data
})
}
@@ -1,208 +0,0 @@
<!-- 临床应用缴费/退费提交信息只读展示 -->
<template>
<div class="clinical-payment-submit-readonly">
<h4 v-if="title" class="detail-block-title">{{ title }}</h4>
<dl class="view-kv-grid view-kv-grid--4">
<div class="view-kv">
<dt>申请单号</dt>
<dd>{{ dash(detail?.applicationNo) }}</dd>
</div>
<div class="view-kv">
<dt>患者姓名</dt>
<dd>{{ dash(detail?.patientName) }}</dd>
</div>
<div class="view-kv">
<dt>渠道名称</dt>
<dd>{{ dash(detail?.channelName) }}</dd>
</div>
<div class="view-kv">
<dt>细胞类型/规格</dt>
<dd>{{ dash(detail?.cellTypeSpec) }}</dd>
</div>
<div class="view-kv">
<dt>输注时间</dt>
<dd>{{ dash(detail?.infusionTime) }}</dd>
</div>
<div v-if="showPayStatus" class="view-kv">
<dt>缴费状态</dt>
<dd>
<el-tag :type="payStatusTagType(String(detail?.payStatus || ''))" size="small">
{{ payStatusLabel(String(detail?.payStatus || '')) }}
</el-tag>
</dd>
</div>
<template v-if="mode === 'refund'">
<div class="view-kv">
<dt>缴费金额(元)</dt>
<dd>{{ formatAmount(detail?.paymentAmount ?? detail?.amount) }}</dd>
</div>
</template>
<template v-if="mode === 'receipt' || mode === 'full'">
<div class="view-kv">
<dt>缴费金额(元)</dt>
<dd>{{ formatAmount(detail?.paymentAmount ?? detail?.amount) }}</dd>
</div>
<div v-if="detail?.remark" class="view-kv view-kv-span-2">
<dt>备注</dt>
<dd>{{ dash(detail?.remark) }}</dd>
</div>
<div class="view-field-block view-kv-span-2 clinical-payment-submit-readonly__attachments">
<div class="view-field-block__label">转账凭证</div>
<ProtocolAttachmentReadonly
:files="receiptAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
<template v-if="mode === 'refund' || (mode === 'full' && hasRefundInfo)">
<div class="view-kv">
<dt>退费金额(元)</dt>
<dd>{{ formatAmount(detail?.refundAmount) }}</dd>
</div>
<div v-if="detail?.refundRemark" class="view-kv view-kv-span-2">
<dt>退费备注</dt>
<dd>{{ dash(detail?.refundRemark) }}</dd>
</div>
<div class="view-field-block view-kv-span-2 clinical-payment-submit-readonly__attachments">
<div class="view-field-block__label">退费附件</div>
<ProtocolAttachmentReadonly
:files="refundAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
</dl>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import { payStatusLabel, payStatusTagType } from '@/utils/status-tag-registry'
const props = withDefaults(
defineProps<{
detail?: Record<string, unknown> | null
title?: string
mode?: 'receipt' | 'refund' | 'full'
showPayStatus?: boolean
}>(),
{ mode: 'receipt', showPayStatus: false }
)
const emit = defineEmits<{
(e: 'preview', file: Record<string, unknown>): void
(e: 'download', file: Record<string, unknown>): void
}>()
function dash(v: unknown) {
if (v == null || v === '') return '—'
return String(v)
}
function formatAmount(v: unknown) {
if (v == null || v === '') return '—'
return '¥' + formatMoneyDisplay(v)
}
function urlsToFiles(urlField: unknown) {
const urls = String(urlField || '')
if (!urls.trim()) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `url_${i}`, fileUrl: url, fileName: name }
})
.filter(Boolean) as Array<Record<string, unknown>>
}
const receiptAttachmentFiles = computed(() => urlsToFiles(props.detail?.transferAttachments))
const refundAttachmentFiles = computed(() => urlsToFiles(props.detail?.refundAttachments))
const hasRefundInfo = computed(() => {
const d = props.detail
if (!d) return false
return (
d.refundAmount != null ||
Boolean(d.refundRemark) ||
Boolean(String(d.refundAttachments || '').trim())
)
})
</script>
<style scoped lang="scss">
.clinical-payment-submit-readonly {
width: 100%;
}
.detail-block-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #334155);
line-height: 1.4;
}
.view-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 20px;
margin: 0;
}
.view-kv-grid--4 {
@media (min-width: 720px) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.view-kv {
min-width: 0;
dt {
margin: 0 0 4px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
font-weight: 500;
}
dd {
margin: 0;
font-size: 14px;
color: var(--mint-text, #334155);
line-height: 1.5;
word-break: break-word;
}
}
.view-kv-span-2 {
grid-column: 1 / -1;
}
.view-field-block__label {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
font-weight: 500;
margin-bottom: 8px;
}
.clinical-payment-submit-readonly__attachments {
margin-top: 4px;
}
.form-section-divider--inner {
height: 1px;
margin: 16px 0;
background: var(--mint-border, #e2e8f0);
}
</style>
@@ -1,729 +0,0 @@
<!-- 临床应用 · 输注计划缴费明细(查看态,对齐 ProtocolViewDialog 缴费明细) -->
<template>
<div
class="payment-detail-card"
:class="{
'payment-detail-card--refunded': payStatus === 'ytf',
'payment-detail-card--refund-pending': payStatus === 'tfqrz'
}"
>
<div class="pdc-header">
<div class="pdc-header-left" :class="payDetailColorClass(payStatus)">
缴费明细 ·
<span class="pdc-header-amount" :class="payDetailColorClass(payStatus)">
{{ fmtMoney(planAmount) }}
</span>
</div>
<el-tag :type="payStatusTagType(payStatus)" size="small">
{{ payStatusLabel(payStatus) }}
</el-tag>
</div>
<div class="pdc-info-row">
<div class="pdc-info-item">
<span class="info-label">应缴费金额(元)</span>
<span class="info-value">{{ fmtMoney(planAmount) }}</span>
</div>
<div class="pdc-info-item">
<span class="info-label">实缴金额(元)</span>
<span class="info-value">{{ fmtMoney(payment?.transferAmount ?? planAmount) }}</span>
</div>
<div v-if="payment?.remark" class="pdc-info-item">
<span class="info-label">备注</span>
<span class="info-value">{{ dash(payment?.remark) }}</span>
</div>
<div v-if="payStatus !== 'dfj' && payment?.updateTime" class="pdc-info-item">
<span class="info-label">提交时间</span>
<span class="info-value">{{ dash(payment?.updateTime) }}</span>
</div>
</div>
<div v-if="transferAttachmentFiles.length" class="pdc-attachments-row">
<div class="data-label">转账凭证</div>
<ProtocolAttachmentReadonly
:files="transferAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
<template v-if="flowSteps.length">
<div class="flow-section">
<div class="mini-flow">
<template v-for="(step, sIdx) in flowSteps" :key="`c-${step.key}`">
<div
v-if="step.connectorToNext"
class="mini-flow-connector"
:class="step.connectorToNext"
:style="miniFlowConnectorStyle(flowSteps.length, sIdx)"
/>
</template>
<div v-for="step in flowSteps" :key="step.key" class="mini-flow-step">
<div class="mini-flow-dot" :class="step.dotClass">{{ step.dotIcon }}</div>
<div class="mini-flow-label" :class="step.labelClass">{{ step.label }}</div>
</div>
</div>
<div class="flow-detail-cards">
<template v-for="step in flowSteps" :key="`d-${step.key}`">
<div
v-if="step.hasDetailCard"
class="flow-detail-card"
:class="'flow-detail-card--' + step.dotClass"
>
<div class="flow-detail-card__header">
<span class="flow-detail-card__dot" :class="step.dotClass">{{
step.dotIcon
}}</span>
<span class="flow-detail-card__title" :class="step.labelClass">{{
step.label
}}</span>
</div>
<div class="flow-detail-card__body">
<template v-if="step.key === 'pay'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认状态</span>
<span class="flow-detail-card__value">
<el-tag type="success" size="small">已提交</el-tag>
</span>
</div>
<div v-if="payment?.remark" class="flow-detail-card__kv">
<span class="flow-detail-card__label">备注</span>
<span class="flow-detail-card__value">{{ payment.remark }}</span>
</div>
<div v-if="transferAttachmentFiles.length" class="flow-detail-card__kv">
<span class="flow-detail-card__label">转账凭证</span>
<ProtocolAttachmentReadonly
:files="transferAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
<template v-else-if="step.key === 'receipt-finance'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认状态</span>
<span class="flow-detail-card__value">
<el-tag v-if="isReceiptFinanceConfirmed" type="success" size="small"
>已确认</el-tag
>
<el-tag v-else type="warning" size="small">待确认</el-tag>
</span>
</div>
<div
v-if="isReceiptFinanceConfirmed && receiptFinanceTime"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">确认时间</span>
<span class="flow-detail-card__value">{{ receiptFinanceTime }}</span>
</div>
<div
v-if="isReceiptFinanceConfirmed && payment?.financeRemark"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">财务备注</span>
<span class="flow-detail-card__value">{{ payment.financeRemark }}</span>
</div>
<div
v-if="isReceiptFinanceConfirmed && financeAttachmentFiles.length"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">财务附件</span>
<ProtocolAttachmentReadonly
:files="financeAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
<template v-else-if="step.key === 'refund-apply'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">退费金额(元)</span>
<span class="flow-detail-card__value" :class="payDetailColorClass(payStatus)">{{
fmtMoney(payment?.refundAmount)
}}</span>
</div>
<div v-if="payment?.refundRemark" class="flow-detail-card__kv">
<span class="flow-detail-card__label">退费备注</span>
<span class="flow-detail-card__value">{{ payment.refundRemark }}</span>
</div>
<div v-if="refundAttachmentFiles.length" class="flow-detail-card__kv">
<span class="flow-detail-card__label">退费附件</span>
<ProtocolAttachmentReadonly
:files="refundAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
<template v-else-if="step.key === 'refund-finance'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认状态</span>
<span class="flow-detail-card__value">
<el-tag type="danger" size="small">已退费</el-tag>
</span>
</div>
<div v-if="refundFinanceTime" class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认时间</span>
<span class="flow-detail-card__value">{{ refundFinanceTime }}</span>
</div>
<div v-if="payment?.refundFinanceRemark" class="flow-detail-card__kv">
<span class="flow-detail-card__label">财务备注</span>
<span class="flow-detail-card__value">{{ payment.refundFinanceRemark }}</span>
</div>
<div v-if="refundFinanceAttachmentFiles.length" class="flow-detail-card__kv">
<span class="flow-detail-card__label">财务附件</span>
<ProtocolAttachmentReadonly
:files="refundFinanceAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import { payStatusLabel, payStatusTagType } from '@/utils/status-tag-registry'
const props = defineProps<{
payment?: Record<string, any> | null
}>()
const emit = defineEmits<{
(e: 'preview', file: Record<string, unknown>): void
(e: 'download', file: Record<string, unknown>): void
}>()
interface MiniFlowStep {
key: string
label: string
dotClass: string
dotIcon: string
labelClass: string
connectorToNext?: string
hasDetailCard?: boolean
}
const payStatus = computed(() => String(props.payment?.payStatus || 'dfj'))
const planAmount = computed(
() => props.payment?.paymentAmount ?? props.payment?.amount ?? null
)
const isReceiptFinanceConfirmed = computed(() => {
const s = payStatus.value
return s !== 'jfqrz' && s !== 'dfj'
})
const receiptFinanceTime = computed(
() => props.payment?.financeConfirmTime || props.payment?.updateTime || ''
)
const refundFinanceTime = computed(
() => props.payment?.refundFinanceConfirmTime || props.payment?.updateTime || ''
)
const transferAttachmentFiles = computed(() =>
urlsToAttachmentFiles(String(props.payment?.transferAttachments || ''))
)
const financeAttachmentFiles = computed(() =>
urlsToAttachmentFiles(String(props.payment?.financeConfirmAttachments || ''))
)
const refundAttachmentFiles = computed(() =>
urlsToAttachmentFiles(String(props.payment?.refundAttachments || ''))
)
const refundFinanceAttachmentFiles = computed(() =>
urlsToAttachmentFiles(String(props.payment?.refundFinanceConfirmAttachments || ''))
)
const flowSteps = computed(() => buildMiniFlowSteps(props.payment || {}))
function dash(v: unknown) {
if (v == null || v === '') return '—'
return String(v)
}
function fmtMoney(v: unknown) {
if (v == null || v === '') return '—'
return '¥' + formatMoneyDisplay(v)
}
function urlsToAttachmentFiles(urls: string) {
if (!urls.trim()) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `url_${i}`, fileUrl: url, fileName: name, fileSize: 0 }
})
.filter(Boolean) as Array<Record<string, unknown>>
}
function payDetailColorClass(status: string) {
if (status === 'ytf') return 'red'
if (status === 'tfqrz') return 'amber'
return 'green'
}
function buildMiniFlowSteps(detail: Record<string, any>): MiniFlowStep[] {
const s = String(detail.payStatus || '')
if (s === 'dfj') return []
const steps: MiniFlowStep[] = []
steps.push({
key: 'pay',
label: '缴费确认',
dotClass: 'done',
dotIcon: '✓',
labelClass: 'done',
connectorToNext: 'done',
hasDetailCard: true
})
if (s === 'jfqrz') {
steps.push({
key: 'receipt-finance',
label: '财务确认',
dotClass: 'current-amber',
dotIcon: '●',
labelClass: 'current-amber',
hasDetailCard: true
})
} else {
steps.push({
key: 'receipt-finance',
label: '财务确认',
dotClass: 'done',
dotIcon: '✓',
labelClass: 'done',
connectorToNext: s === 'yjf' ? undefined : 'done',
hasDetailCard: true
})
}
if (s === 'tfqrz' || s === 'ytf') {
steps.push({
key: 'refund-apply',
label: '退费申请',
dotClass: s === 'tfqrz' ? 'current-amber' : 'done',
dotIcon: s === 'tfqrz' ? '●' : '✓',
labelClass: s === 'tfqrz' ? 'current-amber' : 'done',
connectorToNext: s === 'tfqrz' ? 'pending-gray' : 'done-red',
hasDetailCard: true
})
}
if (s === 'tfqrz') {
steps.push({
key: 'refund-finance',
label: '退费确认',
dotClass: 'pending-gray',
dotIcon: '○',
labelClass: 'pending-gray',
hasDetailCard: false
})
} else if (s === 'ytf') {
steps.push({
key: 'refund-finance',
label: '退费确认',
dotClass: 'done-red',
dotIcon: '✓',
labelClass: 'done-red',
hasDetailCard: true
})
}
if (steps.length) {
delete steps[steps.length - 1].connectorToNext
}
return steps
}
function miniFlowConnectorStyle(total: number, index: number) {
const leftPct = ((index + 0.5) / total) * 100
const widthPct = 100 / total
return `left: calc(${leftPct}% + 12px); width: calc(${widthPct}% - 12px);`
}
</script>
<style scoped lang="scss">
.payment-detail-card {
background: #fff;
border-radius: 8px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
&--refunded {
border-left: 3px solid #dc2626;
border-color: #e2e8f0;
border-left-color: #dc2626;
}
&--refund-pending {
border-left: 3px solid #d97706;
border-color: #e2e8f0;
border-left-color: #d97706;
}
}
.pdc-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
}
.pdc-header-left {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
&::before {
content: '';
width: 2px;
height: 14px;
border-radius: 1px;
flex-shrink: 0;
}
&.green::before {
background: #10b981;
}
&.red::before {
background: #dc2626;
}
&.amber::before {
background: #d97706;
}
}
.pdc-header-amount {
font-size: 14px;
font-weight: 700;
margin-left: 4px;
&.green {
color: #059669;
}
&.red {
color: #dc2626;
}
&.amber {
color: #d97706;
}
}
.pdc-info-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px 20px;
margin-bottom: 12px;
}
.pdc-info-item {
display: flex;
flex-direction: column;
gap: 2px;
.info-label {
font-size: 11px;
color: #94a3b8;
}
.info-value {
font-size: 13px;
color: #1e293b;
font-weight: 500;
}
}
.pdc-attachments-row {
margin-bottom: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
display: flex;
flex-direction: column;
gap: 4px;
.data-label {
font-size: 11px;
color: #94a3b8;
font-weight: 500;
}
}
.flow-section {
margin-top: 12px;
}
.mini-flow {
display: flex;
align-items: center;
gap: 0;
margin: 12px 0;
padding: 10px 14px;
background: #f8fafc;
border-radius: 8px;
position: relative;
}
.mini-flow-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 1;
z-index: 1;
}
.mini-flow-dot {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
z-index: 1;
&.done {
background: #10b981;
color: #fff;
box-shadow: 0 0 0 3px rgb(16 185 129 / 15%);
}
&.done-red {
background: #dc2626;
color: #fff;
box-shadow: 0 0 0 3px rgb(220 38 38 / 15%);
}
&.current-amber {
background: #fff;
border: 2px solid #d97706;
color: #d97706;
box-shadow: 0 0 0 3px rgb(217 119 6 / 15%);
}
&.pending-gray {
background: #f1f5f9;
color: #94a3b8;
border: 2px solid #e2e8f0;
}
}
.mini-flow-label {
font-size: 10px;
margin-top: 6px;
text-align: center;
max-width: 80px;
line-height: 1.3;
&.done {
color: #059669;
font-weight: 600;
}
&.done-red {
color: #dc2626;
font-weight: 600;
}
&.current-amber {
color: #d97706;
font-weight: 600;
}
&.pending-gray {
color: #94a3b8;
}
}
.mini-flow-connector {
position: absolute;
top: 22px;
height: 2px;
z-index: 0;
&.done {
background: #10b981;
}
&.done-red {
background: #dc2626;
}
&.pending-gray {
background: #e2e8f0;
}
}
.flow-detail-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 14px;
}
.flow-detail-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
&--current-amber {
border-color: #fde68a;
background: #fffbeb;
}
&--done-red {
border-left: 3px solid #dc2626;
background: #fff;
}
&--pending-gray {
border-style: dashed;
background: #f8fafc;
}
}
.flow-detail-card__header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #f1f5f9;
background: #f8fafc;
.flow-detail-card--done-red & {
background: #fef2f2;
border-bottom-color: #fecaca;
}
.flow-detail-card--current-amber & {
background: #fffbeb;
border-bottom-color: #fde68a;
}
}
.flow-detail-card__dot {
width: 20px;
height: 20px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
&.done {
background: #10b981;
color: #fff;
}
&.done-red {
background: #dc2626;
color: #fff;
}
&.current-amber {
background: #fff;
border: 2px solid #d97706;
color: #d97706;
}
&.pending-gray {
background: #f1f5f9;
border: 2px solid #e2e8f0;
color: #94a3b8;
}
}
.flow-detail-card__title {
font-size: 13px;
font-weight: 600;
color: #1e293b;
&.done {
color: #059669;
}
&.done-red {
color: #dc2626;
}
&.current-amber {
color: #d97706;
}
}
.flow-detail-card__body {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.flow-detail-card__kv {
display: flex;
flex-direction: column;
gap: 2px;
}
.flow-detail-card__label {
font-size: 11px;
color: #94a3b8;
}
.flow-detail-card__value {
font-size: 13px;
color: #1e293b;
font-weight: 500;
word-break: break-word;
&.red {
color: #dc2626;
}
&.amber {
color: #d97706;
}
&.green {
color: #059669;
}
}
@media (max-width: 768px) {
.pdc-info-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -1,755 +0,0 @@
<!-- 输注计划出库链路只读区(生产/质检/物料/放行/交接/应用确认) -->
<template>
<div v-loading="loading" class="infusion-schedule-outbound-chain">
<template v-if="detail?.id || scheduleId">
<div class="outbound-chain-section">
<div class="section-subtitle">出库生产工单 + 任务</div>
<SampleProductionInfoCards :production-info="detail?.productionInfo" />
</div>
<div class="outbound-chain-section">
<div class="section-subtitle">出库质检工单 + 任务</div>
<SampleQcInfoCards :qc-info="detail?.qcInfo" hide-sample-context />
</div>
<div class="outbound-chain-section">
<div class="section-subtitle">出库物料准备</div>
<div class="outbound-material-view">
<div v-if="!hasOutboundMaterial" class="outbound-material-empty">暂无出库物料准备记录</div>
<template v-else>
<div v-if="outboundMaterialCells.length" class="outbound-material-block">
<h4 class="outbound-material-subtitle">出库细胞</h4>
<el-table
:data="outboundMaterialCells"
class="list-table inner-table"
stripe
size="small"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column
prop="productCode"
label="产品编码"
min-width="150"
show-overflow-tooltip
/>
<el-table-column
prop="productName"
label="产品名称"
min-width="120"
show-overflow-tooltip
/>
<el-table-column prop="productTypeName" label="类型" min-width="90" />
<el-table-column prop="specName" label="规格" min-width="90" />
<el-table-column
prop="storageSlotName"
label="储物格"
min-width="130"
show-overflow-tooltip
/>
<el-table-column label="状态" min-width="90" align="center">
<template #default="{ row }">
<el-tag
v-if="row.productStatus"
:type="cellProductStatusTagType(row.productStatus) as any"
size="small"
>
{{ cellProductStatusLabel(row.productStatus) }}
</el-tag>
<span v-else>—</span>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="outboundMaterialQcRows.length" class="outbound-material-block">
<h4 class="outbound-material-subtitle">使用的物料</h4>
<el-table
:data="outboundMaterialQcRows"
class="list-table inner-table"
stripe
size="small"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column
prop="materialName"
label="物料名称"
min-width="140"
show-overflow-tooltip
/>
<el-table-column prop="specModel" label="规格型号" min-width="110" />
<el-table-column label="计划用量" min-width="100" align="center">
<template #default="{ row }">{{ row.qty ?? '—' }}</template>
</el-table-column>
<el-table-column prop="unit" label="单位" min-width="72" align="center" />
</el-table>
</div>
<div v-if="outboundPaperRows.length" class="outbound-material-block">
<h4 class="outbound-material-subtitle">其他纸质材料</h4>
<el-table
:data="outboundPaperRows"
class="list-table inner-table"
stripe
size="small"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column prop="paperName" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column label="份数" min-width="80" align="center">
<template #default="{ row }">{{ row.copies ?? '—' }}</template>
</el-table-column>
<el-table-column label="附件" min-width="320">
<template #default="{ row }">
<div v-if="parsePaperAttachments(row.attachmentUrl).length" class="paper-att-cards">
<div
v-for="(att, attIdx) in parsePaperAttachments(row.attachmentUrl)"
:key="attIdx"
class="paper-att-card"
:class="{
'paper-att-card--image': isImageFile(att),
'paper-att-card--video': isVideoFile(att)
}"
:title="att.fileName"
>
<div
class="paper-att-card__preview"
:class="{
'is-clickable': isImageFile(att) || isVideoFile(att)
}"
@click="
(isImageFile(att) || isVideoFile(att)) &&
handlePreviewPaperAttachment(att)
"
>
<img
v-if="isImageFile(att)"
:src="attachmentFullUrl(att.attachmentUrl)"
:alt="att.fileName || '附件'"
/>
<template v-else-if="isVideoFile(att)">
<div class="paper-att-card__type-icon paper-att-card__type-icon--video">
<el-icon><VideoPlay /></el-icon>
</div>
</template>
<template v-else>
<el-icon class="paper-att-card__type-icon paper-att-card__type-icon--doc">
<Document />
</el-icon>
<span class="paper-att-card__name">{{ att.fileName || '附件' }}</span>
</template>
</div>
<div class="paper-att-card__actions">
<el-icon
v-if="isImageFile(att) || isVideoFile(att)"
class="paper-att-card__action"
title="预览"
@click.stop="handlePreviewPaperAttachment(att)"
>
<View />
</el-icon>
<el-icon
class="paper-att-card__action"
title="下载"
@click.stop="handleDownloadPaperAttachment(att)"
>
<Download />
</el-icon>
</div>
</div>
</div>
<span v-else>—</span>
</template>
</el-table-column>
</el-table>
</div>
<p v-if="outboundMaterialRemark" class="outbound-material-remark">
备注:{{ outboundMaterialRemark }}
</p>
</template>
</div>
</div>
<div class="outbound-chain-section">
<div class="section-subtitle">出库放行审核</div>
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">审核结论</div>
<div class="data-value">
<el-tag :type="reviewConclusionTag as any" size="small">
{{ reviewConclusionDisplay }}
</el-tag>
</div>
</div>
<div class="data-item">
<div class="data-label">审核人</div>
<div class="data-value">{{ dash(review.reviewByName) }}</div>
</div>
<div class="data-item">
<div class="data-label">审核时间</div>
<div class="data-value">{{ dash(review.reviewTime) }}</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">审核备注</div>
<div class="data-value data-value--muted">{{ dash(review.reviewRemark) }}</div>
</div>
</div>
<div v-if="reviewAttachmentFiles.length" class="attachment-row">
<div class="data-label">审核附件</div>
<ProtocolAttachmentReadonly
:files="reviewAttachmentFiles"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</div>
</div>
<div class="outbound-chain-section">
<div class="section-subtitle">出库交接</div>
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">交接人</div>
<div class="data-value">{{ dash(outboundHandover.handoverPerson) }}</div>
</div>
<div class="data-item">
<div class="data-label">交接时间</div>
<div class="data-value">{{ dash(outboundHandover.handoverTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">交接地点</div>
<div class="data-value">{{ dash(outboundHandover.handoverLocation) }}</div>
</div>
<div class="data-item">
<div class="data-label">接收人</div>
<div class="data-value">{{ dash(outboundHandover.receiverPerson) }}</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(outboundHandover.remark) }}</div>
</div>
</div>
</div>
<div class="outbound-chain-section">
<div class="section-subtitle">应用确认</div>
<div class="data-grid data-grid--3">
<template v-if="hasInfusionConfirm(infusionConfirm)">
<div class="data-item">
<div class="data-label">确认人</div>
<div class="data-value">{{ dash(infusionConfirm.confirmUserName) }}</div>
</div>
<div class="data-item">
<div class="data-label">确认时间</div>
<div class="data-value">{{ dash(infusionConfirm.confirmTime) }}</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">确认说明</div>
<div class="data-value data-value--muted">{{ dash(infusionConfirm.confirmDesc) }}</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(infusionConfirm.remark) }}</div>
</div>
</template>
<template v-else-if="isInfusionConfirmed">
<div class="data-item">
<div class="data-label">确认状态</div>
<div class="data-value">
<el-tag :type="infusionStatusTagType('yrqr') as any" size="small">
{{ infusionStatusLabel('yrqr') }}
</el-tag>
</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">说明</div>
<div class="data-value data-value--muted">
已在输注计划管理中确认,暂无独立应用确认记录
</div>
</div>
</template>
<div v-else class="data-item">
<div class="data-label">确认状态</div>
<div class="data-value">
<el-tag type="info" size="small">未确认</el-tag>
</div>
</div>
</div>
</div>
</template>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
destroy-on-close
append-to-body
style="backdrop-filter: none"
>
<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>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Document, Download, View, VideoPlay } from '@element-plus/icons-vue'
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
import SampleQcInfoCards from '@/components/business/sample-qc-info-cards/index.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { fetchInfusionScheduleMgmtDetail } from '@/api/admin'
import {
storageReleaseConclusionLabel,
storageReleaseConclusionTagType,
infusionStatusLabel,
infusionStatusTagType,
cellProductStatusLabel,
cellProductStatusTagType
} from '@/utils/status-tag-registry'
import { downloadAttachmentFile } from '@/utils/download-attachment'
defineOptions({ name: 'InfusionScheduleOutboundChainView' })
const props = withDefaults(
defineProps<{
scheduleId?: string
/** 外部已加载的详情(如 ScheduleViewDialog),传入则不再请求 */
detailData?: Record<string, any> | null
/** 懒加载:为 false 时不请求(申请单查看折叠态) */
active?: boolean
}>(),
{
scheduleId: '',
detailData: null,
active: true
}
)
const loading = ref(false)
const fetchedDetail = ref<Record<string, any> | null>(null)
const previewVisible = ref(false)
const previewUrl = ref('')
const previewType = ref<'image' | 'video'>('image')
type PaperAttachmentView = {
fileName: string
attachmentUrl: string
fileUrl: string
fileType?: string
}
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const detail = computed(() => props.detailData || fetchedDetail.value)
const review = computed(() => detail.value?.reviewInfo || {})
const outboundHandover = computed(() => detail.value?.outboundHandover || {})
const infusionConfirm = computed(() => detail.value?.infusionConfirm || {})
const outboundMaterial = computed(() => detail.value?.outboundMaterial || null)
const outboundMaterialCells = computed(() => outboundMaterial.value?.cells || [])
const outboundMaterialQcRows = computed(() => outboundMaterial.value?.qcMaterials || [])
const outboundMaterialRemark = computed(() => outboundMaterial.value?.remark || '')
const outboundPaperRows = computed(() => {
const materials = outboundMaterial.value?.paperMaterials || []
if (materials.length) {
return materials.map((p: Record<string, any>) => ({
paperName: p.paperName || '',
copies: p.copies,
attachmentUrl: p.attachmentUrl || ''
}))
}
const urls = outboundMaterial.value?.paperAttachments || ''
if (!urls.trim()) return []
return urls
.split(',')
.map((u: string) => u.trim())
.filter(Boolean)
.map((attachmentUrl: string) => ({
paperName: paperAttachmentName(attachmentUrl),
copies: 1,
attachmentUrl
}))
})
const hasOutboundMaterial = computed(
() =>
outboundMaterialCells.value.length > 0 ||
outboundMaterialQcRows.value.length > 0 ||
outboundPaperRows.value.length > 0 ||
!!outboundMaterialRemark.value.trim()
)
const reviewAttachmentFiles = computed(() => {
const urls = review.value?.reviewAttachments || ''
if (!urls.trim()) return []
return urls
.split(',')
.map((u: string) => u.trim())
.filter(Boolean)
.map((fileUrl: string, i: number) => ({
id: `att_${i}`,
fileUrl,
fileName: fileUrl.split('/').pop() || ''
}))
})
const reviewConclusionValue = computed(() => review.value?.reviewConclusion || '')
const reviewConclusionDisplay = computed(() =>
reviewConclusionValue.value
? storageReleaseConclusionLabel(reviewConclusionValue.value)
: '待审核'
)
const reviewConclusionTag = computed(() =>
reviewConclusionValue.value
? storageReleaseConclusionTagType(reviewConclusionValue.value)
: 'info'
)
const isInfusionConfirmed = computed(() => {
const d = detail.value || {}
const sched = d.infusionSchedule || {}
return sched.infusionStatus === 'yrqr' || d.infusionStatus === 'yrqr'
})
function dash(v: unknown) {
if (v == null || v === '') return '—'
return String(v)
}
function hasInfusionConfirm(confirm: Record<string, any>) {
return !!(confirm?.id || confirm?.confirmTime || confirm?.confirmUserName)
}
function paperAttachmentName(url: string) {
return url.split('/').pop() || '附件'
}
function splitUrls(str: string): string[] {
if (!str?.trim()) return []
return str
.split(',')
.map((u) => u.trim())
.filter(Boolean)
}
function parsePaperAttachments(urlStr: string): PaperAttachmentView[] {
return splitUrls(urlStr).map((url) => ({
fileName: paperAttachmentName(url),
attachmentUrl: url,
fileUrl: url
}))
}
function isImageFile(file: PaperAttachmentView) {
const url = (file.fileUrl || file.attachmentUrl || '').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: PaperAttachmentView) {
const url = (file.fileUrl || file.attachmentUrl || '').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/')
)
}
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 handlePreviewPaperAttachment(att: PaperAttachmentView) {
if (!att.attachmentUrl) return
previewUrl.value = attachmentFullUrl(att.attachmentUrl)
previewType.value = isVideoFile(att) ? 'video' : 'image'
previewVisible.value = true
}
function handleDownloadPaperAttachment(att: PaperAttachmentView) {
if (!att.attachmentUrl) return
downloadAttachmentFile(
{
fileName: att.fileName,
fileUrl: att.attachmentUrl,
fileType: att.fileType || ''
},
{ fullFileUrl: attachmentFullUrl }
)
}
function handleAttachmentPreview(file: Record<string, unknown>) {
const url = attachmentFullUrl(String(file.fileUrl || ''))
if (url) window.open(url, '_blank')
}
function handleAttachmentDownload(file: Record<string, unknown>) {
downloadAttachmentFile(file, { fullFileUrl: attachmentFullUrl })
}
async function loadDetail() {
const id = (props.scheduleId || '').trim()
if (!id || props.detailData) return
loading.value = true
try {
const res: any = await fetchInfusionScheduleMgmtDetail({ scheduleId: id })
fetchedDetail.value =
res && typeof res === 'object' && !Array.isArray(res)
? (res as Record<string, any>)
: ((res as any)?.data ?? null)
} catch {
fetchedDetail.value = null
} finally {
loading.value = false
}
}
watch(
() => [props.scheduleId, props.active, props.detailData] as const,
([id, active, external]) => {
if (external) {
fetchedDetail.value = null
return
}
if (!id || !active) return
loadDetail()
},
{ immediate: true }
)
</script>
<style lang="scss" scoped>
.infusion-schedule-outbound-chain {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed #e2e8f0;
}
.outbound-chain-section {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
.section-subtitle {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: #475569;
}
.outbound-material-view {
width: 100%;
}
.outbound-material-empty {
font-size: 13px;
color: #94a3b8;
}
.outbound-material-block {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.outbound-material-subtitle {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
color: #64748b;
}
.outbound-material-remark {
margin: 12px 0 0;
font-size: 13px;
color: #64748b;
}
.attachment-row {
margin-top: 12px;
}
.data-grid {
display: grid;
gap: 12px 16px;
&--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.data-item--full {
grid-column: 1 / -1;
}
.data-label {
font-size: 11px;
color: #64748b;
margin-bottom: 4px;
}
.data-value {
font-size: 14px;
color: #0f172a;
font-weight: 500;
word-break: break-word;
&--muted {
color: #475569;
font-weight: 400;
}
}
:deep(.sample-card__section-label) {
display: none;
}
:deep(.sample-card__section--qc),
:deep(.sample-card__section--production) {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.paper-att-cards {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.paper-att-card {
width: 100px;
height: 72px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
padding: 0;
position: relative;
overflow: hidden;
}
.paper-att-card__preview {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 6px 4px;
overflow: hidden;
&.is-clickable {
cursor: pointer;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.paper-att-card--image,
.paper-att-card--video {
.paper-att-card__preview {
padding: 0;
}
}
.paper-att-card__type-icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
margin-bottom: 2px;
&--doc {
color: #0284c7;
}
&--video {
width: 36px;
height: 36px;
border-radius: 8px;
background: #ede9fe;
color: #7c3aed;
font-size: 20px;
margin-bottom: 4px;
}
}
.paper-att-card__name {
width: 100%;
font-size: 11px;
color: #334155;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.paper-att-card__actions {
position: absolute;
top: 2px;
right: 2px;
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.paper-att-card:hover .paper-att-card__actions {
opacity: 1;
}
.paper-att-card__action {
width: 18px;
height: 18px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: #fff;
background: rgba(0, 0, 0, 0.35);
cursor: pointer;
&:hover {
background: #10b981;
}
}
.preview-container {
text-align: center;
}
</style>
@@ -1,133 +0,0 @@
<template>
<teleport to="body">
<div
v-show="visibleProxy"
class="order-operate-bridge-layer"
@click.self="visibleProxy = false"
>
<div class="order-operate-bridge-dialog">
<iframe ref="frameRef" class="order-operate-bridge-frame" :src="frameSrc" frameborder="0" />
</div>
</div>
</teleport>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps<{
modelValue: boolean
orderId: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const visibleProxy = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const frameSrc = computed(() => {
return `#/demo/order?embedded=1`
})
const frameRef = ref<HTMLIFrameElement | null>(null)
const handleMessage = (ev: MessageEvent) => {
const data = ev?.data as any
if (!data || typeof data !== 'object') return
if (data.type === 'sqxp-order-operate-close') {
visibleProxy.value = false
}
}
const postOpen = (id: string) => {
const win = frameRef.value?.contentWindow
if (!win) return
win.postMessage({ type: 'sqxp-order-operate-open', orderId: id }, '*')
}
onMounted(() => {
window.addEventListener('message', handleMessage)
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleMessage)
})
watch(
() => [visibleProxy.value, props.orderId] as const,
([vis, id]) => {
if (!vis) return
const orderId = (id || '').trim()
if (!orderId) return
// 给 iframe 一帧时间完成渲染/初始化后再发消息,避免首次打开时丢消息
requestAnimationFrame(() => postOpen(orderId))
},
{ immediate: true }
)
</script>
<style lang="scss" scoped>
.order-operate-bridge-layer {
position: fixed;
inset: 0;
z-index: 4000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.28);
padding: 48px 24px;
box-sizing: border-box;
}
.order-operate-bridge-dialog {
width: min(1400px, calc(100vw - 48px));
max-height: calc(100vh - 96px);
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow:
0 20px 64px rgba(15, 23, 42, 0.25),
0 2px 12px rgba(15, 23, 42, 0.12);
display: flex;
flex-direction: column;
}
.order-operate-bridge-frame {
width: 100%;
height: 100%;
min-height: 680px;
max-height: calc(100vh - 96px);
display: block;
background: transparent;
border: 0;
flex: 1 1 auto;
}
.order-operate-bridge-empty {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
}
@media (max-width: 900px) {
.order-operate-bridge-layer {
padding: 20px 10px;
}
.order-operate-bridge-dialog {
width: calc(100vw - 20px);
max-height: calc(100vh - 40px);
border-radius: 10px;
}
.order-operate-bridge-frame {
min-height: 520px;
max-height: calc(100vh - 40px);
}
}
</style>
@@ -1,72 +0,0 @@
<template>
<teleport to="body">
<div v-show="visibleProxy" class="order-operate-local-layer" @click.self="visibleProxy = false">
<iframe
class="order-operate-local-frame"
:src="frameSrc"
frameborder="0"
allowtransparency="true"
/>
</div>
</teleport>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue'
const props = defineProps<{
modelValue: boolean
orderId: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const visibleProxy = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const frameSrc = computed(() => {
const id = encodeURIComponent((props.orderId || '').trim())
return id ? `#/demo/order?bridge=1&viewOrderId=${id}` : '#/demo/order?bridge=1'
})
const handleMessage = (ev: MessageEvent) => {
const data = ev?.data as any
if (!data || typeof data !== 'object') return
if (data.type === 'sqxp-order-operate-close') {
visibleProxy.value = false
}
}
onMounted(() => {
window.addEventListener('message', handleMessage)
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleMessage)
})
</script>
<style lang="scss" scoped>
.order-operate-local-layer {
position: fixed;
inset: 0;
z-index: 4000;
display: flex;
flex-direction: column;
background: transparent;
overflow: hidden;
}
.order-operate-local-frame {
width: 100%;
height: 100%;
min-height: 0;
display: block;
background: transparent;
border: 0;
}
</style>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,272 +0,0 @@
<!-- 生产任务 — 只读查看(任务信息 + 产出细胞产品) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<SystemViewDialog
v-model:visible="innerVisible"
title="生产任务详情"
width="960px"
:grid-columns="3"
:icon="Operation"
:meta="viewDetail?.taskNo || ''"
:sections="viewSections"
:loading="loading"
append-to-body
compact
modal-class="production-task-view-dialog-mask"
>
<template #task-outputs>
<div v-if="viewOutputs.length" class="view-subtable-wrap">
<el-table :data="viewOutputs" stripe class="list-table view-subtable">
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
header-align="center"
:index="(index) => index + 1"
/>
<el-table-column
label="产品编码"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{
row.productCode || row.outputBarcode || '—'
}}</template>
</el-table-column>
<el-table-column
label="产品名称"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.productName || row.outputName || '—' }}</template>
</el-table-column>
<el-table-column label="产品类型" min-width="100" align="center" header-align="center">
<template #default="{ row }">{{ row.productTypeName || '—' }}</template>
</el-table-column>
<el-table-column label="规格" min-width="80" align="center" header-align="center">
<template #default="{ row }">{{ row.specName || '—' }}</template>
</el-table-column>
<el-table-column
prop="passage"
label="代次"
min-width="70"
align="center"
header-align="center"
/>
<el-table-column
prop="batchNo"
label="批次号"
min-width="110"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="储物格位置" min-width="160" align="center" header-align="center">
<template #default="{ row }">{{ row.storageSlotName || '—' }}</template>
</el-table-column>
<el-table-column
label="预计细胞数量(亿)"
min-width="130"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.expectedCellCount ?? '—' }}</template>
</el-table-column>
<el-table-column label="活性" min-width="80" align="center" header-align="center">
<template #default="{ row }">{{
row.viability != null ? row.viability : '—'
}}</template>
</el-table-column>
<el-table-column label="冻存日期" min-width="110" align="center" header-align="center">
<template #default="{ row }">{{ formatDateOnly(row.freezeDate) }}</template>
</el-table-column>
<el-table-column label="有效期" min-width="110" align="center" header-align="center">
<template #default="{ row }">{{ formatDateOnly(row.expiryDate) }}</template>
</el-table-column>
<el-table-column label="数量" min-width="80" align="center" header-align="center">
<template #default="{ row }">{{ row.quantity ?? '—' }}</template>
</el-table-column>
<el-table-column
prop="unit"
label="单位"
min-width="70"
align="center"
header-align="center"
/>
<el-table-column label="状态" min-width="90" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="productStatusTagType(row.productStatus)" size="small">
{{ productStatusLabel(row.productStatus) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<p v-else class="view-empty-hint">暂无产出产品</p>
</template>
</SystemViewDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Operation } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { fetchProductionTaskDetail } from '@/api/production-task'
import { productionConclusionLabel, productionConclusionTagType } from '@/utils/production-labels'
const props = defineProps<{
visible: boolean
taskId?: string
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const innerVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
const loading = ref(false)
const viewDetail = ref<Record<string, any> | null>(null)
const TASK_STATUS_LABELS: Record<string, string> = {
dpz: '待配置',
dzx: '待执行',
jxz: '执行中',
ywc: '已完成'
}
const TASK_TYPE_LABELS: Record<string, string> = {
cell_storage: '入库生产单',
outbound: '出库生产单'
}
watch(
() => [props.visible, props.taskId] as const,
async ([vis, id]) => {
if (!vis || !id) {
viewDetail.value = null
return
}
loading.value = true
try {
viewDetail.value = await fetchProductionTaskDetail(id)
} catch {
viewDetail.value = null
} finally {
loading.value = false
}
},
{ immediate: true }
)
function taskStatusLabel(status?: string) {
return TASK_STATUS_LABELS[status || ''] || status || '—'
}
function taskStatusTagType(status?: string) {
if (status === 'ywc') return 'success' as const
if (status === 'jxz') return 'warning' as const
if (status === 'dzx') return 'info' as const
return 'info' as const
}
function taskTypeLabel(taskType?: string) {
return TASK_TYPE_LABELS[taskType || ''] || taskType || '—'
}
function taskTypeTagType(taskType?: string) {
if (taskType === 'cell_storage') return 'primary' as const
if (taskType === 'outbound') return 'warning' as const
return 'info' as const
}
function productStatusTagType(status?: string) {
const map: Record<string, 'success' | 'warning' | 'danger' | 'info'> = {
frozen: 'success',
in_production: 'warning',
voided: 'danger',
thawed: 'info'
}
return map[status || ''] || 'info'
}
function productStatusLabel(status?: string) {
const map: Record<string, string> = {
frozen: '已冻存',
in_production: '生产中',
voided: '已作废',
thawed: '已复苏'
}
return map[status || ''] || status || '—'
}
function formatDateOnly(val: unknown) {
if (!val) return '—'
const s = String(val)
return s.length >= 10 ? s.slice(0, 10) : s
}
const viewOutputs = computed(() => {
const rows = (viewDetail.value?.outputs as Array<Record<string, any>>) || []
return rows.map((row) => ({
...row,
productCode: row.productCode || row.outputBarcode || '',
productName: row.productName || row.outputName || ''
}))
})
const viewSections = computed<ViewSection[]>(() => {
const d = viewDetail.value
if (!d) return []
return [
{
title: '任务信息',
fields: [
{ label: '任务号', value: d.taskNo || '—' },
{ label: '任务名称', value: d.taskName || '—' },
{ label: '工单号', value: d.workOrderNo || '—' },
{
label: '任务类型',
value: taskTypeLabel(d.taskType),
tagType: taskTypeTagType(d.taskType)
},
{
label: '任务状态',
value: taskStatusLabel(d.taskStatus),
tagType: taskStatusTagType(d.taskStatus)
},
{ label: '协议号', value: d.protocolNo || '—' },
{ label: '样本编码', value: d.sampleCode || '—' },
{ label: '开始时间', value: d.startedTime || '—' },
{
label: '生产结论',
value: productionConclusionLabel(d.conclusion),
tagType: productionConclusionTagType(d.conclusion)
},
{ label: '完成时间', value: d.completedTime || '—' },
{ label: '备注', value: d.remark || '—', span: true }
]
},
{
title: '产出(细胞产品)',
fields: [],
slot: 'task-outputs'
}
]
})
</script>
<style lang="scss">
.production-task-view-dialog-mask {
z-index: 4000 !important;
}
</style>
@@ -1,14 +0,0 @@
export type FinanceAttachmentItem = {
id: string
fileName: string
fileUrl: string
fileType?: string
_uploading?: boolean
}
export function financeAttachmentsToUrlString(items: FinanceAttachmentItem[]) {
return items
.filter((f) => f.fileUrl && !f._uploading)
.map((f) => f.fileUrl)
.join(',')
}
@@ -1,362 +0,0 @@
<!-- 财务确认弹窗:附件上传 + 财务备注(对齐采集协议单附件 UI) -->
<template>
<div class="protocol-finance-confirm-fields">
<el-form-item v-if="showRemark" :label="remarkLabel" class="form-col-span-2">
<el-input
:model-value="financeRemark"
type="textarea"
:rows="3"
placeholder="选填"
@update:model-value="emit('update:financeRemark', $event)"
/>
</el-form-item>
<el-form-item :label="attachmentLabel" class="form-col-span-2">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
: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="22" color="#10b981"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
<div
v-for="(file, fIdx) in attachments"
:key="file.id || fIdx"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div
class="attachment-preview"
@click="isImageFile(file) && file.fileUrl ? handlePreview(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
v-if="file.fileUrl"
class="action-btn"
title="下载"
@click.stop="downloadFile(file)"
>
<Download />
</el-icon>
<el-icon class="action-btn" title="删除" @click.stop="removeAttachment(fIdx)">
<Delete />
</el-icon>
</div>
</div>
</div>
</div>
<p class="upload-hint">支持图片、视频、文档等。</p>
</div>
</el-form-item>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { Plus, Document, Delete, Download, Loading, 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(
defineProps<{
financeRemark: string
attachments: FinanceAttachmentItem[]
uploadFolder?: string
remarkLabel?: string
attachmentLabel?: string
showRemark?: boolean
}>(),
{
uploadFolder: 'protocol-finance-confirm',
remarkLabel: '财务备注',
attachmentLabel: '财务确认附件',
showRemark: true
}
)
const emit = defineEmits<{
(e: 'update:financeRemark', val: string): void
(e: 'update:attachments', val: FinanceAttachmentItem[]): void
}>()
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function fullFileUrl(url: string) {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
return minioBaseUrl ? `${minioBaseUrl}/${url.replace(/^\//, '')}` : url
}
function isImageFile(file: FinanceAttachmentItem) {
const t = file.fileType || ''
const n = file.fileName || ''
return t.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(n)
}
function isVideoFile(file: FinanceAttachmentItem) {
const t = file.fileType || ''
const n = file.fileName || ''
return t.startsWith('video/') || /\.(mp4|webm|mov|avi|mkv)$/i.test(n)
}
function handlePreview(file: FinanceAttachmentItem) {
if (!file.fileUrl) return
window.open(fullFileUrl(file.fileUrl), '_blank')
}
function downloadFile(file: FinanceAttachmentItem) {
downloadAttachmentFile(file, { fullFileUrl })
}
function removeAttachment(index: number) {
const next = [...props.attachments]
next.splice(index, 1)
emit('update:attachments', next)
}
/** Mutable snapshot used to avoid race conditions when multiple files are picked at once */
let _pendingList: FinanceAttachmentItem[] | null = null
function _getList(): FinanceAttachmentItem[] {
if (_pendingList) return _pendingList
return props.attachments
}
async function handleAttachmentChange(uploadFile: { raw?: File }) {
const file = uploadFile.raw
if (!file) return
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const uploading: FinanceAttachmentItem = {
id: tempId,
fileName: file.name,
fileUrl: '',
fileType: file.type || '',
_uploading: true
}
// Append to the latest snapshot so concurrent calls don't overwrite each other
const current = _getList()
const listWithTemp = [...current, uploading]
_pendingList = listWithTemp
emit('update:attachments', listWithTemp)
try {
const fd = new FormData()
fd.append('folder', props.uploadFolder)
fd.append('file', file)
const res = await fetchUploadAttachment(fd)
// Read from latest snapshot (NOT the closure-captured listWithTemp) to avoid
// overwriting sibling uploads that may have already completed while this one was in-flight
const latest = _pendingList || props.attachments
const updated = latest.map((a) =>
a.id === tempId
? {
id: res.id || tempId,
fileName: file.name || res.fileName,
fileUrl: res.fileUrl,
fileType: res.fileType || file.type || '',
_uploading: false
}
: a
)
_pendingList = updated
emit('update:attachments', updated)
} catch {
ElMessage.error('文件上传失败')
const latest = _pendingList || props.attachments
const remaining = latest.filter((a) => a.id !== tempId)
_pendingList = remaining.length > 0 ? remaining : null
emit('update:attachments', remaining)
}
}
</script>
<style scoped lang="scss">
.protocol-finance-confirm-fields {
width: 100%;
}
.attachment-upload {
width: 100%;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.upload-card {
width: 140px;
height: 96px;
border: 1px dashed #a7f3d0;
border-radius: 8px;
background: #f0fdf4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s;
&:hover {
border-color: #10b981;
border-style: solid;
background: #ecfdf5;
}
}
.upload-card-text {
font-size: 12px;
color: #10b981;
font-weight: 500;
line-height: 1;
}
.upload-hint {
display: block;
font-size: 11px;
color: #94a3b8;
line-height: 1;
margin-top: 8px;
}
.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;
}
}
</style>
@@ -1,258 +0,0 @@
<!-- 缴费明细只读信息(核对用) -->
<template>
<div class="protocol-payment-detail-readonly">
<h4 v-if="title" class="detail-block-title">{{ title }}</h4>
<dl class="view-kv-grid view-kv-grid--4">
<div class="view-kv">
<dt>协议号</dt>
<dd>{{ dash(detail?.protocolNo) }}</dd>
</div>
<div class="view-kv">
<dt>协议状态</dt>
<dd>{{ protocolStatusLabel(String(detail?.protocolStatus || '')) }}</dd>
</div>
<div class="view-kv">
<dt>缴费方式</dt>
<dd>{{ payModeLabel(String(detail?.payMode || '')) }}</dd>
</div>
<div class="view-kv">
<dt>缴费状态</dt>
<dd>
<el-tag :type="payStatusTagType(String(detail?.payStatus || ''))" size="small">
{{ payStatusLabel(String(detail?.payStatus || '')) }}
</el-tag>
</dd>
</div>
<template v-if="showCustomer">
<div class="view-kv">
<dt>客户姓名</dt>
<dd>{{ dash(detail?.motherName) }}</dd>
</div>
<div class="view-kv">
<dt>身份证号</dt>
<dd>{{ maskIdCard(String(detail?.motherIdCard || '')) }}</dd>
</div>
<div class="view-kv">
<dt>新生儿姓名</dt>
<dd>{{ dash(detail?.newbornName) }}</dd>
</div>
<div class="view-kv">
<dt>新生儿身份证号</dt>
<dd>{{ maskIdCard(String(detail?.newbornIdCard || '')) }}</dd>
</div>
</template>
<div v-if="detail?.payMode === 'fq'" class="view-kv">
<dt>分期期序号</dt>
<dd>{{ dash(detail?.installmentNo) }}</dd>
</div>
<div v-if="detail?.payMode === 'fq'" class="view-kv">
<dt>应缴费时间</dt>
<dd>{{ formatPaymentDueDate(detail?.installmentDueDate) }}</dd>
</div>
<div class="view-kv">
<dt>应缴费金额(元)</dt>
<dd>{{ formatPaymentAmount(detail?.dueAmount) }}</dd>
</div>
<div class="view-kv">
<dt>转账方式</dt>
<dd>{{ transferTypeLabel(String(detail?.transferType || '')) }}</dd>
</div>
<div class="view-kv">
<dt>转账金额(元)</dt>
<dd>{{ formatPaymentAmount(detail?.transferAmount) }}</dd>
</div>
<div class="view-kv">
<dt>转账时间</dt>
<dd>{{ dash(detail?.transferTime) }}</dd>
</div>
<div v-if="detail?.remark" class="view-kv view-kv-span-2">
<dt>备注</dt>
<dd>{{ dash(detail?.remark) }}</dd>
</div>
<div class="view-field-block view-kv-span-2 payment-detail-readonly__attachments">
<div class="view-field-block__label">转账凭证</div>
<ProtocolAttachmentReadonly
:files="transferAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</dl>
<template v-if="showRefundApply && hasRefundApplyInfo">
<div class="form-section-divider form-section-divider--inner" />
<h4 v-if="refundApplyTitle" class="detail-block-title">{{ refundApplyTitle }}</h4>
<dl class="view-kv-grid view-kv-grid--4">
<div class="view-kv">
<dt>退费金额(元)</dt>
<dd>{{ formatPaymentAmount(detail?.refundAmount) }}</dd>
</div>
<div v-if="detail?.refundRemark" class="view-kv view-kv-span-2">
<dt>退费备注</dt>
<dd>{{ dash(detail?.refundRemark) }}</dd>
</div>
<div class="view-field-block view-kv-span-2 payment-detail-readonly__attachments">
<div class="view-field-block__label">退费附件</div>
<ProtocolAttachmentReadonly
:files="refundAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</dl>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { maskIdCard } from '@/utils/mask-sensitive'
import {
formatPaymentAmount,
formatPaymentDueDate,
payModeLabel,
payStatusLabel,
payStatusTagType,
protocolStatusLabel,
transferTypeLabel
} from '@/utils/protocol-payment-labels'
const props = withDefaults(
defineProps<{
detail?: Record<string, unknown> | null
title?: string
showCustomer?: boolean
showRefundApply?: boolean
refundApplyTitle?: string
}>(),
{ showCustomer: true, showRefundApply: false, refundApplyTitle: '退费申请(请核对)' }
)
const emit = defineEmits<{
(e: 'preview', file: Record<string, unknown>): void
(e: 'download', file: Record<string, unknown>): void
}>()
function dash(v: unknown) {
if (v == null || v === '') return '—'
return String(v)
}
const transferAttachmentFiles = computed(() => {
const row = props.detail
if (!row) return []
if (Array.isArray(row.attachments)) {
return row.attachments as Array<Record<string, unknown>>
}
const urls = String(row.transferAttachments || '')
if (!urls.trim()) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `url_${i}`, fileUrl: url, fileName: name }
})
.filter(Boolean) as Array<Record<string, unknown>>
})
const refundAttachmentFiles = computed(() => {
const row = props.detail
if (!row) return []
if (Array.isArray(row.refundAttachmentFiles)) {
return row.refundAttachmentFiles as Array<Record<string, unknown>>
}
const urls = String(row.refundAttachments || '')
if (!urls.trim()) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `url_${i}`, fileUrl: url, fileName: name }
})
.filter(Boolean) as Array<Record<string, unknown>>
})
const hasRefundApplyInfo = computed(() => {
const row = props.detail
if (!row) return false
const amount = row.refundAmount
const hasAmount = amount != null && amount !== '' && Number(amount) > 0
return (
hasAmount ||
String(row.refundRemark || '').trim() !== '' ||
refundAttachmentFiles.value.length > 0
)
})
</script>
<style scoped lang="scss">
.protocol-payment-detail-readonly {
width: 100%;
}
.detail-block-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #334155);
line-height: 1.4;
}
.view-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 20px;
margin: 0;
}
.view-kv-grid--4 {
@media (min-width: 720px) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.view-kv {
min-width: 0;
dt {
margin: 0 0 4px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
font-weight: 500;
}
dd {
margin: 0;
font-size: 14px;
color: var(--mint-text, #334155);
line-height: 1.5;
word-break: break-word;
}
}
.view-kv-span-2 {
grid-column: 1 / -1;
}
.view-field-block__label {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
font-weight: 500;
margin-bottom: 8px;
}
.payment-detail-readonly__attachments {
margin-top: 4px;
}
.form-section-divider--inner {
height: 1px;
margin: 16px 0;
background: var(--mint-border, #e2e8f0);
}
</style>
@@ -1,443 +0,0 @@
<!-- 质检任务 — 只读查看(参数、结论、附件) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<SystemViewDialog
v-model:visible="innerVisible"
title="质检任务详情"
width="960px"
:grid-columns="3"
:icon="Tickets"
:meta="viewDetail?.taskNo || ''"
:sections="viewSections"
:loading="loading"
append-to-body
compact
modal-class="qc-task-view-dialog-mask"
>
<template #reception-extra>
<div class="view-reception-attachments">
<div class="view-kv view-kv-span-2">
<dt>接收附件</dt>
<dd>
<ProtocolAttachmentReadonly
:files="viewReceptionAttachmentFiles"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</dd>
</div>
</div>
</template>
<template #material-usages>
<div v-if="viewMaterialUsages.length" class="view-subtable-wrap">
<el-table :data="viewMaterialUsages" stripe class="list-table view-subtable">
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
header-align="center"
:index="(index) => index + 1"
/>
<el-table-column
prop="materialName"
label="物料名称"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column label="规格型号" min-width="120" align="center" header-align="center">
<template #default="{ row }">{{ row.specModel || '—' }}</template>
</el-table-column>
<el-table-column label="用量" min-width="100" align="center" header-align="center">
<template #default="{ row }">
{{ row.qty != null && row.qty !== '' ? row.qty : '—' }}
</template>
</el-table-column>
<el-table-column label="单位" min-width="72" align="center" header-align="center">
<template #default="{ row }">{{ row.unit || '—' }}</template>
</el-table-column>
<el-table-column label="备注" min-width="140" align="center" header-align="center">
<template #default="{ row }">{{ row.remark || '—' }}</template>
</el-table-column>
</el-table>
</div>
<p v-else class="view-empty-hint">暂无物料使用记录</p>
</template>
<template #task-attachments>
<ProtocolAttachmentReadonly
:files="viewDetail?.attachmentFiles || []"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</template>
</SystemViewDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Tickets } from '@element-plus/icons-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 { fetchQcTaskDetail, type QcTaskDetail } from '@/api/qc-task'
import {
fetchQcTaskResults,
fetchQcTaskMaterialUsages,
type QcTaskMaterialUsageItem
} from '@/api/qc-task-result'
import { fetchQcMaterialOptions, type QcMaterialOption } from '@/api/qc-material'
import {
taskTypeLabel,
taskStatusLabel,
qcBizTypeLabel,
qcBizTypeTagType,
receptionSourceLabel,
acceptConclusionLabel,
transportTypeLabel,
workOrderStatusLabel,
qcTaskConclusionLabel
} from '@/utils/qc-labels'
import { downloadAttachmentFile } from '@/utils/download-attachment'
const props = defineProps<{
visible: boolean
taskId?: string
/** 不展示样本信息、运输信息、样本接收(出库放行审核等) */
hideSampleContext?: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const innerVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
type ViewMaterialUsageRow = {
materialId: string
materialName: string
specModel: string
unit: string
qty: number | undefined
remark: string
}
const loading = ref(false)
const viewDetail = ref<QcTaskDetail | null>(null)
const viewMaterialUsages = ref<ViewMaterialUsageRow[]>([])
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function materialRowsFromApi(items: QcTaskMaterialUsageItem[]): ViewMaterialUsageRow[] {
const merged = new Map<string, ViewMaterialUsageRow>()
for (const u of items || []) {
const mid = (u.materialId || '').trim()
if (!mid) continue
const qtyRaw = Math.abs(Number(u.changeQty) || 0)
const prev = merged.get(mid)
if (prev) {
prev.qty = (Number(prev.qty) || 0) + qtyRaw
} else {
merged.set(mid, {
materialId: mid,
materialName: u.materialName || '',
specModel: '',
unit: '',
qty: qtyRaw > 0 ? qtyRaw : undefined,
remark: u.remark || ''
})
}
}
return Array.from(merged.values())
}
function enrichMaterialUsageRows(rows: ViewMaterialUsageRow[], options: QcMaterialOption[]) {
for (const row of rows) {
const opt = options.find((o) => o.id === row.materialId)
if (opt) {
row.materialName = opt.materialName || row.materialName
row.specModel = opt.specModel || ''
row.unit = opt.unit || ''
}
}
}
watch(
() => [props.visible, props.taskId] as const,
async ([vis, id]) => {
if (!vis || !id) {
viewDetail.value = null
viewMaterialUsages.value = []
return
}
loading.value = true
try {
const detail = await fetchQcTaskDetail(id)
try {
const results = await fetchQcTaskResults(id)
;(detail as QcTaskDetail).results = results || []
} catch {
;(detail as QcTaskDetail).results = []
}
viewMaterialUsages.value = []
if (detail.taskType !== 'dsfjc') {
try {
const [savedUsages, opts] = await Promise.all([
fetchQcTaskMaterialUsages(id).catch(() => [] as QcTaskMaterialUsageItem[]),
fetchQcMaterialOptions().catch(() => [] as QcMaterialOption[])
])
const rows = materialRowsFromApi(savedUsages || [])
enrichMaterialUsageRows(rows, opts || [])
viewMaterialUsages.value = rows
} catch {
viewMaterialUsages.value = []
}
}
viewDetail.value = detail
} catch {
viewDetail.value = null
viewMaterialUsages.value = []
} finally {
loading.value = false
}
},
{ immediate: true }
)
function formatMetric(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
return String(v)
}
function formatCheckResults(items?: Array<{ itemName?: string; checked?: boolean }>) {
if (!items?.length) return '—'
return items.map((x) => `${x.itemName || ''}${x.checked ? '(✓)' : '(×)'}`).join('、')
}
const viewReceptionAttachmentFiles = computed(() => {
const r = viewDetail.value?.reception
if (!r) return []
if (Array.isArray(r.receiveAttachmentFiles) && r.receiveAttachmentFiles.length) {
return r.receiveAttachmentFiles as Array<Record<string, unknown>>
}
const urls = String(r.receiveAttachments || '')
if (!urls.trim()) return []
return urls
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((fileUrl, i) => ({ id: `rec_${i}`, fileUrl, fileName: fileUrl.split('/').pop() || '' }))
})
const viewSections = computed<ViewSection[]>(() => {
const d = viewDetail.value
if (!d) return []
const r = d.reception || {}
const isTempQc = d.qcBizType === 'temporary'
const transportFields: ViewSection['fields'] = [
{ label: '运输方式', value: transportTypeLabel(String(r.transportType || '')) }
]
if (r.transportType === 'kdys') {
transportFields.push(
{ label: '快递公司', value: r.expressCompanyName || '—' },
{ label: '快递单号', value: r.waybillNo || '—' }
)
} else if (r.transportType === 'ryxd') {
transportFields.push(
{ label: '携带人', value: r.carrierName || '—' },
{ label: '携带人电话', value: r.carrierPhone || '—' }
)
}
if (r.etaTime) {
transportFields.push({ label: '预计到达', value: r.etaTime })
}
const cellProductSection: ViewSection[] = isTempQc
? [
{
title: '质检对象',
fields: [
{ label: '产品编码', value: d.cellProductCode || '—' },
{ label: '产品名称', value: d.cellProductName || '—' },
{ label: '产品类型', value: d.cellProductTypeName || '—' },
{ label: '代次', value: d.cellProductPassage || '—' },
{ label: '规格', value: d.cellProductSpecName || '—' }
]
}
]
: []
const sampleSections: ViewSection[] =
props.hideSampleContext || isTempQc
? []
: [
{
title: '样本信息',
fields: [
{ label: '协议号', value: r.protocolNo || d.protocolNo || '—' },
{ label: '样本编码', value: r.sampleCode || d.sampleCode || '—' },
{
label: '样本来源',
value:
r.sourceTypeLabel ||
d.receptionSourceLabel ||
receptionSourceLabel(d.receptionSource || ''),
tagType: 'primary'
},
{ label: '样本类型', value: r.sampleTypeName || d.sampleTypeName || '—' }
]
},
...(transportFields.length > 1 || r.transportType
? [{ title: '运输信息', fields: transportFields }]
: []),
{
title: '样本接收',
slot: 'reception-extra',
fields: [
{ label: '接收时间', value: r.receptionTime || d.receptionTime || '—' },
{ label: '接收经手人', value: r.handlerName || d.handlerName || '—' },
{
label: '接收温度(℃)',
value: r.receiveTemperature ? `${r.receiveTemperature}℃` : '—'
},
{ label: '体积(ml)', value: formatMetric(r.volumeMl) },
{ label: '净重(g)', value: formatMetric(r.netWeightG) },
{
label: '结论',
value:
r.acceptConclusionLabel || acceptConclusionLabel(String(r.acceptConclusion || ''))
},
{ label: '检查项', value: formatCheckResults(r.checkResults), span: true },
{ label: '备注', value: r.remark || '—', span: true }
]
}
]
const taskResultSummaryFields: ViewSection['fields'] = [
{
label: '任务状态',
value: taskStatusLabel(d.taskStatus),
tagType: d.taskStatus === 'ywc' ? 'success' : d.taskStatus === 'jxz' ? 'warning' : 'info'
},
{
label: '检测结论',
value: d.conclusionLabel || qcTaskConclusionLabel(d.conclusion || '') || '—',
tagType: d.conclusion === 'tg' ? 'success' : d.conclusion === 'btg' ? 'danger' : 'info'
},
{ label: '备注', value: d.remark || '—' }
]
const resultSection: ViewSection[] = [
{
title: '质检结论',
fields: taskResultSummaryFields
},
...(d.results && d.results.length > 0
? [
{
title: '检测参数',
fields: d.results.map((item) => ({
label: item.paramName + (item.paramUnit ? `(${item.paramUnit})` : ''),
value: item.paramValue || '—'
}))
}
]
: [{ title: '检测参数', fields: [] }])
]
const tailSections: ViewSection[] = []
if (d.taskType !== 'dsfjc') {
tailSections.push({ title: '物料使用', fields: [], slot: 'material-usages' })
}
if (d.attachmentFiles?.length) {
tailSections.push({
title: d.taskType === 'dsfjc' ? '检测材料' : '检测附件',
fields: [],
slot: 'task-attachments'
})
}
return [
{
title: '任务信息',
fields: [
{ label: '任务号', value: d.taskNo },
{
label: '业务类型',
value: d.qcBizTypeLabel || qcBizTypeLabel(d.qcBizType || ''),
tagType: qcBizTypeTagType(d.qcBizType || '')
},
{ label: '工单号', value: d.workOrderNo || '—' },
{
label: '工单状态',
value: d.workOrderStatus ? workOrderStatusLabel(d.workOrderStatus) : '—',
tagType:
d.workOrderStatus === 'ywc'
? 'success'
: d.workOrderStatus === 'ypz'
? 'warning'
: 'info'
},
{
label: '检测类型',
value: taskTypeLabel(d.taskType),
tagType: d.taskType === 'sysz' ? 'success' : 'info'
},
{ label: '检测项目', value: d.itemName },
{ label: '项目编码', value: d.itemCode || '—' },
{ label: '第三方公司', value: d.companyName || '—' },
{ label: '完成时间', value: d.completedTime || '—' },
{ label: '创建时间', value: d.createTime || '—' }
]
},
...cellProductSection,
...sampleSections,
...resultSection,
...tailSections
]
})
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 handleAttachmentPreview(file: Record<string, unknown>) {
const url = attachmentFullUrl(String(file.fileUrl || ''))
if (url) window.open(url, '_blank')
}
function handleAttachmentDownload(file: Record<string, unknown>) {
downloadAttachmentFile(file, { fullFileUrl: attachmentFullUrl })
}
</script>
<style lang="scss">
/* 叠在协议/捐赠查看弹窗之上 */
.qc-task-view-dialog-mask {
z-index: 4000 !important;
}
</style>
<style scoped lang="scss">
.view-reception-attachments {
margin-top: 4px;
.view-kv {
display: grid;
grid-template-columns: 96px 1fr;
gap: 8px 12px;
font-size: 13px;
align-items: start;
dt {
margin: 0;
color: #64748b;
font-weight: 500;
}
dd {
margin: 0;
color: #1e293b;
}
}
}
</style>
@@ -1,303 +0,0 @@
<!-- 样本查看 — 生产信息卡片(协议单查看弹窗复用) -->
<template>
<div class="sample-card__section sample-card__section--production">
<div class="sample-card__section-label">生产信息</div>
<div v-if="!productionInfo" class="sample-production-empty">暂无生产信息</div>
<template v-else>
<div class="production-work-order-row">
<div class="qc-kv">
<span class="qc-kv__label">工单号</span>
<span class="qc-kv__value qc-kv__value--code">{{
productionInfo.workOrderNo || '—'
}}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">工单状态</span>
<span class="qc-kv__value">
<el-tag
:type="productionWorkOrderStatusTagType(productionInfo.workOrderStatus || '')"
size="small"
>
{{
productionInfo.workOrderStatusLabel ||
productionWorkOrderStatusLabel(productionInfo.workOrderStatus || '')
}}
</el-tag>
</span>
</div>
</div>
<div
v-if="productionInfo.workOrderStatus === 'dpz' && !productionInfo.tasks?.length"
class="sample-production-hint"
>
工单待配置生产任务
</div>
<div v-if="productionInfo.tasks?.length" class="production-task-cards">
<div v-for="task in productionInfo.tasks" :key="task.id" class="production-task-card">
<div class="production-task-card__header">
<span class="production-task-card__name">{{
task.taskName || task.taskNo || '—'
}}</span>
<el-tag :type="productionTaskStatusTagType(task.taskStatus || '')" size="small">
{{ task.taskStatusLabel || productionTaskStatusLabel(task.taskStatus || '') }}
</el-tag>
</div>
<div class="production-task-card__body">
<div class="qc-kv">
<span class="qc-kv__label">任务号</span>
<span class="qc-kv__value">{{ task.taskNo || '—' }}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">开始时间</span>
<span class="qc-kv__value">{{ task.startedTime || '—' }}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">完成时间</span>
<span class="qc-kv__value">{{ task.completedTime || '—' }}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">生产结论</span>
<span class="qc-kv__value">
<el-tag
v-if="productionConclusionTagType(task.conclusion)"
:type="productionConclusionTagType(task.conclusion)"
size="small"
>
{{ task.conclusionLabel || productionConclusionLabel(task.conclusion) }}
</el-tag>
<template v-else>—</template>
</span>
</div>
</div>
<div class="production-task-card__foot">
<button
type="button"
class="production-task-card__link"
@click.stop="openTaskView(task)"
>
点击查看详情
</button>
</div>
</div>
</div>
<div v-else-if="productionInfo.workOrderStatus !== 'dpz'" class="sample-production-hint">
暂无生产任务
</div>
</template>
<Teleport to="body">
<ProductionTaskViewDialog v-model:visible="taskViewVisible" :task-id="taskViewId" />
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ProductionTaskViewDialog from '@/components/business/production-task-view-dialog/index.vue'
import { productionConclusionLabel, productionConclusionTagType } from '@/utils/production-labels'
export type ProductionTaskInput = {
inputType?: string
inputCode?: string
inputName?: string
}
export type ProductionTaskOutput = {
outputType?: string
outputCode?: string
outputName?: string
}
export type ProductionTask = {
id?: string
taskNo?: string
taskName?: string
taskStatus?: string
taskStatusLabel?: string
conclusion?: string
conclusionLabel?: string
startedTime?: string
completedTime?: string
inputs?: ProductionTaskInput[]
outputs?: ProductionTaskOutput[]
}
export type ProductionInfo = {
workOrderNo?: string
workOrderStatus?: string
workOrderStatusLabel?: string
tasks?: ProductionTask[]
}
const PRODUCTION_WORK_ORDER_STATUS_LABELS: Record<string, string> = {
dpz: '待配置',
jxz: '进行中',
ywc: '已完成'
}
const PRODUCTION_TASK_STATUS_LABELS: Record<string, string> = {
dpz: '待配置',
dzx: '待执行',
jxz: '执行中',
ywc: '已完成'
}
defineProps<{
productionInfo?: ProductionInfo | null
}>()
const taskViewVisible = ref(false)
const taskViewId = ref('')
function openTaskView(task: ProductionTask) {
if (!task.id) return
taskViewId.value = task.id
taskViewVisible.value = true
}
function productionWorkOrderStatusLabel(status: string) {
return PRODUCTION_WORK_ORDER_STATUS_LABELS[status] || status || '—'
}
function productionWorkOrderStatusTagType(status: string) {
if (status === 'ywc') return 'success' as const
if (status === 'jxz') return 'warning' as const
return 'info' as const
}
function productionTaskStatusLabel(status: string) {
return PRODUCTION_TASK_STATUS_LABELS[status] || status || '—'
}
function productionTaskStatusTagType(status: string) {
if (status === 'ywc') return 'success' as const
if (status === 'jxz') return 'warning' as const
if (status === 'dzx') return 'info' as const
return 'info' as const
}
</script>
<style scoped lang="scss">
.sample-card__section--production {
margin-top: 4px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
.sample-card__section-label {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 10px;
letter-spacing: 0.3px;
}
.sample-production-empty,
.sample-production-hint {
font-size: 13px;
color: #94a3b8;
padding: 8px 0;
}
.production-work-order-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px 24px;
margin-bottom: 12px;
padding: 0 2px;
}
.production-work-order-row .qc-kv {
flex: 1;
min-width: 140px;
}
.production-task-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 10px;
}
.production-task-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.production-task-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #f1f5f9;
}
.production-task-card__name {
font-size: 13px;
font-weight: 600;
color: #1e293b;
text-align: left;
flex: 1;
word-break: break-word;
}
.production-task-card__body {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.production-task-card__foot {
padding: 2px 12px 8px;
text-align: right;
border-top: 1px dashed #f1f5f9;
}
.production-task-card__link {
padding: 0;
border: none;
background: none;
font-size: 11px;
line-height: 1.4;
color: #10b981;
cursor: pointer;
&:hover {
color: #059669;
text-decoration: underline;
}
}
.qc-kv {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
}
.qc-kv__label {
color: #94a3b8;
flex-shrink: 0;
min-width: 56px;
}
.qc-kv__value {
color: #1e293b;
font-weight: 500;
word-break: break-word;
&--code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
}
</style>
@@ -1,306 +0,0 @@
<!-- 样本查看 — 质检信息卡片(协议单/捐赠单查看弹窗复用) -->
<template>
<div class="sample-card__section sample-card__section--qc">
<div class="sample-card__section-label">质检信息</div>
<div v-if="!qcInfo" class="sample-qc-empty">{{ emptyHint }}</div>
<template v-else>
<div class="qc-work-order-row">
<div class="qc-kv">
<span class="qc-kv__label">工单号</span>
<span class="qc-kv__value qc-kv__value--code">{{ qcInfo.workOrderNo || '—' }}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">业务类型</span>
<span class="qc-kv__value">{{
qcInfo.qcBizTypeLabel || qcBizTypeLabel(qcInfo.qcBizType || '')
}}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">工单状态</span>
<span class="qc-kv__value">
<el-tag :type="workOrderStatusTagType(qcInfo.workOrderStatus || '')" size="small">
{{
qcInfo.workOrderStatusLabel || workOrderStatusLabel(qcInfo.workOrderStatus || '')
}}
</el-tag>
</span>
</div>
</div>
<div v-if="qcInfo.workOrderStatus === 'dpz' && !qcInfo.tasks?.length" class="sample-qc-hint">
工单待配置检测项目
</div>
<div v-if="qcInfo.tasks?.length" class="qc-detail-cards qc-detail-cards--tasks">
<div
v-for="task in qcInfo.tasks"
:key="task.id"
class="qc-detail-card qc-detail-card--task"
>
<div class="qc-detail-card__header">
<span class="qc-detail-card__name">{{ task.itemName || '—' }}</span>
<el-tag type="info" size="small">{{
task.taskTypeLabel || taskTypeLabel(task.taskType || '')
}}</el-tag>
</div>
<div class="qc-detail-card__body">
<div class="qc-kv">
<span class="qc-kv__label">任务号</span>
<span class="qc-kv__value">{{ task.taskNo || '—' }}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">任务状态</span>
<span class="qc-kv__value">
<el-tag :type="taskStatusTagType(task.taskStatus)" size="small">
{{ task.taskStatusLabel || taskStatusLabel(task.taskStatus || '') }}
</el-tag>
</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">检测结论</span>
<span class="qc-kv__value">
<el-tag
v-if="task.conclusion"
:type="qcTaskConclusionTagType(task.conclusion)"
size="small"
>
{{ task.conclusionLabel || qcTaskConclusionLabel(task.conclusion) }}
</el-tag>
<span v-else class="qc-kv__muted">—</span>
</span>
</div>
</div>
<div class="qc-detail-card__foot">
<button type="button" class="qc-detail-card__link" @click.stop="openTaskView(task)">
点击查看详情
</button>
</div>
</div>
</div>
<div v-else-if="qcInfo.workOrderStatus !== 'dpz'" class="sample-qc-hint"> 暂无检测任务 </div>
</template>
<Teleport to="body">
<QcTaskViewDialog
v-model:visible="taskViewVisible"
:task-id="taskViewId"
:hide-sample-context="hideSampleContext"
/>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
qcBizTypeLabel,
workOrderStatusLabel,
taskTypeLabel,
taskStatusLabel,
qcTaskConclusionLabel,
qcTaskConclusionTagType
} from '@/utils/qc-labels'
import { workOrderStatusTagType, taskStatusTagType } from '@/utils/status-tag-registry'
import QcTaskViewDialog from '@/components/business/qc-task-view-dialog/index.vue'
export type SampleQcTask = {
id?: string
taskNo?: string
taskType?: string
taskTypeLabel?: string
itemName?: string
itemCode?: string
companyName?: string
taskStatus?: string
taskStatusLabel?: string
conclusion?: string
conclusionLabel?: string
completedTime?: string
remark?: string
}
export type SampleQcInfo = {
workOrderNo?: string
workOrderStatus?: string
workOrderStatusLabel?: string
qcBizType?: string
qcBizTypeLabel?: string
configuredTime?: string
hasLab?: boolean
hasThirdParty?: boolean
labItems?: Array<{ id?: string; itemCode?: string; itemName?: string }>
thirdPartyItems?: Array<{
id?: string
itemCode?: string
itemName?: string
companyName?: string
}>
tasks?: SampleQcTask[]
}
export type SampleReceptionBrief = {
acceptConclusion?: string
}
const props = defineProps<{
qcInfo?: SampleQcInfo | null
sampleReception?: SampleReceptionBrief | null
/** 任务详情弹窗不展示样本信息、样本接收(出库放行等场景) */
hideSampleContext?: boolean
}>()
const taskViewVisible = ref(false)
const taskViewId = ref('')
const emptyHint = computed(() => {
const rec = props.sampleReception
if (!rec) {
return '暂未接收样本,接收结论为「接收」后将自动生成质检工单'
}
if (rec.acceptConclusion === 'bj') {
return '样本拒收,不生成质检工单'
}
if (rec.acceptConclusion === 'js') {
return '已接收,质检工单生成中或待配置'
}
return '暂无质检信息'
})
function openTaskView(task: SampleQcTask) {
if (!task.id) return
taskViewId.value = task.id
taskViewVisible.value = true
}
</script>
<style scoped lang="scss">
.sample-card__section--qc {
margin-top: 4px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
.sample-card__section-label {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 10px;
letter-spacing: 0.3px;
}
.sample-qc-empty,
.sample-qc-hint {
font-size: 13px;
color: #94a3b8;
padding: 8px 0;
}
.qc-work-order-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px 24px;
margin-bottom: 12px;
padding: 0 2px;
}
.qc-work-order-row .qc-kv {
flex: 1;
min-width: 140px;
}
.qc-detail-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
&--tasks {
margin-bottom: 0;
}
}
.qc-detail-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.qc-detail-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #f1f5f9;
}
.qc-detail-card__name {
font-size: 13px;
font-weight: 600;
color: #1e293b;
text-align: left;
flex: 1;
word-break: break-word;
}
.qc-detail-card__body {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.qc-detail-card__foot {
padding: 2px 12px 8px;
text-align: right;
border-top: 1px dashed #f1f5f9;
}
.qc-detail-card__link {
padding: 0;
border: none;
background: none;
font-size: 11px;
line-height: 1.4;
color: #10b981;
cursor: pointer;
&:hover {
color: #059669;
text-decoration: underline;
}
}
.qc-kv__muted {
color: #94a3b8;
font-weight: 400;
}
.qc-kv {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
}
.qc-kv__label {
color: #94a3b8;
flex-shrink: 0;
min-width: 56px;
}
.qc-kv__value {
color: #1e293b;
font-weight: 500;
word-break: break-word;
&--code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
}
</style>
@@ -1,182 +0,0 @@
<!-- 样本查看 — 入库放行审核信息(协议单查看弹窗复用) -->
<template>
<div class="sample-card__section sample-card__section--storage-release">
<div class="sample-card__section-label">入库放行审核信息</div>
<div v-if="!storageReleaseInfo" class="sample-storage-release-empty">
暂无入库放行审核信息
</div>
<template v-else>
<div class="storage-release-info-grid">
<div class="qc-kv">
<span class="qc-kv__label">审核结论</span>
<span class="qc-kv__value">
<el-tag
v-if="storageReleaseInfo.reviewConclusion"
:type="storageReleaseInfo.reviewConclusion === 'tg' ? 'success' : 'danger'"
size="small"
>
{{
storageReleaseInfo.reviewConclusionLabel ||
conclusionLabel(storageReleaseInfo.reviewConclusion)
}}
</el-tag>
<span v-else class="qc-kv__muted">待审核</span>
</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">审核人</span>
<span class="qc-kv__value">{{ storageReleaseInfo.reviewByName || '—' }}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">审核时间</span>
<span class="qc-kv__value">{{ storageReleaseInfo.reviewTime || '—' }}</span>
</div>
<div v-if="storageReleaseInfo.reviewRemark" class="qc-kv qc-kv--full">
<span class="qc-kv__label">审核备注</span>
<span class="qc-kv__value qc-kv__value--muted">{{
storageReleaseInfo.reviewRemark
}}</span>
</div>
</div>
<div v-if="attachmentFiles.length" class="storage-release-attachments">
<div class="qc-kv">
<span class="qc-kv__label">审核附件</span>
<span class="qc-kv__value">
<ProtocolAttachmentReadonly
:files="attachmentFiles"
@preview="handlePreview"
@download="handleDownload"
/>
</span>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { downloadAttachmentFile } from '@/utils/download-attachment'
export type StorageReleaseInfo = {
reviewConclusion?: string
reviewConclusionLabel?: string
reviewByName?: string
reviewTime?: string
reviewRemark?: string
reviewAttachments?: string
}
const props = defineProps<{
storageReleaseInfo?: StorageReleaseInfo | null
}>()
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const attachmentFiles = computed(() => {
const urls = String(props.storageReleaseInfo?.reviewAttachments || '').trim()
if (!urls) return []
return urls
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((fileUrl, i) => ({
id: `srr_${i}`,
fileUrl,
fileName: fileUrl.split('/').pop() || `附件${i + 1}`
}))
})
function conclusionLabel(conclusion: string) {
if (conclusion === 'tg') return '通过'
if (conclusion === 'btg') return '不通过'
return conclusion
}
function fullFileUrl(suffix: string) {
if (!suffix) return ''
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/'))
return suffix
return minioBaseUrl ? `${minioBaseUrl}/${suffix.replace(/^\//, '')}` : suffix
}
function handlePreview(file: Record<string, any>) {
const url = fullFileUrl(String(file.fileUrl || ''))
if (url) window.open(url, '_blank')
}
function handleDownload(file: Record<string, any>) {
downloadAttachmentFile(file, { fullFileUrl })
}
</script>
<style scoped lang="scss">
.sample-card__section--storage-release {
margin-top: 4px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
.sample-card__section-label {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 10px;
letter-spacing: 0.3px;
}
.sample-storage-release-empty {
font-size: 13px;
color: #94a3b8;
padding: 8px 0;
}
.storage-release-info-grid {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px 24px;
padding: 0 2px;
}
.storage-release-attachments {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #f1f5f9;
}
.qc-kv {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
&--full {
flex-basis: 100%;
}
}
.qc-kv__label {
color: #94a3b8;
flex-shrink: 0;
min-width: 56px;
}
.qc-kv__value {
color: #1e293b;
font-weight: 500;
word-break: break-word;
&--muted {
color: #64748b;
font-weight: 400;
}
}
.qc-kv__muted {
color: #94a3b8;
font-weight: 400;
}
</style>
@@ -1,119 +0,0 @@
<!-- 工单编辑(备注;质检工单可选工单状态) -->
<template>
<el-dialog
v-model="dialogVisible"
:title="title"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@close="handleClose"
>
<el-form label-position="top" class="biz-dialog-form">
<div class="form-grid-2">
<el-form-item v-if="showWorkOrderStatus" label="工单状态" class="form-col-span-2">
<el-select v-model="workOrderStatus" placeholder="请选择" style="width: 100%">
<el-option
v-for="opt in workOrderStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input
v-model="remark"
type="textarea"
:rows="4"
placeholder="选填"
maxlength="512"
show-word-limit
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="handleClose"
><el-icon><Close /></el-icon>取消</el-button
>
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
<el-icon><Check /></el-icon>保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Close, Check } from '@element-plus/icons-vue'
import { workOrderStatus as workOrderStatusRegistry, workOrderStatusLabel } from '@/utils/status-tag-registry'
export type WorkOrderRemarkSubmitPayload = {
remark: string
workOrderStatus?: string
}
const props = withDefaults(
defineProps<{
visible: boolean
title?: string
initialRemark?: string
initialWorkOrderStatus?: string
showWorkOrderStatus?: boolean
submitting?: boolean
}>(),
{
title: '编辑备注',
initialRemark: '',
initialWorkOrderStatus: '',
showWorkOrderStatus: false,
submitting: false
}
)
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'submit', payload: WorkOrderRemarkSubmitPayload): void
}>()
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
const remark = ref('')
const workOrderStatus = ref('')
const workOrderStatusOptions = computed(() =>
Object.keys(workOrderStatusRegistry).map((value) => ({
value,
label: workOrderStatusLabel(value)
}))
)
function handleClose() {
dialogVisible.value = false
}
function handleSubmit() {
const payload: WorkOrderRemarkSubmitPayload = {
remark: remark.value.trim()
}
if (props.showWorkOrderStatus) {
payload.workOrderStatus = workOrderStatus.value
}
emit('submit', payload)
}
watch(
() => props.visible,
(val) => {
if (val) {
remark.value = props.initialRemark || ''
workOrderStatus.value = props.initialWorkOrderStatus || ''
}
}
)
</script>
@@ -1,7 +1,7 @@
<!-- 系统logo -->
<template>
<div class="flex-cc">
<img :style="logoStyle" src="/logo.png" alt="中科兴皖" class="block object-contain" />
<img :style="logoStyle" src="/logo.png" alt="山峡化工" class="block object-contain" />
</div>
</template>
@@ -6,8 +6,8 @@
</div>
<header class="exception-page__brand">
<img src="/logo.png" alt="中科兴皖" class="exception-page__logo" />
<span class="exception-page__brand-name">中科兴皖</span>
<img src="/logo.png" alt="山峡化工" class="exception-page__logo" />
<span class="exception-page__brand-name">山峡化工</span>
</header>
<main class="exception-page__main">
+18
View File
@@ -0,0 +1,18 @@
import { computed } from 'vue'
import { useDictStore } from '@/store/modules/dict'
export function useDictTag(dictType: string) {
const dictStore = useDictStore()
const getLabel = (value: string): string => {
return dictStore.getLabel(dictType, value)
}
const getTagType = (value: string): string => {
return dictStore.getTagType(dictType, value)
}
const options = computed(() => dictStore.getOptions(dictType))
return { getLabel, getTagType, options }
}
@@ -41,6 +41,7 @@ import NProgress from 'nprogress'
import { useSettingStore } from '@/store/modules/setting'
import { useUserStore } from '@/store/modules/user'
import { useMenuStore } from '@/store/modules/menu'
import { useDictStore } from '@/store/modules/dict'
import { setWorktab } from '@/utils/navigation'
import { setPageTitle } from '@/utils/router'
import { RoutesAlias } from '../routesAlias'
@@ -291,6 +292,9 @@ async function handleDynamicRoutes(
menuStore.setMenuList(menuList)
menuStore.addRemoveRouteFns(routeRegistry?.getRemoveRouteFns() || [])
// 5.5 加载全量字典数据
useDictStore().loadAllDict()
// 6. 保存 iframe 路由
IframeRouteManager.getInstance().save()
+1 -1
View File
@@ -3,6 +3,6 @@ import { sqxpAdminRoutes } from './sqxp'
/**
* 动态菜单与路由(前端权限模式 VITE_ACCESS_MODE=frontend 时使用)
* 中科兴皖业务菜单,与 backend/app/api/menu.py 中 get_admin_menu_list 对齐
* 山峡化工业务菜单,与 backend/app/api/menu.py 中 get_admin_menu_list 对齐
*/
export const routeModules: AppRouteRecord[] = [...sqxpAdminRoutes]
+11 -941
View File
@@ -1,958 +1,28 @@
import type { AppRouteRecord } from '@/types/router'
/**
* 前端路由(13 个一级菜单:细胞存储、科研捐赠、中试服务、临床应用、
* 质检管理、生产管理、运营支撑、市场基础信息、质检基础信息、生产基础信息、
* 系统基础信息、工作流管理、系统管理)
* 前端路由(3 个一级菜单:控制台、工作流管理、系统管理)
* 与后端菜单对齐时请同步 `backend/app/api/menu.py`。
*/
export const sqxpAdminRoutes: AppRouteRecord[] = [
// ──────────────────────────────────────────────
// 1. 细胞存储
// 1. 控制台(Dashboard)
// ──────────────────────────────────────────────
{
id: 'CEL00000000000000000000000000001',
name: 'CellStorage',
path: '/cell-storage',
component: '/index/index',
id: 'DSH000000000000000000000000000001',
name: 'Dashboard',
path: '/dashboard',
component: '/dashboard/console/index',
meta: {
title: '细胞存储',
icon: 'ri:test-tube-line',
title: '控制台',
icon: 'ri:dashboard-line',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '50000000000000000000000000000002',
name: 'CellStorageProtocol',
path: 'protocol',
component: '/cell-storage/protocol/index',
meta: {
title: '采集协议单录入',
keepAlive: true,
isHide: true,
menuCode: 'biz:cellStorageProtocol:list'
}
},
{
id: '50000000000000000000000000000003',
name: 'CellStorageProtocolAudit',
path: 'protocol-audit',
component: '/cell-storage/protocol-audit/index',
meta: {
title: '采集协议单',
keepAlive: true,
menuCode: 'biz:cellStorageProtocolAudit:list'
}
},
{
id: '904b5173519411f1944300163e3bdd96',
name: 'CellStoragePaymentInfo',
path: 'payment-info',
component: '/cell-storage/payment-info/index',
meta: {
title: '缴费信息',
keepAlive: false,
isHide: true,
menuCode: 'cellStorage:paymentInfo:list'
}
},
{
id: '50000000000000000000000000000004',
name: 'CellStoragePaymentConfirm',
path: 'payment-confirm',
component: '/cell-storage/payment-confirm/index',
meta: {
title: '缴退费财务确认',
keepAlive: true,
menuCode: 'biz:cellStoragePaymentConfirm:list'
}
},
{
id: '70000000000000000000000000000011',
name: 'CellStorageStorageReleaseReview',
path: 'storage-release-review',
component: '/quality-inspection/storage-release-review/index',
meta: {
title: '入库放行审核',
keepAlive: true,
menuCode: 'biz:storageReleaseReview:list'
}
},
{
id: '50000000000000000000000000000005',
name: 'CellStorageRefundConfirm',
path: 'refund-confirm',
component: '/cell-storage/refund-confirm/index',
meta: {
title: '退费确认(财务)',
keepAlive: false,
isHide: true,
menuCode: 'biz:cellStorageRefundConfirm:list'
}
},
{
id: '50000000000000000000000000000011',
name: 'CellStorageSampleReceptionInfo',
path: 'sample-reception-info',
component: '/cell-storage/sample-reception-info/index',
meta: {
title: '样本接收信息',
keepAlive: false,
isHide: true,
menuCode: 'cell-storage:sample-reception-info:list'
}
},
{
id: '50000000000000000000000000000012',
name: 'CellStorageStorageEntryInfo',
path: 'storage-entry-info',
component: '/cell-storage/storage-entry-info/index',
meta: {
title: '样本入库信息',
keepAlive: false,
isHide: true,
menuCode: 'cell-storage:storage-entry-info:list'
}
},
{
id: '50000000000000000000000000000006',
name: 'CellStorageStorageCert',
path: 'storage-cert',
component: '/cell-storage/storage-cert/index',
meta: {
title: '储存证管理',
keepAlive: false,
isHide: true,
menuCode: 'cell-storage:storage-cert:list'
}
},
{
id: '50000000000000000000000000000008',
name: 'CellStorageFullmoonTodo',
path: 'fullmoon-todo',
component: '/cell-storage/fullmoon-todo/index',
meta: {
title: '满月待办管理',
keepAlive: false,
isHide: true,
menuCode: 'cell-storage:fullmoon-todo:list'
}
}
]
}
},
// ──────────────────────────────────────────────
// 2. 科研捐赠
// ──────────────────────────────────────────────
{
id: 'RSC00000000000000000000000000001',
name: 'ResearchDonation',
path: '/research-donation',
component: '/index/index',
meta: {
title: '科研捐赠',
icon: 'ri:hand-heart-line',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '60000000000000000000000000000002',
name: 'ResearchDonationList',
path: 'list',
component: '/research-donation/list/index',
meta: {
title: '捐赠协议单录入',
keepAlive: true,
isHide: true,
menuCode: 'biz:researchDonation:list'
}
},
{
id: '60000000000000000000000000000003',
name: 'ResearchDonationAudit',
path: 'audit',
component: '/research-donation/audit/index',
meta: {
title: '捐赠协议单',
keepAlive: true,
menuCode: 'biz:researchDonationAudit:list'
}
}
]
},
// ──────────────────────────────────────────────
// 3. 中试服务
// ──────────────────────────────────────────────
{
id: 'PIL00000000000000000000000000001',
name: 'PilotService',
path: '/pilot-service',
component: '/index/index',
meta: {
title: '中试服务',
icon: 'ri:flask-line',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '80000000000000000000000000000010',
name: 'PilotApplication',
path: 'pilot-application',
component: '/pilot-service/pilot-application/index',
meta: {
title: '中试服务申请单',
keepAlive: true,
menuCode: 'pilot-service:pilot-application:list'
},
props: { bizType: 'pilot_service' }
},
{
id: 'PIL00000000000000000000000000002',
name: 'PilotCheckupManagement',
path: 'checkup-management',
component: '/pilot-service/checkup-management/index',
meta: {
title: '体检管理',
keepAlive: false,
isHide: true,
menuCode: 'pilot-service:checkup-management:list'
},
props: { bizType: 'pilot_service' }
},
{
id: 'PIL00000000000000000000000000003',
name: 'PilotInfusionSchedule',
path: 'infusion-schedule-management',
component: '/pilot-service/infusion-schedule-management/index',
meta: {
title: '输注计划管理',
keepAlive: false,
isHide: true,
menuCode: 'pilot-service:infusion-schedule-management:list'
},
props: { bizType: 'pilot_service' }
},
{
id: 'PIL00000000000000000000000000004',
name: 'PilotOutboundRelease',
path: 'outbound-release-review',
component: '/pilot-service/outbound-release-review/index',
meta: {
title: '出库放行审核',
keepAlive: true,
menuCode: 'pilot-service:outbound-release-review:list'
},
props: { bizType: 'pilot_service' }
}
]
},
// ──────────────────────────────────────────────
// 4. 临床应用
// ──────────────────────────────────────────────
{
id: 'CLI00000000000000000000000000001',
name: 'ClinicalApplication',
path: '/clinical-application',
component: '/index/index',
meta: {
title: '临床应用',
icon: 'ri:heart-pulse-line',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: 'CLI00000000000000000000000000002',
name: 'ClinicalApplicationList',
path: 'clinical-application',
component: '/pilot-service/pilot-application/index',
meta: {
title: '临床申请单',
keepAlive: true,
menuCode: 'clinical-application:application:list'
},
props: { bizType: 'clinical_application' }
},
{
id: 'CLI00000000000000000000000000003',
name: 'ClinicalCheckupManagement',
path: 'clinical-checkup-management',
component: '/pilot-service/checkup-management/index',
meta: {
title: '体检管理',
keepAlive: true,
isHide: true,
menuCode: 'clinical-application:checkup-management:list'
},
props: { bizType: 'clinical_application' }
},
{
id: 'CLI00000000000000000000000000004',
name: 'ClinicalInfusionSchedule',
path: 'clinical-infusion-schedule-management',
component: '/pilot-service/infusion-schedule-management/index',
meta: {
title: '输注计划管理',
keepAlive: false,
isHide: true,
menuCode: 'clinical-application:infusion-schedule-management:list'
},
props: { bizType: 'clinical_application' }
},
{
id: 'CLI00000000000000000000000000005',
name: 'ClinicalPaymentInfo',
path: 'clinical-payment-info',
component: '/pilot-service/clinical-payment-info/index',
meta: {
title: '缴费信息',
keepAlive: false,
isHide: true,
menuCode: 'clinical-application:payment-info:list'
}
},
{
id: 'CLI00000000000000000000000000006',
name: 'ClinicalPaymentConfirm',
path: 'clinical-payment-confirm',
component: '/pilot-service/clinical-payment-confirm/index',
meta: {
title: '缴退费财务确认',
keepAlive: true,
menuCode: 'clinical-application:payment-confirm:list'
}
},
{
id: 'CLI00000000000000000000000000007',
name: 'ClinicalRefundConfirm',
path: 'clinical-refund-confirm',
component: '/pilot-service/clinical-refund-confirm/index',
meta: {
title: '退费确认(财务)',
keepAlive: false,
isHide: true,
menuCode: 'clinical-application:refund-confirm:list'
}
},
{
id: 'CLI00000000000000000000000000008',
name: 'ClinicalOutboundRelease',
path: 'clinical-outbound-release-review',
component: '/pilot-service/outbound-release-review/index',
meta: {
title: '出库放行审核',
keepAlive: true,
menuCode: 'clinical-application:outbound-release-review:list'
},
props: { bizType: 'clinical_application' }
}
]
},
// ──────────────────────────────────────────────
// 5. 质检管理
// ──────────────────────────────────────────────
{
id: 'QCM00000000000000000000000000001',
name: 'QualityManage',
path: '/quality-manage',
component: '/index/index',
meta: {
title: '质检管理',
icon: 'ri:shield-check-line',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '70000000000000000000000000000002',
name: 'QualityInspectionSampleReception',
path: 'sample-reception',
component: '/quality-inspection/sample-reception/index',
meta: {
title: '样本接收',
keepAlive: true,
menuCode: 'biz:sampleReception:list'
}
},
{
id: '70000000000000000000000000000007',
name: 'QualityInspectionQcWorkOrder',
path: 'qc-work-order',
component: '/quality-inspection/qc-work-order/index',
meta: {
title: '质检工单',
keepAlive: true,
menuCode: 'biz:qcWorkOrder:list'
}
},
{
id: '70000000000000000000000000000008',
name: 'QualityInspectionQcTask',
path: 'qc-task',
component: '/quality-inspection/qc-task/index',
meta: {
title: '质检任务',
keepAlive: false,
isHide: true,
menuCode: 'biz:qcTask:list'
}
},
{
name: 'QualityInspectionStorageReleaseReviewRedirect',
path: 'storage-release-review',
redirect: '/cell-storage/storage-release-review',
meta: {
title: '入库放行审核',
keepAlive: false,
isHide: true
}
}
]
},
// ──────────────────────────────────────────────
// 6. 生产管理
// ──────────────────────────────────────────────
{
id: 'PRM00000000000000000000000000001',
name: 'ProductionManage',
path: '/production',
component: '/index/index',
meta: {
title: '生产管理',
icon: 'grid',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '61000000000000000000000000000003',
name: 'ProductionStorageFacility',
path: 'storage-facility',
component: '/operations/storage-facility/index',
meta: {
title: '库位管理',
keepAlive: true,
menuCode: 'operations:storage-facility:list'
}
},
{
id: '90000000000000000000000000000013',
name: 'CellProduct',
path: 'cell-product',
component: '/production/cell-product/index',
meta: {
title: '细胞产品库',
keepAlive: true,
menuCode: 'production:cell-product:list'
}
},
{
id: '90000000000000000000000000000011',
name: 'ProductionWorkOrder',
path: 'production-work-order',
component: '/production/production-work-order/index',
meta: {
title: '生产工单',
keepAlive: true,
menuCode: 'production:work-order:list'
}
},
{
id: '90000000000000000000000000000012',
name: 'ProductionTask',
path: 'production-task',
component: '/production/production-task/index',
meta: {
title: '生产任务',
keepAlive: false,
isHide: true,
menuCode: 'production:task:list'
}
}
]
},
// ──────────────────────────────────────────────
// 7. 运营支撑
// ──────────────────────────────────────────────
{
id: 'OPS00000000000000000000000000001',
name: 'Operations',
path: '/operations',
component: '/index/index',
meta: {
title: '运营支撑',
icon: 'operation',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '61000000000000000000000000000002',
name: 'CleanRoom',
path: 'clean-room',
component: '/operations/clean-room/index',
meta: {
title: '洁净室管理',
keepAlive: true,
menuCode: 'operations:clean-room:list'
}
},
{
id: '61000000000000000000000000000004',
name: 'ExhibitionAppointment',
path: 'exhibition-appointment',
component: '/operations/exhibition-appointment/index',
meta: {
title: '展馆预约',
keepAlive: true,
menuCode: 'operations:exhibition-appointment:list'
}
}
]
},
// ──────────────────────────────────────────────
// 8. 市场基础信息
// ──────────────────────────────────────────────
{
id: 'MKT00000000000000000000000000001',
name: 'MarketInfo',
path: '/market-info',
component: '/index/index',
meta: {
title: '市场基础信息',
icon: 'ri:price-tag-3-line',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '122',
name: 'BizCellSampleType',
path: 'cell-sample-type',
component: '/basic-info/cell-sample-type/index',
meta: {
title: '细胞样本基础信息维护',
keepAlive: true,
menuCode: 'biz:cellSampleType:list'
}
},
{
id: '125',
name: 'BizSampleCode',
path: 'sample-code',
component: '/basic-info/sample-code/index',
meta: {
title: '样本编码管理',
keepAlive: false,
isHide: true,
menuCode: 'biz:sampleCode:list'
}
},
{
id: '123',
name: 'BizCollectionRegion',
path: 'collection-region',
component: '/basic-info/collection-region/index',
meta: {
title: '细胞采集基础信息维护',
keepAlive: true,
menuCode: 'biz:collectionRegion:list'
}
},
{
id: '124',
name: 'BizCollectionProtocolNo',
path: 'collection-protocol-no',
component: '/basic-info/collection-protocol-no/index',
meta: {
title: '采集协议号管理',
keepAlive: false,
isHide: true,
menuCode: 'biz:collectionProtocolNo:list'
}
},
{
id: '80000000000000000000000000000002',
name: 'CellProductType',
path: 'cell-product-type',
component: '/pilot-service/cell-product-type/index',
meta: {
title: '细胞产品基础信息维护',
keepAlive: true,
menuCode: 'pilot-service:cell-product-type:list'
}
},
{
id: '80000000000000000000000000000003',
name: 'CellProductSpec',
path: 'cell-product-spec',
component: '/pilot-service/cell-product-spec/index',
meta: {
title: '细胞产品规格管理',
keepAlive: false,
isHide: true,
menuCode: 'pilot-service:cell-product-spec:list'
}
},
{
id: '80000000000000000000000000000004',
name: 'CellProductPrice',
path: 'cell-product-price',
component: '/pilot-service/cell-product-price/index',
meta: {
title: '细胞产品价格体系管理',
keepAlive: false,
isHide: true,
menuCode: 'pilot-service:cell-product-price:list'
}
},
{
id: '80000000000000000000000000000005',
name: 'ProjectPriceSystem',
path: 'project-price-system',
component: '/pilot-service/project-price-system/index',
meta: {
title: '细胞产品项目价格管理',
keepAlive: false,
isHide: true,
menuCode: 'pilot-service:project-price-system:list'
}
},
{
id: '40000000000000000000000000000007',
name: 'BasicInfoInsuranceCompany',
path: 'insurance-company',
component: '/basic-info/insurance-company/index',
meta: {
title: '保险公司及险种价格',
keepAlive: true,
menuCode: 'basic-info:insurance-company:list'
}
},
{
id: '80000000000000000000000000000007',
name: 'CheckupInstitution',
path: 'checkup-institution',
component: '/pilot-service/checkup-institution/index',
meta: {
title: '体检机构及项目价格',
keepAlive: true,
menuCode: 'pilot-service:checkup-institution:list'
}
},
{
id: '50000000000000000000000000000009',
name: 'MarketMaterial',
path: 'material',
component: '/cell-storage/material/index',
meta: {
title: '市场物料库',
keepAlive: true,
menuCode: 'biz:material:list'
}
},
{
id: '50000000000000000000000000000010',
name: 'MarketMaterialStockLog',
path: 'material-stock-log',
component: '/cell-storage/material-stock-log/index',
meta: {
title: '市场物料变动记录',
keepAlive: false,
isHide: true,
menuCode: 'biz:materialStockLog:list'
}
},
{
id: '121',
name: 'BizCustomer',
path: 'customer',
component: '/basic-info/customer/index',
meta: { title: '客户管理', keepAlive: true, menuCode: 'biz:customer:list' }
},
{
id: '80000000000000000000000000000006',
name: 'Channel',
path: 'channel',
component: '/pilot-service/channel/index',
meta: {
title: '渠道信息管理',
keepAlive: true,
menuCode: 'pilot-service:channel:list'
}
},
{
id: '80000000000000000000000000000008',
name: 'ChannelServiceOrder',
path: 'channel-service-order',
component: '/pilot-service/channel-service-order/index',
meta: {
title: '渠道服务购买订单',
keepAlive: false,
isHide: true,
menuCode: 'pilot-service:channel-service-order:list'
}
},
{
id: '80000000000000000000000000000009',
name: 'ChannelServiceOrderFinance',
path: 'channel-service-order-finance',
component: '/pilot-service/channel-service-order-finance/index',
meta: {
title: '渠道服务订单(财务确认)',
keepAlive: true,
menuCode: 'pilot-service:channel-service-order-finance:list'
}
}
]
},
// ──────────────────────────────────────────────
// 9. 质检基础信息
// ──────────────────────────────────────────────
{
id: 'QCB00000000000000000000000000001',
name: 'QualityBase',
path: '/quality-base',
component: '/index/index',
meta: {
title: '质检基础信息',
icon: 'ri:clipboard-line',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '70000000000000000000000000000003',
name: 'QualityInspectionSampleReceptionCheckItem',
path: 'sample-reception-check-item',
component: '/quality-inspection/sample-reception-check-item/index',
meta: {
title: '接收检查项配置',
keepAlive: true,
menuCode: 'biz:sampleReceptionCheckItem:list'
}
},
{
id: '70000000000000000000000000000006',
name: 'QualityInspectionLabTestItem',
path: 'lab-test-item',
component: '/quality-inspection/lab-test-item/index',
meta: {
title: '质检项目管理',
keepAlive: true,
menuCode: 'biz:qcLabTestItem:list'
}
},
{
id: '70000000000000000000000000000004',
name: 'QualityInspectionThirdPartyCompany',
path: 'third-party-company',
component: '/quality-inspection/third-party-company/index',
meta: {
title: '第三方检测公司及项目',
keepAlive: false,
isHide: true,
menuCode: 'biz:qcThirdPartyCompany:list'
}
},
{
id: '70000000000000000000000000000009',
name: 'QualityInspectionQcMaterial',
path: 'qc-material',
component: '/quality-inspection/qc-material/index',
meta: {
title: '质检物料库',
keepAlive: true,
menuCode: 'biz:qcMaterial:list'
}
},
{
id: '70000000000000000000000000000010',
name: 'QualityInspectionQcMaterialUsage',
path: 'qc-material-usage',
component: '/quality-inspection/qc-material-usage/index',
meta: {
title: '质检物料变动记录',
keepAlive: false,
isHide: true,
menuCode: 'biz:qcMaterialUsage:list'
}
}
]
},
// ──────────────────────────────────────────────
// 10. 生产基础信息
// ──────────────────────────────────────────────
{
id: 'PRB00000000000000000000000000001',
name: 'ProductionBase',
path: '/production-base',
component: '/index/index',
meta: {
title: '生产基础信息',
icon: 'ri:tools-line',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '90000000000000000000000000000002',
name: 'ProductionConsumable',
path: 'consumable',
component: '/production/consumable/index',
meta: {
title: '耗材管理',
keepAlive: true,
menuCode: 'production:consumable:list'
}
},
{
id: '90000000000000000000000000000007',
name: 'ProductionConsumablePack',
path: 'consumable-pack',
component: '/production/consumable-pack/index',
meta: {
title: '耗材包管理',
keepAlive: false,
isHide: true,
menuCode: 'production:consumable-pack:list'
}
},
{
id: '90000000000000000000000000000003',
name: 'ProductionReagent',
path: 'reagent',
component: '/production/reagent/index',
meta: {
title: '试剂管理',
keepAlive: true,
menuCode: 'production:reagent:list'
}
},
{
id: '90000000000000000000000000000004',
name: 'ProductionSop',
path: 'sop',
component: '/production/sop/index',
meta: {
title: 'SOP文档',
keepAlive: true,
menuCode: 'production:sop-document:list'
}
},
{
id: '90000000000000000000000000000005',
name: 'ProductionProcessCategory',
path: 'process-category',
component: '/production/process-category/index',
meta: {
title: '工艺基础信息维护',
keepAlive: true,
menuCode: 'production:process-category:list'
}
},
{
id: '90000000000000000000000000000006',
name: 'ProductionOutputType',
path: 'output-type',
component: '/production/output-type/index',
meta: {
title: '工艺产出物类型',
keepAlive: false,
isHide: true,
menuCode: 'production:output-type:list'
}
},
{
id: '90000000000000000000000000000008',
name: 'ProductionProcess',
path: 'process',
component: '/production/process/index',
meta: {
title: '工艺库管理',
keepAlive: true,
menuCode: 'production:process:list'
}
},
{
id: '90000000000000000000000000000010',
name: 'ProductionProcessRoute',
path: 'process-route',
component: '/production/process-route/index',
meta: {
title: '工艺路线管理',
keepAlive: false,
isHide: true,
menuCode: 'production:process-route:list'
}
}
]
},
// ──────────────────────────────────────────────
// 11. 系统基础信息
// ──────────────────────────────────────────────
{
id: 'SYSB000000000000000000000000000001',
name: 'SystemBase',
path: '/system-base',
component: '/index/index',
meta: {
title: '系统基础信息',
icon: 'ri:archive-line',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '107',
name: 'Site',
path: 'site',
component: '/system/site/index',
meta: {
title: '站点管理',
keepAlive: true,
menuCode: 'system:site:list'
}
},
{
id: '109',
name: 'Unit',
path: 'unit',
component: '/system/unit/index',
meta: {
title: '物品单位管理',
keepAlive: true,
menuCode: 'system:unit:list'
}
},
{
id: '110',
name: 'Express',
path: 'express',
component: '/system/express/index',
meta: {
title: '快递公司',
keepAlive: true,
menuCode: 'system:express:list'
}
}
]
},
// ──────────────────────────────────────────────
// 12. 工作流管理
// 2. 工作流管理
// ──────────────────────────────────────────────
{
id: 'WKF00000000000000000000000000001',
@@ -1026,7 +96,7 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
},
// ──────────────────────────────────────────────
// 13. 系统管理
// 3. 系统管理
// ──────────────────────────────────────────────
{
id: 'SYSM000000000000000000000000000001',
+50
View File
@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import { fetchAllDictData } from '@/api/admin'
interface DictItem {
label: string
value: string
tagType: string
}
export const useDictStore = defineStore('dictStore', {
state: () => ({
dictMap: {} as Record<string, DictItem[]>,
loaded: false,
}),
getters: {
getLabel: (state) => (dictType: string, value: string): string => {
const items = state.dictMap[dictType]
if (!items) return value || '—'
const item = items.find((i) => i.value === value)
return item?.label ?? value ?? '—'
},
getTagType: (state) => (dictType: string, value: string): string => {
const items = state.dictMap[dictType]
if (!items) return 'info'
const item = items.find((i) => i.value === value)
return item?.tagType || 'info'
},
getOptions: (state) => (dictType: string): DictItem[] => {
return state.dictMap[dictType] || []
},
},
actions: {
async loadAllDict() {
if (this.loaded) return
try {
const res = await fetchAllDictData()
this.dictMap = (res as any)?.data || res || {}
this.loaded = true
} catch (e) {
console.error('加载字典数据失败', e)
}
},
clearDict() {
this.dictMap = {}
this.loaded = false
},
},
})
+4
View File
@@ -421,6 +421,7 @@ declare namespace Api {
dictLabel: string
dictValue: string
dictType: string
tagType: string
tenantNo: string
orderNum: number
remark: string
@@ -430,7 +431,10 @@ declare namespace Api {
interface DictDataOption {
label: string
value: string
tagType: string
}
/** 全量字典响应:dictType → { label, value, tagType }[] */
type DictAllResponse = Record<string, { label: string; value: string; tagType: string }[]>
}
namespace Site {
+1 -1
View File
@@ -1,5 +1,5 @@
import { ElMessage } from 'element-plus'
import { fetchAttachmentDownloadUrl } from '@/api/business'
import { fetchAttachmentDownloadUrl } from '@/api/admin'
export type AttachmentDownloadItem = {
fileUrl?: string
@@ -1,141 +0,0 @@
import type { OutboundQcDetail } from '@/api/qc-work-order'
import type { ViewField, ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { maskIdCard } from '@/utils/mask-sensitive'
import {
applicationStatusLabel,
applicationStatusTagType,
infusionMethodLabel,
infusionStatusLabel,
infusionStatusTagType,
qcStatusLabel,
qcStatusTagType,
scheduleStatusLabel,
scheduleStatusTagType,
sexLabel
} from '@/utils/status-tag-registry'
export type OutboundViewContextVariant = 'pilot_service' | 'clinical_application'
const SECTION_TITLES: Record<
OutboundViewContextVariant,
{ application: string; schedule: string }
> = {
pilot_service: {
application: '中试服务申请单信息',
schedule: '本次输注计划信息'
},
clinical_application: {
application: '临床应用申请信息',
schedule: '临床应用输注计划信息'
}
}
/** 出库工单/质检:申请单 / 患者 / 输注方案 / 输注计划 / 产品明细(码值转中文标签) */
export function buildOutboundPilotContextSections(
detail: OutboundQcDetail,
variant: OutboundViewContextVariant = 'pilot_service'
): ViewSection[] {
const app = detail.application
const patient = detail.patient
const plan = detail.infusionPlan
const schedule = detail.infusionSchedule
const products = detail.products || []
const titles = SECTION_TITLES[variant]
const result: ViewSection[] = []
if (app) {
result.push({
title: titles.application,
fields: [
{ label: '申请单号', value: app.applicationNo || '—' },
{ label: '申请单名称', value: app.applicationName || '—' },
{
label: '申请单状态',
value: applicationStatusLabel(app.applicationStatus || ''),
tagType: applicationStatusTagType(app.applicationStatus || '') as ViewField['tagType']
},
{ label: '渠道名称', value: app.channelName || '—' }
]
})
}
if (patient) {
result.push({
title: '患者信息',
fields: [
{ label: '患者姓名', value: patient.patientName || '—' },
{ label: '性别', value: sexLabel(String(patient.sex ?? '')) || '—' },
{ label: '年龄', value: patient.age || '—' },
{ label: '联系电话', value: patient.phoneNo || '—' },
{
label: '身份证号',
value: patient.idCardNo ? maskIdCard(patient.idCardNo) : '—'
},
{ label: '紧急联系人', value: patient.emergencyContact || '—' },
{ label: '紧急联系电话', value: patient.emergencyPhone || '—' },
{ label: '临床诊断', value: patient.clinicalDiagnosis || '—', span: true }
],
slotTitle: '诊断附件',
slotTitleStyle: 'field',
slot: 'diagnosis-attachments'
})
}
if (plan) {
result.push({
title: '输注方案信息',
fields: [
{
label: '总疗程数',
value: plan.totalCourseCount != null ? String(plan.totalCourseCount) : '—'
},
{ label: '疗程间隔', value: plan.courseInterval || '—' },
{ label: '输注开始日期', value: plan.infusionStartDate || '—' }
]
})
}
if (schedule) {
const scheduleFields: ViewField[] = [
{ label: '输注时间', value: schedule.infusionTime || '—' },
{ label: '输注地点', value: schedule.infusionLocation || '—' },
{ label: '输注方式', value: infusionMethodLabel(schedule.infusionMethod || '') },
{
label: '计划状态',
value: scheduleStatusLabel(schedule.scheduleStatus || ''),
tagType: scheduleStatusTagType(schedule.scheduleStatus || '') as ViewField['tagType']
},
{
label: '输注状态',
value: infusionStatusLabel(schedule.infusionStatus || ''),
tagType: infusionStatusTagType(schedule.infusionStatus || '') as ViewField['tagType']
},
{
label: '质检状态',
value: qcStatusLabel(schedule.qcStatus || ''),
tagType: qcStatusTagType(schedule.qcStatus || '') as ViewField['tagType']
}
]
if (schedule.cancelReason) {
scheduleFields.push({ label: '取消原因', value: schedule.cancelReason, span: true })
}
result.push({ title: titles.schedule, fields: scheduleFields })
}
if (products.length) {
result.push({
title: '产品明细',
fields: [],
slot: 'product-table'
})
}
return result
}
export function isOutboundPilotServiceQcTask(d?: {
qcBizType?: string
receptionSource?: string
} | null): boolean {
return d?.qcBizType === 'outbound' && d?.receptionSource === 'pilot_service'
}
-49
View File
@@ -1,49 +0,0 @@
import type { TagType } from '@/utils/status-tag-registry'
/** 生产结论 cg=成功 sb=失败 */
export function productionConclusionLabel(code?: string | null): string {
const c = (code || '').trim()
if (c === 'cg') return '成功'
if (c === 'sb') return '失败'
return c || '—'
}
export function productionConclusionTagType(code?: string | null): TagType | undefined {
const c = (code || '').trim()
if (c === 'cg') return 'success'
if (c === 'sb') return 'danger'
return undefined
}
/** 生产任务来源:inbound / temporary / outbound */
export function productionOrderSourceTag(orderSource?: string | null): {
label: string
type: TagType
} {
const s = (orderSource || '').trim() || 'inbound'
if (s === 'temporary') return { label: '临时', type: 'warning' }
if (s === 'outbound') return { label: '出库', type: 'primary' }
return { label: '入库', type: 'success' }
}
/** 生产任务类型(临时单看 orderSource,入库/出库看 taskType) */
export function productionTaskTypeTag(
taskType?: string | null,
orderSource?: string | null
): { label: string; type: TagType } {
if ((orderSource || '').trim() === 'temporary') {
return { label: '临时生产单', type: 'warning' }
}
const t = (taskType || '').trim() || 'cell_storage'
const map: Record<string, { label: string; type: TagType }> = {
cell_storage: { label: '入库生产单', type: 'primary' },
outbound: { label: '出库生产单', type: 'warning' }
}
return map[t] || { label: t, type: 'info' }
}
/** 按工单类型派生创建任务时的 task_type */
export function productionTaskTypeForWorkOrder(workOrderType?: string | null): string {
if (workOrderType === 'outbound') return 'outbound'
return 'cell_storage'
}
@@ -1,53 +0,0 @@
export {
protocolStatusLabel,
protocolStatusTagType,
payStatusLabel,
payStatusTagType,
payModeLabel,
payModeTagType,
transferTypeLabel,
transferTypeTagType
} from './status-tag-registry'
export function formatPaymentDueDate(d: unknown) {
const s = String(d || '')
if (!s) return '-'
if (s.length === 8) return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`
return s
}
export const PAYMENT_DUE_WARN_DAYS = 15
function parsePaymentDueYmd(ymd: unknown): Date | null {
const raw = String(ymd || '').replace(/\D/g, '')
if (raw.length !== 8) return null
const y = Number(raw.slice(0, 4))
const m = Number(raw.slice(4, 6)) - 1
const d = Number(raw.slice(6, 8))
const dt = new Date(y, m, d)
if (dt.getFullYear() !== y || dt.getMonth() !== m || dt.getDate() !== d) return null
return dt
}
function startOfLocalDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
export function isPaymentDueWithinWarnWindow(
installmentDueDate: unknown,
warnDays = PAYMENT_DUE_WARN_DAYS
) {
const due = parsePaymentDueYmd(installmentDueDate)
if (!due) return false
const today = startOfLocalDay(new Date())
const limit = new Date(today)
limit.setDate(limit.getDate() + warnDays)
return due.getTime() <= limit.getTime()
}
export function formatPaymentAmount(v: unknown) {
if (v == null || v === '') return '-'
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return '¥' + n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
-18
View File
@@ -1,18 +0,0 @@
export {
qcBizTypeLabel,
qcBizTypeTagType,
receptionSourceLabel,
receptionSourceTagType,
acceptConclusionLabel,
acceptConclusionTagType,
transportTypeLabel,
transportTypeTagType,
workOrderStatusLabel,
workOrderStatusTagType,
taskTypeLabel,
taskTypeTagType,
qcTaskConclusionLabel,
qcTaskConclusionTagType,
taskStatusLabel,
taskStatusTagType
} from './status-tag-registry'
-603
View File
@@ -1,603 +0,0 @@
/**
* 状态标签注册表 — 全局集中定义所有枚举字段的 label + tagType
*
* 使用方式:
* import { protocolStatusLabel, protocolStatusTagType } from '@/utils/status-tag-registry'
* <el-tag :type="protocolStatusTagType(row.status)">{{ protocolStatusLabel(row.status) }}</el-tag>
*
* 规范:同一枚举值在任何地方(表格、查看弹窗、导出)颜色必须一致。
* tagType 语义色:success=正常/已完成, warning=进行中/已过期, danger=异常/已作废,
* info=待执行/停用, primary=特殊分类/转出, ''=默认
*/
// ── 类型定义 ──────────────────────────────────────────────────────────────────
/** el-tag 的 type 属性值 */
export type TagType = 'success' | 'warning' | 'danger' | 'info' | '' | 'primary'
/** 单条枚举映射 */
export interface StatusItem {
label: string
tagType: TagType
}
/** 注册表类型 */
export type StatusRegistry = Record<string, StatusItem>
// ── 工厂函数 ──────────────────────────────────────────────────────────────────
function createLabelGetter(registry: StatusRegistry) {
return (code: string): string => registry[code]?.label ?? code ?? '—'
}
function createTagTypeGetter(registry: StatusRegistry) {
return (code: string): TagType => registry[code]?.tagType ?? 'info'
}
// ══════════════════════════════════════════════════════════════════════════════
// 协议 / 存储 域
// ══════════════════════════════════════════════════════════════════════════════
export const protocolStatus: StatusRegistry = {
dsx: { label: '待生效', tagType: 'info' },
sxz: { label: '生效中', tagType: 'success' },
ygq: { label: '已过期', tagType: 'warning' },
yzf: { label: '已作废', tagType: 'danger' },
zky: { label: '转科研', tagType: 'primary' }
}
export const protocolStatusLabel = createLabelGetter(protocolStatus)
export const protocolStatusTagType = createTagTypeGetter(protocolStatus)
export const donationStatus: StatusRegistry = {
dsx: { label: '待生效', tagType: 'info' },
sxz: { label: '生效中', tagType: 'success' },
ygq: { label: '已过期', tagType: 'warning' },
yzf: { label: '已作废', tagType: 'danger' },
zckc: { label: '转细胞存储', tagType: 'primary' }
}
export const donationStatusLabel = createLabelGetter(donationStatus)
export const donationStatusTagType = createTagTypeGetter(donationStatus)
export const sampleStatus: StatusRegistry = {
ycj: { label: '已采集', tagType: 'primary' },
jcz: { label: '检测中', tagType: 'warning' },
yrk: { label: '已入库', tagType: 'success' },
yzf: { label: '已作废', tagType: 'danger' }
}
export const sampleStatusLabel = createLabelGetter(sampleStatus)
export const sampleStatusTagType = createTagTypeGetter(sampleStatus)
export const payStatus: StatusRegistry = {
dfj: { label: '待缴费', tagType: 'info' },
jfqrz: { label: '缴费确认中', tagType: 'warning' },
yjf: { label: '已缴费', tagType: 'success' },
tfqrz: { label: '退费确认中', tagType: 'warning' },
ytf: { label: '已退费', tagType: 'danger' }
}
export const payStatusLabel = createLabelGetter(payStatus)
export const payStatusTagType = createTagTypeGetter(payStatus)
export const auditStatus: StatusRegistry = {
shtg: { label: '审核通过', tagType: 'success' },
shth: { label: '审核退回', tagType: 'danger' }
}
export const auditStatusLabel = createLabelGetter(auditStatus)
export const auditStatusTagType = createTagTypeGetter(auditStatus)
export const payMode: StatusRegistry = {
dj: { label: '趸交', tagType: 'info' },
fq: { label: '分期', tagType: 'warning' }
}
export const payModeLabel = createLabelGetter(payMode)
export const payModeTagType = createTagTypeGetter(payMode)
export const transferType: StatusRegistry = {
wxzz: { label: '微信转账', tagType: 'success' },
zzbzz: { label: '支付宝转账', tagType: 'primary' },
yhkzz: { label: '银行卡转账', tagType: 'info' },
xj: { label: '现金', tagType: 'warning' }
}
export const transferTypeLabel = createLabelGetter(transferType)
export const transferTypeTagType = createTagTypeGetter(transferType)
export const transportType: StatusRegistry = {
kdys: { label: '快递运输', tagType: 'info' },
ryxd: { label: '人员携带', tagType: 'success' }
}
export const transportTypeLabel = createLabelGetter(transportType)
export const transportTypeTagType = createTagTypeGetter(transportType)
export const receptionSource: StatusRegistry = {
cell_storage: { label: '细胞存储', tagType: 'primary' },
research_donation: { label: '科研捐赠', tagType: 'warning' },
outbound: { label: '出库', tagType: 'primary' },
pilot_service: { label: '中试服务', tagType: 'primary' },
clinical_application: { label: '临床应用', tagType: 'warning' }
}
export const receptionSourceLabel = createLabelGetter(receptionSource)
export const receptionSourceTagType = createTagTypeGetter(receptionSource)
export const newbornSex: StatusRegistry = {
'1': { label: '男', tagType: 'primary' },
'0': { label: '女', tagType: 'success' }
}
export const newbornSexLabel = createLabelGetter(newbornSex)
export const newbornSexTagType = createTagTypeGetter(newbornSex)
export const deliveryMode: StatusRegistry = {
sc: { label: '顺产', tagType: 'info' },
pgc: { label: '剖宫产', tagType: 'warning' }
}
export const deliveryModeLabel = createLabelGetter(deliveryMode)
export const deliveryModeTagType = createTagTypeGetter(deliveryMode)
export const sourceType: StatusRegistry = {
direct: { label: '独立新建', tagType: 'info' },
from_storage: { label: '从存储转来', tagType: 'primary' }
}
export const sourceTypeLabel = createLabelGetter(sourceType)
export const sourceTypeTagType = createTagTypeGetter(sourceType)
// ══════════════════════════════════════════════════════════════════════════════
// 质检域
// ══════════════════════════════════════════════════════════════════════════════
export const qcBizType: StatusRegistry = {
inbound: { label: '入库质检', tagType: 'success' },
outbound: { label: '出库质检', tagType: 'primary' },
temporary: { label: '临时质检', tagType: 'warning' }
}
export const qcBizTypeLabel = createLabelGetter(qcBizType)
export const qcBizTypeTagType = createTagTypeGetter(qcBizType)
export const workOrderStatus: StatusRegistry = {
dpz: { label: '待配置', tagType: 'info' },
ypz: { label: '已配置', tagType: 'success' },
ywc: { label: '已完成', tagType: 'success' },
yzf: { label: '已作废', tagType: 'danger' }
}
export const workOrderStatusLabel = createLabelGetter(workOrderStatus)
export const workOrderStatusTagType = createTagTypeGetter(workOrderStatus)
export const taskType: StatusRegistry = {
sysz: { label: '实验室自检', tagType: 'info' },
dsfjc: { label: '第三方检测', tagType: 'primary' }
}
export const taskTypeLabel = createLabelGetter(taskType)
export const taskTypeTagType = createTagTypeGetter(taskType)
export const qcConclusion: StatusRegistry = {
tg: { label: '通过', tagType: 'success' },
btg: { label: '不通过', tagType: 'danger' }
}
export const qcTaskConclusionLabel = createLabelGetter(qcConclusion)
export const qcTaskConclusionTagType = createTagTypeGetter(qcConclusion)
export const taskStatus: StatusRegistry = {
dzx: { label: '待执行', tagType: 'info' },
jxz: { label: '进行中', tagType: 'warning' },
ywc: { label: '已完成', tagType: 'success' }
}
export const taskStatusLabel = createLabelGetter(taskStatus)
export const taskStatusTagType = createTagTypeGetter(taskStatus)
export const acceptConclusion: StatusRegistry = {
js: { label: '接收', tagType: 'success' },
bj: { label: '拒收', tagType: 'danger' }
}
export const acceptConclusionLabel = createLabelGetter(acceptConclusion)
export const acceptConclusionTagType = createTagTypeGetter(acceptConclusion)
// ══════════════════════════════════════════════════════════════════════════════
// 渠道域
// ══════════════════════════════════════════════════════════════════════════════
export const channelType: StatusRegistry = {
yy: { label: '医院', tagType: 'primary' },
hzjg: { label: '合作机构', tagType: 'success' },
zs: { label: '诊所', tagType: 'warning' },
zfjg: { label: '政府机构', tagType: 'danger' },
qt: { label: '其他', tagType: 'info' }
}
export const channelTypeLabel = createLabelGetter(channelType)
export const channelTypeTagType = createTagTypeGetter(channelType)
export const agreementType: StatusRegistry = {
kyxy: { label: '框架协议', tagType: 'primary' },
zsxy: { label: '中试协议', tagType: 'success' },
lcxy: { label: '临床协议', tagType: 'warning' }
}
export const agreementTypeLabel = createLabelGetter(agreementType)
export const agreementTypeTagType = createTagTypeGetter(agreementType)
export const agreementStatus: StatusRegistry = {
dsx: { label: '待生效', tagType: 'info' },
sxz: { label: '生效中', tagType: 'success' },
yzf: { label: '已作废', tagType: 'danger' }
}
export const agreementStatusLabel = createLabelGetter(agreementStatus)
export const agreementStatusTagType = createTagTypeGetter(agreementStatus)
export const settlementType: StatusRegistry = {
yj: { label: '月结', tagType: '' },
jj: { label: '季结', tagType: 'success' },
nj: { label: '年结', tagType: 'warning' },
dcjs: { label: '单次结算', tagType: 'danger' }
}
export const settlementTypeLabel = createLabelGetter(settlementType)
export const settlementTypeTagType = createTagTypeGetter(settlementType)
// ══════════════════════════════════════════════════════════════════════════════
// 订单域
// ══════════════════════════════════════════════════════════════════════════════
export const orderStatus: StatusRegistry = {
pending_payment: { label: '待支付', tagType: 'warning' },
pending_confirm: { label: '待收货', tagType: 'info' },
completed: { label: '已完成', tagType: 'success' },
cancelled: { label: '已取消', tagType: 'danger' }
}
export const orderStatusLabel = createLabelGetter(orderStatus)
export const orderStatusTagType = createTagTypeGetter(orderStatus)
export const deliveryStatusMap: StatusRegistry = {
pending_dispatch: { label: '待分配配送', tagType: 'info' },
delivering: { label: '配送中', tagType: 'warning' },
delivered: { label: '配送完成', tagType: 'success' }
}
export const deliveryStatusLabel = createLabelGetter(deliveryStatusMap)
export const deliveryStatusTagType = createTagTypeGetter(deliveryStatusMap)
export const paymentMethod: StatusRegistry = {
online: { label: '线上支付', tagType: 'success' },
offline: { label: '线下支付', tagType: 'info' }
}
export const paymentMethodLabel = createLabelGetter(paymentMethod)
export const paymentMethodTagType = createTagTypeGetter(paymentMethod)
export const orderPayStatus: StatusRegistry = {
pending_payment: { label: '待支付', tagType: 'warning' },
paid: { label: '已支付', tagType: 'success' }
}
export const orderPayStatusLabel = createLabelGetter(orderPayStatus)
export const orderPayStatusTagType = createTagTypeGetter(orderPayStatus)
// ══════════════════════════════════════════════════════════════════════════════
// 系统 / 通用域
// ══════════════════════════════════════════════════════════════════════════════
/** 通用启用/停用(数据库 status 字段 1/0) */
export const enableStatus: StatusRegistry = {
'1': { label: '正常', tagType: 'success' },
'0': { label: '停用', tagType: 'danger' }
}
export const enableStatusLabel = createLabelGetter(enableStatus)
export const enableStatusTagType = createTagTypeGetter(enableStatus)
/** 性别(客户等业务表) */
export const sex: StatusRegistry = {
'0': { label: '男', tagType: 'primary' },
'1': { label: '女', tagType: 'success' }
}
export const sexLabel = createLabelGetter(sex)
export const sexTagType = createTagTypeGetter(sex)
/** 请假状态 */
export const leaveStatus: StatusRegistry = {
draft: { label: '草稿', tagType: 'info' },
pending: { label: '审批中', tagType: 'warning' },
approved: { label: '已通过', tagType: 'success' },
rejected: { label: '已驳回', tagType: 'danger' },
cancelled: { label: '已取消', tagType: 'info' }
}
export const leaveStatusLabel = createLabelGetter(leaveStatus)
export const leaveStatusTagType = createTagTypeGetter(leaveStatus)
/** 数据权限范围 */
export const dataScope: StatusRegistry = {
'1': { label: '全部数据', tagType: '' },
'2': { label: '本部门数据', tagType: 'info' },
'3': { label: '本部门及以下', tagType: 'info' },
'4': { label: '仅本人', tagType: 'warning' },
'5': { label: '自定义', tagType: 'primary' }
}
export const dataScopeLabel = createLabelGetter(dataScope)
export const dataScopeTagType = createTagTypeGetter(dataScope)
/** 洁净室等级 */
export const cleanRoomGrade: StatusRegistry = {
A: { label: 'A级', tagType: 'warning' },
B: { label: 'B级', tagType: '' },
C: { label: 'C级', tagType: 'info' },
D: { label: 'D级', tagType: 'info' }
}
export const cleanRoomGradeLabel = createLabelGetter(cleanRoomGrade)
export const cleanRoomGradeTagType = createTagTypeGetter(cleanRoomGrade)
/** 样本编码类型 */
export const sampleCodeType: StatusRegistry = {
zxbm: { label: '造血编码', tagType: 'info' },
qdbm: { label: '脐带编码', tagType: 'primary' }
}
export const sampleCodeTypeLabel = createLabelGetter(sampleCodeType)
export const sampleCodeTypeTagType = createTagTypeGetter(sampleCodeType)
/** 耗材包类型 */
export const packType: StatusRegistry = {
bzhcb: { label: '标准耗材包', tagType: 'primary' },
zdyhcb: { label: '自定义耗材包', tagType: 'warning' }
}
export const packTypeLabel = createLabelGetter(packType)
export const packTypeTagType = createTagTypeGetter(packType)
// ══════════════════════════════════════════════════════════════════════════════
// 入库放行审核域
// ══════════════════════════════════════════════════════════════════════════════
export const storageReleaseConclusion: StatusRegistry = {
tg: { label: '通过', tagType: 'success' },
btg: { label: '不通过', tagType: 'danger' }
}
export const storageReleaseConclusionLabel = createLabelGetter(storageReleaseConclusion)
export const storageReleaseConclusionTagType = createTagTypeGetter(storageReleaseConclusion)
export const storageStatus: StatusRegistry = {
drk: { label: '待入库', tagType: 'warning' },
yrk: { label: '已入库', tagType: 'success' }
}
export const storageStatusLabel = createLabelGetter(storageStatus)
export const storageStatusTagType = createTagTypeGetter(storageStatus)
// ══════════════════════════════════════════════════════════════════════════════
// 渠道服务订单域
// ══════════════════════════════════════════════════════════════════════════════
/** 渠道服务订单类型 */
export const channelServiceOrderType: StatusRegistry = {
yfk: { label: '预付款充值', tagType: 'primary' },
gmcp: { label: '购买产品份数', tagType: 'success' }
}
export const channelServiceOrderTypeLabel = createLabelGetter(channelServiceOrderType)
export const channelServiceOrderTypeTagType = createTagTypeGetter(channelServiceOrderType)
/** 渠道服务订单状态 */
export const channelServiceOrderStatus: StatusRegistry = {
dsx: { label: '待生效', tagType: 'info' },
dqr: { label: '待确认', tagType: 'warning' },
yqr: { label: '已确认', tagType: 'success' },
sxz: { label: '生效中', tagType: 'success' },
tfqrz: { label: '退款确认中', tagType: 'danger' },
ytf: { label: '已退款', tagType: 'info' },
yzf: { label: '已作废', tagType: 'info' }
}
export const channelServiceOrderStatusLabel = createLabelGetter(channelServiceOrderStatus)
export const channelServiceOrderStatusTagType = createTagTypeGetter(channelServiceOrderStatus)
// ══════════════════════════════════════════════════════════════════════════════
// 流水域
// ══════════════════════════════════════════════════════════════════════════════
// 流水类型
export const txnTypeLabel = (type: string): string => {
const map: Record<string, string> = {
recharge: '充值',
refund: '退款',
purchase: '购买产品',
unsubscribe: '退订产品',
settlement: '输注结算'
}
return map[type] || type
}
export const txnTypeTagType = (type: string): string => {
const map: Record<string, string> = {
recharge: '',
refund: 'danger',
purchase: 'success',
unsubscribe: 'warning',
settlement: 'primary'
}
return map[type] || 'info'
}
// 流水状态
export const txnStatusLabel = (status: string): string => {
const map: Record<string, string> = {
dsx: '待生效',
dqr: '待财务确认',
yqr: '已确认',
yzf: '已作废'
}
return map[status] || status
}
export const txnStatusTagType = (status: string): string => {
const map: Record<string, string> = {
dsx: 'info',
dqr: 'warning',
yqr: 'success',
yzf: 'danger'
}
return map[status] || 'info'
}
/** 中试申请单状态 */
export function applicationStatusLabel(status: string): string {
const map: Record<string, string> = { dks: '待开始', jxz: '进行中', ywc: '已完成', yzf: '已作废' }
return map[status] || status || '-'
}
export function applicationStatusTagType(
status: string
): '' | 'success' | 'warning' | 'danger' | 'info' {
const map: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
dks: 'info',
jxz: 'warning',
ywc: 'success',
yzf: 'danger'
}
return map[status] || 'info'
}
// ========== 中试服务体检状态 ==========
export function checkupStatusLabel(status: string): string {
const map: Record<string, string> = {
dtj: '待体检',
ytj: '已体检'
}
return map[status] || status || '-'
}
export function checkupStatusTagType(status: string): string {
const map: Record<string, string> = {
dtj: 'warning',
ytj: 'success'
}
return map[status] || 'info'
}
export function feeStatusLabel(status: string): string {
const map: Record<string, string> = {
wjs: '未结算',
yjs: '已结算'
}
return map[status] || status || '-'
}
export function feeStatusTagType(status: string): string {
const map: Record<string, string> = {
wjs: 'info',
yjs: 'success'
}
return map[status] || 'info'
}
// ========== 中试服务 - 输注计划状态 ==========
export function scheduleStatusLabel(status: string): string {
const map: Record<string, string> = {
dsc: '待生产',
ysc: '已生产',
yjf: '已交付',
yqx: '已取消'
}
return map[status] || status || '-'
}
export function scheduleStatusTagType(status: string): string {
const map: Record<string, string> = {
dsc: 'info',
ysc: 'primary',
yjf: 'success',
yqx: 'danger'
}
return map[status] || 'info'
}
// ========== 中试服务 - 输注状态 ==========
export function infusionStatusLabel(status: string): string {
const map: Record<string, string> = {
dtz: '待输注',
yrqr: '已用户确认',
yqx: '已取消',
ywc: '已完成'
}
return map[status] || status || '-'
}
export function infusionStatusTagType(status: string): string {
const map: Record<string, string> = {
dtz: 'info',
yrqr: 'warning',
yqx: 'danger',
ywc: 'success'
}
return map[status] || 'info'
}
// ========== 中试服务 - 输注方式(字典 szfs) ==========
export function infusionMethodLabel(method: string): string {
const map: Record<string, string> = {
jmsz: '静脉输注',
pxzs: '皮下注射',
jbzs: '局部注射',
qt: '其他'
}
return map[method] || method || '—'
}
// ========== 中试服务 - 质检状态 ==========
export function qcStatusLabel(status: string): string {
const map: Record<string, string> = { dzc: '待质检', yzj: '已质检', yqx: '已取消' }
return map[status] || status || '-'
}
export function qcStatusTagType(status: string): string {
const map: Record<string, string> = { dzc: 'info', yzj: 'success', yqx: 'danger' }
return map[status] || 'info'
}
// ========== 中试服务 - 放行审核结论 ==========
export function releaseReviewConclusionLabel(conclusion: string): string {
const map: Record<string, string> = { tg: '通过', btg: '不通过' }
return map[conclusion] || conclusion || '-'
}
export function releaseReviewConclusionTagType(conclusion: string): string {
const map: Record<string, string> = { tg: 'success', btg: 'danger' }
return map[conclusion] || 'info'
}
// ========== 中试服务 - 业务类型 ==========
export function bizTypeLabel(type: string): string {
const map: Record<string, string> = {
pilot_service: '中试服务',
clinical_application: '临床应用'
}
return map[type] || type || '-'
}
export function bizTypeTagType(type: string): string {
const map: Record<string, string> = {
pilot_service: 'primary',
clinical_application: 'warning'
}
return map[type] || 'info'
}
// ========== 中试服务 - 产品状态 ==========
export function productStatusLabel(status: string): string {
const map: Record<string, string> = {
dtz: '待输注',
yrqr: '已用户确认',
yqx: '已取消',
ywc: '已完成'
}
return map[status] || status || '-'
}
export function productStatusTagType(status: string): string {
const map: Record<string, string> = {
dtz: 'info',
yrqr: 'warning',
yqx: 'danger',
ywc: 'success'
}
return map[status] || 'info'
}
// ========== 细胞产品库 - 产品状态 ==========
export function cellProductStatusLabel(status: string): string {
const map: Record<string, string> = {
frozen: '已冻存',
in_production: '生产中',
sold: '已售卖',
used: '已使用',
voided: '已作废',
thawed: '已复苏'
}
return map[status] || status || '—'
}
export function cellProductStatusTagType(status: string): string {
const map: Record<string, string> = {
frozen: 'success',
in_production: 'warning',
sold: 'warning',
used: 'info',
voided: 'danger',
thawed: 'info'
}
return map[status] || 'info'
}
+2 -2
View File
@@ -5,7 +5,7 @@
<div class="login-left">
<div class="brand-content">
<div class="logo">
<img class="logo-img" src="/logo.png" alt="中科兴皖" />
<img class="logo-img" src="/logo.png" alt="山峡化工" />
</div>
<h1 class="slogan">一体化管理门户</h1>
<p class="description"> 安全 · 高效 · 可扩展 </p>
@@ -76,7 +76,7 @@
</el-form>
<div class="form-footer">
<span class="copyright">中科兴皖 © {{ currentYear }}</span>
<span class="copyright">山峡化工 © {{ currentYear }}</span>
</div>
</div>
</div>
@@ -1,836 +0,0 @@
<!-- 细胞样本基础信息维护 · 样本编码 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="sample-code-tab">
<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-form-item>
<el-form-item label="编码类型">
<el-select
v-model="searchForm.sampleCodeType"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="造血编码 (ZKZX)" value="zxbm" />
<el-option label="脐带编码 (ZKQD)" value="qdbm" />
</el-select>
</el-form-item>
<el-form-item label="下发对象">
<el-select
v-model="searchForm.issuedUserId"
placeholder="全部"
filterable
clearable
style="width: 100%"
>
<el-option
v-for="opt in userOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增样本编码
</el-button>
<el-button class="btn-ghost" @click="openBatchGenerate">
<el-icon><DocumentAdd /></el-icon>
批量生成
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length === 0"
@click="openBatchIssue"
>
<el-icon><Promotion /></el-icon>
批量下发
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length === 0"
@click="handleBatchRevoke"
>
<el-icon><RefreshRight /></el-icon>
批量收回
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="sampleCode"
label="样本编码"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
prop="sampleCodeType"
label="编码类型"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{ sampleCodeTypeLabel(row.sampleCodeType) }}</template>
</el-table-column>
<el-table-column
prop="issued"
label="是否下发"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ row }">
<span>{{ row.issued === 1 ? '是' : '否' }}</span>
</template>
</el-table-column>
<el-table-column
prop="issuedUserName"
label="下发对象"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.issuedUserName || '-' }}</template>
</el-table-column>
<el-table-column
prop="issuedTime"
label="下发时间"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.issuedTime || '-' }}</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<!-- 批量生成弹窗 -->
<el-dialog
v-model="batchGenVisible"
title="批量生成样本编码"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="batchGenFormRef"
:model="batchGenForm"
:rules="batchGenRules"
label-position="top"
scroll-to-error
>
<div class="form-grid-2">
<el-form-item label="编码类型" prop="sampleCodeType">
<el-select
v-model="batchGenForm.sampleCodeType"
placeholder="请选择"
style="width: 100%"
>
<el-option label="造血编码 (ZKZX)" value="zxbm" />
<el-option label="脐带编码 (ZKQD)" value="qdbm" />
</el-select>
</el-form-item>
<el-form-item label="中间段" prop="middle">
<el-input v-model="batchGenForm.middle" placeholder="如 34" />
</el-form-item>
<el-form-item label="开始序号" prop="seqStart">
<el-input
type="number"
step="any"
v-model="batchGenForm.seqStart"
:min="0"
:max="99999"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="结束序号" prop="seqEnd">
<el-input
type="number"
step="any"
v-model="batchGenForm.seqEnd"
:min="0"
:max="99999"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="序号位数" prop="digitCount">
<template #label>序号位数<span class="form-label-tip">补零后的总位数</span></template>
<el-input
type="number"
step="any"
v-model="batchGenForm.digitCount"
:min="1"
:max="10"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="预览" class="form-col-span-2">
<span class="text-sm text-gray-500">{{ batchGenPreview }}</span>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="batchGenVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="batchGenLoading"
@click="handleBatchGenerate"
>
<el-icon><Check /></el-icon>
生成
</el-button>
</template>
</el-dialog>
<!-- 批量下发弹窗 -->
<el-dialog
v-model="batchIssueVisible"
title="批量下发样本编码"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="batchIssueFormRef"
:model="batchIssueForm"
:rules="batchIssueRules"
label-position="top"
scroll-to-error
>
<el-form-item label="下发用户" prop="issuedUserId">
<el-select
v-model="batchIssueForm.issuedUserId"
placeholder="请选择用户"
filterable
style="width: 100%"
>
<el-option
v-for="opt in userOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="已选编码">
<span class="text-sm text-gray-500">
已选 {{ selectedRows.length }} 条,下发后"是否下发"将变为"是"
</span>
</el-form-item>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="batchIssueVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="batchIssueLoading"
@click="handleBatchIssue"
>
<el-icon><Check /></el-icon>
确定下发
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="biz-dialog-form"
scroll-to-error
>
<div class="form-grid-2">
<el-form-item label="样本编码" prop="sampleCode">
<el-input
v-model="formData.sampleCode"
placeholder="租户内唯一,最多32位"
maxlength="32"
/>
</el-form-item>
<el-form-item v-if="!isEdit" label="编码类型" prop="sampleCodeType">
<el-select v-model="formData.sampleCodeType" placeholder="请选择" style="width: 100%">
<el-option label="造血编码 (ZKZX)" value="zxbm" />
<el-option label="脐带编码 (ZKQD)" value="qdbm" />
</el-select>
</el-form-item>
<el-form-item v-else label="编码类型">
<span class="text-sm text-gray-500">{{
sampleCodeTypeLabel(formData.sampleCodeType)
}}</span>
</el-form-item>
<el-form-item label="是否下发">
<el-switch
v-model="formData.issued"
:active-value="1"
:inactive-value="0"
inline-prompt
active-text="是"
inactive-text="否"
/>
</el-form-item>
<el-form-item v-if="formData.issued === 1" label="下发用户" prop="issuedUserId">
<el-select
v-model="formData.issuedUserId"
placeholder="请选择用户"
filterable
style="width: 100%"
>
<el-option
v-for="opt in userOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="formData.issued === 1 && formData.issuedTime" label="下发时间">
<span class="text-sm text-gray-500">{{ formData.issuedTime }}</span>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.sampleCode || '样本编码详情'"
:icon="Ticket"
:meta="`编码类型:${sampleCodeTypeLabel(viewRow?.sampleCodeType || '')}${viewRow?.createTime ? ' · ' + viewRow.createTime : ''}`"
:sections="viewSections"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Close,
Check,
View,
Ticket,
RefreshLeft,
Search,
DocumentAdd,
Promotion,
RefreshRight,
More,
Edit,
Delete
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchSampleCodeList,
fetchCreateSampleCode,
fetchUpdateSampleCode,
fetchDeleteSampleCode,
fetchUserOptions,
fetchBatchGenerateSampleCodes,
fetchBatchIssueSampleCodes,
fetchBatchRevokeSampleCodes
} from '@/api/admin'
import { sampleCodeTypeLabel } from '@/utils/status-tag-registry'
defineOptions({ name: 'SampleCodeTab' })
const searchForm = reactive<{ keyword: string; sampleCodeType: string; issuedUserId: string }>({
keyword: '',
sampleCodeType: '',
issuedUserId: ''
})
const tableData = ref<Api.BizSampleCode.SampleCodeItem[]>([])
const selectedRows = ref<Api.BizSampleCode.SampleCodeItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const dialogTitle = ref('新增样本编码')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
// ---- 查看弹窗 ----
const viewVisible = ref(false)
const viewRow = ref<Api.BizSampleCode.SampleCodeItem | null>(null)
const viewSections = computed(() => {
const row = viewRow.value
if (!row) return []
return [
{
title: '基本信息',
fields: [
{ label: '样本编码', value: row.sampleCode },
{ label: '编码类型', value: sampleCodeTypeLabel(row.sampleCodeType) },
{ label: '是否下发', value: row.issued === 1 ? '是' : '否' },
{ label: '下发对象', value: row.issuedUserName || '-' },
{ label: '下发时间', value: row.issuedTime || '-' },
{ label: '创建时间', value: row.createTime },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
function handleView(row: Api.BizSampleCode.SampleCodeItem) {
viewRow.value = row
viewVisible.value = true
}
// --- 批量生成 ---
const batchGenVisible = ref(false)
const batchGenLoading = ref(false)
const batchGenFormRef = ref()
const batchGenForm = reactive({
sampleCodeType: 'zxbm',
middle: '34',
seqStart: 1,
seqEnd: 100,
digitCount: 6
})
const batchGenRules = {
sampleCodeType: [{ required: true, message: '请选择编码类型', trigger: 'change' }],
middle: [{ required: true, message: '请输入中间段', trigger: 'blur' }],
seqStart: [{ required: true, message: '请输入开始序号', trigger: 'blur' }],
seqEnd: [{ required: true, message: '请输入结束序号', trigger: 'blur' }],
digitCount: [{ required: true, message: '请输入序号位数', trigger: 'blur' }]
}
const typePrefixMap: Record<string, string> = { zxbm: 'ZKZX', qdbm: 'ZKQD' }
const batchGenPreview = computed(() => {
const t = batchGenForm.sampleCodeType || ''
const m = batchGenForm.middle || ''
const s = batchGenForm.seqStart ?? 0
const e = batchGenForm.seqEnd ?? 0
const d = batchGenForm.digitCount ?? 6
const prefix = typePrefixMap[t] || ''
if (!prefix || e < s) return '—'
const first = `${prefix}${m}${String(s).padStart(d, '0')}`
const last = `${prefix}${m}${String(e).padStart(d, '0')}`
return `${first} ~ ${last}(共 ${e - s + 1} 条)`
})
// --- 批量下发 ---
const batchIssueVisible = ref(false)
const batchIssueLoading = ref(false)
const batchIssueFormRef = ref()
const batchIssueForm = reactive({
issuedUserId: ''
})
const batchIssueRules = {
issuedUserId: [{ required: true, message: '请选择下发用户', trigger: 'change' }]
}
const userOptions = ref<Array<{ label: string; value: string }>>([])
const formData = reactive({
sampleCode: '',
sampleCodeType: '',
issued: 0,
issuedUserId: '',
issuedUserName: '',
issuedTime: '',
orderNum: 0,
remark: ''
})
const formRules = {
sampleCode: [{ required: true, message: '请输入样本编码', trigger: 'blur' }],
sampleCodeType: [{ required: true, message: '请选择编码类型', trigger: 'change' }],
issuedUserId: [
{
validator: (_rule: unknown, value: string, callback: (err?: Error) => void) => {
if (formData.issued === 1 && !(value || '').trim()) {
callback(new Error('请选择下发用户'))
return
}
callback()
},
trigger: 'change'
}
]
}
const handleSelectionChange = (rows: Api.BizSampleCode.SampleCodeItem[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchSampleCodeList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
sampleCodeType: searchForm.sampleCodeType || undefined,
issuedUserId: searchForm.issuedUserId || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
searchForm.sampleCodeType = ''
searchForm.issuedUserId = ''
pagination.page = 1
loadData()
}
function resetForm() {
formData.sampleCode = ''
formData.sampleCodeType = 'zxbm'
formData.issued = 0
formData.issuedUserId = ''
formData.issuedUserName = ''
formData.issuedTime = ''
formData.orderNum = 0
formData.remark = ''
editingId.value = null
}
async function loadUserOptions() {
if (userOptions.value.length) return
try {
userOptions.value = await fetchUserOptions()
} catch {
/* ignore */
}
}
async function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增样本编码'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
await loadUserOptions()
}
async function handleEdit(row: Api.BizSampleCode.SampleCodeItem) {
isEdit.value = true
dialogTitle.value = '编辑样本编码'
editingId.value = row.id
formData.sampleCode = row.sampleCode || ''
formData.sampleCodeType = row.sampleCodeType || ''
formData.issued = row.issued === 1 ? 1 : 0
formData.issuedUserId = row.issuedUserId || ''
formData.issuedUserName = row.issuedUserName || ''
formData.issuedTime = row.issuedTime || ''
formData.orderNum = row.orderNum ?? 0
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
await loadUserOptions()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const issueUserOpt =
formData.issued === 1
? userOptions.value.find((u) => u.value === formData.issuedUserId)
: undefined
const payload = {
sampleCode: formData.sampleCode.trim(),
sampleCodeType: !isEdit.value ? formData.sampleCodeType || undefined : undefined,
issued: formData.issued,
issuedUserId: formData.issued === 1 ? formData.issuedUserId || undefined : null,
issuedUserName:
formData.issued === 1
? (formData.issuedUserName || issueUserOpt?.label || '').trim() || undefined
: null,
issuedTime: formData.issuedTime || undefined,
orderNum: formData.orderNum,
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateSampleCode(editingId.value, payload)
} else {
await fetchCreateSampleCode(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* */
} finally {
submitLoading.value = false
}
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
}
}
async function handleDelete(row: Api.BizSampleCode.SampleCodeItem) {
await ElMessageBox.confirm('确定要删除该样本编码吗?', '提示', { type: 'warning' })
await fetchDeleteSampleCode(row.id)
ElMessage.success('删除成功')
await loadData()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 条吗?`, '提示', { type: 'warning' })
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteSampleCode(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
async function handleBatchRevoke() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(
`确定收回选中的 ${ids.length} 条吗?收回后将清除下发信息。`,
'提示',
{ type: 'warning' }
)
await withFullscreenLoading(async () => {
await fetchBatchRevokeSampleCodes(ids)
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// --- 批量生成 ---
function openBatchGenerate() {
batchGenForm.sampleCodeType = 'zxbm'
batchGenForm.middle = '34'
batchGenForm.seqStart = 1
batchGenForm.seqEnd = 100
batchGenForm.digitCount = 6
batchGenVisible.value = true
}
async function handleBatchGenerate() {
await batchGenFormRef.value?.validate?.()
if (batchGenForm.seqStart > batchGenForm.seqEnd) {
ElMessage.warning('开始序号不能大于结束序号')
return
}
batchGenLoading.value = true
try {
await fetchBatchGenerateSampleCodes({
sampleCodeType: batchGenForm.sampleCodeType,
middle: batchGenForm.middle.trim(),
seqStart: batchGenForm.seqStart,
seqEnd: batchGenForm.seqEnd,
digitCount: batchGenForm.digitCount
})
batchGenVisible.value = false
await loadData()
} finally {
batchGenLoading.value = false
}
}
// --- 批量下发 ---
async function openBatchIssue() {
if (!selectedRows.value.length) return
batchIssueForm.issuedUserId = ''
batchIssueVisible.value = true
await loadUserOptions()
}
async function handleBatchIssue() {
await batchIssueFormRef.value?.validate?.()
batchIssueLoading.value = true
try {
const ids = selectedRows.value.map((r) => r.id)
const userOpt = userOptions.value.find((u) => u.value === batchIssueForm.issuedUserId)
await fetchBatchIssueSampleCodes({
ids,
issuedUserId: batchIssueForm.issuedUserId,
issuedUserName: userOpt?.label || undefined
})
batchIssueVisible.value = false
selectedRows.value = []
await loadData()
} finally {
batchIssueLoading.value = false
}
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(async () => {
await loadUserOptions()
loadData()
})
</script>
<style scoped lang="scss">
.biz-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -1,461 +0,0 @@
<!-- 细胞样本类型管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>细胞样本基础信息维护</h2>
</div>
<el-tabs v-model="activeTab" class="panel-tabs">
<el-tab-pane label="细胞样本类型" name="type">
<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-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按编码、名称模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增类型
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="typeCode"
label="类型编码"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="typeName"
label="类型名称"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
prop="storageYears"
label="存储年限(年)"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="storagePrice"
label="存储价格(元)"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ row.storagePrice != null ? row.storagePrice : '—' }}
</template>
</el-table-column>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="640px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="biz-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="类型编码" prop="typeCode">
<el-input v-model="formData.typeCode" placeholder="租户内唯一" :disabled="isEdit" />
</el-form-item>
<el-form-item label="类型名称" prop="typeName">
<el-input v-model="formData.typeName" placeholder="必填" />
</el-form-item>
<el-form-item label="存储年限(年)">
<el-input
type="number"
step="any"
v-model="formData.storageYears"
:min="0"
:max="200"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="存储价格(元)">
<el-input
type="number"
step="any"
v-model="formData.storagePrice"
:min="0"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.typeName || '类型详情'"
:icon="Collection"
:code="viewRow?.typeCode"
code-label="类型编码"
:sections="viewSections"
/>
</el-tab-pane>
<el-tab-pane label="样本编码" name="sample-code" lazy>
<SampleCodeTab />
</el-tab-pane>
</el-tabs>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Close,
Check,
View,
Collection,
RefreshLeft,
Search,
More,
Edit,
Delete
} from '@element-plus/icons-vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCellSampleTypeList,
fetchCreateCellSampleType,
fetchUpdateCellSampleType,
fetchDeleteCellSampleType
} from '@/api/admin'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import SampleCodeTab from './SampleCodeTab.vue'
defineOptions({ name: 'BizCellSampleType' })
const route = useRoute()
const activeTab = ref('type')
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<Api.BizCellSampleType.CellSampleTypeItem[]>([])
const selectedRows = ref<Api.BizCellSampleType.CellSampleTypeItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const dialogTitle = ref('新增类型')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
// ---- 查看弹窗 ----
const viewVisible = ref(false)
const viewRow = ref<Api.BizCellSampleType.CellSampleTypeItem | null>(null)
const viewSections = computed(() => {
const row = viewRow.value
if (!row) return []
return [
{
title: '基本信息',
fields: [
{ label: '类型编码', value: row.typeCode },
{ label: '类型名称', value: row.typeName },
{ label: '存储年限(年)', value: row.storageYears },
{ label: '存储价格(元)', value: row.storagePrice },
{ label: '创建时间', value: row.createTime },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
function handleView(row: Api.BizCellSampleType.CellSampleTypeItem) {
viewRow.value = row
viewVisible.value = true
}
const formData = reactive({
typeCode: '',
typeName: '',
storageYears: null as number | null,
storagePrice: null as number | null,
orderNum: 0,
remark: ''
})
const formRules = {
typeCode: [{ required: true, message: '请输入类型编码', trigger: 'blur' }],
typeName: [{ required: true, message: '请输入类型名称', trigger: 'blur' }]
}
const handleSelectionChange = (rows: Api.BizCellSampleType.CellSampleTypeItem[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchCellSampleTypeList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
pagination.page = 1
loadData()
}
function resetForm() {
formData.typeCode = ''
formData.typeName = ''
formData.storageYears = null
formData.storagePrice = null
formData.orderNum = 0
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增类型'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleEdit(row: Api.BizCellSampleType.CellSampleTypeItem) {
isEdit.value = true
dialogTitle.value = '编辑类型'
editingId.value = row.id
formData.typeCode = row.typeCode || ''
formData.typeName = row.typeName || ''
formData.storageYears = row.storageYears ?? null
formData.storagePrice = row.storagePrice ?? null
formData.orderNum = row.orderNum ?? 0
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
typeCode: formData.typeCode.trim(),
typeName: formData.typeName.trim(),
storageYears: formData.storageYears,
storagePrice: formData.storagePrice,
orderNum: formData.orderNum,
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateCellSampleType(editingId.value, {
typeName: payload.typeName,
storageYears: payload.storageYears,
storagePrice: payload.storagePrice,
orderNum: payload.orderNum,
remark: payload.remark
})
} else {
await fetchCreateCellSampleType(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* 全局 */
} finally {
submitLoading.value = false
}
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
}
}
async function handleDelete(row: Api.BizCellSampleType.CellSampleTypeItem) {
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
await fetchDeleteCellSampleType(row.id)
ElMessage.success('删除成功')
await loadData()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 条吗?`, '提示', { type: 'warning' })
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteCellSampleType(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
const tab = route.query.tab
if (tab === 'sample-code') {
activeTab.value = 'sample-code'
}
loadData()
})
</script>
<style scoped lang="scss">
.biz-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -1,18 +0,0 @@
<!-- 旧「采集协议号管理」菜单入口:重定向至细胞采集基础信息维护 · 采集协议号 Tab -->
<template>
<div />
</template>
<script setup lang="ts">
defineOptions({ name: 'BizCollectionProtocolNo' })
const router = useRouter()
const route = useRoute()
onMounted(() => {
router.replace({
name: 'BizCollectionRegion',
query: { ...route.query, tab: 'protocol-no' }
})
})
</script>
@@ -1,795 +0,0 @@
<!-- 细胞采集基础信息维护 · 采集协议号 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="collection-protocol-no-tab">
<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-form-item>
<el-form-item label="下发对象">
<el-select
v-model="searchForm.issuedUserId"
placeholder="全部"
filterable
clearable
style="width: 100%"
>
<el-option
v-for="opt in userOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增协议号
</el-button>
<el-button class="btn-ghost" @click="openBatchGenerate">
<el-icon><DocumentAdd /></el-icon>
批量生成
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length === 0"
@click="openBatchIssue"
>
<el-icon><Promotion /></el-icon>
批量下发
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length === 0"
@click="handleBatchRevoke"
>
<el-icon><RefreshRight /></el-icon>
批量收回
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="protocolNo"
label="采集协议号"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
prop="issued"
label="是否下发"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ row }">
<span>{{ row.issued === 1 ? '是' : '否' }}</span>
</template>
</el-table-column>
<el-table-column
prop="issuedUserName"
label="下发对象"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{ resolveIssuedUserName(row) }}</template>
</el-table-column>
<el-table-column
prop="issuedTime"
label="下发时间"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.issuedTime || '-' }}</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<!-- 批量生成弹窗 -->
<el-dialog
v-model="batchGenVisible"
title="批量生成协议号"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="batchGenFormRef"
:model="batchGenForm"
:rules="batchGenRules"
label-position="top"
scroll-to-error
>
<div class="form-grid-2">
<el-form-item label="固定前缀" prop="prefix">
<el-input v-model="batchGenForm.prefix" placeholder="如 ZKZX" />
</el-form-item>
<el-form-item label="中间段" prop="middle">
<el-input v-model="batchGenForm.middle" placeholder="如 34" />
</el-form-item>
<el-form-item label="开始序号" prop="seqStart">
<el-input
type="number"
step="any"
v-model="batchGenForm.seqStart"
:min="0"
:max="99999"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="结束序号" prop="seqEnd">
<el-input
type="number"
step="any"
v-model="batchGenForm.seqEnd"
:min="0"
:max="99999"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="序号位数" prop="digitCount">
<template #label>序号位数<span class="form-label-tip">补零后的总位数</span></template>
<el-input
type="number"
step="any"
v-model="batchGenForm.digitCount"
:min="1"
:max="10"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="预览" class="form-col-span-2">
<span class="text-sm text-gray-500">{{ batchGenPreview }}</span>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="batchGenVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="batchGenLoading"
@click="handleBatchGenerate"
>
<el-icon><Check /></el-icon>
生成
</el-button>
</template>
</el-dialog>
<!-- 批量下发弹窗 -->
<el-dialog
v-model="batchIssueVisible"
title="批量下发协议号"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="batchIssueFormRef"
:model="batchIssueForm"
:rules="batchIssueRules"
label-position="top"
scroll-to-error
>
<el-form-item label="下发用户" prop="issuedUserId">
<el-select
v-model="batchIssueForm.issuedUserId"
placeholder="请选择用户"
filterable
style="width: 100%"
>
<el-option
v-for="opt in userOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="已选协议号">
<span class="text-sm text-gray-500">
已选 {{ selectedRows.length }} 条,下发后"是否下发"将变为"是"
</span>
</el-form-item>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="batchIssueVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="batchIssueLoading"
@click="handleBatchIssue"
>
<el-icon><Check /></el-icon>
确定下发
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="biz-dialog-form"
scroll-to-error
>
<div class="form-grid-2">
<el-form-item label="采集协议号" prop="protocolNo">
<el-input
v-model="formData.protocolNo"
placeholder="租户内唯一,最多32位"
maxlength="32"
/>
</el-form-item>
<el-form-item label="是否下发">
<el-switch
v-model="formData.issued"
:active-value="1"
:inactive-value="0"
inline-prompt
active-text="是"
inactive-text="否"
/>
</el-form-item>
<el-form-item v-if="formData.issued === 1" label="下发用户" prop="issuedUserId">
<el-select
v-model="formData.issuedUserId"
placeholder="请选择用户"
filterable
style="width: 100%"
>
<el-option
v-for="opt in userOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="formData.issued === 1 && formData.issuedTime" label="下发时间">
<span class="text-sm text-gray-500">{{ formData.issuedTime }}</span>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.protocolNo || '协议号详情'"
:icon="Tickets"
:meta="viewRow?.createTime ? '创建时间:' + viewRow.createTime : ''"
:sections="viewSections"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Close,
Check,
View,
Tickets,
RefreshLeft,
Search,
DocumentAdd,
Promotion,
RefreshRight,
More,
Edit,
Delete
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCollectionProtocolNoList,
fetchCreateCollectionProtocolNo,
fetchUpdateCollectionProtocolNo,
fetchDeleteCollectionProtocolNo,
fetchUserOptions,
fetchBatchGenerateProtocolNos,
fetchBatchIssueProtocolNos,
fetchBatchRevokeProtocolNos
} from '@/api/admin'
defineOptions({ name: 'CollectionProtocolNoTab' })
const searchForm = reactive<{ keyword: string; issuedUserId: string }>({
keyword: '',
issuedUserId: ''
})
const tableData = ref<Api.BizCollectionProtocolNo.ProtocolNoItem[]>([])
const selectedRows = ref<Api.BizCollectionProtocolNo.ProtocolNoItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const dialogTitle = ref('新增协议号')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
// ---- 查看弹窗 ----
const viewVisible = ref(false)
const viewRow = ref<Api.BizCollectionProtocolNo.ProtocolNoItem | null>(null)
const viewSections = computed(() => {
const row = viewRow.value
if (!row) return []
return [
{
title: '基本信息',
fields: [
{ label: '采集协议号', value: row.protocolNo },
{ label: '是否下发', value: row.issued === 1 ? '是' : '否' },
{ label: '下发对象', value: resolveIssuedUserName(row) },
{ label: '下发时间', value: row.issuedTime || '-' },
{ label: '创建时间', value: row.createTime },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
function handleView(row: Api.BizCollectionProtocolNo.ProtocolNoItem) {
viewRow.value = row
viewVisible.value = true
}
// --- 批量生成 ---
const batchGenVisible = ref(false)
const batchGenLoading = ref(false)
const batchGenFormRef = ref()
const batchGenForm = reactive({
prefix: 'ZKZX',
middle: '34',
seqStart: 1,
seqEnd: 100,
digitCount: 6
})
const batchGenRules = {
prefix: [{ required: true, message: '请输入固定前缀', trigger: 'blur' }],
middle: [{ required: true, message: '请输入中间段', trigger: 'blur' }],
seqStart: [{ required: true, message: '请输入开始序号', trigger: 'blur' }],
seqEnd: [{ required: true, message: '请输入结束序号', trigger: 'blur' }],
digitCount: [{ required: true, message: '请输入序号位数', trigger: 'blur' }]
}
const batchGenPreview = computed(() => {
const p = batchGenForm.prefix || ''
const m = batchGenForm.middle || ''
const s = batchGenForm.seqStart ?? 0
const e = batchGenForm.seqEnd ?? 0
const d = batchGenForm.digitCount ?? 6
if (!p || e < s) return '—'
const first = `${p}${m}${String(s).padStart(d, '0')}`
const last = `${p}${m}${String(e).padStart(d, '0')}`
return `${first} ~ ${last}(共 ${e - s + 1} 条)`
})
// --- 批量下发 ---
const batchIssueVisible = ref(false)
const batchIssueLoading = ref(false)
const batchIssueFormRef = ref()
const batchIssueForm = reactive({
issuedUserId: ''
})
const batchIssueRules = {
issuedUserId: [{ required: true, message: '请选择下发用户', trigger: 'change' }]
}
const userOptions = ref<Array<{ label: string; value: string }>>([])
function resolveIssuedUserName(row: Api.BizCollectionProtocolNo.ProtocolNoItem): string {
const name = (row.issuedUserName || '').trim()
if (name) return name
const uid = (row.issuedUserId || '').trim()
if (!uid) return '-'
const opt = userOptions.value.find((u) => u.value === uid)
return (opt?.label || '').trim() || '-'
}
const formData = reactive({
protocolNo: '',
issued: 0,
issuedUserId: '',
issuedUserName: '',
issuedTime: '',
orderNum: 0,
remark: ''
})
const formRules = {
protocolNo: [{ required: true, message: '请输入采集协议号', trigger: 'blur' }],
issuedUserId: [
{
validator: (_rule: unknown, value: string, callback: (err?: Error) => void) => {
if (formData.issued === 1 && !(value || '').trim()) {
callback(new Error('请选择下发用户'))
return
}
callback()
},
trigger: 'change'
}
]
}
const handleSelectionChange = (rows: Api.BizCollectionProtocolNo.ProtocolNoItem[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchCollectionProtocolNoList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
issuedUserId: searchForm.issuedUserId || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
searchForm.issuedUserId = ''
pagination.page = 1
loadData()
}
function resetForm() {
formData.protocolNo = ''
formData.issued = 0
formData.issuedUserId = ''
formData.issuedUserName = ''
formData.issuedTime = ''
formData.orderNum = 0
formData.remark = ''
editingId.value = null
}
async function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增协议号'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
await loadUserOptions()
}
async function handleEdit(row: Api.BizCollectionProtocolNo.ProtocolNoItem) {
isEdit.value = true
dialogTitle.value = '编辑协议号'
editingId.value = row.id
formData.protocolNo = row.protocolNo || ''
formData.issued = row.issued === 1 ? 1 : 0
formData.issuedUserId = row.issuedUserId || ''
formData.issuedUserName = row.issuedUserName || ''
formData.issuedTime = row.issuedTime || ''
formData.orderNum = row.orderNum ?? 0
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
await loadUserOptions()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const issueUserOpt =
formData.issued === 1
? userOptions.value.find((u) => u.value === formData.issuedUserId)
: undefined
const payload = {
protocolNo: formData.protocolNo.trim(),
issued: formData.issued,
issuedUserId: formData.issued === 1 ? formData.issuedUserId || undefined : null,
issuedUserName:
formData.issued === 1
? (formData.issuedUserName || issueUserOpt?.label || '').trim() || undefined
: null,
issuedTime: formData.issuedTime || undefined,
orderNum: formData.orderNum,
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateCollectionProtocolNo(editingId.value, payload)
} else {
await fetchCreateCollectionProtocolNo(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* */
} finally {
submitLoading.value = false
}
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
}
}
async function handleDelete(row: Api.BizCollectionProtocolNo.ProtocolNoItem) {
await ElMessageBox.confirm('确定要删除该协议号吗?', '提示', { type: 'warning' })
await fetchDeleteCollectionProtocolNo(row.id)
ElMessage.success('删除成功')
await loadData()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 条吗?`, '提示', { type: 'warning' })
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteCollectionProtocolNo(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
async function handleBatchRevoke() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(
`确定收回选中的 ${ids.length} 条吗?收回后将清除下发信息。`,
'提示',
{ type: 'warning' }
)
await withFullscreenLoading(async () => {
await fetchBatchRevokeProtocolNos(ids)
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// --- 批量生成 ---
function openBatchGenerate() {
batchGenForm.prefix = 'ZKZX'
batchGenForm.middle = '34'
batchGenForm.seqStart = 1
batchGenForm.seqEnd = 100
batchGenForm.digitCount = 6
batchGenVisible.value = true
}
async function handleBatchGenerate() {
await batchGenFormRef.value?.validate?.()
if (batchGenForm.seqStart > batchGenForm.seqEnd) {
ElMessage.warning('开始序号不能大于结束序号')
return
}
batchGenLoading.value = true
try {
await fetchBatchGenerateProtocolNos({
prefix: batchGenForm.prefix.trim(),
middle: batchGenForm.middle.trim(),
seqStart: batchGenForm.seqStart,
seqEnd: batchGenForm.seqEnd,
digitCount: batchGenForm.digitCount
})
batchGenVisible.value = false
await loadData()
} finally {
batchGenLoading.value = false
}
}
// --- 批量下发 ---
async function openBatchIssue() {
if (!selectedRows.value.length) return
batchIssueForm.issuedUserId = ''
batchIssueVisible.value = true
await loadUserOptions()
}
async function handleBatchIssue() {
await batchIssueFormRef.value?.validate?.()
batchIssueLoading.value = true
try {
const ids = selectedRows.value.map((r) => r.id)
const userOpt = userOptions.value.find((u) => u.value === batchIssueForm.issuedUserId)
await fetchBatchIssueProtocolNos({
ids,
issuedUserId: batchIssueForm.issuedUserId,
issuedUserName: userOpt?.label || undefined
})
batchIssueVisible.value = false
selectedRows.value = []
await loadData()
} finally {
batchIssueLoading.value = false
}
}
async function loadUserOptions() {
if (userOptions.value.length) return
try {
userOptions.value = await fetchUserOptions()
} catch {
/* ignore */
}
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(async () => {
await loadUserOptions()
loadData()
})
</script>
<style scoped lang="scss">
.biz-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -1,741 +0,0 @@
<!-- 采集地区及医院管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>细胞采集基础信息维护</h2>
</div>
<el-tabs v-model="activeTab" class="panel-tabs">
<el-tab-pane label="采集地区及医院" name="region">
<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-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按地区名称模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增地区
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="regionName"
label="地区名称"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="医院信息"
min-width="240"
align="left"
header-align="center"
class-name="hospital-info-col"
>
<template #default="{ row }">
<template v-if="row.hospitalNames && row.hospitalNames.length">
<div v-for="(name, idx) in row.hospitalNames" :key="idx" class="hospital-cell-line">
{{ name }}
</div>
</template>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="row.remark">{{ row.remark }}</span>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item primary" @click="openHospitalDialog(row)">
地区医院配置
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="regionDialogVisible"
:title="regionDialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="regionFormRef"
:model="regionForm"
:rules="regionFormRules"
label-position="top"
class="biz-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="地区名称" prop="regionName" class="form-col-span-2">
<el-input v-model="regionForm.regionName" placeholder="必填" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="regionForm.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="regionDialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
@click="submitRegion"
:loading="regionSubmitLoading"
>
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="hospitalDialogVisible"
:title="hospitalDialogTitle"
width="800px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@closed="onHospitalDialogClosed"
>
<div class="hospital-dialog-head">
<span class="muted">当前地区:{{ currentRegionName }}</span>
</div>
<div class="list-toolbar hospital-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ hospitalSelected.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAddHospital">
<el-icon><Plus /></el-icon>
新增医院
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="hospitalSelected.length === 0"
@click="handleBatchDeleteHospital"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap hospital-table-wrap">
<el-table
class="list-table"
:data="hospitalTable"
v-loading="hospitalLoading"
stripe
row-key="id"
max-height="360"
@selection-change="onHospitalSelectionChange"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="hospitalName"
label="医院名称"
min-width="200"
align="center"
header-align="center"
/>
<el-table-column
prop="remark"
label="备注"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="row.remark">{{ row.remark }}</span>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="260"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="handleEditHospital(row)"
>编辑</el-button
>
<el-button class="btn-op-item" @click="handleDeleteHospital(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="hospitalPagination.page"
:page-size="hospitalPagination.pageSize"
:total="hospitalPagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="
(val) => (
(hospitalPagination.page = 1),
(hospitalPagination.pageSize = val),
loadHospitals()
)
"
@current-change="(val) => ((hospitalPagination.page = val), loadHospitals())"
/>
</div>
</div>
<el-dialog
v-model="hospitalFormVisible"
:title="hospitalFormTitle"
width="520px"
class="dialog-form-layout"
align-center
append-to-body
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="hospitalFormRef"
:model="hospitalForm"
:rules="hospitalFormRules"
label-position="top"
>
<el-form-item label="医院名称" prop="hospitalName">
<el-input v-model="hospitalForm.hospitalName" placeholder="必填" />
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="hospitalForm.remark"
type="textarea"
:rows="2"
placeholder="可选"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="hospitalFormVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
@click="submitHospital"
:loading="hospitalSubmitLoading"
>
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<template #footer>
<el-button class="btn-ghost" @click="hospitalDialogVisible = false">
<el-icon><Close /></el-icon>
关闭
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.regionName || '地区详情'"
:icon="Location"
:meta="viewRow?.createTime ? '创建时间:' + viewRow.createTime : ''"
:sections="viewSections"
/>
</el-tab-pane>
<el-tab-pane label="采集协议号" name="protocol-no" lazy>
<CollectionProtocolNoTab />
</el-tab-pane>
</el-tabs>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check, View, Edit, Delete, More, Location, RefreshLeft, Search } from '@element-plus/icons-vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import {
fetchCollectionRegionList,
fetchCreateCollectionRegion,
fetchUpdateCollectionRegion,
fetchDeleteCollectionRegion,
fetchHospitalList,
fetchCreateHospital,
fetchUpdateHospital,
fetchDeleteHospital
} from '@/api/admin'
import CollectionProtocolNoTab from './CollectionProtocolNoTab.vue'
defineOptions({ name: 'BizCollectionRegion' })
const route = useRoute()
const activeTab = ref('region')
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<Api.BizCollectionRegion.RegionItem[]>([])
const selectedRows = ref<Api.BizCollectionRegion.RegionItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const regionDialogVisible = ref(false)
const regionDialogTitle = ref('新增地区')
const regionIsEdit = ref(false)
const regionSubmitLoading = ref(false)
const regionFormRef = ref()
const editingRegionId = ref<string | null>(null)
const regionForm = reactive({ regionName: '', orderNum: 0, remark: '' })
const regionFormRules = {
regionName: [{ required: true, message: '请输入地区名称', trigger: 'blur' }]
}
const hospitalDialogVisible = ref(false)
const hospitalDialogTitle = ref('地区医院配置')
const currentRegionId = ref('')
const currentRegionName = ref('')
const hospitalTable = ref<Api.BizCollectionHospital.HospitalItem[]>([])
const hospitalSelected = ref<Api.BizCollectionHospital.HospitalItem[]>([])
const hospitalLoading = ref(false)
const hospitalPagination = reactive({ page: 1, pageSize: 10, total: 0 })
const hospitalFormVisible = ref(false)
const hospitalFormTitle = ref('新增医院')
const hospitalIsEdit = ref(false)
const hospitalSubmitLoading = ref(false)
const hospitalFormRef = ref()
const editingHospitalId = ref<string | null>(null)
// ---- 查看弹窗 ----
const viewVisible = ref(false)
const viewRow = ref<Api.BizCollectionRegion.RegionItem | null>(null)
const viewSections = computed(() => {
const row = viewRow.value
if (!row) return []
const hospitals =
row.hospitalNames && row.hospitalNames.length > 0 ? row.hospitalNames.join('、') : '无'
return [
{
title: '基本信息',
fields: [
{ label: '地区名称', value: row.regionName },
{ label: '创建时间', value: row.createTime },
{ label: '关联医院', value: hospitals, span: true },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
function handleView(row: Api.BizCollectionRegion.RegionItem) {
viewRow.value = row
viewVisible.value = true
}
const hospitalForm = reactive({ hospitalName: '', orderNum: 0, remark: '' })
const hospitalFormRules = {
hospitalName: [{ required: true, message: '请输入医院名称', trigger: 'blur' }]
}
function onHospitalSelectionChange(rows: Api.BizCollectionHospital.HospitalItem[]) {
hospitalSelected.value = rows
}
const handleSelectionChange = (rows: Api.BizCollectionRegion.RegionItem[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchCollectionRegionList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
pagination.page = 1
loadData()
}
function handleAdd() {
regionIsEdit.value = false
regionDialogTitle.value = '新增地区'
editingRegionId.value = null
regionForm.regionName = ''
regionForm.orderNum = 0
regionForm.remark = ''
regionDialogVisible.value = true
regionFormRef.value?.clearValidate?.()
}
function handleEdit(row: Api.BizCollectionRegion.RegionItem) {
regionIsEdit.value = true
regionDialogTitle.value = '编辑地区'
editingRegionId.value = row.id
regionForm.regionName = row.regionName || ''
regionForm.orderNum = row.orderNum ?? 0
regionForm.remark = row.remark || ''
regionDialogVisible.value = true
regionFormRef.value?.clearValidate?.()
}
async function submitRegion() {
await regionFormRef.value?.validate?.()
regionSubmitLoading.value = true
try {
const payload = {
regionName: regionForm.regionName.trim(),
orderNum: regionForm.orderNum,
remark: regionForm.remark?.trim() || undefined
}
if (regionIsEdit.value && editingRegionId.value) {
await fetchUpdateCollectionRegion(editingRegionId.value, payload)
} else {
await fetchCreateCollectionRegion(payload)
}
ElMessage.success(regionIsEdit.value ? '更新成功' : '创建成功')
regionDialogVisible.value = false
await loadData()
} catch {
/* */
} finally {
regionSubmitLoading.value = false
}
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'view': handleView(row); break
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
}
}
async function handleDelete(row: Api.BizCollectionRegion.RegionItem) {
await ElMessageBox.confirm('删除地区将同时软删除其下属全部医院,确定继续?', '提示', {
type: 'warning'
})
await fetchDeleteCollectionRegion(row.id)
ElMessage.success('删除成功')
await loadData()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(
`确定删除选中的 ${ids.length} 个地区吗?下属医院将一并软删除。`,
'提示',
{
type: 'warning'
}
)
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteCollectionRegion(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
function openHospitalDialog(row: Api.BizCollectionRegion.RegionItem) {
currentRegionId.value = row.id
currentRegionName.value = row.regionName || ''
hospitalPagination.page = 1
hospitalSelected.value = []
hospitalDialogTitle.value = `地区医院配置 — ${row.regionName}`
hospitalDialogVisible.value = true
loadHospitals()
}
async function loadHospitals() {
if (!currentRegionId.value) return
hospitalLoading.value = true
try {
const res = await fetchHospitalList(currentRegionId.value, {
page: hospitalPagination.page,
pageSize: hospitalPagination.pageSize
})
hospitalTable.value = res.list || []
hospitalPagination.total = res.total ?? 0
} finally {
hospitalLoading.value = false
}
}
function onHospitalDialogClosed() {
currentRegionId.value = ''
currentRegionName.value = ''
hospitalTable.value = []
loadData()
}
function handleAddHospital() {
hospitalIsEdit.value = false
hospitalFormTitle.value = '新增医院'
editingHospitalId.value = null
hospitalForm.hospitalName = ''
hospitalForm.orderNum = 0
hospitalForm.remark = ''
hospitalFormVisible.value = true
hospitalFormRef.value?.clearValidate?.()
}
function handleEditHospital(row: Api.BizCollectionHospital.HospitalItem) {
hospitalIsEdit.value = true
hospitalFormTitle.value = '编辑医院'
editingHospitalId.value = row.id
hospitalForm.hospitalName = row.hospitalName || ''
hospitalForm.orderNum = row.orderNum ?? 0
hospitalForm.remark = row.remark || ''
hospitalFormVisible.value = true
hospitalFormRef.value?.clearValidate?.()
}
async function submitHospital() {
await hospitalFormRef.value?.validate?.()
hospitalSubmitLoading.value = true
try {
const rid = currentRegionId.value
const payload = {
hospitalName: hospitalForm.hospitalName.trim(),
orderNum: hospitalForm.orderNum,
remark: hospitalForm.remark?.trim() || undefined
}
if (hospitalIsEdit.value && editingHospitalId.value) {
await fetchUpdateHospital(rid, editingHospitalId.value, payload)
} else {
await fetchCreateHospital(rid, payload)
}
ElMessage.success(hospitalIsEdit.value ? '更新成功' : '创建成功')
hospitalFormVisible.value = false
await loadHospitals()
} catch {
/* */
} finally {
hospitalSubmitLoading.value = false
}
}
async function handleDeleteHospital(row: Api.BizCollectionHospital.HospitalItem) {
await ElMessageBox.confirm('确定删除该医院吗?', '提示', { type: 'warning' })
await fetchDeleteHospital(currentRegionId.value, row.id)
ElMessage.success('删除成功')
await loadHospitals()
}
async function handleBatchDeleteHospital() {
const ids = hospitalSelected.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 家医院吗?`, '提示', {
type: 'warning'
})
const rid = currentRegionId.value
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteHospital(rid, id)
}
ElMessage.success('批量删除成功')
hospitalSelected.value = []
await loadHospitals()
}, fullscreenLoadingText.batchDelete)
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
const tab = route.query.tab
if (tab === 'protocol-no') {
activeTab.value = 'protocol-no'
}
loadData()
})
</script>
<style scoped lang="scss">
.biz-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.hospital-dialog-head {
margin-bottom: 12px;
}
.hospital-dialog-head .muted {
font-size: 13px;
color: var(--mint-text-muted);
}
.hospital-toolbar {
margin-bottom: 0;
}
.hospital-table-wrap {
padding-top: 0;
}
:deep(.hospital-info-col .cell) {
white-space: normal;
line-height: 1.5;
}
.hospital-cell-line {
line-height: 1.8;
}
.hospital-cell-line + .hospital-cell-line {
margin-top: 2px;
}
.muted {
color: var(--mint-text-muted);
}
</style>
@@ -1,492 +0,0 @@
<!-- 客户管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>客户列表</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-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="createTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">支持模糊查询;默认展示近 30 天内创建的客户。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增客户
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="customerName"
label="客户姓名"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="sex"
label="性别"
min-width="72"
align="center"
header-align="center"
>
<template #default="{ row }">{{ sexLabel(row.sex) }}</template>
</el-table-column>
<el-table-column
prop="idCardNo"
label="身份证号"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
prop="age"
label="年龄"
min-width="72"
align="center"
header-align="center"
/>
<el-table-column
prop="phoneNo"
label="联系电话"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="customerTags"
label="客户标签"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="720px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="biz-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="客户姓名" prop="customerName">
<el-input v-model="formData.customerName" placeholder="必填" />
</el-form-item>
<el-form-item label="性别" prop="sex">
<el-select v-model="formData.sex" placeholder="选择" style="width: 100%">
<el-option label="男" value="0" />
<el-option label="女" value="1" />
<el-option label="未知" value="2" />
</el-select>
</el-form-item>
<el-form-item label="身份证号" prop="idCardNo">
<el-input
v-model="formData.idCardNo"
placeholder="可选;填写后租户内不可重复"
clearable
/>
</el-form-item>
<el-form-item label="年龄">
<el-input
type="number"
step="any"
v-model="formData.age"
:min="0"
:max="150"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="formData.phoneNo" placeholder="可选" clearable />
</el-form-item>
<el-form-item label="客户标签" class="form-col-span-2">
<el-input v-model="formData.customerTags" placeholder="多个标签用英文逗号分隔" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.customerName || '客户详情'"
:icon="User"
:meta="`身份证:${viewRow?.idCardNo || '—'}${viewRow?.createTime ? ' · ' + viewRow.createTime : ''}`"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check, View, User, RefreshLeft, Search, More, Edit, Delete } from '@element-plus/icons-vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCustomerList,
fetchCreateCustomer,
fetchUpdateCustomer,
fetchDeleteCustomer
} from '@/api/admin'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { sexLabel } from '@/utils/status-tag-registry'
defineOptions({ name: 'BizCustomer' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultCreateTimeRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const createTimeRange = ref<[string, string] | null>(getDefaultCreateTimeRange())
const tableData = ref<Api.BizCustomer.CustomerItem[]>([])
const selectedRows = ref<Api.BizCustomer.CustomerItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const dialogTitle = ref('新增客户')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
// ---- 查看弹窗 ----
const viewVisible = ref(false)
const viewRow = ref<Api.BizCustomer.CustomerItem | null>(null)
const viewSections = computed(() => {
const row = viewRow.value
if (!row) return []
return [
{
title: '基本信息',
fields: [
{ label: '客户姓名', value: row.customerName },
{ label: '性别', value: sexLabel(row.sex) },
{ label: '身份证号', value: row.idCardNo },
{ label: '年龄', value: row.age },
{ label: '联系电话', value: row.phoneNo },
{ label: '客户标签', value: row.customerTags, span: true },
{ label: '创建时间', value: row.createTime },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
function handleView(row: Api.BizCustomer.CustomerItem) {
viewRow.value = row
viewVisible.value = true
}
const formData = reactive({
customerName: '',
sex: '0',
idCardNo: '',
age: null as number | null,
phoneNo: '',
customerTags: '',
orderNum: 0,
remark: ''
})
const formRules = {
customerName: [{ required: true, message: '请输入客户姓名', trigger: 'blur' }]
}
const handleSelectionChange = (rows: Api.BizCustomer.CustomerItem[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchCustomerList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
createTimeStart: createTimeRange.value?.[0] || undefined,
createTimeEnd: createTimeRange.value?.[1] || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
createTimeRange.value = getDefaultCreateTimeRange()
pagination.page = 1
loadData()
}
function resetForm() {
formData.customerName = ''
formData.sex = '0'
formData.idCardNo = ''
formData.age = null
formData.phoneNo = ''
formData.customerTags = ''
formData.orderNum = 0
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增客户'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleEdit(row: Api.BizCustomer.CustomerItem) {
isEdit.value = true
dialogTitle.value = '编辑客户'
editingId.value = row.id
formData.customerName = row.customerName || ''
formData.sex = row.sex || '2'
formData.idCardNo = row.idCardNo || ''
formData.age = row.age ?? null
formData.phoneNo = row.phoneNo || ''
formData.customerTags = row.customerTags || ''
formData.orderNum = row.orderNum ?? 0
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
customerName: formData.customerName.trim(),
sex: formData.sex,
idCardNo: formData.idCardNo?.trim() || undefined,
age: formData.age,
phoneNo: formData.phoneNo?.trim() || undefined,
customerTags: formData.customerTags?.trim() || undefined,
orderNum: formData.orderNum,
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateCustomer(editingId.value, payload)
} else {
await fetchCreateCustomer(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* 全局处理 */
} finally {
submitLoading.value = false
}
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
}
}
async function handleDelete(row: Api.BizCustomer.CustomerItem) {
await ElMessageBox.confirm('确定要删除该客户吗?', '提示', { type: 'warning' })
await fetchDeleteCustomer(row.id)
ElMessage.success('删除成功')
await loadData()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 条客户吗?`, '提示', {
type: 'warning'
})
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteCustomer(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
[searchForm, createTimeRange],
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.biz-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -1,896 +0,0 @@
<!-- 保险公司 — 险种价格配置 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>保险公司列表</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-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.tag" placeholder="全部" clearable style="width: 100%">
<el-option label="正常" value="1" />
<el-option label="停用" value="0" />
</el-select>
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增保险公司
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1"
/>
<el-table-column
prop="companyName"
label="保险公司名称"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
prop="companyAddress"
label="地址"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
prop="contactName"
label="联系人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="contactPhone"
label="联系电话"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="tag"
label="状态"
min-width="90"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="row.status == '1' ? 'success' : 'info'" size="small">{{
row.status == '1' ? '正常' : '停用'
}}</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item primary" @click="openItemDialog(row)">
<el-icon><Setting /></el-icon>险种价格配置
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(v) => ((pagination.page = 1), (pagination.pageSize = v), loadData())"
@current-change="(v) => ((pagination.page = v), loadData())"
/>
</div>
</div>
<!-- 新增/编辑保险公司弹窗 -->
<el-dialog
v-model="companyDialogVisible"
:title="companyDialogTitle"
width="720px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="companyFormRef"
:model="companyForm"
:rules="companyFormRules"
label-position="top"
class="biz-dialog-form"
>
<div class="form-grid-2">
<el-form-item label="保险公司名称" prop="companyName">
<el-input v-model="companyForm.companyName" placeholder="请输入保险公司名称" />
</el-form-item>
<el-form-item label="公司地址">
<el-input v-model="companyForm.companyAddress" placeholder="请输入公司地址" />
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="companyForm.contactName" placeholder="请输入联系人" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="companyForm.contactPhone" placeholder="请输入联系电话" />
</el-form-item>
<el-form-item label="状态" prop="tag">
<el-radio-group v-model="companyForm.tag">
<el-radio :value="1">正常</el-radio>
<el-radio :value="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="companyForm.remark" type="textarea" :rows="2" />
</el-form-item>
</div>
<div class="form-section">
<div class="section-title">开票信息</div>
<div class="form-grid-2">
<el-form-item label="发票抬头">
<el-input v-model="companyForm.invoiceTitle" placeholder="与营业执照一致" />
</el-form-item>
<el-form-item label="纳税人识别号">
<el-input v-model="companyForm.taxNo" placeholder="统一社会信用代码" />
</el-form-item>
<el-form-item label="开户银行">
<el-input v-model="companyForm.bankName" placeholder="收款银行全称" />
</el-form-item>
<el-form-item label="银行账号">
<el-input v-model="companyForm.bankAccount" placeholder="收款账号" />
</el-form-item>
</div>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="companyDialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="companySubmitLoading"
@click="submitCompany"
>
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
<!-- 险种价格配置弹窗 -->
<el-dialog
v-model="itemDialogVisible"
:title="itemDialogTitle"
width="960px"
class="dialog-form-layout insurance-item-config-dialog"
align-center
destroy-on-close
:close-on-click-modal="false"
@closed="onItemDialogClosed"
>
<div class="item-dialog-head">
<span class="muted">当前保险公司:{{ currentCompanyName }}</span>
</div>
<div class="list-toolbar item-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ itemSelected.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAddItem">
<el-icon><Plus /></el-icon>新增险种
</el-button>
</div>
</div>
<div class="list-body list-wrap item-table-wrap">
<el-table
class="list-table"
:data="itemTable"
v-loading="itemLoading"
stripe
row-key="id"
max-height="460"
@selection-change="onItemSelectionChange"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="projectName"
label="险种名称"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
prop="projectFee"
label="险种费用(元)"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{
row.projectFee != null ? '¥' + formatMoneyDisplay(row.projectFee) : '—'
}}</template>
</el-table-column>
<el-table-column
prop="status"
label="状态"
min-width="90"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="row.status == '1' ? 'success' : 'info'" size="small">{{
row.status == '1' ? '正常' : '停用'
}}</el-tag>
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="操作"
min-width="260"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="handleEditItem(row)"
><el-icon><Edit /></el-icon>编辑</el-button
>
<el-button class="btn-op-item" @click="handleDeleteItem(row)"
><el-icon><Delete /></el-icon>删除</el-button
>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="itemPagination.page"
:page-size="itemPagination.pageSize"
:total="itemPagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="
(v) => ((itemPagination.page = 1), (itemPagination.pageSize = v), loadItems())
"
@current-change="(v) => ((itemPagination.page = v), loadItems())"
/>
</div>
</div>
<!-- 新增/编辑险种嵌套弹窗 -->
<el-dialog
v-model="itemFormVisible"
:title="itemFormTitle"
width="640px"
class="dialog-form-layout"
align-center
append-to-body
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="itemFormRef"
:model="itemForm"
:rules="itemFormRules"
label-position="top"
class="biz-dialog-form"
>
<div class="form-grid-2">
<el-form-item label="险种名称" prop="projectName">
<el-input v-model="itemForm.projectName" placeholder="请输入险种名称" />
</el-form-item>
<el-form-item label="险种费用(元)" prop="projectFee">
<AmountInput v-model="itemForm.projectFee" :min="0" placeholder="请输入项目费用" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="itemForm.status">
<el-radio value="1">正常</el-radio>
<el-radio value="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="itemForm.remark" type="textarea" :rows="2" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="itemFormVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="itemSubmitLoading"
@click="submitItem"
>
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
<template #footer>
<el-button class="btn-ghost" @click="itemDialogVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
<!-- 查看弹窗 -->
<SystemViewDialog
v-model:visible="viewVisible"
title="查看保险公司"
:icon="OfficeBuilding"
:meta="viewCompanyName"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
import {
Plus,
Close,
Check,
Search,
RefreshLeft,
View,
Setting,
Edit,
Delete,
More,
OfficeBuilding
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import {
fetchInsuranceCompanyList,
fetchCreateInsuranceCompany,
fetchUpdateInsuranceCompany,
fetchDeleteInsuranceCompany,
fetchInsuranceCompanyInvoices,
fetchCreateInsuranceCompanyInvoice,
fetchUpdateInsuranceCompanyInvoice,
fetchDeleteInsuranceCompanyInvoice,
fetchInsuranceProjectPrices,
fetchCreateInsuranceProjectPrice,
fetchUpdateInsuranceProjectPrice,
fetchDeleteInsuranceProjectPrice
} from '@/api/admin'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import AmountInput from '@/components/common/amount-input/index.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
defineOptions({ name: 'BasicInfoInsuranceCompany' })
// ---------------------------------------------------------------------------
// 搜索 & 主表
// ---------------------------------------------------------------------------
const searchForm = reactive({ keyword: '', tag: '' })
const tableData = ref<any[]>([])
const selectedRows = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
// ---------------------------------------------------------------------------
// 保险公司弹窗
// ---------------------------------------------------------------------------
const companyDialogVisible = ref(false)
const companyDialogTitle = ref('新增保险公司')
const companyIsEdit = ref(false)
const companySubmitLoading = ref(false)
const companyFormRef = ref()
const editingId = ref<string | null>(null)
const companyForm = reactive({
companyName: '',
companyAddress: '',
contactName: '',
contactPhone: '',
invoiceTitle: '',
taxNo: '',
bankName: '',
bankAccount: '',
tag: 1 as number,
remark: ''
})
const companyFormRules = {
companyName: [{ required: true, message: '请输入保险公司名称', trigger: 'blur' }],
tag: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
// ---------------------------------------------------------------------------
// 险种价格配置弹窗
// ---------------------------------------------------------------------------
const itemDialogVisible = ref(false)
const itemDialogTitle = ref('险种价格配置')
const currentCompanyId = ref('')
const currentCompanyName = ref('')
const itemTable = ref<any[]>([])
const itemSelected = ref<any[]>([])
const itemLoading = ref(false)
const itemPagination = reactive({ page: 1, pageSize: 10, total: 0 })
const itemFormVisible = ref(false)
const itemFormTitle = ref('新增险种')
const itemIsEdit = ref(false)
const itemSubmitLoading = ref(false)
const itemFormRef = ref()
const editingItemId = ref<string | null>(null)
const itemForm = reactive({
projectName: '',
projectFee: 0 as number | null,
status: '1',
remark: ''
})
const itemFormRules = {
projectName: [{ required: true, message: '请输入险种名称', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
// ---------------------------------------------------------------------------
// 查看弹窗
// ---------------------------------------------------------------------------
const viewVisible = ref(false)
const viewCompanyName = ref('')
const viewSections = ref<ViewSection[]>([])
// ---------------------------------------------------------------------------
// 主表数据加载
// ---------------------------------------------------------------------------
async function loadData() {
loading.value = true
try {
const res = await fetchInsuranceCompanyList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
status: searchForm.tag || undefined
})
tableData.value = (res as any)?.list || []
pagination.total = (res as any)?.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
searchForm.tag = ''
pagination.page = 1
loadData()
}
function handleSelectionChange(rows: any[]) {
selectedRows.value = rows
}
// ---------------------------------------------------------------------------
// 保险公司弹窗操作
// ---------------------------------------------------------------------------
function resetCompanyForm() {
companyForm.companyName = ''
companyForm.companyAddress = ''
companyForm.contactName = ''
companyForm.contactPhone = ''
companyForm.invoiceTitle = ''
companyForm.taxNo = ''
companyForm.bankName = ''
companyForm.bankAccount = ''
companyForm.tag = 1
companyForm.remark = ''
editingId.value = null
}
function handleAdd() {
companyIsEdit.value = false
companyDialogTitle.value = '新增保险公司'
resetCompanyForm()
companyDialogVisible.value = true
}
async function handleEdit(row: any) {
companyIsEdit.value = true
companyDialogTitle.value = '编辑保险公司'
editingId.value = row.id
companyForm.companyName = row.companyName || ''
companyForm.companyAddress = row.companyAddress || ''
companyForm.contactName = row.contactName || ''
companyForm.contactPhone = row.contactPhone || ''
companyForm.tag = row.status != null ? Number(row.status) : 1
companyForm.remark = row.remark || ''
// 加载开票信息
try {
const invRes = await fetchInsuranceCompanyInvoices(row.id)
const invoices = (invRes as any)?.list || (invRes as any) || []
if (invoices.length > 0) {
const inv = invoices[0]
companyForm.invoiceTitle = inv.invoiceTitle || inv.companyName || ''
companyForm.taxNo = inv.taxNo || ''
companyForm.bankName = inv.bankName || ''
companyForm.bankAccount = inv.bankAccount || ''
} else {
companyForm.invoiceTitle = ''
companyForm.taxNo = ''
companyForm.bankName = ''
companyForm.bankAccount = ''
}
} catch {
companyForm.invoiceTitle = ''
companyForm.taxNo = ''
companyForm.bankName = ''
companyForm.bankAccount = ''
}
companyDialogVisible.value = true
}
async function submitCompany() {
const valid = await companyFormRef.value?.validate?.().catch(() => false)
if (!valid) return
companySubmitLoading.value = true
try {
const payload = {
companyName: companyForm.companyName.trim(),
companyAddress: companyForm.companyAddress?.trim() || undefined,
contactName: companyForm.contactName?.trim() || undefined,
contactPhone: companyForm.contactPhone?.trim() || undefined,
status: String(companyForm.tag),
remark: companyForm.remark?.trim() || undefined
}
if (companyIsEdit.value && editingId.value) {
await fetchUpdateInsuranceCompany(editingId.value, payload)
// 同步开票信息
const invRes = await fetchInsuranceCompanyInvoices(editingId.value)
const invoices = (invRes as any)?.list || (invRes as any) || []
const invoicePayload = {
companyName: companyForm.invoiceTitle?.trim() || undefined,
invoiceTitle: companyForm.invoiceTitle?.trim() || undefined,
taxNo: companyForm.taxNo?.trim() || undefined,
bankName: companyForm.bankName?.trim() || undefined,
bankAccount: companyForm.bankAccount?.trim() || undefined
}
if (invoices.length > 0) {
await fetchUpdateInsuranceCompanyInvoice(invoices[0].id, invoicePayload)
} else if (companyForm.invoiceTitle) {
await fetchCreateInsuranceCompanyInvoice(editingId.value, invoicePayload)
}
ElMessage.success('已保存')
} else {
const newInst = await fetchCreateInsuranceCompany(payload)
// 新增时同步创建开票信息
if ((newInst as any)?.id && companyForm.invoiceTitle?.trim()) {
const invoicePayload = {
companyName: companyForm.invoiceTitle?.trim() || undefined,
invoiceTitle: companyForm.invoiceTitle?.trim() || undefined,
taxNo: companyForm.taxNo?.trim() || undefined,
bankName: companyForm.bankName?.trim() || undefined,
bankAccount: companyForm.bankAccount?.trim() || undefined
}
await fetchCreateInsuranceCompanyInvoice((newInst as any).id, invoicePayload)
}
ElMessage.success('已新增')
}
companyDialogVisible.value = false
loadData()
} finally {
companySubmitLoading.value = false
}
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'view': handleView(row); break
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定删除「${row.companyName}」?其下属险种价格将一并删除。`,
'提示',
{
type: 'warning'
}
)
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteInsuranceCompany(row.id)
ElMessage.success('已删除')
await loadData()
}, fullscreenLoadingText.delete)
}
// ---------------------------------------------------------------------------
// 查看弹窗
// ---------------------------------------------------------------------------
async function handleView(row: any) {
viewCompanyName.value = row.companyName || ''
const sections: ViewSection[] = [
{
title: '基本信息',
fields: [
{ label: '保险公司名称', value: row.companyName },
{ label: '公司地址', value: row.companyAddress, span: true },
{ label: '联系人', value: row.contactName },
{ label: '联系电话', value: row.contactPhone },
{
label: '状态',
value: row.status == '1' ? '正常' : '停用',
tagType: row.status == '1' ? 'success' : 'info'
},
{ label: '备注', value: row.remark, span: true }
]
}
]
// 加载开票信息
try {
const invRes = await fetchInsuranceCompanyInvoices(row.id)
const invoices = (invRes as any)?.list || (invRes as any) || []
if (invoices.length > 0) {
const inv = invoices[0]
sections.push({
title: '开票信息',
fields: [
{ label: '发票抬头', value: inv.invoiceTitle || inv.companyName },
{ label: '纳税人识别号', value: inv.taxNo },
{ label: '开户银行', value: inv.bankName },
{ label: '银行账号', value: inv.bankAccount }
]
})
}
} catch {
// ignore
}
viewSections.value = sections
viewVisible.value = true
}
// ---------------------------------------------------------------------------
// 险种价格配置弹窗
// ---------------------------------------------------------------------------
function openItemDialog(row: any) {
currentCompanyId.value = row.id
currentCompanyName.value = row.companyName || ''
itemPagination.page = 1
itemSelected.value = []
itemDialogTitle.value = `险种价格配置 — ${row.companyName}`
itemDialogVisible.value = true
loadItems()
}
async function loadItems() {
if (!currentCompanyId.value) return
itemLoading.value = true
try {
const res = await fetchInsuranceProjectPrices(currentCompanyId.value)
const list = Array.isArray(res) ? res : (res as any)?.list || []
itemTable.value = list
itemPagination.total = Array.isArray(res) ? list.length : ((res as any)?.total ?? list.length)
} finally {
itemLoading.value = false
}
}
function onItemDialogClosed() {
currentCompanyId.value = ''
currentCompanyName.value = ''
itemTable.value = []
}
function onItemSelectionChange(rows: any[]) {
itemSelected.value = rows
}
function resetItemForm() {
itemForm.projectName = ''
itemForm.projectFee = 0
itemForm.status = '1'
itemForm.remark = ''
editingItemId.value = null
}
function handleAddItem() {
itemIsEdit.value = false
itemFormTitle.value = '新增险种'
resetItemForm()
itemFormVisible.value = true
}
function handleEditItem(row: any) {
itemIsEdit.value = true
itemFormTitle.value = '编辑险种'
editingItemId.value = row.id
itemForm.projectName = row.projectName || ''
itemForm.projectFee = row.projectFee ?? 0
itemForm.status = row.status != null ? String(row.status) : '1'
itemForm.remark = row.remark || ''
itemFormVisible.value = true
}
async function submitItem() {
const valid = await itemFormRef.value?.validate?.().catch(() => false)
if (!valid) return
itemSubmitLoading.value = true
try {
const payload = {
projectName: itemForm.projectName.trim(),
projectFee: itemForm.projectFee,
status: itemForm.status,
remark: itemForm.remark?.trim() || undefined
}
if (itemIsEdit.value && editingItemId.value) {
await fetchUpdateInsuranceProjectPrice(editingItemId.value, payload)
ElMessage.success('已保存')
} else {
await fetchCreateInsuranceProjectPrice(currentCompanyId.value, payload)
ElMessage.success('已新增')
}
itemFormVisible.value = false
loadItems()
} finally {
itemSubmitLoading.value = false
}
}
async function handleDeleteItem(row: any) {
try {
await ElMessageBox.confirm(`确定删除「${row.projectName}」?`, '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteInsuranceProjectPrice(row.id)
ElMessage.success('已删除')
await loadItems()
}, fullscreenLoadingText.delete)
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => loadData())
</script>
<style scoped lang="scss">
.biz-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.item-dialog-head {
margin-bottom: 12px;
}
.item-dialog-head .muted {
font-size: 13px;
color: var(--mint-text-muted);
}
.item-toolbar {
margin-bottom: 0;
}
.item-table-wrap {
padding-top: 0;
}
.form-section {
background: #fafafa;
border-radius: 8px;
padding: 20px;
margin: 4px 0;
}
.form-section .section-title {
font-size: 15px;
font-weight: 500;
color: #303133;
margin-bottom: 16px;
padding-bottom: 12px;
padding-left: 10px;
border-bottom: 1px solid #ebeef5;
border-left: 3px solid #10b981;
}
</style>
<!-- 弹窗 teleport 到 body,高度类名需非 scoped -->
<style lang="scss">
.el-dialog.insurance-item-config-dialog.dialog-form-layout {
min-height: min(640px, calc(100vh - 80px));
}
</style>
@@ -1,18 +0,0 @@
<!-- 旧「样本编码管理」菜单入口:重定向至细胞样本基础信息维护 · 样本编码 Tab -->
<template>
<div />
</template>
<script setup lang="ts">
defineOptions({ name: 'BizSampleCode' })
const router = useRouter()
const route = useRoute()
onMounted(() => {
router.replace({
name: 'BizCellSampleType',
query: { ...route.query, tab: 'sample-code' }
})
})
</script>
@@ -1,18 +0,0 @@
<!-- 旧「满月待办管理」菜单入口:重定向至采集协议单管理 · 满月待办 Tab -->
<template>
<div />
</template>
<script setup lang="ts">
defineOptions({ name: 'CellStorageFullmoonTodo' })
const router = useRouter()
const route = useRoute()
onMounted(() => {
router.replace({
name: 'CellStorageProtocolAudit',
query: { ...route.query, tab: 'fullmoon-todo' }
})
})
</script>
@@ -1,18 +0,0 @@
<!-- 旧「市场物料变动记录」菜单入口:重定向至市场物料库 · 变动记录 Tab -->
<template>
<div />
</template>
<script setup lang="ts">
defineOptions({ name: 'MarketMaterialStockLog' })
const router = useRouter()
const route = useRoute()
onMounted(() => {
router.replace({
name: 'MarketMaterial',
query: { ...route.query, tab: 'stock-log' }
})
})
</script>
@@ -1,292 +0,0 @@
<!-- 市场物料库 · 变动记录 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="material-stock-log-tab">
<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-form-item>
<el-form-item label="变动类型">
<el-select v-model="searchForm.changeType" placeholder="全部" clearable>
<el-option label="入库" value="rk" />
<el-option label="出库" value="ck" />
</el-select>
</el-form-item>
<el-form-item label="日期范围">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按物料名称/编码查询,可按变动类型和日期范围筛选。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@row-dblclick="handleView"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="materialCode"
label="物料编码"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column
prop="materialName"
label="物料名称"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column label="变动类型" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="row.changeType === 'rk' ? 'success' : 'warning'" size="small">
{{ row.changeType === 'rk' ? '入库' : '出库' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="变动数量" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<span :class="row.changeType === 'ck' ? 'qty-negative' : 'qty-positive'">
{{ formatChangeQty(row).text }}
</span>
</template>
</el-table-column>
<el-table-column
prop="stockBefore"
label="变动前库存"
min-width="110"
align="center"
header-align="center"
/>
<el-table-column
prop="stockAfter"
label="变动后库存"
min-width="110"
align="center"
header-align="center"
/>
<el-table-column label="领用人" min-width="100" align="center" header-align="center">
<template #default="{ row }">
{{ row.changeType === 'ck' ? row.receiver || '-' : '-' }}
</template>
</el-table-column>
<el-table-column label="用途" min-width="120" align="center" header-align="center">
<template #default="{ row }">
{{ row.changeType === 'ck' ? row.purpose || '-' : '-' }}
</template>
</el-table-column>
<el-table-column label="操作人" min-width="100" align="center" header-align="center">
<template #default="{ row }">
{{ row.createByName || row.createBy || '-' }}
</template>
</el-table-column>
<el-table-column
label="备注"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
{{ row.remark || '-' }}
</template>
</el-table-column>
<el-table-column
prop="createTime"
label="操作时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="160"
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>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<!-- 查看弹窗 -->
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.materialName || '变动记录详情'"
:icon="Document"
:meta="viewRow?.createTime || ''"
:sections="viewSections"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { RefreshLeft, Search, View, Document } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { fetchMaterialStockLogList } from '@/api/material'
import type { MaterialStockLog } from '@/api/material'
defineOptions({ name: 'MaterialStockLogTab' })
/** 入库为正数带 +,出库为负数展示(与质检物料变动记录一致) */
function formatChangeQty(row: MaterialStockLog) {
const qty = Number(row.changeQty ?? 0)
const signed = row.changeType === 'ck' ? -qty : qty
const text = signed < 0 ? String(signed) : `+${signed}`
return { signed, text }
}
const searchForm = reactive({ keyword: '', changeType: '' })
const dateRange = ref<[string, string] | null>(null)
const tableData = ref<MaterialStockLog[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const viewVisible = ref(false)
const viewRow = ref<MaterialStockLog | null>(null)
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '变动信息',
fields: [
{ label: '物料编码', value: row.materialCode },
{ label: '物料名称', value: row.materialName },
{
label: '变动类型',
value: row.changeType === 'rk' ? '入库' : '出库',
tagType: row.changeType === 'rk' ? 'success' : 'warning'
},
{ label: '变动数量', value: formatChangeQty(row).text },
{ label: '变动前库存', value: String(row.stockBefore ?? 0) },
{ label: '变动后库存', value: String(row.stockAfter ?? 0) },
{ label: '领用人', value: row.changeType === 'ck' ? row.receiver || '-' : '-' },
{ label: '用途', value: row.changeType === 'ck' ? row.purpose || '-' : '-' },
{ label: '操作人', value: row.createByName || row.createBy || '-' },
{ label: '备注', value: row.remark || '—', span: true },
{ label: '操作时间', value: row.createTime }
]
}
]
})
function handleView(row: MaterialStockLog) {
viewRow.value = row
viewVisible.value = true
}
async function loadData() {
loading.value = true
try {
const res = await fetchMaterialStockLogList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
changeType: searchForm.changeType || undefined,
startTime: dateRange.value?.[0] || undefined,
endTime: dateRange.value?.[1] || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
searchForm.changeType = ''
dateRange.value = null
pagination.page = 1
loadData()
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
// 日期范围变更时自动搜索
watch(dateRange, () => {
pagination.page = 1
loadData()
})
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.qty-positive {
color: var(--el-color-success);
font-weight: 500;
}
.qty-negative {
color: var(--el-color-danger);
font-weight: 500;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -1,634 +0,0 @@
<!-- 缴退费财务确认 · 退费 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="refund-confirm-tab">
<el-tabs
v-model="activeTab"
class="work-order-type-tabs"
@tab-change="handleStatusTabChange"
>
<el-tab-pane label="待确认" name="pending" />
<el-tab-pane label="已确认" name="confirmed" />
</el-tabs>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" label-position="top" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="协议号/客户姓名/身份证号/新生儿姓名/新生儿身份证号"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="应缴费时间">
<el-date-picker
v-model="installmentDueDateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>支持协议号、客户姓名、身份证号、新生儿姓名、新生儿身份证号模糊检索;默认展示应缴费时间前
15 天至后 15 天内的明细。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-body list-wrap refund-confirm-table-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@row-dblclick="handleRowDblClick"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="protocolNo"
label="协议号"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column
prop="motherName"
label="客户姓名"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.motherName || '-' }}</template>
</el-table-column>
<el-table-column
prop="motherIdCard"
label="身份证号"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ maskIdCard(row.motherIdCard) }}</template>
</el-table-column>
<el-table-column
prop="newbornName"
label="新生儿姓名"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.newbornName || '-' }}</template>
</el-table-column>
<el-table-column
prop="newbornIdCard"
label="新生儿身份证号"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ maskIdCard(row.newbornIdCard) }}</template>
</el-table-column>
<el-table-column
prop="protocolStatus"
label="协议状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="protocolStatusTagType(String(row.protocolStatus || ''))"
size="small"
>{{ protocolStatusLabel(String(row.protocolStatus || '')) }}</el-tag
>
</template>
</el-table-column>
<el-table-column
prop="payMode"
label="缴费方式"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{ payModeLabel(String(row.payMode || '')) }}</template>
</el-table-column>
<el-table-column
prop="installmentNo"
label="期数"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.installmentNo ?? '-' }}</template>
</el-table-column>
<el-table-column
prop="installmentDueDate"
label="应缴费时间"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{
formatPaymentDueDate(row.installmentDueDate)
}}</template>
</el-table-column>
<el-table-column
prop="dueAmount"
label="应缴金额(元)"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatPaymentAmount(row.dueAmount) }}</template>
</el-table-column>
<el-table-column
prop="transferAmount"
label="实缴金额(元)"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatPaymentAmount(row.transferAmount) }}</template>
</el-table-column>
<el-table-column
prop="transferType"
label="转账方式"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{
transferTypeLabel(String(row.transferType || ''))
}}</template>
</el-table-column>
<el-table-column
prop="transferTime"
label="转账时间"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.transferTime || '-' }}</template>
</el-table-column>
<el-table-column
prop="refundAmount"
label="退费金额(元)"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatPaymentAmount(row.refundAmount) }}</template>
</el-table-column>
<el-table-column
prop="refundRemark"
label="退费备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.refundRemark || '-' }}</template>
</el-table-column>
<el-table-column
prop="payStatus"
label="状态"
min-width="110"
align="center"
header-align="center"
>
<template #default>
<el-tag :type="activeTab === 'pending' ? 'warning' : 'success'" size="small">
{{ activeTab === 'pending' ? '退费确认中' : '已退费' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="activeTab === 'confirmed'"
prop="updateTime"
label="确认时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
v-if="activeTab === 'confirmed'"
prop="refundFinanceRemark"
label="财务备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.refundFinanceRemark || '-' }}</template>
</el-table-column>
<el-table-column
v-if="activeTab === 'pending'"
prop="createTime"
label="创建时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="140"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button
v-if="activeTab === 'pending'"
class="btn-op-item primary"
@click="openConfirm(row)"
>
确认退费
</el-button>
<el-button v-else class="btn-op-item" @click="openViewConfirm(row)"
>查看确认信息</el-button
>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
title="确认退费"
width="900px"
class="dialog-form-layout refund-confirm-dialog"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<ProtocolPaymentDetailReadonly
:detail="confirmRow"
title="缴费明细(请核对)"
show-refund-apply
refund-apply-title="退费申请(请核对)"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
<div class="form-section-divider" />
<h4 class="detail-section-title">财务确认</h4>
<el-form label-position="top" class="biz-dialog-form">
<ProtocolFinanceConfirmFields
v-model:finance-remark="financeRemark"
v-model:attachments="attachments"
upload-folder="protocol-finance-refund"
/>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="submitLoading"
@click="submitConfirm"
>
<el-icon><Check /></el-icon>
确认
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="viewDialogVisible"
title="确认退费信息"
width="900px"
class="dialog-form-layout refund-confirm-dialog"
align-center
destroy-on-close
>
<ProtocolPaymentDetailReadonly
:detail="viewRow"
title="缴费明细"
show-refund-apply
refund-apply-title="退费申请"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
<div class="form-section-divider" />
<h4 class="detail-section-title">财务确认信息</h4>
<el-form label-position="top" class="biz-dialog-form view-confirm-form">
<div class="form-grid-2">
<el-form-item label="确认时间" class="form-col-span-2">
<span class="view-field-text">{{ viewRow?.updateTime || '-' }}</span>
</el-form-item>
<el-form-item label="财务备注" class="form-col-span-2">
<span class="view-field-text view-field-text--multiline">{{
viewRow?.refundFinanceRemark || '—'
}}</span>
</el-form-item>
<el-form-item label="财务确认附件" class="form-col-span-2">
<ProtocolAttachmentReadonly
:files="viewFinanceAttachmentFiles"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="viewDialogVisible = false">
<el-icon><Close /></el-icon>
关闭
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Check, RefreshLeft, Search } from '@element-plus/icons-vue'
import {
fetchRefundConfirmList,
fetchRefundConfirmedList,
fetchRefundConfirmSubmit
} from '@/api/collection-protocol'
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
import ProtocolPaymentDetailReadonly from '@/components/business/protocol-payment-detail-readonly/index.vue'
import ProtocolAttachmentReadonly from '../protocol/ProtocolAttachmentReadonly.vue'
import {
financeAttachmentsToUrlString,
type FinanceAttachmentItem
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
import { maskIdCard } from '@/utils/mask-sensitive'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import {
formatPaymentDueDate,
payModeLabel,
protocolStatusLabel,
protocolStatusTagType,
transferTypeLabel
} from '@/utils/protocol-payment-labels'
import { formatMoneyDisplay } from '@/utils/money-format'
defineOptions({ name: 'CellStorageRefundConfirmTab' })
function formatPaymentAmount(v: unknown): string {
if (v == null || v === '') return '-'
return '¥' + formatMoneyDisplay(v)
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultInstallmentDueDateRange(): [string, string] {
const today = new Date()
const start = new Date(today)
const end = new Date(today)
start.setDate(start.getDate() - 15)
end.setDate(end.getDate() + 15)
return [formatLocalDate(start), formatLocalDate(end)]
}
const searchForm = reactive({ keyword: '' })
const installmentDueDateRange = ref<[string, string] | null>(getDefaultInstallmentDueDateRange())
const activeTab = ref('pending')
const tableData = ref<Record<string, unknown>[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const viewDialogVisible = ref(false)
const submitLoading = ref(false)
const financeRemark = ref('')
const attachments = ref<FinanceAttachmentItem[]>([])
const currentDetailId = ref<string | null>(null)
const confirmRow = ref<Record<string, unknown> | null>(null)
const viewRow = ref<Record<string, unknown> | null>(null)
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const viewFinanceAttachmentFiles = computed(() => {
const row = viewRow.value
if (!row) return []
if (Array.isArray(row.refundFinanceConfirmAttachmentFiles)) {
return row.refundFinanceConfirmAttachmentFiles as Array<Record<string, unknown>>
}
const urls = String(row.refundFinanceConfirmAttachments || '')
if (!urls.trim()) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `url_${i}`, fileUrl: url, fileName: name }
})
.filter(Boolean) as Array<Record<string, unknown>>
})
function fullFileUrl(url: string) {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
return minioBaseUrl ? `${minioBaseUrl}/${url.replace(/^\//, '')}` : url
}
function handleAttachmentPreview(file: Record<string, unknown>) {
const url = String(file.fileUrl || '')
if (!url) return
window.open(fullFileUrl(url), '_blank')
}
function handleAttachmentDownload(file: Record<string, unknown>) {
downloadAttachmentFile(file, { fullFileUrl })
}
function buildListParams() {
return {
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
installmentDueDateStart: installmentDueDateRange.value?.[0] || undefined,
installmentDueDateEnd: installmentDueDateRange.value?.[1] || undefined
}
}
async function loadData() {
loading.value = true
try {
const params = buildListParams()
const res =
activeTab.value === 'pending'
? await fetchRefundConfirmList(params)
: await fetchRefundConfirmedList(params)
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleStatusTabChange() {
pagination.page = 1
loadData()
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
installmentDueDateRange.value = getDefaultInstallmentDueDateRange()
pagination.page = 1
loadData()
}
function openConfirm(row: Record<string, unknown>) {
currentDetailId.value = String(row.id)
confirmRow.value = row
financeRemark.value = ''
attachments.value = []
dialogVisible.value = true
}
function openViewConfirm(row: Record<string, unknown>) {
viewRow.value = row
viewDialogVisible.value = true
}
/** 双击行:非待确认 tab 时等同于点击「查看确认信息」 */
function handleRowDblClick(row: Record<string, unknown>) {
if (activeTab.value !== 'pending') openViewConfirm(row)
}
async function submitConfirm() {
if (!currentDetailId.value) return
if (attachments.value.some((f) => f._uploading)) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
await fetchRefundConfirmSubmit(currentDetailId.value, {
refundFinanceConfirmAttachments:
financeAttachmentsToUrlString(attachments.value) || undefined,
refundFinanceRemark: financeRemark.value.trim() || undefined
})
dialogVisible.value = false
loadData()
} finally {
submitLoading.value = false
}
}
watchDebounced(
[searchForm, installmentDueDateRange],
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.work-order-type-tabs {
padding: 0 16px;
margin-bottom: 4px;
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
min-width: 0;
}
.filter-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
padding: 0 0 4px;
line-height: 1.2;
}
.refund-confirm-table-wrap {
overflow-x: auto;
}
.refund-confirm-dialog {
:deep(.el-dialog__body) {
padding-top: 16px;
}
}
.form-section-divider {
height: 1px;
margin: 20px 0;
background: var(--mint-border, #e2e8f0);
}
.detail-section-title {
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #334155);
line-height: 1.4;
}
.view-confirm-form :deep(.el-form-item) {
margin-bottom: 0;
}
.view-field-text {
display: block;
font-size: 14px;
color: var(--mint-text, #334155);
line-height: 1.5;
word-break: break-word;
&--multiline {
white-space: pre-wrap;
}
}
</style>
@@ -1,738 +0,0 @@
<!-- 缴退费财务确认 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-entity-switch" role="tablist" aria-label="视图类型">
<button
type="button"
class="entity-switch-card"
:class="{ 'is-active': mainTab === 'payment' }"
role="tab"
:aria-selected="mainTab === 'payment'"
@click="mainTab = 'payment'"
>
<span class="entity-switch-card__icon"><el-icon><Wallet /></el-icon></span>
<span class="entity-switch-card__main">
<span class="entity-switch-card__title">缴费确认</span>
<span class="entity-switch-card__desc">缴费记录确认与附件审核</span>
</span>
</button>
<button
type="button"
class="entity-switch-card"
:class="{ 'is-active': mainTab === 'refund' }"
role="tab"
:aria-selected="mainTab === 'refund'"
@click="mainTab = 'refund'"
>
<span class="entity-switch-card__icon"><el-icon><Refresh /></el-icon></span>
<span class="entity-switch-card__main">
<span class="entity-switch-card__title">退费确认</span>
<span class="entity-switch-card__desc">退费记录确认与附件审核</span>
</span>
</button>
</div>
<div v-show="mainTab === 'payment'" class="panel-entity-body">
<el-tabs
v-model="activeTab"
class="work-order-type-tabs"
@tab-change="handleStatusTabChange"
>
<el-tab-pane label="待确认" name="pending" />
<el-tab-pane label="已确认" name="confirmed" />
</el-tabs>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" label-position="top" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="协议号/客户姓名/身份证号/新生儿姓名/新生儿身份证号"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="应缴费时间">
<el-date-picker
v-model="installmentDueDateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>支持协议号、客户姓名、身份证号、新生儿姓名、新生儿身份证号模糊检索;默认展示应缴费时间前
15 天至后 15 天内的明细。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-body list-wrap payment-confirm-table-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@row-dblclick="handleRowDblClick"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="protocolNo"
label="协议号"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column
prop="motherName"
label="客户姓名"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.motherName || '-' }}</template>
</el-table-column>
<el-table-column
prop="motherIdCard"
label="身份证号"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ maskIdCard(row.motherIdCard) }}</template>
</el-table-column>
<el-table-column
prop="newbornName"
label="新生儿姓名"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.newbornName || '-' }}</template>
</el-table-column>
<el-table-column
prop="newbornIdCard"
label="新生儿身份证号"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ maskIdCard(row.newbornIdCard) }}</template>
</el-table-column>
<el-table-column
prop="protocolStatus"
label="协议状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="protocolStatusTagType(String(row.protocolStatus || ''))"
size="small"
>{{ protocolStatusLabel(String(row.protocolStatus || '')) }}</el-tag
>
</template>
</el-table-column>
<el-table-column
prop="payMode"
label="缴费方式"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{ payModeLabel(String(row.payMode || '')) }}</template>
</el-table-column>
<el-table-column
prop="installmentNo"
label="期数"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.installmentNo ?? '-' }}</template>
</el-table-column>
<el-table-column
prop="installmentDueDate"
label="应缴费时间"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{
formatPaymentDueDate(row.installmentDueDate)
}}</template>
</el-table-column>
<el-table-column
prop="dueAmount"
label="应缴金额(元)"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatPaymentAmount(row.dueAmount) }}</template>
</el-table-column>
<el-table-column
prop="transferAmount"
label="实缴金额(元)"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatPaymentAmount(row.transferAmount) }}</template>
</el-table-column>
<el-table-column
prop="transferType"
label="转账方式"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{
transferTypeLabel(String(row.transferType || ''))
}}</template>
</el-table-column>
<el-table-column
prop="transferTime"
label="转账时间"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.transferTime || '-' }}</template>
</el-table-column>
<el-table-column
prop="payStatus"
label="状态"
min-width="110"
align="center"
header-align="center"
>
<template #default>
<el-tag :type="activeTab === 'pending' ? 'warning' : 'success'" size="small">
{{ activeTab === 'pending' ? '缴费确认中' : '已缴费' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="activeTab === 'confirmed'"
prop="updateTime"
label="确认时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
v-if="activeTab === 'confirmed'"
prop="financeRemark"
label="财务备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.financeRemark || '-' }}</template>
</el-table-column>
<el-table-column
v-if="activeTab === 'pending'"
prop="createTime"
label="创建时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="140"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button
v-if="activeTab === 'pending'"
class="btn-op-item primary"
@click="openConfirm(row)"
>
确认收款
</el-button>
<el-button v-else class="btn-op-item" @click="openViewConfirm(row)"
>查看确认信息</el-button
>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
title="确认收款"
width="900px"
class="dialog-form-layout payment-confirm-dialog"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<ProtocolPaymentDetailReadonly
:detail="confirmRow"
title="缴费明细(请核对)"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
<div class="form-section-divider" />
<h4 class="detail-section-title">财务确认</h4>
<el-form label-position="top" class="biz-dialog-form">
<ProtocolFinanceConfirmFields
v-model:finance-remark="financeRemark"
v-model:attachments="attachments"
upload-folder="protocol-finance-receipt"
/>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="submitLoading"
@click="submitConfirm"
>
<el-icon><Check /></el-icon>
确认
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="viewDialogVisible"
title="确认收款信息"
width="900px"
class="dialog-form-layout payment-confirm-dialog"
align-center
destroy-on-close
>
<ProtocolPaymentDetailReadonly
:detail="viewRow"
title="缴费明细"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
<div class="form-section-divider" />
<h4 class="detail-section-title">财务确认信息</h4>
<el-form label-position="top" class="biz-dialog-form view-confirm-form">
<div class="form-grid-2">
<el-form-item label="确认时间" class="form-col-span-2">
<span class="view-field-text">{{ viewRow?.updateTime || '-' }}</span>
</el-form-item>
<el-form-item label="财务备注" class="form-col-span-2">
<span class="view-field-text view-field-text--multiline">{{
viewRow?.financeRemark || '—'
}}</span>
</el-form-item>
<el-form-item label="财务确认附件" class="form-col-span-2">
<ProtocolAttachmentReadonly
:files="viewFinanceAttachmentFiles"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="viewDialogVisible = false">
<el-icon><Close /></el-icon>
关闭
</el-button>
</template>
</el-dialog>
</div>
<RefundConfirmTab v-if="mainTab === 'refund'" />
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Check, RefreshLeft, Search, Wallet, Refresh } from '@element-plus/icons-vue'
import {
fetchReceiptConfirmList,
fetchReceiptConfirmedList,
fetchReceiptConfirmSubmit
} from '@/api/collection-protocol'
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
import ProtocolPaymentDetailReadonly from '@/components/business/protocol-payment-detail-readonly/index.vue'
import ProtocolAttachmentReadonly from '../protocol/ProtocolAttachmentReadonly.vue'
import {
financeAttachmentsToUrlString,
type FinanceAttachmentItem
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
import { maskIdCard } from '@/utils/mask-sensitive'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import {
formatPaymentDueDate,
payModeLabel,
protocolStatusLabel,
protocolStatusTagType,
transferTypeLabel
} from '@/utils/protocol-payment-labels'
import { formatMoneyDisplay } from '@/utils/money-format'
import RefundConfirmTab from './RefundConfirmTab.vue'
defineOptions({ name: 'CellStoragePaymentConfirm' })
const route = useRoute()
const mainTab = ref('payment')
function formatPaymentAmount(v: unknown): string {
if (v == null || v === '') return '-'
return '¥' + formatMoneyDisplay(v)
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultInstallmentDueDateRange(): [string, string] {
const today = new Date()
const start = new Date(today)
const end = new Date(today)
start.setDate(start.getDate() - 15)
end.setDate(end.getDate() + 15)
return [formatLocalDate(start), formatLocalDate(end)]
}
const searchForm = reactive({ keyword: '' })
const installmentDueDateRange = ref<[string, string] | null>(getDefaultInstallmentDueDateRange())
const activeTab = ref('pending')
const tableData = ref<Record<string, unknown>[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const viewDialogVisible = ref(false)
const submitLoading = ref(false)
const financeRemark = ref('')
const attachments = ref<FinanceAttachmentItem[]>([])
const currentDetailId = ref<string | null>(null)
const confirmRow = ref<Record<string, unknown> | null>(null)
const viewRow = ref<Record<string, unknown> | null>(null)
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const viewFinanceAttachmentFiles = computed(() => {
const row = viewRow.value
if (!row) return []
if (Array.isArray(row.financeConfirmAttachmentFiles)) {
return row.financeConfirmAttachmentFiles as Array<Record<string, unknown>>
}
const urls = String(row.financeConfirmAttachments || '')
if (!urls.trim()) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `url_${i}`, fileUrl: url, fileName: name }
})
.filter(Boolean) as Array<Record<string, unknown>>
})
function fullFileUrl(url: string) {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
return minioBaseUrl ? `${minioBaseUrl}/${url.replace(/^\//, '')}` : url
}
function handleAttachmentPreview(file: Record<string, unknown>) {
const url = String(file.fileUrl || '')
if (!url) return
window.open(fullFileUrl(url), '_blank')
}
function handleAttachmentDownload(file: Record<string, unknown>) {
downloadAttachmentFile(file, { fullFileUrl })
}
function buildListParams() {
return {
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
installmentDueDateStart: installmentDueDateRange.value?.[0] || undefined,
installmentDueDateEnd: installmentDueDateRange.value?.[1] || undefined
}
}
async function loadData() {
loading.value = true
try {
const params = buildListParams()
const res =
activeTab.value === 'pending'
? await fetchReceiptConfirmList(params)
: await fetchReceiptConfirmedList(params)
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleStatusTabChange() {
pagination.page = 1
loadData()
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
installmentDueDateRange.value = getDefaultInstallmentDueDateRange()
pagination.page = 1
loadData()
}
function openConfirm(row: Record<string, unknown>) {
currentDetailId.value = String(row.id)
confirmRow.value = row
financeRemark.value = ''
attachments.value = []
dialogVisible.value = true
}
function openViewConfirm(row: Record<string, unknown>) {
viewRow.value = row
viewDialogVisible.value = true
}
/** 双击行:非待确认 tab 时等同于点击「查看确认信息」 */
function handleRowDblClick(row: Record<string, unknown>) {
if (activeTab.value !== 'pending') openViewConfirm(row)
}
async function submitConfirm() {
if (!currentDetailId.value) return
if (attachments.value.some((f) => f._uploading)) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
await fetchReceiptConfirmSubmit(currentDetailId.value, {
financeConfirmAttachments: financeAttachmentsToUrlString(attachments.value) || undefined,
financeRemark: financeRemark.value.trim() || undefined
})
dialogVisible.value = false
loadData()
} finally {
submitLoading.value = false
}
}
watchDebounced(
[searchForm, installmentDueDateRange],
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
const tab = route.query.tab
if (tab === 'refund') {
mainTab.value = 'refund'
}
loadData()
})
</script>
<style scoped lang="scss">
.panel-entity-switch {
display: flex;
justify-content: center;
gap: 12px;
padding: 16px 16px 12px;
}
.entity-switch-card {
display: flex;
align-items: center;
gap: 12px;
min-width: 220px;
max-width: 280px;
flex: 1;
padding: 14px 18px;
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: 10px;
background: var(--surface, #fff);
cursor: pointer;
text-align: left;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
&:hover:not(.is-active) {
border-color: #a7f3d0;
background: #f8fafc;
}
&.is-active {
border-color: var(--primary, #10b981);
background: var(--primary-bg, #ecfdf5);
box-shadow: 0 0 0 1px rgb(16 185 129 / 12%);
}
}
.entity-switch-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 8px;
background: #f1f5f9;
color: #64748b;
font-size: 20px;
.entity-switch-card.is-active & {
background: #fff;
color: var(--primary-dark, #059669);
}
}
.entity-switch-card__main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.entity-switch-card__title {
font-size: 14px;
font-weight: 600;
color: var(--text, #1e293b);
line-height: 1.4;
.entity-switch-card.is-active & {
color: var(--primary-dark, #059669);
}
}
.entity-switch-card__desc {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
}
.panel-entity-body {
min-width: 0;
}
.work-order-type-tabs {
padding: 0 16px;
margin-bottom: 4px;
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
min-width: 0;
}
.filter-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
padding: 0 0 4px;
line-height: 1.2;
}
.payment-confirm-table-wrap {
overflow-x: auto;
}
.payment-confirm-dialog {
:deep(.el-dialog__body) {
padding-top: 16px;
}
}
.form-section-divider {
height: 1px;
margin: 20px 0;
background: var(--mint-border, #e2e8f0);
}
.detail-section-title {
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #334155);
line-height: 1.4;
}
.view-confirm-form :deep(.el-form-item) {
margin-bottom: 0;
}
.view-field-text {
display: block;
font-size: 14px;
color: var(--mint-text, #334155);
line-height: 1.5;
word-break: break-word;
&--multiline {
white-space: pre-wrap;
}
}
</style>
@@ -1,18 +0,0 @@
<!-- 旧「缴费信息」菜单入口:重定向至采集协议单管理 · 缴费信息 Tab -->
<template>
<div />
</template>
<script setup lang="ts">
defineOptions({ name: 'CellStoragePaymentInfo' })
const router = useRouter()
const route = useRoute()
onMounted(() => {
router.replace({
name: 'CellStorageProtocolAudit',
query: { ...route.query, tab: 'payment-info' }
})
})
</script>
File diff suppressed because it is too large. Load diff
@@ -1,862 +0,0 @@
<!-- 采集协议单管理 · 缴费信息 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="payment-info-tab">
<el-tabs
v-model="activeTab"
class="work-order-type-tabs"
@tab-change="handleStatusTabChange"
>
<el-tab-pane label="待缴费" name="dfj" />
<el-tab-pane label="缴费确认中" name="jfqrz" />
<el-tab-pane label="已缴费" name="yjf" />
<el-tab-pane label="退费确认中" name="tfqrz" />
<el-tab-pane label="已退费" name="ytf" />
</el-tabs>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form filter-form--inline-label" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="协议号"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="转账方式">
<el-select v-model="searchForm.transferType" placeholder="全部" clearable>
<el-option label="全部" value="" />
<el-option label="微信转账" value="wxzz" />
<el-option label="支付宝转账" value="zzbzz" />
<el-option label="银行卡转账" value="yhkzz" />
<el-option label="现金" value="xj" />
</el-select>
</el-form-item>
<el-form-item label="应缴费时间">
<el-date-picker
v-model="installmentDueDateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>支持按协议号模糊检索;默认展示应缴费时间为当天前 10 天至后 20 天的明细。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div v-if="activeTab === 'dfj' || activeTab === 'yjf'" class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button
class="btn-main"
type="primary"
:disabled="!selectedRows.length"
@click="handleBatchSubmitFinance"
>
{{ activeTab === 'dfj' ? '批量缴费确认' : '批量退费确认' }}
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
:row-class-name="paymentRowClassName"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column
v-if="activeTab === 'dfj' || activeTab === 'yjf'"
type="selection"
min-width="55"
align="center"
header-align="center"
/>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="protocolNo"
label="协议号"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="motherName"
label="客户姓名"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ row.motherName || '-' }}
</template>
</el-table-column>
<el-table-column
prop="motherIdCard"
label="身份证号"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ maskIdCard(row.motherIdCard) }}
</template>
</el-table-column>
<el-table-column
prop="newbornName"
label="新生儿姓名"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ row.newbornName || '-' }}
</template>
</el-table-column>
<el-table-column
prop="newbornIdCard"
label="新生儿身份证号"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ maskIdCard(row.newbornIdCard) }}
</template>
</el-table-column>
<el-table-column
prop="protocolStatus"
label="协议状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="protocolStatusTagType(String(row.protocolStatus || ''))"
size="small"
>{{ protocolStatusLabel(String(row.protocolStatus || '')) }}</el-tag
>
</template>
</el-table-column>
<el-table-column
prop="payMode"
label="缴费方式"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ payModeLabel(String(row.payMode || '')) }}
</template>
</el-table-column>
<el-table-column
prop="installmentNo"
label="期数"
min-width="80"
align="center"
header-align="center"
/>
<el-table-column
prop="installmentDueDate"
label="应缴费时间"
min-width="130"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ formatDate(String(row.installmentDueDate || '')) }}
</template>
</el-table-column>
<el-table-column
prop="dueAmount"
label="应缴金额(元)"
min-width="130"
align="center"
header-align="center"
>
<template #default="{ row }">
<span class="amount-text">{{ formatAmount(row.dueAmount) }}</span>
</template>
</el-table-column>
<el-table-column
prop="transferAmount"
label="实缴金额(元)"
min-width="130"
align="center"
header-align="center"
>
<template #default="{ row }">
<span class="amount-text">{{ formatAmount(row.transferAmount) }}</span>
</template>
</el-table-column>
<el-table-column
prop="transferType"
label="转账方式"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ transferTypeLabel(String(row.transferType || '')) }}
</template>
</el-table-column>
<el-table-column
prop="transferTime"
label="转账时间"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ row.transferTime || '-' }}
</template>
</el-table-column>
<el-table-column
prop="payStatus"
label="状态"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="payStatusTagType(String(row.payStatus || ''))" size="small">
{{ payStatusLabel(String(row.payStatus || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createTime"
label="创建时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
:min-width="activeTab === 'dfj' || activeTab === 'yjf' ? 280 : 120"
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
v-if="activeTab === 'dfj'"
class="btn-op-item primary"
@click="handleSubmitReceiptFinance(row)"
>
缴费确认
</el-button>
<el-button
v-if="activeTab === 'yjf'"
class="btn-op-item primary"
@click="openRefundFinanceDialog(row)"
>
退费确认
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="refundDialogVisible"
title="退费确认"
width="640px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="refundFormRef"
:model="refundForm"
:rules="refundFormRules"
label-position="top"
class="biz-dialog-form"
>
<div class="form-grid-2">
<el-form-item label="退费金额(元)" prop="refundAmount" class="form-col-span-2">
<AmountInput
v-model="refundForm.refundAmount"
:min="0.01"
:step="100"
placeholder="请输入退费金额"
/>
</el-form-item>
<el-form-item label="退费备注" prop="refundRemark" class="form-col-span-2">
<el-input
v-model="refundForm.refundRemark"
type="textarea"
:rows="3"
placeholder="选填"
/>
</el-form-item>
<el-form-item label="退费附件" class="form-col-span-2">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
@change="handleRefundAttachmentChange"
>
<div class="upload-card">
<el-icon :size="22" color="#10b981"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
<div
v-for="(file, fIdx) in refundForm.attachments"
:key="file.id || fIdx"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div class="attachment-preview">
<template v-if="file.fileUrl && isImageUrl(file.fileUrl)">
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
</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>
<el-icon
class="action-btn"
title="删除"
@click="removeRefundAttachment(fIdx)"
>
<Delete />
</el-icon>
</div>
</div>
</div>
<p class="form-tip">支持图片、文档等,多个附件将一并提交。</p>
</div>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="refundDialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="refundSubmitLoading"
@click="submitRefundFinance"
>
<el-icon><Check /></el-icon>
确认提交
</el-button>
</template>
</el-dialog>
<ProtocolViewDialog
v-model="viewVisible"
:protocol-id="viewProtocolId"
:list-row="viewRow"
show-audit-history
show-storage-cert
show-fullmoon-insurance
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
RefreshLeft,
Search,
View,
Plus,
Document,
Delete,
Close,
Check
} from '@element-plus/icons-vue'
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
import {
fetchPaymentDetailList,
fetchSubmitReceiptFinance,
fetchSubmitReceiptFinanceBatch,
fetchSubmitRefundFinance,
fetchSubmitRefundFinanceBatch,
type CollectionProtocolRow
} from '@/api/collection-protocol'
import { fetchUploadAttachment } from '@/api/business'
import { maskIdCard } from '@/utils/mask-sensitive'
import { isPaymentDueWithinWarnWindow } from '@/utils/protocol-payment-labels'
import AmountInput from '@/components/common/amount-input/index.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import {
protocolStatusLabel,
protocolStatusTagType,
payModeLabel,
payStatusLabel,
payStatusTagType,
transferTypeLabel
} from '@/utils/status-tag-registry'
defineOptions({ name: 'CellStoragePaymentInfoTab' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultInstallmentDueDateRange(): [string, string] {
const start = new Date()
const end = new Date()
start.setDate(start.getDate() - 10)
end.setDate(end.getDate() + 20)
return [formatLocalDate(start), formatLocalDate(end)]
}
const activeTab = ref('dfj')
const searchForm = reactive({ keyword: '', transferType: '' })
const installmentDueDateRange = ref<[string, string] | null>(getDefaultInstallmentDueDateRange())
const tableData = ref<Record<string, unknown>[]>([])
const selectedRows = ref<Record<string, unknown>[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const viewVisible = ref(false)
const viewProtocolId = ref<string | null>(null)
const viewRow = ref<CollectionProtocolRow | null>(null)
const refundDialogVisible = ref(false)
const refundSubmitLoading = ref(false)
const refundTargetIds = ref<string[]>([])
const refundFormRef = ref<FormInstance>()
const refundForm = reactive({
refundAmount: null as number | null,
refundRemark: '',
attachments: [] as Array<{
id: string
fileName: string
fileUrl: string
_uploading?: boolean
}>
})
const refundFormRules: FormRules = {
refundAmount: [{ required: true, message: '请填写退费金额', trigger: 'blur' }]
}
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function handleSelectionChange(rows: Record<string, unknown>[]) {
selectedRows.value = rows
}
function handleView(row: Record<string, unknown>) {
const pid = String(row.collectionProtocolId || '')
if (!pid) {
ElMessage.warning('缺少关联协议单,无法查看')
return
}
viewProtocolId.value = pid
viewRow.value = {
id: pid,
protocolNo: row.protocolNo,
protocolStatus: row.protocolStatus,
motherName: row.motherName,
motherIdCard: row.motherIdCard,
newbornName: row.newbornName,
newbornIdCard: row.newbornIdCard
}
viewVisible.value = true
}
function formatDate(d: string) {
if (!d || d.length !== 8) return d || '-'
return `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`
}
function paymentRowClassName({ row }: { row: Record<string, unknown> }) {
if (activeTab.value !== 'dfj') return ''
if (String(row.payStatus || '') !== 'dfj') return ''
return isPaymentDueWithinWarnWindow(row.installmentDueDate) ? 'row-due-soon' : ''
}
function formatAmount(v: unknown): string {
if (v == null || v === '') return '-'
return '¥' + formatMoneyDisplay(v)
}
async function loadData() {
loading.value = true
try {
const res = await fetchPaymentDetailList({
page: pagination.page,
pageSize: pagination.pageSize,
payStatus: activeTab.value,
keyword: searchForm.keyword || undefined,
transferType: searchForm.transferType || undefined,
installmentDueDateStart: installmentDueDateRange.value?.[0] || undefined,
installmentDueDateEnd: installmentDueDateRange.value?.[1] || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleStatusTabChange() {
selectedRows.value = []
pagination.page = 1
loadData()
}
async function handleSubmitReceiptFinance(row: Record<string, unknown>) {
const id = String(row.id || '')
if (!id) return
try {
await ElMessageBox.confirm(
'确认对该缴费明细进行缴费确认?提交后状态变为「缴费确认中」。',
'缴费确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await fetchSubmitReceiptFinance(id)
loadData()
} catch {
/* cancel */
}
}
async function handleBatchSubmitFinance() {
if (!selectedRows.value.length) return
const ids = selectedRows.value.map((r) => String(r.id || '')).filter(Boolean)
if (!ids.length) return
if (activeTab.value === 'dfj') {
try {
await ElMessageBox.confirm(
`确认将选中的 ${ids.length} 条待缴费明细批量缴费确认?`,
'批量缴费确认',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
await fetchSubmitReceiptFinanceBatch(ids)
selectedRows.value = []
loadData()
} catch {
/* cancel */
}
return
}
if (activeTab.value === 'yjf') {
openRefundFinanceDialog(undefined, ids)
}
}
function resetRefundForm(defaultAmount?: number) {
refundForm.refundAmount = defaultAmount ?? null
refundForm.refundRemark = ''
refundForm.attachments = []
refundFormRef.value?.clearValidate()
}
function openRefundFinanceDialog(row?: Record<string, unknown>, batchIds?: string[]) {
const ids = batchIds?.length ? batchIds : row?.id ? [String(row.id)] : []
if (!ids.length) return
refundTargetIds.value = ids
const defaultAmount =
!batchIds?.length && row?.transferAmount != null ? Number(row.transferAmount) : undefined
resetRefundForm(
defaultAmount != null && !Number.isNaN(defaultAmount) ? defaultAmount : undefined
)
refundDialogVisible.value = true
}
function refundAttachmentsToUrls() {
return refundForm.attachments
.filter((f) => f.fileUrl && !f._uploading)
.map((f) => f.fileUrl)
.join(',')
}
function fullFileUrl(url: string) {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
return minioBaseUrl ? `${minioBaseUrl}/${url.replace(/^\//, '')}` : url
}
function isImageUrl(url: string) {
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(url)
}
async function handleRefundAttachmentChange(uploadFile: { raw?: File }) {
const file = uploadFile.raw
if (!file) return
const tempId = `temp_${Date.now()}`
refundForm.attachments.push({
id: tempId,
fileName: file.name,
fileUrl: '',
_uploading: true
})
const idx = refundForm.attachments.length - 1
try {
const fd = new FormData()
fd.append('folder', 'protocol-payment-refund')
fd.append('file', file)
const res = await fetchUploadAttachment(fd)
refundForm.attachments[idx] = {
id: res.id || tempId,
fileName: file.name || res.fileName,
fileUrl: res.fileUrl,
_uploading: false
}
} catch {
ElMessage.error('文件上传失败')
refundForm.attachments.splice(idx, 1)
}
}
function removeRefundAttachment(index: number) {
refundForm.attachments.splice(index, 1)
}
async function submitRefundFinance() {
if (!refundTargetIds.value.length) return
const valid = await refundFormRef.value?.validate().catch(() => false)
if (!valid) return
if (refundForm.attachments.some((f) => f._uploading)) {
ElMessage.warning('附件上传中,请稍候')
return
}
const payload = {
refundAmount: Number(refundForm.refundAmount),
refundRemark: refundForm.refundRemark?.trim() || undefined,
refundAttachments: refundAttachmentsToUrls() || undefined
}
refundSubmitLoading.value = true
try {
if (refundTargetIds.value.length === 1) {
await fetchSubmitRefundFinance(refundTargetIds.value[0], payload)
} else {
await fetchSubmitRefundFinanceBatch({
detailIds: refundTargetIds.value,
...payload
})
}
refundDialogVisible.value = false
selectedRows.value = []
loadData()
} finally {
refundSubmitLoading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
searchForm.transferType = ''
installmentDueDateRange.value = getDefaultInstallmentDueDateRange()
pagination.page = 1
loadData()
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
[searchForm, installmentDueDateRange],
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.work-order-type-tabs {
padding: 0 16px;
margin-bottom: 4px;
}
.filter-form--inline-label :deep(.el-form-item) {
margin-bottom: 0;
min-width: 0;
align-items: center;
}
.filter-form--inline-label :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
padding-right: 8px;
line-height: 36px;
height: 36px;
}
.filter-form--inline-label :deep(.el-form-item__content) {
flex: 1;
min-width: 0;
}
.attachment-upload {
width: 100%;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.upload-card {
width: 120px;
height: 96px;
border: 1px dashed #a7f3d0;
border-radius: 8px;
background: #f0fdf4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
&:hover {
border-color: #10b981;
background: #ecfdf5;
}
}
.upload-card-text {
font-size: 12px;
color: #10b981;
font-weight: 500;
}
.attachment-item {
width: 120px;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 8px;
overflow: hidden;
background: #fff;
&.is-uploading {
opacity: 0.6;
pointer-events: none;
}
}
.attachment-preview {
height: 96px;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.attachment-preview__icon--doc {
color: #64748b;
}
.attachment-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
padding: 6px 8px;
font-size: 12px;
}
.attachment-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-btn {
cursor: pointer;
color: #94a3b8;
&:hover {
color: #ef4444;
}
}
.list-table :deep(tr.row-due-soon > td.el-table__cell) {
background-color: #fee2e2 !important;
color: #991b1b;
}
.list-table :deep(tr.row-due-soon:hover > td.el-table__cell) {
background-color: #fecaca !important;
}
</style>
@@ -1,582 +0,0 @@
<!-- 采集协议单管理 · 协议单 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="protocol-audit-tab">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="协议号/姓名/身份证号/电话"
clearable
/>
</el-form-item>
<el-form-item label="协议状态">
<el-select
v-model="searchForm.protocolStatus"
clearable
placeholder="全部"
style="width: 100%"
>
<el-option label="待生效" value="dsx" />
<el-option label="生效中" value="sxz" />
<el-option label="已过期" value="ygq" />
<el-option label="已作废" value="yzf" />
<el-option label="转科研" value="zky" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="createTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="样本采集时间">
<el-date-picker
v-model="sampleCollectTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>列表为概要信息,详情见弹窗;默认展示近 30 天内创建的协议单。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button 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="openStatusChangeDialog('activate')"
>
协议生效
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedRows.length"
@click="openStatusChangeDialog('expire')"
>
协议过期
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedRows.length"
@click="openStatusChangeDialog('void')"
>
协议作废
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedRows.length"
@click="openStatusChangeDialog('toResearch')"
>
协议转科研
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="protocolNo"
label="协议号"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="protocolStatus"
label="状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="protocolStatusTagType(String(row.protocolStatus || ''))"
size="small"
>{{ protocolStatusLabel(String(row.protocolStatus || '')) }}</el-tag
>
</template>
</el-table-column>
<el-table-column
prop="registerDate"
label="登记日期"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="motherName"
label="母亲姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
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="新生儿性别"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{
newbornSexLabel(String(row.newbornSex || ''))
}}</template>
</el-table-column>
<el-table-column
prop="deliveryMode"
label="分娩方式"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{
deliveryModeLabel(String(row.deliveryMode || ''))
}}</template>
</el-table-column>
<el-table-column
prop="newbornWeightGram"
label="新生儿体重(克)"
min-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="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="openAuditDialog(row)">
<el-icon><CircleCheck /></el-icon>审核
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<ProtocolFormDialog
v-model="formDialogVisible"
:edit-id="formDialogEditId"
:list-row="formDialogRow"
@saved="onFormSaved"
/>
<!-- 审核弹窗 -->
<ProtocolAuditDialog
v-model="auditDialogVisible"
:targets="auditTargets"
@submitted="onAuditSubmitted"
/>
<!-- 状态变更弹窗(生效/过期/作废/转科研) -->
<ProtocolStatusChangeDialog
v-model="statusChangeDialogVisible"
:action-type="statusChangeAction"
:targets="statusChangeTargets"
@submitted="onStatusChangeSubmitted"
/>
<!-- 查看弹窗 -->
<ProtocolViewDialog
v-model="viewVisible"
:protocol-id="viewProtocolId"
:list-row="viewRow"
show-audit-history
show-storage-cert
show-fullmoon-insurance
/>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
View,
Edit,
Delete,
Search,
RefreshLeft,
CircleCheck,
More
} from '@element-plus/icons-vue'
import ProtocolFormDialog from '../protocol/ProtocolFormDialog.vue'
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
import ProtocolAuditDialog, {
type ProtocolAuditTarget
} from '../protocol/ProtocolAuditDialog.vue'
import ProtocolStatusChangeDialog, {
type StatusChangeAction,
type StatusChangeTarget
} from '../protocol/ProtocolStatusChangeDialog.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCollectionProtocolList,
fetchDeleteCollectionProtocol,
type CollectionProtocolRow
} from '@/api/collection-protocol'
import {
protocolStatusLabel,
protocolStatusTagType,
newbornSexLabel,
deliveryModeLabel
} from '@/utils/status-tag-registry'
defineOptions({ name: 'CellStorageProtocolAuditTab' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultCreateTimeRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
/* ====== 列表 ====== */
const searchForm = reactive<{ keyword: string; protocolStatus: string }>({
keyword: '',
protocolStatus: ''
})
const createTimeRange = ref<[string, string] | null>(getDefaultCreateTimeRange())
const sampleCollectTimeRange = ref<[string, string] | null>(null)
const tableData = ref<CollectionProtocolRow[]>([])
const selectedRows = ref<CollectionProtocolRow[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const handleSelectionChange = (rows: CollectionProtocolRow[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchCollectionProtocolList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
protocolStatus: searchForm.protocolStatus || undefined,
createTimeStart: createTimeRange.value?.[0] || undefined,
createTimeEnd: createTimeRange.value?.[1] || undefined,
sampleCollectTimeStart: sampleCollectTimeRange.value?.[0] || undefined,
sampleCollectTimeEnd: sampleCollectTimeRange.value?.[1] || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
searchForm.protocolStatus = ''
createTimeRange.value = getDefaultCreateTimeRange()
sampleCollectTimeRange.value = null
pagination.page = 1
loadData()
}
/* ====== 查看弹窗 ====== */
const viewVisible = ref(false)
const viewProtocolId = ref<string | null>(null)
const viewRow = ref<CollectionProtocolRow | null>(null)
function handleView(row: CollectionProtocolRow) {
viewProtocolId.value = String(row.id)
viewRow.value = row
viewVisible.value = true
}
/* ====== 新增/编辑弹窗 ====== */
const formDialogVisible = ref(false)
const formDialogEditId = ref<string | null>(null)
const formDialogRow = ref<CollectionProtocolRow | null>(null)
function handleAdd() {
formDialogEditId.value = null
formDialogRow.value = null
formDialogVisible.value = true
}
function handleEdit(row: CollectionProtocolRow) {
formDialogEditId.value = row.id
formDialogRow.value = row
formDialogVisible.value = true
}
function onFormSaved() {
loadData()
}
/* ====== 审核 ====== */
const auditDialogVisible = ref(false)
const auditTargets = ref<ProtocolAuditTarget[]>([])
function openAuditDialog(row?: CollectionProtocolRow) {
const rows = row ? [row] : selectedRows.value
if (!rows.length) {
ElMessage.warning('请先选择要审核的协议单')
return
}
auditTargets.value = rows.map((r) => ({
id: String(r.id),
protocolNo: r.protocolNo != null ? String(r.protocolNo) : undefined
}))
auditDialogVisible.value = true
}
function onAuditSubmitted() {
selectedRows.value = []
loadData()
}
/* ====== 状态变更(生效/过期/作废/转科研) ====== */
const statusChangeDialogVisible = ref(false)
const statusChangeAction = ref<StatusChangeAction>('activate')
const statusChangeTargets = ref<StatusChangeTarget[]>([])
function openStatusChangeDialog(action: StatusChangeAction, row?: CollectionProtocolRow) {
const rows = row ? [row] : selectedRows.value
if (!rows.length) {
const labels: Record<StatusChangeAction, string> = {
activate: '协议生效',
expire: '协议过期',
void: '协议作废',
toResearch: '协议转科研'
}
ElMessage.warning(`请先选择要操作的协议单`)
return
}
statusChangeAction.value = action
statusChangeTargets.value = rows.map((r) => ({
id: String(r.id),
protocolNo: r.protocolNo != null ? String(r.protocolNo) : undefined
}))
statusChangeDialogVisible.value = true
}
function onStatusChangeSubmitted() {
selectedRows.value = []
loadData()
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'view': handleView(row); break
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
}
}
/* ====== 删除 ====== */
function handleDelete(row: CollectionProtocolRow) {
ElMessageBox.confirm(`确认删除协议号「${row.protocolNo}」?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await fetchDeleteCollectionProtocol(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 fetchDeleteCollectionProtocol(String(row.id))
}
ElMessage.success('批量删除成功')
selectedRows.value = []
loadData()
}, fullscreenLoadingText.batchDelete)
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
[searchForm, createTimeRange, sampleCollectTimeRange],
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
@@ -1,352 +0,0 @@
<!-- 采集协议单管理 · 样本接收信息 Tab -->
<template>
<div class="sample-reception-info-tab">
<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-form-item>
<el-form-item label="接收结论">
<el-select
v-model="searchForm.acceptConclusion"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="接收" value="js" />
<el-option label="拒收" value="bj" />
<el-option label="待接收确认" value="dqr" />
</el-select>
</el-form-item>
<el-form-item label="通知状态">
<el-select
v-model="searchForm.smsNotified"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="已通知" value="1" />
<el-option label="未通知" value="0" />
</el-select>
</el-form-item>
<el-form-item label="接收时间">
<el-date-picker
v-model="receptionTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>支持按协议号、姓名、样本编码模糊检索;默认展示近 30 天内接收的样本。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@row-dblclick="handleView"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1"
/>
<el-table-column
prop="protocolNo"
label="协议号"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="protocolStatus"
label="协议状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="protocolStatusTagType(String(row.protocolStatus || ''))"
size="small"
>{{ protocolStatusLabel(String(row.protocolStatus || '')) }}</el-tag
>
</template>
</el-table-column>
<el-table-column
prop="motherName"
label="母亲姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="newbornName"
label="新生儿姓名"
min-width="110"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="sampleCode"
label="样本编码"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column
prop="sampleTypeName"
label="样本类型"
min-width="110"
align="center"
header-align="center"
/>
<el-table-column
prop="receptionTime"
label="接收时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
prop="acceptConclusion"
label="接收结论"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="
row.acceptConclusion === 'js'
? 'success'
: row.acceptConclusion === 'bj'
? 'danger'
: 'warning'
"
size="small"
>
{{
row.acceptConclusionLabel ||
(row.acceptConclusion === 'js'
? '接收'
: row.acceptConclusion === 'bj'
? '拒收'
: '待接收确认')
}}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="smsNotified"
label="通知状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="row.smsNotified == 1 ? 'success' : 'info'" size="small">
{{ row.smsNotified == 1 ? '已通知' : '未通知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="360"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="handleExtractSms(row)">
<el-icon><ChatDotRound /></el-icon>提取短信
</el-button>
<el-button class="btn-op-item" @click="handleToggleNotified(row)">
<el-icon><Bell /></el-icon>{{ row.smsNotified == 1 ? '标记未通知' : '标记已通知' }}
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<ProtocolViewDialog
v-model="viewVisible"
:protocol-id="viewProtocolId"
:list-row="viewRow"
show-audit-history
show-storage-cert
show-fullmoon-insurance
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, RefreshLeft, View, ChatDotRound, Bell } from '@element-plus/icons-vue'
import { useClipboard } from '@vueuse/core'
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
import {
fetchSampleSmsNotificationList,
fetchToggleSmsNotified,
fetchGenerateSms
} from '@/api/sample-sms-notification'
import { type CollectionProtocolRow } from '@/api/collection-protocol'
import { protocolStatusLabel, protocolStatusTagType } from '@/utils/status-tag-registry'
defineOptions({ name: 'CellStorageSampleReceptionInfoTab' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultReceptionTimeRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
const searchForm = reactive({ keyword: '', acceptConclusion: '', smsNotified: '' })
const receptionTimeRange = ref<[string, string] | null>(getDefaultReceptionTimeRange())
const tableData = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const viewVisible = ref(false)
const viewProtocolId = ref<string | null>(null)
const viewRow = ref<CollectionProtocolRow | null>(null)
const { copy } = useClipboard()
async function loadData() {
loading.value = true
try {
const result = await fetchSampleSmsNotificationList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
acceptConclusion: searchForm.acceptConclusion || undefined,
smsNotified: searchForm.smsNotified || undefined,
receptionTimeStart: receptionTimeRange.value?.[0] || undefined,
receptionTimeEnd: receptionTimeRange.value?.[1] || undefined
})
const res = (result as any) || {}
tableData.value = res.list || []
pagination.total = res.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
Object.assign(searchForm, { keyword: '', acceptConclusion: '', smsNotified: '' })
receptionTimeRange.value = getDefaultReceptionTimeRange()
handleSearch()
}
function handleView(row: Record<string, unknown>) {
const pid = String(row.collectionProtocolId || row.protocolId || '')
if (!pid) {
ElMessage.warning('缺少关联协议单,无法查看')
return
}
viewProtocolId.value = pid
viewRow.value = {
id: pid,
protocolNo: row.protocolNo,
motherName: row.motherName,
newbornName: row.newbornName
}
viewVisible.value = true
}
async function handleExtractSms(row: any) {
try {
const result = await fetchGenerateSms(row.id, row.sampleReceptionId)
const content = (result as any)?.smsContent || ''
if (!content) {
ElMessage.warning('短信内容为空')
return
}
await copy(content)
ElMessage.success('短信内容已复制到剪贴板')
} catch {
ElMessage.error('提取短信失败')
}
}
async function handleToggleNotified(row: any) {
const newVal = row.smsNotified == 1 ? 0 : 1
try {
await fetchToggleSmsNotified(row.id, {
smsNotified: newVal,
sampleReceptionId: row.sampleReceptionId
})
ElMessage.success(newVal === 1 ? '已标记为已通知' : '已标记为未通知')
loadData()
} catch {
ElMessage.error('操作失败')
}
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
[searchForm, receptionTimeRange],
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
@@ -1,879 +0,0 @@
<!-- 采集协议单管理 · 储存证 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="storage-cert-tab">
<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-form-item>
<el-form-item label="制作状态">
<el-select
v-model="searchForm.makeStatus"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="未制作" value="0" />
<el-option label="已制作" value="1" />
</el-select>
</el-form-item>
<el-form-item label="入库日期">
<el-date-picker
v-model="storageInDateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>支持按协议号、姓名模糊检索;制作状态默认未制作,入库日期默认近 30 天。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
:row-class-name="storageCertRowClassName"
@row-dblclick="handleView"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1"
/>
<el-table-column
prop="protocolNo"
label="采集协议号"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
prop="protocolStatus"
label="协议状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="protocolStatusTagType(String(row.protocolStatus || ''))"
size="small"
>{{ protocolStatusLabel(String(row.protocolStatus || '')) }}</el-tag
>
</template>
</el-table-column>
<el-table-column
prop="motherName"
label="母亲姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="motherPhone"
label="母亲联系方式"
min-width="130"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.motherPhone || '—' }}</template>
</el-table-column>
<el-table-column
prop="motherIdCard"
label="身份证号"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ maskIdCard(row.motherIdCard) }}</template>
</el-table-column>
<el-table-column
prop="newbornInfo"
label="新生儿信息"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="sampleInfo"
label="样本信息"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="storageInDate"
label="入库日期"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatStorageInDate(row.storageInDate) }}</template>
</el-table-column>
<el-table-column
prop="makeStatus"
label="制作状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="row.makeStatus == '1' ? 'success' : 'warning'" size="small">
{{ row.makeStatus == '1' ? '已制作' : '未制作' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="makeDate"
label="制作日期"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatStorageInDate(row.makeDate) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="260"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="handleUpload(row)">
<el-icon><Upload /></el-icon>上传储存证
</el-button>
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<!-- 上传储存证弹窗 -->
<el-dialog
v-model="uploadDialogVisible"
title="上传储存证"
width="720px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="uploadFormRef"
:model="uploadForm"
:rules="uploadFormRules"
label-position="top"
class="storage-cert-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="采集协议号">
<el-input :model-value="uploadRow?.protocolNo" disabled />
</el-form-item>
<el-form-item label="母亲姓名">
<el-input :model-value="uploadRow?.motherName" disabled />
</el-form-item>
</div>
<el-form-item label="储存证附件" class="form-col-span-2">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
ref="certAttachmentUploadRef"
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
@change="handleCertAttachmentChange"
>
<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 certAttachments"
: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="removeCertAttachment(index)"
><Delete
/></el-icon>
</div>
</div>
</div>
</div>
<span class="upload-hint">支持图片、视频、PDF、Word、Excel等文件</span>
</div>
</el-form-item>
<div class="form-grid-2">
<el-form-item label="制作日期" prop="makeDate">
<el-date-picker
v-model="uploadForm.makeDate"
type="date"
format="YYYYMMDD"
value-format="YYYYMMDD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="uploadForm.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="uploadDialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="submitLoading"
@click="handleSubmitUpload"
>
<el-icon><Check /></el-icon>确定
</el-button>
</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>
<!-- 查看弹窗(与满月待办管理等一致:审核+储存证+保险) -->
<ProtocolViewDialog
v-model="viewVisible"
:protocol-id="viewProtocolId"
:list-row="viewRow"
show-audit-history
show-storage-cert
show-fullmoon-insurance
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import {
Plus,
Close,
Check,
Search,
RefreshLeft,
View,
Upload,
Delete,
Document,
VideoPlay,
Loading,
Download
} from '@element-plus/icons-vue'
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
import { fetchStorageCertList } from '@/api/admin'
import {
fetchUpdateCollectionProtocol,
type CollectionProtocolRow
} from '@/api/collection-protocol'
import { fetchUploadAttachment } from '@/api/business'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import { maskIdCard } from '@/utils/mask-sensitive'
import { protocolStatusLabel, protocolStatusTagType } from '@/utils/status-tag-registry'
interface CertAttachmentItem {
id: string
fileName: string
fileUrl: string
fileSize?: number
fileSizeDisplay?: string
fileType?: string
_uploading?: boolean
}
defineOptions({ name: 'CellStorageStorageCertTab' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultStorageInDateRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
// ---------------------------------------------------------------------------
// 搜索 & 主表
// ---------------------------------------------------------------------------
const searchForm = reactive({ keyword: '', makeStatus: '0' })
const storageInDateRange = ref<[string, string] | null>(getDefaultStorageInDateRange())
const tableData = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
// ---------------------------------------------------------------------------
// 上传弹窗
// ---------------------------------------------------------------------------
const uploadDialogVisible = ref(false)
const uploadRow = ref<any>(null)
const uploadFormRef = ref()
const certAttachmentUploadRef = ref()
const certAttachments = ref<CertAttachmentItem[]>([])
const submitLoading = ref(false)
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const uploadForm = reactive({
makeDate: '',
remark: ''
})
const uploadFormRules = {
makeDate: [{ required: true, message: '请选择制作日期', trigger: 'change' }]
}
const previewVisible = ref(false)
const previewUrl = ref('')
const previewType = ref<'image' | 'video'>('image')
// ---------------------------------------------------------------------------
// 查看弹窗(与满月待办管理等一致)
// ---------------------------------------------------------------------------
const viewVisible = ref(false)
const viewProtocolId = ref<string | null>(null)
const viewRow = ref<CollectionProtocolRow | null>(null)
/** 新生儿已满月且储存证未制作时标红(列表仅含已生效协议) */
function isStorageCertMakePendingRow(row: Record<string, unknown>) {
if (String(row.makeStatus ?? '0') === '1') return false
const delivery = String(row.newbornDeliveryTime || '').trim()
if (!delivery) return false
const deliveryDate = new Date(delivery.replace(/-/g, '/'))
if (Number.isNaN(deliveryDate.getTime())) return false
const fullmoon = new Date(deliveryDate)
fullmoon.setDate(fullmoon.getDate() + 30)
const today = new Date()
today.setHours(0, 0, 0, 0)
fullmoon.setHours(0, 0, 0, 0)
return fullmoon <= today
}
function storageCertRowClassName({ row }: { row: Record<string, unknown> }) {
return isStorageCertMakePendingRow(row) ? 'row-storage-cert-pending' : ''
}
function formatStorageInDate(val: unknown) {
const s = String(val || '').trim()
if (!s) return '—'
const digits = s.replace(/\D/g, '')
if (digits.length >= 8) {
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`
}
return s
}
// ---------------------------------------------------------------------------
// 数据加载
// ---------------------------------------------------------------------------
async function loadData() {
loading.value = true
try {
const res = await fetchStorageCertList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
makeStatus: searchForm.makeStatus || undefined,
storageInDateStart: storageInDateRange.value?.[0] || undefined,
storageInDateEnd: storageInDateRange.value?.[1] || undefined
})
tableData.value = (res as any)?.list || []
pagination.total = (res as any)?.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
searchForm.makeStatus = '0'
storageInDateRange.value = getDefaultStorageInDateRange()
pagination.page = 1
loadData()
}
// ---------------------------------------------------------------------------
// 上传储存证
// ---------------------------------------------------------------------------
function fullFileUrl(suffix: string) {
if (!suffix) return ''
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/')) {
return suffix
}
return `${minioBaseUrl}/${suffix}`
}
function isImageFile(file: CertAttachmentItem) {
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: CertAttachmentItem) {
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/')
)
}
function parseCertUrlsToAttachments(urls: string): CertAttachmentItem[] {
return urls
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((url, index) => {
const rawName = url.split('/').pop() || ''
const fileName = decodeURIComponent(rawName) || `附件${index + 1}`
return {
id: `existing_${index}`,
fileName,
fileUrl: url,
fileType: '',
_uploading: false
}
})
}
function certAttachmentsToUrls() {
return certAttachments.value
.filter((f) => f.fileUrl && !f._uploading)
.map((f) => f.fileUrl)
.join(',')
}
async function handleCertAttachmentChange(uploadFile: any) {
const file = uploadFile.raw || uploadFile
if (!file) return
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
certAttachments.value.push({
id: tempId,
fileName: file.name,
fileUrl: '',
fileSize: file.size,
fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
fileType: file.type || '',
_uploading: true
})
const idx = certAttachments.value.length - 1
try {
const fd = new FormData()
fd.append('folder', 'storage-cert')
fd.append('file', file)
const res = await fetchUploadAttachment(fd)
certAttachments.value[idx] = {
...certAttachments.value[idx],
id: res.id || tempId,
fileName: file.name || res.fileName,
fileUrl: res.fileUrl,
fileSize: res.fileSize || file.size,
fileSizeDisplay: res.fileSizeDisplay || `${(file.size / 1024).toFixed(1)} KB`,
fileType: res.fileType || file.type || '',
_uploading: false
}
} catch {
ElMessage.error('文件上传失败')
certAttachments.value.splice(idx, 1)
} finally {
nextTick(() => certAttachmentUploadRef.value?.clearFiles())
}
}
function removeCertAttachment(index: number) {
certAttachments.value.splice(index, 1)
}
function handlePreviewAttachment(file: CertAttachmentItem) {
if (file._uploading) return
previewUrl.value = fullFileUrl(file.fileUrl)
previewType.value = isVideoFile(file) ? 'video' : 'image'
previewVisible.value = true
}
function handleDownloadAttachment(file: CertAttachmentItem) {
downloadAttachmentFile(file, { fullFileUrl })
}
function handleUpload(row: any) {
uploadRow.value = row
certAttachments.value = parseCertUrlsToAttachments(String(row.certImageUrls || ''))
uploadForm.makeDate = String(row.makeDate || '').trim() || getTodayStr()
uploadForm.remark = String(row.remark || '').trim()
uploadDialogVisible.value = true
}
async function handleSubmitUpload() {
if (certAttachments.value.some((f) => f._uploading)) {
ElMessage.warning('附件上传中,请稍候')
return
}
const uploaded = certAttachments.value.filter((f) => f.fileUrl && !f._uploading)
const hasAttachments = uploaded.length > 0
if (hasAttachments) {
const valid = await uploadFormRef.value?.validate?.().catch(() => false)
if (!valid) return
}
submitLoading.value = true
try {
const protocolId = uploadRow.value?.protocolId || uploadRow.value?.id
await fetchUpdateCollectionProtocol(protocolId, {
storageCert: {
makeStatus: hasAttachments ? '1' : '0',
certImageUrls: hasAttachments ? certAttachmentsToUrls() : '',
makeDate: hasAttachments ? uploadForm.makeDate : '',
remark: uploadForm.remark?.trim() || undefined
}
})
ElMessage.success(hasAttachments ? '上传成功' : '保存成功')
uploadDialogVisible.value = false
await loadData()
} catch {
/* 全局已处理 */
} finally {
submitLoading.value = false
}
}
function handleView(row: any) {
viewProtocolId.value = String(row.protocolId || row.id)
viewRow.value = row as CollectionProtocolRow
viewVisible.value = true
}
// ---------------------------------------------------------------------------
// 工具函数
// ---------------------------------------------------------------------------
function getTodayStr(): string {
const d = new Date()
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${yyyy}${mm}${dd}`
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
[searchForm, storageInDateRange],
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.storage-cert-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.form-col-span-2 {
grid-column: 1 / -1;
}
.attachment-upload {
width: 100%;
}
.upload-card {
width: 140px;
height: 96px;
border: 1px dashed #a7f3d0;
border-radius: 8px;
background: #f0fdf4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s;
&:hover {
border-color: #10b981;
border-style: solid;
background: #ecfdf5;
}
}
.upload-card-text {
font-size: 12px;
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;
}
.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;
}
.list-table :deep(tr.row-storage-cert-pending > td.el-table__cell) {
background-color: #fee2e2 !important;
color: #991b1b;
}
.list-table :deep(tr.row-storage-cert-pending:hover > td.el-table__cell) {
background-color: #fecaca !important;
}
</style>
@@ -1,345 +0,0 @@
<!-- 采集协议单管理 · 样本入库信息 Tab -->
<template>
<div class="storage-entry-info-tab">
<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-form-item>
<el-form-item label="入库状态">
<el-select
v-model="searchForm.storageStatus"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="已入库" value="yrk" />
<el-option label="拒绝入库" value="btg" />
<el-option label="待入库确认" value="drk" />
</el-select>
</el-form-item>
<el-form-item label="通知状态">
<el-select
v-model="searchForm.smsNotified"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="已通知" value="1" />
<el-option label="未通知" value="0" />
</el-select>
</el-form-item>
<el-form-item label="入库时间">
<el-date-picker
v-model="storageTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>支持按协议号、姓名、样本编码模糊检索;默认展示近 30 天内入库的样本。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@row-dblclick="handleView"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1"
/>
<el-table-column
prop="protocolNo"
label="协议号"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="protocolStatus"
label="协议状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="protocolStatusTagType(String(row.protocolStatus || ''))"
size="small"
>{{ protocolStatusLabel(String(row.protocolStatus || '')) }}</el-tag
>
</template>
</el-table-column>
<el-table-column
prop="motherName"
label="母亲姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="newbornName"
label="新生儿姓名"
min-width="110"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="sampleCode"
label="样本编码"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column
prop="sampleTypeName"
label="样本类型"
min-width="110"
align="center"
header-align="center"
/>
<el-table-column
prop="storageTime"
label="入库时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
prop="storageStatusLabel"
label="入库状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="
row.storageStatusLabel === '已入库'
? 'success'
: row.storageStatusLabel === '拒绝入库'
? 'danger'
: 'warning'
"
size="small"
>
{{ row.storageStatusLabel }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="smsNotified"
label="通知状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="row.smsNotified == 1 ? 'success' : 'info'" size="small">
{{ row.smsNotified == 1 ? '已通知' : '未通知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="360"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="handleExtractSms(row)">
<el-icon><ChatDotRound /></el-icon>提取短信
</el-button>
<el-button class="btn-op-item" @click="handleToggleNotified(row)">
<el-icon><Bell /></el-icon>{{ row.smsNotified == 1 ? '标记未通知' : '标记已通知' }}
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<ProtocolViewDialog
v-model="viewVisible"
:protocol-id="viewProtocolId"
:list-row="viewRow"
show-audit-history
show-storage-cert
show-fullmoon-insurance
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, RefreshLeft, View, ChatDotRound, Bell } from '@element-plus/icons-vue'
import { useClipboard } from '@vueuse/core'
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
import {
fetchStorageEntrySmsList,
fetchToggleStorageEntrySmsNotified,
fetchGenerateStorageEntrySms
} from '@/api/storage-entry-sms-notification'
import { type CollectionProtocolRow } from '@/api/collection-protocol'
import { protocolStatusLabel, protocolStatusTagType } from '@/utils/status-tag-registry'
defineOptions({ name: 'CellStorageStorageEntryInfoTab' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultStorageTimeRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
const searchForm = reactive({ keyword: '', storageStatus: '', smsNotified: '' })
const storageTimeRange = ref<[string, string] | null>(getDefaultStorageTimeRange())
const tableData = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const viewVisible = ref(false)
const viewProtocolId = ref<string | null>(null)
const viewRow = ref<CollectionProtocolRow | null>(null)
const { copy } = useClipboard()
async function loadData() {
loading.value = true
try {
const result = await fetchStorageEntrySmsList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
storageStatus: searchForm.storageStatus || undefined,
smsNotified: searchForm.smsNotified || undefined,
storageTimeStart: storageTimeRange.value?.[0] || undefined,
storageTimeEnd: storageTimeRange.value?.[1] || undefined
})
const res = (result as any) || {}
tableData.value = res.list || []
pagination.total = res.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
Object.assign(searchForm, { keyword: '', storageStatus: '', smsNotified: '' })
storageTimeRange.value = getDefaultStorageTimeRange()
handleSearch()
}
function handleView(row: Record<string, unknown>) {
const pid = String(row.collectionProtocolId || row.protocolId || '')
if (!pid) {
ElMessage.warning('缺少关联协议单,无法查看')
return
}
viewProtocolId.value = pid
viewRow.value = {
id: pid,
protocolNo: row.protocolNo,
motherName: row.motherName,
newbornName: row.newbornName
}
viewVisible.value = true
}
async function handleExtractSms(row: any) {
try {
const result = await fetchGenerateStorageEntrySms(row.id, row.storageReviewId)
const content = (result as any)?.smsContent || ''
if (!content) {
ElMessage.warning('短信内容为空')
return
}
await copy(content)
ElMessage.success('短信内容已复制到剪贴板')
} catch {
ElMessage.error('提取短信失败')
}
}
async function handleToggleNotified(row: any) {
const newVal = row.smsNotified == 1 ? 0 : 1
try {
await fetchToggleStorageEntrySmsNotified(row.id, {
smsNotified: newVal,
storageReviewId: row.storageReviewId
})
ElMessage.success(newVal === 1 ? '已标记为已通知' : '已标记为未通知')
loadData()
} catch {
ElMessage.error('操作失败')
}
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
[searchForm, storageTimeRange],
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
@@ -1,225 +0,0 @@
<!-- 采集协议单管理(协议单 / 缴费 / 样本接收 / 入库 / 储存证 / 满月待办) -->
<template>
<div class="page-container">
<section class="panel">
<div
class="panel-entity-switch panel-entity-switch--multi"
role="tablist"
aria-label="视图类型"
>
<button
v-for="item in entityTabs"
:key="item.key"
type="button"
class="entity-switch-card"
:class="{ 'is-active': activeTab === item.key }"
role="tab"
:aria-selected="activeTab === item.key"
@click="activeTab = item.key"
>
<span class="entity-switch-card__icon"
><el-icon><component :is="item.icon" /></el-icon
></span>
<span class="entity-switch-card__main">
<span class="entity-switch-card__title">{{ item.title }}</span>
<span class="entity-switch-card__desc">{{ item.desc }}</span>
</span>
</button>
</div>
<ProtocolAuditTab v-if="activeTab === 'protocol'" />
<PaymentInfoTab v-else-if="activeTab === 'payment-info'" />
<SampleReceptionInfoTab v-else-if="activeTab === 'sample-reception'" />
<StorageEntryInfoTab v-else-if="activeTab === 'storage-entry'" />
<StorageCertTab v-else-if="activeTab === 'storage-cert'" />
<FullmoonTodoTab v-else-if="activeTab === 'fullmoon-todo'" />
</section>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { Document, Wallet, Box, FolderOpened, Medal, Calendar } from '@element-plus/icons-vue'
import ProtocolAuditTab from './ProtocolAuditTab.vue'
import PaymentInfoTab from './PaymentInfoTab.vue'
import SampleReceptionInfoTab from './SampleReceptionInfoTab.vue'
import StorageEntryInfoTab from './StorageEntryInfoTab.vue'
import StorageCertTab from './StorageCertTab.vue'
import FullmoonTodoTab from './FullmoonTodoTab.vue'
defineOptions({ name: 'CellStorageProtocolAudit' })
const VALID_TABS = [
'protocol',
'payment-info',
'sample-reception',
'storage-entry',
'storage-cert',
'fullmoon-todo'
] as const
type EntityTabKey = (typeof VALID_TABS)[number]
const entityTabs: Array<{
key: EntityTabKey
title: string
desc: string
icon: Component
}> = [
{
key: 'protocol',
title: '协议单管理',
desc: '协议审核与状态维护',
icon: Document
},
{
key: 'payment-info',
title: '缴费信息',
desc: '缴费计划与到账查询',
icon: Wallet
},
{
key: 'sample-reception',
title: '样本接收信息',
desc: '接收结论与短信通知',
icon: Box
},
{
key: 'storage-entry',
title: '样本入库信息',
desc: '样本入库记录查询',
icon: FolderOpened
},
{
key: 'storage-cert',
title: '储存证管理',
desc: '储存证开具与管理',
icon: Medal
},
{
key: 'fullmoon-todo',
title: '满月待办管理',
desc: '满月保险待办办理',
icon: Calendar
}
]
const route = useRoute()
const activeTab = ref<EntityTabKey>('protocol')
onMounted(() => {
const tab = String(route.query.tab || '')
if (VALID_TABS.includes(tab as EntityTabKey)) {
activeTab.value = tab as EntityTabKey
}
})
</script>
<style scoped lang="scss">
.panel-entity-switch {
display: flex;
justify-content: center;
gap: 12px;
padding: 16px 16px 12px;
}
.panel-entity-switch--multi {
flex-wrap: wrap;
gap: 10px;
.entity-switch-card {
flex: 0 1 168px;
min-width: 152px;
max-width: 180px;
padding: 10px 12px;
gap: 8px;
}
.entity-switch-card__icon {
width: 32px;
height: 32px;
font-size: 17px;
}
.entity-switch-card__title {
font-size: 13px;
}
.entity-switch-card__desc {
font-size: 11px;
line-height: 1.35;
}
}
.entity-switch-card {
display: flex;
align-items: center;
gap: 12px;
min-width: 220px;
max-width: 280px;
flex: 1;
padding: 14px 18px;
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: 10px;
background: var(--surface, #fff);
cursor: pointer;
text-align: left;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
&:hover:not(.is-active) {
border-color: #a7f3d0;
background: #f8fafc;
}
&.is-active {
border-color: var(--primary, #10b981);
background: var(--primary-bg, #ecfdf5);
box-shadow: 0 0 0 1px rgb(16 185 129 / 12%);
}
}
.entity-switch-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 8px;
background: #f1f5f9;
color: #64748b;
font-size: 20px;
.entity-switch-card.is-active & {
background: #fff;
color: var(--primary-dark, #059669);
}
}
.entity-switch-card__main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.entity-switch-card__title {
font-size: 14px;
font-weight: 600;
color: var(--text, #1e293b);
line-height: 1.4;
.entity-switch-card.is-active & {
color: var(--primary-dark, #059669);
}
}
.entity-switch-card__desc {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
}
</style>
@@ -1,208 +0,0 @@
<!-- 采集协议单 — 只读附件列表(样式对齐新增/编辑弹窗 attachment-upload) -->
<template>
<span v-if="!files.length" class="protocol-attachment-empty">暂无附件</span>
<div v-else class="attachment-upload">
<div class="attachment-list">
<div
v-for="(file, index) in files"
:key="file.id || file.fileUrl || index"
class="attachment-item"
>
<div
class="attachment-preview"
@click="isImageFile(file) && file.fileUrl ? emit('preview', file) : undefined"
>
<template v-if="isImageFile(file) && file.fileUrl">
<img :src="resolveFileUrl(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="emit('download', file)">
<Download />
</el-icon>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Document, Download, VideoPlay } from '@element-plus/icons-vue'
defineProps<{
files: Array<Record<string, any>>
}>()
const emit = defineEmits<{
(e: 'preview', file: Record<string, any>): void
(e: 'download', file: Record<string, any>): void
}>()
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function resolveFileUrl(suffix: string) {
if (!suffix) return ''
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/')) {
return suffix
}
return `${minioBaseUrl}/${suffix}`
}
function isImageFile(file: { fileType?: string; fileName?: string; fileUrl?: string }) {
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: { fileType?: string; fileName?: string; fileUrl?: string }) {
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/')
)
}
</script>
<style scoped lang="scss">
.protocol-attachment-empty {
font-size: 13px;
color: #94a3b8;
}
.attachment-upload {
width: 100%;
}
.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);
}
}
.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;
}
.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;
}
}
</style>
@@ -1,165 +0,0 @@
<!-- 采集协议单 — 提交审核(支持批量,写入 biz_protocol_audit) -->
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="resetForm"
>
<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].protocolNo || '—' }}</strong>
</p>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="biz-dialog-form"
@submit.prevent
>
<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-group>
</el-form-item>
<el-form-item label="审核备注">
<el-input
v-model="form.auditRemark"
type="textarea"
:rows="3"
placeholder="可选"
maxlength="1000"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
</template>
<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 { fetchSubmitProtocolAudit } from '@/api/collection-protocol'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
export type ProtocolAuditTarget = {
id: string
protocolNo?: string
}
defineOptions({ name: 'ProtocolAuditDialog' })
const props = defineProps<{
modelValue: boolean
targets: ProtocolAuditTarget[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'submitted'): void
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
auditStatus: 'shtg',
auditRemark: ''
})
const rules: FormRules = {
auditStatus: [{ required: true, message: '请选择审核状态', trigger: 'change' }]
}
const dialogTitle = computed(() =>
props.targets.length > 1 ? `批量审核(${props.targets.length} 条)` : '协议单审核'
)
function resetForm() {
form.auditStatus = 'shtg'
form.auditRemark = ''
formRef.value?.clearValidate()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
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 () => {
let ok = 0
const failed: string[] = []
for (const row of rows) {
try {
await fetchSubmitProtocolAudit(String(row.id), payload, {
showSuccessMessage: !isBatch
})
ok += 1
} catch {
failed.push(row.protocolNo || 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')
}
},
isBatch ? fullscreenLoadingText.batchAudit : fullscreenLoadingText.save
)
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
.audit-target-hint {
margin: 0 0 16px;
font-size: 13px;
line-height: 1.5;
color: var(--mint-text-muted, #64748b);
strong {
color: #1e293b;
font-weight: 600;
}
}
</style>
File diff suppressed because it is too large. Load diff
@@ -1,269 +0,0 @@
<!-- 采集协议单 — 状态变更弹窗(协议生效 / 协议过期 / 协议作废 / 协议转科研) -->
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="resetForm"
>
<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].protocolNo || '—' }}</strong>
</p>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="biz-dialog-form"
@submit.prevent
>
<el-form-item :label="timeLabel" prop="changeTime">
<el-date-picker
v-model="form.changeTime"
type="datetime"
placeholder="请选择时间"
style="width: 100%"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item :label="descLabel">
<el-input
v-model="form.changeDesc"
type="textarea"
:rows="3"
placeholder="可选"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
</template>
<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 { fetchUpdateCollectionProtocol } from '@/api/collection-protocol'
import { fetchConvertFromStorage } from '@/api/research-donation'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
export type StatusChangeAction = 'activate' | 'expire' | 'void' | 'toResearch'
export type StatusChangeTarget = {
id: string
protocolNo?: string
}
defineOptions({ name: 'ProtocolStatusChangeDialog' })
const props = defineProps<{
modelValue: boolean
actionType: StatusChangeAction
targets: StatusChangeTarget[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'submitted'): void
}>()
const ACTION_CONFIG: Record<
StatusChangeAction,
{
label: string
timeLabel: string
descLabel: string
status: string
timeKey: string
descKey: string
}
> = {
activate: {
label: '协议生效',
timeLabel: '协议生效时间',
descLabel: '协议生效描述',
status: 'sxz',
timeKey: 'effectiveTime',
descKey: 'effectiveDesc'
},
expire: {
label: '协议过期',
timeLabel: '协议过期时间',
descLabel: '协议过期描述',
status: 'ygq',
timeKey: 'expireTime',
descKey: 'expireDesc'
},
void: {
label: '协议作废',
timeLabel: '协议作废时间',
descLabel: '协议作废描述',
status: 'yzf',
timeKey: 'voidTime',
descKey: 'voidDesc'
},
toResearch: {
label: '协议转科研',
timeLabel: '协议转科研时间',
descLabel: '协议转科研描述',
status: 'zky',
timeKey: 'toResearchTime',
descKey: 'toResearchDesc'
}
}
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 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({
changeTime: '',
changeDesc: ''
})
const rules: FormRules = {
changeTime: [{ required: true, message: '请选择时间', trigger: 'change' }]
}
function resetForm() {
form.changeTime = ''
form.changeDesc = ''
formRef.value?.clearValidate()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || !props.targets.length) return
const cfg = config.value
const rows = [...props.targets]
const isBatch = rows.length > 1
// 转科研:确认 + 调用专用接口
if (props.actionType === 'toResearch') {
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 fetchConvertFromStorage(String(row.id))
ok += 1
} catch {
failed.push(row.protocolNo || 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 payload: Record<string, unknown> = {
protocolStatus: cfg.status,
[cfg.timeKey]: form.changeTime,
[cfg.descKey]: form.changeDesc.trim() || undefined
}
submitting.value = true
try {
await withFullscreenLoading(
async () => {
let ok = 0
const failed: string[] = []
for (const row of rows) {
try {
await fetchUpdateCollectionProtocol(String(row.id), payload)
ok += 1
} catch {
failed.push(row.protocolNo || 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
}
}
</script>
<style scoped lang="scss">
.status-change-hint {
margin: 0 0 16px;
font-size: 13px;
line-height: 1.5;
color: var(--mint-text-muted, #64748b);
strong {
color: #1e293b;
font-weight: 600;
}
}
</style>
File diff suppressed because it is too large. Load diff
@@ -1,467 +0,0 @@
<!-- 采集协议单录入 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>协议单列表</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-form-item>
<el-form-item label="协议状态">
<el-select
v-model="searchForm.protocolStatus"
clearable
placeholder="全部"
style="width: 100%"
>
<el-option label="待生效" value="dsx" />
<el-option label="生效中" value="sxz" />
<el-option label="已过期" value="ygq" />
<el-option label="已作废" value="yzf" />
<el-option label="转科研" value="zky" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="createTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="样本采集时间">
<el-date-picker
v-model="sampleCollectTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>列表为概要信息,详情见弹窗;默认展示近 30 天内创建的协议单。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button type="primary" class="btn-main" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="protocolNo"
label="协议号"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="protocolStatus"
label="状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="protocolStatusTagType(String(row.protocolStatus || ''))"
size="small"
>{{ protocolStatusLabel(String(row.protocolStatus || '')) }}</el-tag
>
</template>
</el-table-column>
<el-table-column
prop="registerDate"
label="登记日期"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="motherName"
label="母亲姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
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="新生儿性别"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{
newbornSexLabel(String(row.newbornSex || ''))
}}</template>
</el-table-column>
<el-table-column
prop="deliveryMode"
label="分娩方式"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{
deliveryModeLabel(String(row.deliveryMode || ''))
}}</template>
</el-table-column>
<el-table-column
prop="newbornWeightGram"
label="新生儿体重(克)"
min-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="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<ProtocolFormDialog
v-model="formDialogVisible"
:edit-id="formDialogEditId"
:list-row="formDialogRow"
@saved="onFormSaved"
/>
<!-- 查看弹窗 -->
<ProtocolViewDialog
v-model="viewVisible"
:protocol-id="viewProtocolId"
:list-row="viewRow"
show-audit-history
show-storage-cert
show-fullmoon-insurance
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, View, Edit, Delete, Search, RefreshLeft, More } from '@element-plus/icons-vue'
import ProtocolFormDialog from './ProtocolFormDialog.vue'
import ProtocolViewDialog from './ProtocolViewDialog.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCollectionProtocolList,
fetchDeleteCollectionProtocol,
type CollectionProtocolRow
} from '@/api/collection-protocol'
import {
protocolStatusLabel,
protocolStatusTagType,
newbornSexLabel,
deliveryModeLabel
} from '@/utils/status-tag-registry'
defineOptions({ name: 'CellStorageProtocol' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultCreateTimeRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
/* ====== 列表 ====== */
const searchForm = reactive<{ keyword: string; protocolStatus: string }>({
keyword: '',
protocolStatus: ''
})
const createTimeRange = ref<[string, string] | null>(getDefaultCreateTimeRange())
const sampleCollectTimeRange = ref<[string, string] | null>(null)
const tableData = ref<CollectionProtocolRow[]>([])
const selectedRows = ref<CollectionProtocolRow[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const handleSelectionChange = (rows: CollectionProtocolRow[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchCollectionProtocolList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
protocolStatus: searchForm.protocolStatus || undefined,
createTimeStart: createTimeRange.value?.[0] || undefined,
createTimeEnd: createTimeRange.value?.[1] || undefined,
sampleCollectTimeStart: sampleCollectTimeRange.value?.[0] || undefined,
sampleCollectTimeEnd: sampleCollectTimeRange.value?.[1] || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
searchForm.protocolStatus = ''
createTimeRange.value = getDefaultCreateTimeRange()
sampleCollectTimeRange.value = null
pagination.page = 1
loadData()
}
/* ====== 查看弹窗 ====== */
const viewVisible = ref(false)
const viewProtocolId = ref<string | null>(null)
const viewRow = ref<CollectionProtocolRow | null>(null)
function handleView(row: CollectionProtocolRow) {
viewProtocolId.value = String(row.id)
viewRow.value = row
viewVisible.value = true
}
/* ====== 新增/编辑弹窗 ====== */
const formDialogVisible = ref(false)
const formDialogEditId = ref<string | null>(null)
const formDialogRow = ref<CollectionProtocolRow | null>(null)
function handleAdd() {
formDialogEditId.value = null
formDialogRow.value = null
formDialogVisible.value = true
}
function handleEdit(row: CollectionProtocolRow) {
formDialogEditId.value = row.id
formDialogRow.value = row
formDialogVisible.value = true
}
function onFormSaved() {
loadData()
}
/* ====== 删除 ====== */
function handleDropdownCommand(cmd: string, row: CollectionProtocolRow) {
if (cmd === 'edit') handleEdit(row)
else if (cmd === 'delete') handleDelete(row)
}
function handleDelete(row: CollectionProtocolRow) {
ElMessageBox.confirm(`确认删除协议号「${row.protocolNo}」?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await fetchDeleteCollectionProtocol(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 fetchDeleteCollectionProtocol(String(row.id))
}
ElMessage.success('批量删除成功')
selectedRows.value = []
loadData()
}, fullscreenLoadingText.batchDelete)
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
[searchForm, createTimeRange, sampleCollectTimeRange],
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
@@ -1,18 +0,0 @@
<!-- 旧「退费确认(财务)」菜单入口:重定向至缴退费财务确认 · 退费 Tab -->
<template>
<div />
</template>
<script setup lang="ts">
defineOptions({ name: 'CellStorageRefundConfirm' })
const router = useRouter()
const route = useRoute()
onMounted(() => {
router.replace({
name: 'CellStoragePaymentConfirm',
query: { ...route.query, tab: 'refund' }
})
})
</script>
@@ -1,18 +0,0 @@
<!-- 旧「样本接收信息」菜单入口:重定向至采集协议单管理 · 样本接收 Tab -->
<template>
<div />
</template>
<script setup lang="ts">
defineOptions({ name: 'CellStorageSampleReceptionInfo' })
const router = useRouter()
const route = useRoute()
onMounted(() => {
router.replace({
name: 'CellStorageProtocolAudit',
query: { ...route.query, tab: 'sample-reception' }
})
})
</script>
@@ -1,18 +0,0 @@
<!-- 旧「储存证管理」菜单入口:重定向至采集协议单管理 · 储存证 Tab -->
<template>
<div />
</template>
<script setup lang="ts">
defineOptions({ name: 'CellStorageStorageCert' })
const router = useRouter()
const route = useRoute()
onMounted(() => {
router.replace({
name: 'CellStorageProtocolAudit',
query: { ...route.query, tab: 'storage-cert' }
})
})
</script>
@@ -1,18 +0,0 @@
<!-- 旧「样本入库信息」菜单入口:重定向至采集协议单管理 · 样本入库 Tab -->
<template>
<div />
</template>
<script setup lang="ts">
defineOptions({ name: 'CellStorageStorageEntryInfo' })
const router = useRouter()
const route = useRoute()
onMounted(() => {
router.replace({
name: 'CellStorageProtocolAudit',
query: { ...route.query, tab: 'storage-entry' }
})
})
</script>
@@ -1,991 +0,0 @@
<!-- 洁净室管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>洁净室列表</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-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按编号、名称模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增洁净室
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="roomCode"
label="编号"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="roomName"
label="名称"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column label="等级" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
class="clean-room-grade-tag"
:class="gradeTagClass(row.roomGrade)"
effect="light"
>
{{ gradeLabel(row.roomGrade) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="row.roomStatus === 'zc' ? 'success' : 'danger'" effect="plain">
{{ row.roomStatus === 'zc' ? '正常' : '异常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="areaSqm"
label="面积(m²)"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleAccessLog(row)">人员进出</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<!-- 新增/编辑 洁净室弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="clean-room-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="编号" prop="roomCode">
<el-input v-model="formData.roomCode" placeholder="如:CR-001" />
</el-form-item>
<el-form-item label="名称" prop="roomName">
<el-input v-model="formData.roomName" placeholder="如:一号洁净室" />
</el-form-item>
<el-form-item label="等级" prop="roomGrade">
<el-select v-model="formData.roomGrade" placeholder="请选择等级" style="width: 100%">
<el-option label="A级" value="A" />
<el-option label="B级" value="B" />
<el-option label="C级" value="C" />
<el-option label="D级" value="D" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="roomStatus">
<el-select v-model="formData.roomStatus" placeholder="请选择状态" style="width: 100%">
<el-option label="正常" value="zc" />
<el-option label="异常" value="yc" />
</el-select>
</el-form-item>
<el-form-item label="面积(m²)" prop="areaSqm">
<el-input
type="number"
step="any"
v-model="formData.areaSqm"
:min="0"
:controls="false"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<!-- 查看详情 -->
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.roomName || '洁净室详情'"
:icon="OfficeBuilding"
:meta="viewRow?.createTime || ''"
:sections="viewSections"
/>
<!-- 人员进出记录弹窗 -->
<el-dialog
v-model="accessLogVisible"
:title="`人员进出记录 — ${accessLogRoom?.roomName || ''}`"
width="960px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<div class="list-toolbar" style="margin-bottom: 12px">
<div class="list-toolbar-row">
<el-button class="btn-main" type="primary" size="small" @click="handleAddLog">
<el-icon><Plus /></el-icon>
新增记录
</el-button>
</div>
</div>
<el-table
:data="accessLogData"
v-loading="accessLogLoading"
stripe
size="small"
row-key="id"
@row-dblclick="handleViewLog"
>
<el-table-column
type="index"
label="序号"
min-width="60"
align="center"
header-align="center"
:index="(index) => (logPagination.page - 1) * logPagination.pageSize + index + 1"
/>
<el-table-column
prop="personName"
label="人员姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="deptName"
label="部门"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="enterTime"
label="进入时间"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
prop="purpose"
label="目的"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="leaveTime"
label="离开时间"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
prop="bodyTemp"
label="体温(℃)"
min-width="90"
align="center"
header-align="center"
>
<template #default="{ row }">{{
row.bodyTemp != null ? row.bodyTemp + '℃' : '-'
}}</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="操作"
min-width="260"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleViewLog(row)"
><el-icon><View /></el-icon>查看</el-button
>
<el-button class="btn-op-item primary" @click="handleEditLog(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button class="btn-op-item" @click="handleDeleteLog(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar" style="margin-top: 12px">
<el-pagination
:current-page="logPagination.page"
:page-size="logPagination.pageSize"
:total="logPagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="
(val) => (
(logPagination.page = 1),
(logPagination.pageSize = val),
loadAccessLogData()
)
"
@current-change="(val) => ((logPagination.page = val), loadAccessLogData())"
/>
</div>
<!-- 进出记录新增/编辑弹窗(嵌套) -->
<el-dialog
v-model="logDialogVisible"
:title="logDialogTitle"
width="520px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
append-to-body
>
<el-form
ref="logFormRef"
:model="logFormData"
:rules="logFormRules"
label-position="top"
class="access-log-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="人员姓名" prop="personName">
<el-select
v-model="logFormData.personName"
class="w-full"
filterable
remote
clearable
allow-create
default-first-option
:remote-method="remoteSearchUsers"
:loading="userSearchLoading"
placeholder="输入姓名检索或手填"
@change="onPersonSelectChange"
>
<el-option
v-for="u in userOptions"
:key="u.id"
:label="formatUserLabel(u)"
:value="(u.nickName || '').trim()"
/>
</el-select>
</el-form-item>
<el-form-item label="部门" prop="deptName">
<el-input
v-model="logFormData.deptName"
placeholder="选择人员后自动填入,可手填修改"
/>
</el-form-item>
<el-form-item label="进入时间" prop="enterTime">
<el-date-picker
v-model="logFormData.enterTime"
type="datetime"
placeholder="选择日期时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="离开时间">
<el-date-picker
v-model="logFormData.leaveTime"
type="datetime"
placeholder="选择日期时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="目的" class="form-col-span-2">
<el-input
v-model="logFormData.purpose"
type="textarea"
:rows="2"
placeholder="请输入目的"
/>
</el-form-item>
<el-form-item label="体温(℃)">
<el-input
type="number"
step="any"
v-model="logFormData.bodyTemp"
:min="34"
:max="42"
:controls="false"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="logFormData.remark" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="logDialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
@click="handleLogSubmit"
:loading="logSubmitLoading"
>
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<!-- 进出记录查看详情 -->
<SystemViewDialog
v-model:visible="logViewVisible"
:title="logViewRow?.personName || '进出记录详情'"
:icon="User"
:meta="logViewRow?.enterTime || ''"
:sections="logViewSections"
/>
</el-dialog>
</section>
</div>
</template>
<script setup lang="ts">
import {
Plus,
Close,
Check,
View,
Edit,
Delete,
More,
User,
OfficeBuilding,
RefreshLeft,
Search
} from '@element-plus/icons-vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import { buildTreePathLabelFromFlat } from '@/utils/tree-path'
import { cleanRoomGradeLabel } from '@/utils/status-tag-registry'
import {
fetchCleanRoomList,
fetchCreateCleanRoom,
fetchUpdateCleanRoom,
fetchDeleteCleanRoom,
fetchAccessLogList,
fetchCreateAccessLog,
fetchUpdateAccessLog,
fetchDeleteAccessLog,
fetchUserList,
fetchDeptTree
} from '@/api/admin'
defineOptions({ name: 'CleanRoom' })
// ── 主表 ──────────────────────────────────────────
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<any[]>([])
const selectedRows = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const dialogTitle = ref('新增洁净室')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
const viewVisible = ref(false)
const viewRow = ref<any>(null)
const formData = reactive({
roomCode: '',
roomName: '',
roomGrade: '',
roomStatus: 'zc',
areaSqm: null as number | null,
remark: ''
})
const formRules = {
roomCode: [{ required: true, message: '请输入编号', trigger: 'blur' }],
roomName: [{ required: true, message: '请输入名称', trigger: 'blur' }]
}
// ── 等级映射(使用 registry 函数) ──────────────────
const gradeLabel = cleanRoomGradeLabel
const gradeTagClass = (grade?: string) => {
const code = (grade || '').toUpperCase()
return code ? `clean-room-grade-tag--${code}` : ''
}
// ── 选择 ──────────────────────────────────────────
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
// ── 查看详情 sections ────────────────────────────
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '编号', value: row.roomCode },
{ label: '名称', value: row.roomName },
{ label: '等级', value: gradeLabel(row.roomGrade) },
{
label: '状态',
value: row.roomStatus === 'zc' ? '正常' : '异常',
tagType: row.roomStatus === 'zc' ? 'success' : 'danger'
},
{ label: '面积', value: row.areaSqm ? `${row.areaSqm} m²` : '' },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
// ── 主表 加载 ─────────────────────────────────────
async function loadData() {
loading.value = true
try {
const res: any = await fetchCleanRoomList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
pagination.page = 1
loadData()
}
// ── 主表 表单重置 ─────────────────────────────────
function resetForm() {
formData.roomCode = ''
formData.roomName = ''
formData.roomGrade = ''
formData.roomStatus = 'zc'
formData.areaSqm = null
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增洁净室'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleEdit(row: any) {
isEdit.value = true
dialogTitle.value = '编辑洁净室'
editingId.value = row.id
formData.roomCode = row.roomCode || ''
formData.roomName = row.roomName || ''
formData.roomGrade = row.roomGrade || ''
formData.roomStatus = row.roomStatus || 'zc'
formData.areaSqm = row.areaSqm ?? null
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
roomCode: formData.roomCode.trim(),
roomName: formData.roomName.trim(),
roomGrade: formData.roomGrade || undefined,
roomStatus: formData.roomStatus || 'zc',
areaSqm: formData.areaSqm ?? undefined,
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateCleanRoom(editingId.value, payload)
} else {
await fetchCreateCleanRoom(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
function handleView(row: any) {
viewRow.value = row
viewVisible.value = true
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'view': handleView(row); break
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
}
}
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该洁净室吗?', '提示', { type: 'warning' })
await fetchDeleteCleanRoom(row.id)
ElMessage.success('删除成功')
await loadData()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个洁净室吗?`, '提示', {
type: 'warning'
})
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteCleanRoom(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// ── 人员进出记录 ──────────────────────────────────
const accessLogVisible = ref(false)
const accessLogRoom = ref<any>(null)
const accessLogData = ref<any[]>([])
const accessLogLoading = ref(false)
const logPagination = reactive({ page: 1, pageSize: 10, total: 0 })
const logDialogVisible = ref(false)
const logDialogTitle = ref('新增记录')
const logIsEdit = ref(false)
const logSubmitLoading = ref(false)
const logFormRef = ref()
const logEditingId = ref<string | null>(null)
const logViewVisible = ref(false)
const logViewRow = ref<any>(null)
const logFormData = reactive({
personName: '',
deptName: '',
enterTime: '',
purpose: '',
leaveTime: '',
bodyTemp: null as number | null,
remark: ''
})
const logFormRules = {
personName: [{ required: true, message: '请输入人员姓名', trigger: 'blur' }]
}
// ── 人员检索 ──────────────────────────────────────
const userOptions = ref<Api.User.UserItem[]>([])
const userSearchLoading = ref(false)
const flatDeptList = ref<any[]>([])
function formatUserLabel(u: Api.User.UserItem) {
const name = (u.nickName || '').trim() || '-'
const no = (u.userNo || '').trim()
const phone = (u.phoneNo || '').trim()
const parts = [name]
if (no) parts.push(no)
if (phone) parts.push(phone)
return parts.join(' · ')
}
async function remoteSearchUsers(query: string) {
userSearchLoading.value = true
try {
const q = (query || '').trim()
const res: any = await fetchUserList({
page: 1,
pageSize: 50,
keyword: q || undefined,
status: '1'
})
userOptions.value = res.list || []
} catch (e) {
console.error('检索用户失败:', e)
userOptions.value = []
} finally {
userSearchLoading.value = false
}
}
function onPersonSelectChange(val: string | null | undefined) {
if (!val) {
logFormData.deptName = ''
return
}
const user = userOptions.value.find((u) => (u.nickName || '').trim() === val)
if (user && user.deptId) {
const deptPath = buildTreePathLabelFromFlat(user.deptId, flatDeptList.value, {
nameKey: 'deptName'
})
logFormData.deptName = deptPath || (user.deptName || '').trim()
}
}
async function loadDeptData() {
try {
const tree: any = await fetchDeptTree()
const flat: any[] = []
const walk = (nodes: any[]) => {
for (const n of nodes || []) {
flat.push(n)
if (n.children?.length) walk(n.children)
}
}
walk(tree || [])
flatDeptList.value = flat
} catch (e) {
console.error('加载部门数据失败:', e)
}
}
async function resolvePersonForEdit(name: string) {
if (!name) return
userSearchLoading.value = true
try {
const res: any = await fetchUserList({
page: 1,
pageSize: 50,
keyword: name,
status: '1'
})
const list = res.list || []
const match = list.find((u: any) => (u.nickName || '').trim() === name)
if (match) {
userOptions.value = list
}
} catch (e) {
console.error('匹配用户失败:', e)
} finally {
userSearchLoading.value = false
}
}
function handleAccessLog(row: any) {
accessLogRoom.value = row
logPagination.page = 1
accessLogVisible.value = true
loadAccessLogData()
}
async function loadAccessLogData() {
if (!accessLogRoom.value) return
accessLogLoading.value = true
try {
const res: any = await fetchAccessLogList({
page: logPagination.page,
pageSize: logPagination.pageSize,
cleanRoomId: accessLogRoom.value.id
})
accessLogData.value = res.list || []
logPagination.total = res.total ?? 0
} finally {
accessLogLoading.value = false
}
}
function resetLogForm() {
logFormData.personName = ''
logFormData.deptName = ''
logFormData.enterTime = ''
logFormData.purpose = ''
logFormData.leaveTime = ''
logFormData.bodyTemp = null
logFormData.remark = ''
logEditingId.value = null
userOptions.value = []
}
function handleAddLog() {
logIsEdit.value = false
logDialogTitle.value = '新增记录'
resetLogForm()
logDialogVisible.value = true
logFormRef.value?.clearValidate?.()
}
function handleEditLog(row: any) {
logIsEdit.value = true
logDialogTitle.value = '编辑记录'
logEditingId.value = row.id
logFormData.personName = row.personName || ''
logFormData.deptName = row.deptName || ''
logFormData.enterTime = row.enterTime || ''
logFormData.purpose = row.purpose || ''
logFormData.leaveTime = row.leaveTime || ''
logFormData.bodyTemp = row.bodyTemp ?? null
logFormData.remark = row.remark || ''
logDialogVisible.value = true
logFormRef.value?.clearValidate?.()
resolvePersonForEdit(row.personName || '')
}
async function handleLogSubmit() {
await logFormRef.value?.validate?.()
logSubmitLoading.value = true
try {
const payload = {
cleanRoomId: accessLogRoom.value.id,
personName: logFormData.personName.trim(),
deptName: logFormData.deptName?.trim() || undefined,
enterTime: logFormData.enterTime || undefined,
purpose: logFormData.purpose?.trim() || undefined,
leaveTime: logFormData.leaveTime || undefined,
bodyTemp: logFormData.bodyTemp != null ? String(logFormData.bodyTemp) : undefined,
remark: logFormData.remark?.trim() || undefined
}
if (logIsEdit.value && logEditingId.value) {
await fetchUpdateAccessLog(logEditingId.value, payload)
} else {
await fetchCreateAccessLog(payload)
}
ElMessage.success(logIsEdit.value ? '更新成功' : '创建成功')
logDialogVisible.value = false
await loadAccessLogData()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
logSubmitLoading.value = false
}
}
async function handleDeleteLog(row: any) {
await ElMessageBox.confirm('确定要删除该记录吗?', '提示', { type: 'warning' })
await fetchDeleteAccessLog(row.id)
ElMessage.success('删除成功')
await loadAccessLogData()
}
// ── 进出记录查看详情 ──────────────────────────────
const logViewSections = computed(() => {
if (!logViewRow.value) return []
const row = logViewRow.value
return [
{
title: '进出记录',
fields: [
{ label: '人员姓名', value: row.personName },
{ label: '部门', value: row.deptName },
{ label: '进入时间', value: row.enterTime },
{ label: '离开时间', value: row.leaveTime },
{ label: '目的', value: row.purpose, span: true },
{ label: '体温(℃)', value: row.bodyTemp != null ? `${row.bodyTemp}℃` : '' },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
function handleViewLog(row: any) {
logViewRow.value = row
logViewVisible.value = true
}
// ── 初始化 ────────────────────────────────────────
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
loadDeptData()
})
</script>
<style scoped lang="scss">
.clean-room-grade-tag {
border-width: 1px;
border-style: solid;
}
.clean-room-grade-tag--A {
color: #065f46;
background-color: #d1fae5;
border-color: #10b981;
}
.clean-room-grade-tag--B {
color: #047857;
background-color: #ecfdf5;
border-color: #6ee7b7;
}
.clean-room-grade-tag--C {
color: #059669;
background-color: #f0fdf4;
border-color: #a7f3d0;
}
.clean-room-grade-tag--D {
color: #10b981;
background-color: #f6fef9;
border-color: #d1fae5;
}
.clean-room-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.access-log-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -1,30 +0,0 @@
<!-- Demo 下拉框示例 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>选择项</h2>
</div>
<div class="filter-block">
<el-form :model="form" label-position="top" @submit.prevent>
<el-form-item label="请选择">
<el-select v-model="form.value" placeholder="请选择" clearable style="width: 240px">
<el-option label="A" value="A" />
<el-option label="B" value="B" />
<el-option label="C" value="C" />
</el-select>
</el-form-item>
</el-form>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
const form = reactive({
value: ''
})
</script>
@@ -1,482 +0,0 @@
<!-- 展馆预约管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>预约列表</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-form-item>
<el-form-item label="到访日期">
<el-date-picker
v-model="visitDateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>按预约编号、来访单位、联系人模糊查询;默认展示未来 30 天内的到访预约。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增预约
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="appointmentNo"
label="预约编号"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="visitOrg"
label="来访单位"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="contactName"
label="联系人"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="visitDate"
label="到访日期"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
prop="visitorCount"
label="人数"
min-width="80"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="appointment-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="预约编号">
<el-input
v-model="formData.appointmentNo"
placeholder="提交后自动生成"
:disabled="true"
/>
</el-form-item>
<el-form-item label="来访单位" prop="visitOrg">
<el-input v-model="formData.visitOrg" placeholder="请输入来访单位" />
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="formData.contactName" placeholder="请输入联系人" />
</el-form-item>
<el-form-item label="到访日期">
<el-date-picker
v-model="formData.visitDate"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择到访日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="人数">
<el-input
type="number"
step="any"
v-model="formData.visitorCount"
:min="1"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.appointmentNo || '预约详情'"
:icon="Calendar"
:meta="viewRow?.createTime || ''"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { Plus, Close, Check, View, Calendar, RefreshLeft, Search, More, Edit, Delete } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchExhibitionAppointmentList,
fetchCreateExhibitionAppointment,
fetchUpdateExhibitionAppointment,
fetchDeleteExhibitionAppointment
} from '@/api/admin'
defineOptions({ name: 'ExhibitionAppointment' })
interface AppointmentRow {
id: string
appointmentNo: string
visitOrg: string
contactName: string
visitDate: string
visitorCount: number
remark: string
createTime: string
[key: string]: any
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultVisitDateRange(): [string, string] {
const start = new Date()
const end = new Date()
end.setDate(end.getDate() + 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const visitDateRange = ref<[string, string] | null>(getDefaultVisitDateRange())
const tableData = ref<AppointmentRow[]>([])
const selectedRows = ref<AppointmentRow[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const dialogTitle = ref('新增预约')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
const viewVisible = ref(false)
const viewRow = ref<AppointmentRow | null>(null)
const formData = reactive({
appointmentNo: '',
visitOrg: '',
contactName: '',
visitDate: '',
visitorCount: 1,
remark: ''
})
const formRules = {
visitOrg: [{ required: true, message: '请输入来访单位', trigger: 'blur' }]
}
const handleSelectionChange = (rows: AppointmentRow[]) => {
selectedRows.value = rows
}
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '预约信息',
fields: [
{ label: '预约编号', value: row.appointmentNo },
{ label: '来访单位', value: row.visitOrg },
{ label: '联系人', value: row.contactName },
{ label: '到访日期', value: row.visitDate },
{ label: '人数', value: row.visitorCount },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
async function loadData() {
loading.value = true
try {
const res = await fetchExhibitionAppointmentList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
visitDateStart: visitDateRange.value?.[0] || undefined,
visitDateEnd: visitDateRange.value?.[1] || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
visitDateRange.value = getDefaultVisitDateRange()
pagination.page = 1
loadData()
}
function resetForm() {
formData.appointmentNo = '提交后自动生成'
formData.visitOrg = ''
formData.contactName = ''
formData.visitDate = ''
formData.visitorCount = 1
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增预约'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleEdit(row: AppointmentRow) {
isEdit.value = true
dialogTitle.value = '编辑预约'
editingId.value = row.id
formData.appointmentNo = row.appointmentNo || ''
formData.visitOrg = row.visitOrg || ''
formData.contactName = row.contactName || ''
formData.visitDate = row.visitDate || ''
formData.visitorCount = row.visitorCount ?? 1
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload: Record<string, any> = {
visitOrg: formData.visitOrg.trim(),
contactName: formData.contactName?.trim() || undefined,
visitDate: formData.visitDate || undefined,
visitorCount: formData.visitorCount,
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateExhibitionAppointment(editingId.value, payload)
} else {
await fetchCreateExhibitionAppointment(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
function handleView(row: AppointmentRow) {
viewRow.value = row
viewVisible.value = true
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
}
}
async function handleDelete(row: AppointmentRow) {
await ElMessageBox.confirm('确定要删除该预约吗?', '提示', { type: 'warning' })
await fetchDeleteExhibitionAppointment(row.id)
ElMessage.success('删除成功')
await loadData()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个预约吗?`, '提示', {
type: 'warning'
})
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteExhibitionAppointment(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
[searchForm, visitDateRange],
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.appointment-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
File diff suppressed because it is too large. Load diff
@@ -1,18 +0,0 @@
<!-- 旧「细胞产品价格体系管理」菜单入口:重定向至细胞产品基础信息维护 · 价格体系 Tab -->
<template>
<div />
</template>
<script setup lang="ts">
defineOptions({ name: 'PilotCellProductPrice' })
const router = useRouter()
const route = useRoute()
onMounted(() => {
router.replace({
name: 'CellProductType',
query: { ...route.query, tab: 'price' }
})
})
</script>
@@ -1,18 +0,0 @@
<!-- 旧「细胞产品规格管理」菜单入口:重定向至细胞产品基础信息维护 · 规格 Tab -->
<template>
<div />
</template>
<script setup lang="ts">
defineOptions({ name: 'PilotCellProductSpec' })
const router = useRouter()
const route = useRoute()
onMounted(() => {
router.replace({
name: 'CellProductType',
query: { ...route.query, tab: 'spec' }
})
})
</script>
@@ -1,547 +0,0 @@
<!-- 细胞产品基础信息维护 · 价格体系 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="cell-product-price-tab">
<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-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按项目名称、编码模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增价格
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="projectName"
label="项目名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="projectCode"
label="项目编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="cellTypeName"
label="细胞类型名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="specText"
label="规格"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="unitPrice"
label="单价"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ row.unitPrice != null ? `${formatMoneyDisplay(row.unitPrice)} 元` : '-' }}
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown
trigger="hover"
@command="(cmd) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="price-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="项目名称" prop="projectName">
<el-input v-model="formData.projectName" placeholder="请输入项目名称" />
</el-form-item>
<el-form-item label="项目编码" prop="projectCode">
<el-input v-model="formData.projectCode" placeholder="请输入项目编码" />
</el-form-item>
<el-form-item label="细胞产品类型" prop="cellProductTypeId">
<el-select
v-model="formData.cellProductTypeId"
placeholder="请选择细胞产品类型"
clearable
style="width: 100%"
@change="handleTypeChange"
>
<el-option
v-for="item in typeOptions"
:key="item.id"
:label="item.productTypeName"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="规格" prop="specId">
<el-select
v-model="formData.specId"
placeholder="请选择规格"
clearable
style="width: 100%"
@change="handleSpecChange"
>
<el-option
v-for="item in specOptions"
:key="item.id"
:label="item.specName"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="单价(元)" prop="unitPrice">
<AmountInput v-model="formData.unitPrice" :min="0" placeholder="请输入单价" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.projectName || '价格详情'"
:icon="ScaleToOriginal"
:code="viewRow?.projectCode"
code-label="项目编码"
:sections="viewSections"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Close,
Check,
View,
Edit,
Delete,
More,
ScaleToOriginal,
RefreshLeft,
Search
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import AmountInput from '@/components/common/amount-input/index.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCellProductPriceList,
fetchCreateCellProductPrice,
fetchUpdateCellProductPrice,
fetchDeleteCellProductPrice,
fetchCellProductTypeList,
fetchCellProductSpecList
} from '@/api/admin'
defineOptions({ name: 'CellProductPriceTab' })
// --- Search ---
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
// --- Table ---
const tableData = ref<any[]>([])
const selectedRows = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
// --- Dialog ---
const dialogVisible = ref(false)
const dialogTitle = ref('新增价格')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
// --- View ---
const viewVisible = ref(false)
const viewRow = ref<any>(null)
// --- Dropdown options ---
const typeOptions = ref<any[]>([])
const specOptions = ref<any[]>([])
// --- Form data ---
const formData = reactive({
projectName: '',
projectCode: '',
cellProductTypeId: '' as string,
cellTypeCode: '',
cellTypeName: '',
specId: '' as string,
specText: '',
unitPrice: null as number | null,
remark: ''
})
const formRules = {
projectName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
projectCode: [{ required: true, message: '请输入项目编码', trigger: 'blur' }]
}
// --- Selection ---
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
// --- View sections ---
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '项目名称', value: row.projectName },
{ label: '项目编码', value: row.projectCode },
{ label: '细胞类型名称', value: row.cellTypeName },
{ label: '规格', value: row.specText },
{
label: '单价(元)',
value: row.unitPrice != null ? `${formatMoneyDisplay(row.unitPrice)} 元` : '-'
},
{ label: '备注', value: row.remark, span: true }
]
}
]
})
// --- Load options ---
async function loadOptions() {
const [typeRes, specRes] = await Promise.all([
fetchCellProductTypeList({ page: 1, pageSize: 100 }),
fetchCellProductSpecList({ page: 1, pageSize: 100 })
])
typeOptions.value = (typeRes as any)?.list || []
specOptions.value = (specRes as any)?.list || []
}
// --- Load data ---
async function loadData() {
loading.value = true
try {
const res = await fetchCellProductPriceList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined
})
tableData.value = (res as any)?.list || []
pagination.total = (res as any)?.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
pagination.page = 1
loadData()
}
// --- Reset form ---
function resetForm() {
formData.projectName = ''
formData.projectCode = ''
formData.cellProductTypeId = ''
formData.cellTypeCode = ''
formData.cellTypeName = ''
formData.specId = ''
formData.specText = ''
formData.unitPrice = null
formData.remark = ''
editingId.value = null
}
// --- Select handlers ---
function handleTypeChange(val: string) {
const selected = typeOptions.value.find((item) => item.id === val)
if (selected) {
formData.cellTypeCode = selected.productTypeCode || ''
formData.cellTypeName = selected.productTypeName || ''
} else {
formData.cellTypeCode = ''
formData.cellTypeName = ''
}
}
function handleSpecChange(val: string) {
const selected = specOptions.value.find((item) => item.id === val)
if (selected) {
formData.specText = selected.specName || ''
} else {
formData.specText = ''
}
}
// --- Add ---
async function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增价格'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
await loadOptions()
}
// --- Edit ---
async function handleEdit(row: any) {
isEdit.value = true
dialogTitle.value = '编辑价格'
editingId.value = row.id
formData.projectName = row.projectName || ''
formData.projectCode = row.projectCode || ''
formData.cellProductTypeId = row.cellProductTypeId || ''
formData.cellTypeCode = row.cellTypeCode || ''
formData.cellTypeName = row.cellTypeName || ''
formData.specId = row.specId || ''
formData.specText = row.specText || ''
formData.unitPrice = row.unitPrice ?? null
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
await loadOptions()
}
// --- Submit ---
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
projectName: formData.projectName.trim(),
projectCode: formData.projectCode.trim(),
cellProductTypeId: formData.cellProductTypeId || undefined,
cellTypeCode: formData.cellTypeCode || undefined,
cellTypeName: formData.cellTypeName || undefined,
specId: formData.specId || undefined,
specText: formData.specText || undefined,
unitPrice: formData.unitPrice ?? undefined,
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateCellProductPrice(editingId.value, payload)
} else {
await fetchCreateCellProductPrice(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
// --- View ---
function handleView(row: any) {
viewRow.value = row
viewVisible.value = true
}
function handleDropdownCommand(cmd: string, row: any) {
if (cmd === 'edit') handleEdit(row)
else if (cmd === 'delete') handleDelete(row)
}
// --- Delete ---
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm('确定要删除该价格记录吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteCellProductPrice(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
// --- Batch delete ---
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 条记录吗?`, '提示', {
type: 'warning'
})
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteCellProductPrice(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.price-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -1,395 +0,0 @@
<!-- 细胞产品基础信息维护 · 规格 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="cell-product-spec-tab">
<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-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按编码、名称模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增规格
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="specCode"
label="规格编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="specName"
label="规格名称"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="spec-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="规格编码" prop="specCode">
<el-input v-model="formData.specCode" placeholder="请输入规格编码" />
</el-form-item>
<el-form-item label="规格名称" prop="specName">
<el-input v-model="formData.specName" placeholder="请输入规格名称" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="formData.remark" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.specName || '规格详情'"
:icon="ScaleToOriginal"
:code="viewRow?.specCode"
code-label="规格编码"
:sections="viewSections"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Close,
Check,
View,
Edit,
Delete,
ScaleToOriginal,
RefreshLeft,
Search,
More
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCellProductSpecList,
fetchCreateCellProductSpec,
fetchUpdateCellProductSpec,
fetchDeleteCellProductSpec
} from '@/api/admin'
defineOptions({ name: 'CellProductSpecTab' })
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<Api.CellProductSpec.CellProductSpecItem[]>([])
const selectedRows = ref<Api.CellProductSpec.CellProductSpecItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const dialogTitle = ref('新增规格')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
const viewVisible = ref(false)
const viewRow = ref<Api.CellProductSpec.CellProductSpecItem | null>(null)
const formData = reactive({
specCode: '',
specName: '',
remark: ''
})
const formRules = {
specCode: [{ required: true, message: '请输入规格编码', trigger: 'blur' }],
specName: [{ required: true, message: '请输入规格名称', trigger: 'blur' }]
}
const handleSelectionChange = (rows: Api.CellProductSpec.CellProductSpecItem[]) => {
selectedRows.value = rows
}
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '规格编码', value: row.specCode },
{ label: '规格名称', value: row.specName },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
async function loadData() {
loading.value = true
try {
const res = await fetchCellProductSpecList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
pagination.page = 1
loadData()
}
function resetForm() {
formData.specCode = ''
formData.specName = ''
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增规格'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleEdit(row: Api.CellProductSpec.CellProductSpecItem) {
isEdit.value = true
dialogTitle.value = '编辑规格'
editingId.value = row.id
formData.specCode = row.specCode || ''
formData.specName = row.specName || ''
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
specCode: formData.specCode.trim(),
specName: formData.specName.trim(),
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateCellProductSpec(editingId.value, payload)
} else {
await fetchCreateCellProductSpec(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
function handleView(row: Api.CellProductSpec.CellProductSpecItem) {
viewRow.value = row
viewVisible.value = true
}
function handleDropdownCommand(cmd: string, row: Api.CellProductSpec.CellProductSpecItem) {
if (cmd === 'edit') handleEdit(row)
else if (cmd === 'delete') handleDelete(row)
}
async function handleDelete(row: Api.CellProductSpec.CellProductSpecItem) {
try {
await ElMessageBox.confirm('确定要删除该规格吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteCellProductSpec(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'
})
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteCellProductSpec(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.spec-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -1,807 +0,0 @@
<!-- 细胞产品基础信息维护 · 项目价格 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="project-price-system-tab">
<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-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按体系名称、编码模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增体系
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="systemName"
label="体系名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="systemCode"
label="体系编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="status"
label="状态"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="String(row.status) === '1' ? 'success' : 'danger'" size="small">
{{ String(row.status) === '1' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<!-- 新增 / 编辑弹窗 — 主子表 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="1100px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="system-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="体系名称" prop="systemName">
<el-input v-model="formData.systemName" placeholder="请输入体系名称" />
</el-form-item>
<el-form-item label="体系编码" prop="systemCode">
<el-input v-model="formData.systemCode" placeholder="请输入体系编码" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择状态" style="width: 100%">
<el-option label="正常" value="1" />
<el-option label="停用" value="0" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="formData.remark" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<!-- 价格明细 — 子表 -->
<div class="detail-section">
<div class="detail-section-head">
<h3 class="detail-section-title">价格明细</h3>
<el-button
class="btn-main"
type="primary"
size="small"
@click="handleOpenPriceSelector"
>
<el-icon><Plus /></el-icon>
从价格体系添加
</el-button>
</div>
<el-table :data="detailItems" size="small" class="detail-table" max-height="320">
<el-table-column
type="index"
label="序号"
min-width="55"
align="center"
header-align="center"
/>
<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"
/>
<el-table-column
prop="specText"
label="规格"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column label="单价(元)" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<AmountInput v-model="row.unitPrice" :min="0" placeholder="请输入单价" />
</template>
</el-table-column>
<el-table-column label="备注" min-width="140" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" clearable />
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="80"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ $index }">
<el-button
class="btn-op-item"
type="danger"
link
size="small"
@click="detailItems.splice($index, 1)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<!-- 从价格体系添加 — 子弹窗 -->
<el-dialog
v-model="priceSelectorVisible"
title="从价格体系添加"
width="860px"
class="dialog-form-layout"
align-center
destroy-on-close
append-to-body
:close-on-click-modal="false"
>
<div style="margin-bottom: 12px">
<el-input
v-model="priceSelectorKeyword"
placeholder="搜索项目名称/编码"
clearable
style="width: 260px"
/>
</div>
<el-table
ref="priceSelectorTableRef"
:data="filteredPriceSelectorList"
max-height="400"
@selection-change="handlePriceSelectorChange"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<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"
/>
<el-table-column
prop="specText"
label="规格"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column label="单价" min-width="80" align="center" header-align="center">
<template #default="{ row }">
{{ row.unitPrice != null ? `${formatMoneyDisplay(row.unitPrice)} 元` : '-' }}
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="120"
align="center"
header-align="center"
/>
</el-table>
<template #footer>
<el-button class="btn-ghost" @click="priceSelectorVisible = false">取消</el-button>
<el-button
class="btn-main"
type="primary"
:disabled="priceSelectorSelected.length === 0"
@click="handleConfirmPriceSelector"
>
确认添加({{ priceSelectorSelected.length }})
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.systemName || '体系详情'"
:icon="ScaleToOriginal"
:code="viewRow?.systemCode"
code-label="体系编码"
:sections="viewSections"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Close,
Check,
View,
Edit,
Delete,
ScaleToOriginal,
RefreshLeft,
Search,
More
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import AmountInput from '@/components/common/amount-input/index.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchProjectSystemList,
fetchCreateProjectSystem,
fetchUpdateProjectSystem,
fetchDeleteProjectSystem,
fetchProjectSystemItems,
fetchCreateProjectSystemItem,
fetchUpdateProjectSystemItem,
fetchDeleteProjectSystemItem,
fetchCellProductPriceList
} from '@/api/admin'
defineOptions({ name: 'ProjectPriceSystemTab' })
// --- Search ---
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
// --- Table ---
const tableData = ref<any[]>([])
const selectedRows = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
// --- Dialog ---
const dialogVisible = ref(false)
const dialogTitle = ref('新增体系')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
// --- View ---
const viewVisible = ref(false)
const viewRow = ref<any>(null)
// --- Detail items ---
const detailItems = ref<any[]>([])
// --- Price selector ---
const priceSelectorVisible = ref(false)
const priceSelectorList = ref<any[]>([])
const priceSelectorSelected = ref<any[]>([])
const priceSelectorTableRef = ref()
const priceSelectorKeyword = ref('')
const filteredPriceSelectorList = computed(() => {
const kw = priceSelectorKeyword.value.trim().toLowerCase()
if (!kw) return priceSelectorList.value
return priceSelectorList.value.filter(
(row: any) =>
(row.projectName || '').toLowerCase().includes(kw) ||
(row.projectCode || '').toLowerCase().includes(kw)
)
})
// --- Form data ---
const formData = reactive({
systemName: '',
systemCode: '',
status: '1' as string,
remark: ''
})
const formRules = {
systemName: [{ required: true, message: '请输入体系名称', trigger: 'blur' }],
systemCode: [{ required: true, message: '请输入体系编码', trigger: 'blur' }]
}
/** 价格明细按 orderNum、创建时间稳定排序,避免保存后顺序颠倒 */
function sortProjectSystemDetailItems(items: any[]) {
return [...items].sort((a, b) => {
const oa = a.orderNum ?? a.order_num ?? 0
const ob = b.orderNum ?? b.order_num ?? 0
if (oa !== ob) return oa - ob
const ta = a.createTime || ''
const tb = b.createTime || ''
return ta.localeCompare(tb)
})
}
// --- Selection ---
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
// --- View sections ---
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
const items = viewDetailItems.value
return [
{
title: '基本信息',
fields: [
{ label: '体系名称', value: row.systemName },
{ label: '体系编码', value: row.systemCode },
{
label: '状态',
value: String(row.status) === '1' ? '正常' : '停用',
tagType: String(row.status) === '1' ? 'success' : 'info'
},
{ label: '备注', value: row.remark, span: true },
{ label: '创建时间', value: row.createTime }
]
},
{
title: '价格明细',
fields: items.map((item: any, idx: number) => ({
label: `明细 ${idx + 1}`,
value: `${item.projectName || '-'} / ${item.cellTypeName || '-'} / ${item.specText || '-'} / ${item.unitPrice != null ? formatMoneyDisplay(item.unitPrice) + ' 元' : '-'} / ${item.remark || '-'}`,
span: true
}))
}
]
})
const viewDetailItems = ref<any[]>([])
// --- Load data ---
async function loadData() {
loading.value = true
try {
const res = await fetchProjectSystemList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined
})
tableData.value = (res as any)?.list || []
pagination.total = (res as any)?.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
pagination.page = 1
loadData()
}
// --- Reset form ---
function resetForm() {
formData.systemName = ''
formData.systemCode = ''
formData.status = '1'
formData.remark = ''
editingId.value = null
detailItems.value = []
}
// --- Add ---
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增体系'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
// --- Edit ---
async function handleEdit(row: any) {
isEdit.value = true
dialogTitle.value = '编辑体系'
editingId.value = row.id
formData.systemName = row.systemName || ''
formData.systemCode = row.systemCode || ''
formData.status = row.status != null ? String(row.status) : '1'
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
// load existing detail items
try {
const res = await fetchProjectSystemItems(row.id)
detailItems.value = ((res as any)?.list || (res as any) || []).map((item: any) => ({
...item
}))
} catch {
detailItems.value = []
}
}
// --- Submit ---
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const masterPayload = {
systemName: formData.systemName.trim(),
systemCode: formData.systemCode.trim(),
status: formData.status,
remark: formData.remark?.trim() || undefined
}
let masterId: string
if (isEdit.value && editingId.value) {
await fetchUpdateProjectSystem(editingId.value, masterPayload)
masterId = editingId.value
} else {
const createRes = await fetchCreateProjectSystem(masterPayload)
masterId = (createRes as any)?.id || editingId.value!
}
// Handle detail items: for simplicity, delete all existing then re-create
if (isEdit.value) {
const existingRes = await fetchProjectSystemItems(masterId)
const existingItems: any[] = (existingRes as any)?.list || (existingRes as any) || []
for (const existing of existingItems) {
if (existing.id) {
await fetchDeleteProjectSystemItem(existing.id)
}
}
}
// Create all current detail items(按列表顺序写入 orderNum,保证再次打开顺序一致)
for (let i = 0; i < detailItems.value.length; i++) {
const item = detailItems.value[i]
const itemPayload = {
sourcePriceId: item.sourcePriceId || undefined,
projectName: item.projectName || undefined,
projectCode: item.projectCode || undefined,
cellTypeCode: item.cellTypeCode || undefined,
cellTypeName: item.cellTypeName || undefined,
specText: item.specText || undefined,
specId: item.specId || undefined,
cellProductTypeId: item.cellProductTypeId || undefined,
unitPrice: item.unitPrice ?? undefined,
remark: item.remark?.trim() || undefined,
orderNum: i
}
await fetchCreateProjectSystemItem(masterId, itemPayload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
// --- View ---
async function handleView(row: any) {
viewRow.value = row
try {
const res = await fetchProjectSystemItems(row.id)
const raw = (res as any)?.list || (res as any) || []
viewDetailItems.value = sortProjectSystemDetailItems(raw)
} catch {
viewDetailItems.value = []
}
viewVisible.value = true
}
function handleDropdownCommand(cmd: string, row: any) {
if (cmd === 'edit') handleEdit(row)
else if (cmd === 'delete') handleDelete(row)
}
// --- Delete ---
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm('确定要删除该体系吗?删除后关联的价格明细也将清除。', '提示', {
type: 'warning'
})
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteProjectSystem(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
// --- Batch delete ---
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 条记录吗?`, '提示', {
type: 'warning'
})
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteProjectSystem(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// --- Price selector ---
async function handleOpenPriceSelector() {
priceSelectorKeyword.value = ''
priceSelectorSelected.value = []
priceSelectorVisible.value = true
try {
const res = await fetchCellProductPriceList({ page: 1, pageSize: 100 })
priceSelectorList.value = (res as any)?.list || []
} catch {
priceSelectorList.value = []
}
// Pre-check items already in detailItems (by sourcePriceId)
await nextTick()
const existingIds = new Set(detailItems.value.map((item) => item.sourcePriceId))
const tableRef = priceSelectorTableRef.value
if (tableRef) {
priceSelectorList.value.forEach((row: any) => {
if (existingIds.has(row.id)) {
tableRef.toggleRowSelection(row, true)
}
})
}
}
function handlePriceSelectorChange(rows: any[]) {
priceSelectorSelected.value = rows
}
function handleConfirmPriceSelector() {
// Filter out items that already exist in detailItems (by sourcePriceId)
const existingIds = new Set(detailItems.value.map((item) => item.sourcePriceId))
for (const row of priceSelectorSelected.value) {
if (existingIds.has(row.id)) continue
detailItems.value.push({
sourcePriceId: row.id,
projectName: row.projectName || '',
projectCode: row.projectCode || '',
cellTypeCode: row.cellTypeCode || '',
cellTypeName: row.cellTypeName || '',
specText: row.specText || '',
specId: row.specId || '',
cellProductTypeId: row.cellProductTypeId || '',
unitPrice: row.unitPrice ?? null,
remark: row.remark || ''
})
}
priceSelectorVisible.value = false
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.system-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.detail-section {
margin-top: 20px;
.detail-section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.detail-section-title {
margin: 0;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
border-left: 3px solid #10b981;
line-height: 1;
}
.detail-table {
border: none !important;
box-shadow: none !important;
&::before,
&::after {
display: none !important;
}
:deep(.el-table__inner-wrapper) {
border: none !important;
&::before {
display: none !important;
}
}
:deep(.el-table__cell) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td.el-table__cell) {
border-bottom: none !important;
}
}
}
</style>
Loaded 100 of 614 files, more files were not shown because too many files have changed in this diff. Show more