提交“
This commit is contained in:
1 parent
69ee0f505c
commit
9b88581450
614 files changed
+90318
-689022
No files matched your search
+24
-1738
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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` })
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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}`
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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}` })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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}` })
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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}` })
|
||||
}
|
||||
@@ -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}` })
|
||||
}
|
||||
@@ -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`
|
||||
})
|
||||
}
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
-14
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
Vendored
+4
@@ -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,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'
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user