This commit is contained in:
zlwu committed 2026-05-18 16:21:38 +08:00
1 parent 2a4767a014
commit f269a994f4
61 files changed
+27430 -8

No files matched your search

+1 -1
View File
@@ -80,7 +80,7 @@
const panelTitleClassList = panelTitleIconByText.map(([, cls]) => cls)
const panelTitleBarClass = 'title-bar-heading'
const panelTitleBarTextRules: RegExp[] = [
/(数据概览|快捷菜单|分类列表|单位列表|商品列表|参数列表|用户列表|租户列表|区域列表|区域与询报价概览|报价管理|订单列表|最新订单|待结算订单|结算单记录|配送任务列表|预警列表|订单设置|客服设置|请求列表|近30日趋势|今日各区域|组织|组织管理|角色列表|菜单列表|字典类型|字典数据|站点列表|职位列表|快递公司列表|流程配置|流程列表|待办列表|已办列表|申请列表|客户列表|细胞样本类型列表|采集地区列表|采集协议号列表|样本编码列表|医院列表|协议单列表|审核记录列表|缴费确认列表|退费确认列表|分期预警列表|请假列表|洁净室列表|库位树|预约列表|缴费信息列表|样本接收列表|接收检查项列表|公司列表|项目列表|工单列表|任务列表)/
/(数据概览|快捷菜单|分类列表|单位列表|商品列表|参数列表|用户列表|租户列表|区域列表|区域与询报价概览|报价管理|订单列表|最新订单|待结算订单|结算单记录|配送任务列表|预警列表|订单设置|客服设置|请求列表|近30日趋势|今日各区域|组织|组织管理|角色列表|菜单列表|字典类型|字典数据|站点列表|职位列表|快递公司列表|流程配置|流程列表|待办列表|已办列表|申请列表|客户列表|细胞样本类型列表|采集地区列表|采集协议号列表|样本编码列表|医院列表|协议单列表|审核记录列表|缴费确认列表|退费确认列表|分期预警列表|请假列表|洁净室列表|库位树|预约列表|缴费信息列表|样本接收列表|接收检查项列表|公司列表|项目列表|工单列表|任务列表|类型列表|规格列表|价格列表|体系列表|渠道列表|机构列表|耗材列表|试剂列表|SOP列表|工艺列表|耗材包列表)/
]
const refreshPanelTitleIcons = () => {
+359
View File
@@ -1368,3 +1368,362 @@ export function fetchUpdateExhibitionAppointment(id: string, data: Record<string
export function fetchDeleteExhibitionAppointment(id: string) {
return request.del({ url: `/web/admin/exhibition-appointments/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 细胞产品类型
// ---------------------------------------------------------------------------
export function fetchCellProductTypeList(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
return request.get({
url: '/web/admin/cell-product-types',
params
})
}
export function fetchCreateCellProductType(data: Record<string, any>) {
return request.post({ url: '/web/admin/cell-product-types', params: data })
}
export function fetchUpdateCellProductType(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/cell-product-types/${id}`, params: data })
}
export function fetchDeleteCellProductType(id: string) {
return request.del({ url: `/web/admin/cell-product-types/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 细胞产品规格
// ---------------------------------------------------------------------------
export function fetchCellProductSpecList(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
return request.get({
url: '/web/admin/cell-product-specs',
params
})
}
export function fetchCreateCellProductSpec(data: Record<string, any>) {
return request.post({ url: '/web/admin/cell-product-specs', params: data })
}
export function fetchUpdateCellProductSpec(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/cell-product-specs/${id}`, params: data })
}
export function fetchDeleteCellProductSpec(id: string) {
return request.del({ url: `/web/admin/cell-product-specs/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 细胞产品价格
// ---------------------------------------------------------------------------
export function fetchCellProductPriceList(params?: {
page?: number
pageSize?: number
keyword?: string
cellProductTypeId?: string
}) {
return request.get({
url: '/web/admin/cell-product-prices',
params
})
}
export function fetchCreateCellProductPrice(data: Record<string, any>) {
return request.post({ url: '/web/admin/cell-product-prices', params: data })
}
export function fetchUpdateCellProductPrice(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/cell-product-prices/${id}`, params: data })
}
export function fetchDeleteCellProductPrice(id: string) {
return request.del({ url: `/web/admin/cell-product-prices/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 项目价格体系(主表)
// ---------------------------------------------------------------------------
export function fetchProjectSystemList(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
return request.get({
url: '/web/admin/project-price-systems',
params
})
}
export function fetchCreateProjectSystem(data: Record<string, any>) {
return request.post({ url: '/web/admin/project-price-systems', params: data })
}
export function fetchUpdateProjectSystem(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/project-price-systems/${id}`, params: data })
}
export function fetchDeleteProjectSystem(id: string) {
return request.del({ url: `/web/admin/project-price-systems/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 项目价格体系明细(子表)
// ---------------------------------------------------------------------------
export function fetchProjectSystemItems(systemId: string) {
return request.get({ url: `/web/admin/project-price-systems/${systemId}/items` })
}
export function fetchCreateProjectSystemItem(systemId: string, data: Record<string, any>) {
return request.post({ url: `/web/admin/project-price-systems/${systemId}/items`, params: data })
}
export function fetchUpdateProjectSystemItem(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/project-system-items/${id}`, params: data })
}
export function fetchDeleteProjectSystemItem(id: string) {
return request.del({ url: `/web/admin/project-system-items/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 渠道信息(主表)
// ---------------------------------------------------------------------------
export function fetchChannelList(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
return request.get({
url: '/web/admin/channels',
params
})
}
export function fetchCreateChannel(data: Record<string, any>) {
return request.post({ url: '/web/admin/channels', params: data })
}
export function fetchUpdateChannel(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/channels/${id}`, params: data })
}
export function fetchDeleteChannel(id: string) {
return request.del({ url: `/web/admin/channels/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 渠道开票信息(子表)
// ---------------------------------------------------------------------------
export function fetchChannelInvoices(channelId: string) {
return request.get({ url: `/web/admin/channels/${channelId}/invoices` })
}
export function fetchCreateChannelInvoice(channelId: string, data: Record<string, any>) {
return request.post({ url: `/web/admin/channels/${channelId}/invoices`, params: data })
}
export function fetchUpdateChannelInvoice(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/channel-invoices/${id}`, params: data })
}
export function fetchDeleteChannelInvoice(id: string) {
return request.del({ url: `/web/admin/channel-invoices/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 渠道邮寄地址(子表)
// ---------------------------------------------------------------------------
export function fetchChannelMailAddresses(channelId: string) {
return request.get({ url: `/web/admin/channels/${channelId}/mail-addresses` })
}
export function fetchCreateChannelMailAddress(channelId: string, data: Record<string, any>) {
return request.post({ url: `/web/admin/channels/${channelId}/mail-addresses`, params: data })
}
export function fetchUpdateChannelMailAddress(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/channel-mail-addresses/${id}`, params: data })
}
export function fetchDeleteChannelMailAddress(id: string) {
return request.del({ url: `/web/admin/channel-mail-addresses/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 渠道协议(子表)
// ---------------------------------------------------------------------------
export function fetchChannelAgreements(channelId: string) {
return request.get({ url: `/web/admin/channels/${channelId}/agreements` })
}
export function fetchCreateChannelAgreement(channelId: string, data: Record<string, any>) {
return request.post({ url: `/web/admin/channels/${channelId}/agreements`, params: data })
}
export function fetchUpdateChannelAgreement(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/channel-agreements/${id}`, params: data })
}
export function fetchDeleteChannelAgreement(id: string) {
return request.del({ url: `/web/admin/channel-agreements/${id}` })
}
export function fetchActivateChannelAgreement(id: string) {
return request.put({ url: `/web/admin/channel-agreements/${id}/activate` })
}
export function fetchVoidChannelAgreement(id: string) {
return request.put({ url: `/web/admin/channel-agreements/${id}/void` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 渠道协议产品价格(子表)
// ---------------------------------------------------------------------------
export function fetchChannelAgreementPrices(agreementId: string) {
return request.get({ url: `/web/admin/channel-agreements/${agreementId}/product-prices` })
}
export function fetchCreateChannelAgreementPrice(agreementId: string, data: Record<string, any>) {
return request.post({
url: `/web/admin/channel-agreements/${agreementId}/product-prices`,
params: data
})
}
export function fetchUpdateChannelAgreementPrice(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/channel-agreement-product-prices/${id}`, params: data })
}
export function fetchDeleteChannelAgreementPrice(id: string) {
return request.del({ url: `/web/admin/channel-agreement-product-prices/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 体检机构(主表)
// ---------------------------------------------------------------------------
export function fetchCheckupInstitutionList(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
return request.get({
url: '/web/admin/checkup-institutions',
params
})
}
export function fetchCreateCheckupInstitution(data: Record<string, any>) {
return request.post({ url: '/web/admin/checkup-institutions', params: data })
}
export function fetchUpdateCheckupInstitution(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/checkup-institutions/${id}`, params: data })
}
export function fetchDeleteCheckupInstitution(id: string) {
return request.del({ url: `/web/admin/checkup-institutions/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 体检机构开票信息(子表)
// ---------------------------------------------------------------------------
export function fetchCheckupInstitutionInvoices(institutionId: string) {
return request.get({ url: `/web/admin/checkup-institutions/${institutionId}/invoices` })
}
export function fetchCreateCheckupInstitutionInvoice(
institutionId: string,
data: Record<string, any>
) {
return request.post({
url: `/web/admin/checkup-institutions/${institutionId}/invoices`,
params: data
})
}
export function fetchUpdateCheckupInstitutionInvoice(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/checkup-institution-invoices/${id}`, params: data })
}
export function fetchDeleteCheckupInstitutionInvoice(id: string) {
return request.del({ url: `/web/admin/checkup-institution-invoices/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 体检机构项目及价格(子表)
// ---------------------------------------------------------------------------
export function fetchCheckupInstitutionProjects(institutionId: string) {
return request.get({ url: `/web/admin/checkup-institutions/${institutionId}/projects` })
}
export function fetchCreateCheckupInstitutionProject(
institutionId: string,
data: Record<string, any>
) {
return request.post({
url: `/web/admin/checkup-institutions/${institutionId}/projects`,
params: data
})
}
export function fetchUpdateCheckupInstitutionProject(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/checkup-institution-projects/${id}`, params: data })
}
export function fetchDeleteCheckupInstitutionProject(id: string) {
return request.del({ url: `/web/admin/checkup-institution-projects/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 体检机构项目套餐
// ---------------------------------------------------------------------------
export function fetchCheckupInstitutionPackages(institutionId: string) {
return request.get({ url: `/web/admin/checkup-institutions/${institutionId}/packages` })
}
export function fetchCheckupInstitutionPackageDetail(packageId: string) {
return request.get({ url: `/web/admin/checkup-institution-packages/${packageId}` })
}
export function fetchCreateCheckupInstitutionPackage(
institutionId: string,
data: Record<string, unknown>
) {
return request.post({
url: `/web/admin/checkup-institutions/${institutionId}/packages`,
params: data
})
}
export function fetchUpdateCheckupInstitutionPackage(
packageId: string,
data: Record<string, unknown>
) {
return request.put({ url: `/web/admin/checkup-institution-packages/${packageId}`, params: data })
}
export function fetchDeleteCheckupInstitutionPackage(packageId: string) {
return request.del({ url: `/web/admin/checkup-institution-packages/${packageId}` })
}
+227
View File
@@ -0,0 +1,227 @@
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}` })
}
+131
View File
@@ -0,0 +1,131 @@
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
}) {
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: { donationStatus: string; desc?: string; time?: 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
})
}
@@ -589,7 +589,6 @@ body {
}
.detail-section {
border: 1px solid var(--mint-border);
border-radius: var(--mint-radius);
overflow: hidden;
margin-bottom: 14px;
+147
View File
@@ -250,6 +250,34 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
}
]
},
{
id: '60000000000000000000000000000001',
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 }
},
{
id: '60000000000000000000000000000003',
name: 'ResearchDonationAudit',
path: 'audit',
component: '/research-donation/audit/index',
meta: { title: '捐赠协议审核', keepAlive: true }
}
]
},
{
id: '70000000000000000000000000000001',
name: 'QualityInspection',
@@ -374,5 +402,124 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
meta: { title: '请假申请', keepAlive: true }
}
]
},
{
id: '90000000000000000000000000000001',
name: 'ProductionManagement',
path: '/production',
component: '/index/index',
meta: {
title: '生产管理',
icon: 'grid',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '90000000000000000000000000000010',
name: 'ProductionConsumable',
path: 'consumable',
component: '/production/consumable/index',
meta: { title: '耗材管理', keepAlive: true, menuCode: 'production:consumable:list' }
},
{
id: '90000000000000000000000000000020',
name: 'ProductionReagent',
path: 'reagent',
component: '/production/reagent/index',
meta: { title: '试剂管理', keepAlive: true, menuCode: 'production:reagent:list' }
},
{
id: '90000000000000000000000000000030',
name: 'ProductionSop',
path: 'sop',
component: '/production/sop/index',
meta: { title: 'SOP文档', keepAlive: true, menuCode: 'production:sop:list' }
},
{
id: '90000000000000000000000000000040',
name: 'ProductionProcessCategory',
path: 'process-category',
component: '/production/process-category/index',
meta: { title: '工艺工序分类', keepAlive: true, menuCode: 'production:process-category:list' }
},
{
id: '90000000000000000000000000000050',
name: 'ProductionOutputType',
path: 'output-type',
component: '/production/output-type/index',
meta: { title: '工艺产出物类型', keepAlive: true, menuCode: 'production:output-type:list' }
},
{
id: '90000000000000000000000000000060',
name: 'ProductionConsumablePack',
path: 'consumable-pack',
component: '/production/consumable-pack/index',
meta: { title: '耗材包管理', keepAlive: true, menuCode: 'production:consumable-pack:list' }
},
{
id: '90000000000000000000000000000070',
name: 'ProductionProcess',
path: 'process',
component: '/production/process/index',
meta: { title: '工艺库管理', keepAlive: true, menuCode: 'production:process:list' }
}
]
},
{
id: '80000000000000000000000000000001',
name: 'PilotService',
path: '/pilot-service',
component: '/index/index',
meta: {
title: '中试服务管理',
icon: 'ri:flask-line',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '80000000000000000000000000000002',
name: 'CellProductType',
path: 'cell-product-type',
component: '/pilot-service/cell-product-type/index',
meta: { title: '细胞产品类型管理', keepAlive: true }
},
{
id: '80000000000000000000000000000003',
name: 'CellProductSpec',
path: 'cell-product-spec',
component: '/pilot-service/cell-product-spec/index',
meta: { title: '细胞产品规格管理', keepAlive: true }
},
{
id: '80000000000000000000000000000004',
name: 'CellProductPrice',
path: 'cell-product-price',
component: '/pilot-service/cell-product-price/index',
meta: { title: '细胞产品价格体系管理', keepAlive: true }
},
{
id: '80000000000000000000000000000005',
name: 'ProjectPriceSystem',
path: 'project-price-system',
component: '/pilot-service/project-price-system/index',
meta: { title: '细胞产品项目价格管理', keepAlive: true }
},
{
id: '80000000000000000000000000000006',
name: 'Channel',
path: 'channel',
component: '/pilot-service/channel/index',
meta: { title: '渠道信息管理', keepAlive: true }
},
{
id: '80000000000000000000000000000007',
name: 'CheckupInstitution',
path: 'checkup-institution',
component: '/pilot-service/checkup-institution/index',
meta: { title: '体检机构及项目价格', keepAlive: true }
}
]
}
]
+26
View File
@@ -759,6 +759,32 @@ declare namespace Api {
}>
}
}
namespace CellProductType {
interface CellProductTypeItem {
id: string
productTypeCode: string
productTypeName: string
tenantNo: string
orderNum: number
remark: string
createTime: string
updateTime: string
}
}
namespace CellProductSpec {
interface CellProductSpecItem {
id: string
specCode: string
specName: string
tenantNo: string
orderNum: number
remark: string
createTime: string
updateTime: string
}
}
}
declare module '*.vue' {
@@ -64,9 +64,10 @@
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } 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'
@@ -165,13 +166,59 @@
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
}
const rows = [...props.targets]
const isBatch = rows.length > 1
submitting.value = true
try {
@@ -0,0 +1,460 @@
<!-- 细胞产品价格体系管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>细胞产品价格体系管理</h1>
<p class="page-desc">维护细胞产品定价体系,关联类型与规格。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">价格列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="项目名称/编码" clearable />
</el-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-button>
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</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" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
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 ? `${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="操作" width="320" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)"
><el-icon><View /></el-icon>查看</el-button
>
<el-button class="btn-op-item primary" @click="handleEdit(row)"
><el-icon><Edit /></el-icon>编辑</el-button
>
<el-button class="btn-op-item" @click="handleDelete(row)"
><el-icon><Delete /></el-icon>删除</el-button
>
</template>
</el-table-column>
</el-table>
<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">
<el-input-number
v-model="formData.unitPrice"
:precision="2"
:min="0"
:controls="false"
placeholder="请输入单价"
style="width: 100%"
/>
</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"
:meta="viewRow?.createTime || ''"
:sections="viewSections"
/>
</section>
</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 } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCellProductPriceList,
fetchCreateCellProductPrice,
fetchUpdateCellProductPrice,
fetchDeleteCellProductPrice,
fetchCellProductTypeList,
fetchCellProductSpecList
} from '@/api/admin'
defineOptions({ name: 'PilotCellProductPrice' })
// --- 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 ? 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
}
// --- Delete ---
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该价格记录吗?', '提示', { type: 'warning' })
await fetchDeleteCellProductPrice(row.id)
ElMessage.success('删除成功')
await loadData()
}
// --- 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)
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.price-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -0,0 +1,327 @@
<!-- 细胞产品规格管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>细胞产品规格管理</h1>
<p class="page-desc">维护细胞产品的规格编码与名称。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">规格列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="规格编码/名称" clearable />
</el-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-button>
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</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" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
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="操作" width="320" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)"
><el-icon><View /></el-icon>查看</el-button
>
<el-button class="btn-op-item primary" @click="handleEdit(row)"
><el-icon><Edit /></el-icon>编辑</el-button
>
<el-button class="btn-op-item" @click="handleDelete(row)"
><el-icon><Delete /></el-icon>删除</el-button
>
</template>
</el-table-column>
</el-table>
<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"
:meta="viewRow?.createTime || ''"
:sections="viewSections"
/>
</section>
</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 } 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: 'PilotCellProductSpec' })
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
}
async function handleDelete(row: Api.CellProductSpec.CellProductSpecItem) {
await ElMessageBox.confirm('确定要删除该规格吗?', '提示', { type: 'warning' })
await fetchDeleteCellProductSpec(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 fetchDeleteCellProductSpec(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.spec-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -0,0 +1,327 @@
<!-- 细胞产品类型管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>细胞产品类型管理</h1>
<p class="page-desc">维护细胞产品的类型编码与名称。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">类型列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="类型编码/名称" clearable />
</el-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-button>
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</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" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="productTypeCode" label="类型编码" min-width="120" align="center" header-align="center" />
<el-table-column prop="productTypeName" 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="操作" width="320" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)"
><el-icon><View /></el-icon>查看</el-button
>
<el-button class="btn-op-item primary" @click="handleEdit(row)"
><el-icon><Edit /></el-icon>编辑</el-button
>
<el-button class="btn-op-item" @click="handleDelete(row)"
><el-icon><Delete /></el-icon>删除</el-button
>
</template>
</el-table-column>
</el-table>
<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="type-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="类型编码" prop="productTypeCode">
<el-input v-model="formData.productTypeCode" placeholder="请输入类型编码" />
</el-form-item>
<el-form-item label="类型名称" prop="productTypeName">
<el-input v-model="formData.productTypeName" 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?.productTypeName || '类型详情'"
:icon="ScaleToOriginal"
:meta="viewRow?.createTime || ''"
:sections="viewSections"
/>
</section>
</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 } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCellProductTypeList,
fetchCreateCellProductType,
fetchUpdateCellProductType,
fetchDeleteCellProductType
} from '@/api/admin'
defineOptions({ name: 'PilotCellProductType' })
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<Api.CellProductType.CellProductTypeItem[]>([])
const selectedRows = ref<Api.CellProductType.CellProductTypeItem[]>([])
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.CellProductType.CellProductTypeItem | null>(null)
const formData = reactive({
productTypeCode: '',
productTypeName: '',
remark: ''
})
const formRules = {
productTypeCode: [{ required: true, message: '请输入类型编码', trigger: 'blur' }],
productTypeName: [{ required: true, message: '请输入类型名称', trigger: 'blur' }]
}
const handleSelectionChange = (rows: Api.CellProductType.CellProductTypeItem[]) => {
selectedRows.value = rows
}
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '类型编码', value: row.productTypeCode },
{ label: '类型名称', value: row.productTypeName },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
async function loadData() {
loading.value = true
try {
const res = await fetchCellProductTypeList({
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.productTypeCode = ''
formData.productTypeName = ''
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增类型'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleEdit(row: Api.CellProductType.CellProductTypeItem) {
isEdit.value = true
dialogTitle.value = '编辑类型'
editingId.value = row.id
formData.productTypeCode = row.productTypeCode || ''
formData.productTypeName = row.productTypeName || ''
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
productTypeCode: formData.productTypeCode.trim(),
productTypeName: formData.productTypeName.trim(),
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateCellProductType(editingId.value, payload)
} else {
await fetchCreateCellProductType(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
function handleView(row: Api.CellProductType.CellProductTypeItem) {
viewRow.value = row
viewVisible.value = true
}
async function handleDelete(row: Api.CellProductType.CellProductTypeItem) {
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
await fetchDeleteCellProductType(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 fetchDeleteCellProductType(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.type-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -0,0 +1,815 @@
<!-- 渠道信息 — 只读查看(样式对齐采集协议单 ProtocolViewDialog) -->
<template>
<div class="channel-view-dialog-root">
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="1100px"
class="dialog-form-layout channel-view-dialog"
align-center
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="handleClosed"
>
<div v-loading="loading" class="channel-view-body">
<template v-if="channel">
<div class="detail-tabs">
<div
v-for="tab in viewTabs"
:key="tab.key"
class="detail-tab"
:class="{ active: activeTab === tab.key }"
@click="scrollToSection(tab.key)"
>
{{ tab.label }}
</div>
</div>
<div class="kpi-strip">
<div class="kpi-card">
<div class="kpi-label">渠道名称</div>
<div class="kpi-value">{{ dash(channel.channelName) }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">渠道类型</div>
<div class="kpi-value">
<span class="badge" :class="channelTypeBadgeClass(channel.channelType)">
{{ channelTypeLabel(channel.channelType) }}
</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">联系人</div>
<div class="kpi-value">{{ dash(channel.contactName) }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">创建时间</div>
<div class="kpi-value">{{ dash(channel.createTime) }}</div>
</div>
</div>
<div id="view-section-basic" class="section-card section-card--white">
<div class="section-title"><h3>基础信息</h3></div>
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">渠道名称</div>
<div class="data-value">{{ dash(channel.channelName) }}</div>
</div>
<div class="data-item">
<div class="data-label">统一社会信用代码</div>
<div class="data-value">{{ dash(channel.creditCode) }}</div>
</div>
<div class="data-item">
<div class="data-label">渠道类型</div>
<div class="data-value">
<span class="badge" :class="channelTypeBadgeClass(channel.channelType)">
{{ channelTypeLabel(channel.channelType) }}
</span>
</div>
</div>
<div class="data-item">
<div class="data-label">联系人</div>
<div class="data-value">{{ dash(channel.contactName) }}</div>
</div>
<div class="data-item">
<div class="data-label">联系电话</div>
<div class="data-value">{{ dash(channel.contactPhone) }}</div>
</div>
<div class="data-item">
<div class="data-label">联系邮箱</div>
<div class="data-value">{{ dash(channel.contactEmail) }}</div>
</div>
<div class="data-item">
<div class="data-label">创建时间</div>
<div class="data-value">{{ dash(channel.createTime) }}</div>
</div>
</div>
<div v-if="protocolAttachmentFiles.length" class="attachment-row">
<div class="data-label">渠道协议附件</div>
<ProtocolAttachmentReadonly
:files="protocolAttachmentFiles"
@preview="openPreview"
@download="downloadFile"
/>
</div>
<div v-if="channel.remark" class="remark-row">
<div class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(channel.remark) }}</div>
</div>
</div>
</div>
<div id="view-section-invoice" class="section-card section-card--green">
<div class="section-title"><h3>开票信息</h3></div>
<div v-if="invoiceList.length" class="sub-cards">
<div v-for="(row, idx) in invoiceList" :key="row.id || idx" class="sub-card">
<div class="sub-card-header">
<h4>{{ row.companyName || '未命名' }}</h4>
</div>
<div class="data-grid data-grid--2">
<div v-if="row.taxNo" class="data-item">
<div class="data-label">税号</div>
<div class="data-value">{{ dash(row.taxNo) }}</div>
</div>
<div v-if="row.phone || row.companyPhone" class="data-item">
<div class="data-label">电话</div>
<div class="data-value">{{ dash(row.phone || row.companyPhone) }}</div>
</div>
<div v-if="row.companyAddress" class="data-item data-item--full">
<div class="data-label">单位地址</div>
<div class="data-value data-value--muted">{{ dash(row.companyAddress) }}</div>
</div>
<div v-if="row.bankName" class="data-item">
<div class="data-label">开户银行</div>
<div class="data-value">{{ dash(row.bankName) }}</div>
</div>
<div v-if="row.bankAccount" class="data-item">
<div class="data-label">银行账号</div>
<div class="data-value">{{ dash(row.bankAccount) }}</div>
</div>
</div>
</div>
</div>
<el-empty v-else description="暂无开票信息" :image-size="48" />
</div>
<div id="view-section-mail" class="section-card section-card--white">
<div class="section-title"><h3>邮寄地址</h3></div>
<div v-if="mailList.length" class="sub-cards">
<div v-for="(row, idx) in mailList" :key="row.id || idx" class="sub-card">
<div class="sub-card-header">
<h4>{{ row.mailAddress || '未命名' }}</h4>
</div>
<div class="data-grid data-grid--2">
<div v-if="row.mailContactName || row.mailContact" class="data-item">
<div class="data-label">联系人</div>
<div class="data-value">{{ dash(row.mailContactName || row.mailContact) }}</div>
</div>
<div v-if="row.mailContactPhone || row.mailPhone" class="data-item">
<div class="data-label">联系电话</div>
<div class="data-value">{{ dash(row.mailContactPhone || row.mailPhone) }}</div>
</div>
</div>
</div>
</div>
<el-empty v-else description="暂无邮寄地址" :image-size="48" />
</div>
<div id="view-section-agreement" class="section-card section-card--gray">
<div class="section-title"><h3>协议信息</h3></div>
<div v-if="agreementList.length" class="agreement-cards">
<div
v-for="(agr, idx) in agreementList"
:key="agr.id || idx"
class="agreement-card"
>
<div class="agreement-card__header">
<div class="agreement-card__tags">
<span class="badge" :class="agreementTypeBadgeClass(agr.agreementType)">
{{ agreementTypeLabel(agr.agreementType) }}
</span>
<span class="badge" :class="agreementStatusBadgeClass(agr.status)">
{{ agreementStatusLabel(agr.status) }}
</span>
</div>
</div>
<div class="data-grid data-grid--4">
<div v-if="agr.settlementType" class="data-item">
<div class="data-label">结算方式</div>
<div class="data-value">
<span class="badge" :class="settlementTypeBadgeClass(agr.settlementType)">
{{ settlementTypeLabel(agr.settlementType) }}
</span>
</div>
</div>
<div v-if="agr.creditLimit != null" class="data-item">
<div class="data-label">授信额度(元)</div>
<div class="data-value data-value--price">{{ dash(agr.creditLimit) }}</div>
</div>
<div v-if="agr.effectiveDate" class="data-item">
<div class="data-label">生效日期</div>
<div class="data-value">{{ dash(agr.effectiveDate) }}</div>
</div>
<div v-if="agr.expiryDate" class="data-item">
<div class="data-label">到期日期</div>
<div class="data-value">{{ dash(agr.expiryDate) }}</div>
</div>
</div>
</div>
</div>
<el-empty v-else description="暂无协议信息" :image-size="48" />
</div>
</template>
</div>
<template #footer>
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
<el-icon><Close /></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>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { Close } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import {
fetchChannelInvoices,
fetchChannelMailAddresses,
fetchChannelAgreements
} from '@/api/admin'
const props = defineProps<{
modelValue: boolean
channelId: string | null
listRow?: Record<string, any> | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
}>()
const viewTabs = [
{ key: 'basic', label: '基础信息' },
{ key: 'invoice', label: '开票信息' },
{ key: 'mail', label: '邮寄地址' },
{ key: 'agreement', label: '协议信息' }
] as const
const channelTypeMap: Record<string, string> = {
yy: '医院',
hzjg: '合作机构',
zs: '诊所',
zfjg: '政府机构',
qt: '其他'
}
const agreementTypeMap: Record<string, string> = {
kyxy: '框架协议',
zsxy: '中试协议',
lcxy: '临床协议'
}
const settlementTypeMap: Record<string, string> = {
yj: '月结',
jj: '季结',
nj: '年结',
dcjs: '单次结算'
}
const agreementStatusMap: Record<string, string> = {
dsx: '待生效',
sxz: '生效中',
yzf: '已作废'
}
const loading = ref(false)
const activeTab = ref('basic')
const invoiceList = ref<any[]>([])
const mailList = ref<any[]>([])
const agreementList = ref<any[]>([])
const channel = computed(() => props.listRow || null)
const dialogTitle = computed(() => {
const name = channel.value?.channelName
return name ? `查看渠道 — ${name}` : '查看渠道'
})
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const protocolAttachmentFiles = computed(() => {
const urls = String(channel.value?.protocolAttachments || '').trim()
if (!urls) 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: `att_${i}`, fileUrl: url, fileName: name, fileSize: 0 }
})
.filter(Boolean) as Array<Record<string, any>>
})
function dash(val: unknown) {
if (val === null || val === undefined || val === '') return '—'
return String(val)
}
function channelTypeLabel(type: string) {
const key = String(type || '').trim()
if (!key) return '—'
return channelTypeMap[key] || key
}
function channelTypeBadgeClass(type: string): string {
const m: Record<string, string> = {
yy: 'badge--blue',
hzjg: 'badge--green',
zs: 'badge--amber',
zfjg: 'badge--red',
qt: 'badge--gray'
}
return m[String(type || '')] || 'badge--gray'
}
function agreementTypeLabel(type: string) {
return agreementTypeMap[String(type || '')] || dash(type)
}
function agreementTypeBadgeClass(type: string): string {
const m: Record<string, string> = {
kyxy: 'badge--blue',
zsxy: 'badge--green',
lcxy: 'badge--amber'
}
return m[String(type || '')] || 'badge--gray'
}
function settlementTypeLabel(type: string) {
return settlementTypeMap[String(type || '')] || dash(type)
}
function settlementTypeBadgeClass(type: string): string {
const m: Record<string, string> = {
yj: 'badge--gray',
jj: 'badge--green',
nj: 'badge--amber',
dcjs: 'badge--red'
}
return m[String(type || '')] || 'badge--gray'
}
function normalizeAgreementRow(row: Record<string, any>) {
const status = String(row.agreementStatus ?? row.status ?? 'dsx').trim() || 'dsx'
return {
...row,
settlementType: row.settleMode ?? row.settlementType ?? '',
creditLimit: row.creditLimitAmount ?? row.creditLimit ?? null,
expiryDate: row.expireDate ?? row.expiryDate ?? '',
effectiveDate: row.effectiveDate ?? '',
status
}
}
function agreementStatusLabel(status: string) {
return agreementStatusMap[String(status || '')] || dash(status)
}
function agreementStatusBadgeClass(status: string): string {
const m: Record<string, string> = {
dsx: 'badge--amber',
sxz: 'badge--green',
yzf: 'badge--red'
}
return m[String(status || '')] || 'badge--gray'
}
function fullFileUrl(suffix: string) {
if (!suffix) return ''
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/')) {
return suffix
}
return `${minioBaseUrl}/${suffix}`
}
function isVideoFile(file: { fileType?: string; fileName?: string }) {
const t = (file.fileType || '').toLowerCase()
if (t.startsWith('video/')) return true
return /\.(mp4|webm|ogg|mov)$/i.test(file.fileName || '')
}
const previewVisible = ref(false)
const previewUrl = ref('')
const previewType = ref<'image' | 'video'>('image')
function openPreview(file: Record<string, any>) {
if (!file.fileUrl) return
previewUrl.value = fullFileUrl(file.fileUrl)
previewType.value = isVideoFile(file) ? 'video' : 'image'
previewVisible.value = true
}
function downloadFile(file: Record<string, any>) {
const url = fullFileUrl(file.fileUrl)
if (!url) return
const a = document.createElement('a')
a.href = url
a.download = file.fileName || ''
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
async function loadSubTables() {
if (!props.channelId) {
invoiceList.value = []
mailList.value = []
agreementList.value = []
return
}
loading.value = true
try {
const [invRes, mailRes, agrRes] = await Promise.all([
fetchChannelInvoices(props.channelId),
fetchChannelMailAddresses(props.channelId),
fetchChannelAgreements(props.channelId)
])
const invRows = Array.isArray(invRes) ? invRes : (invRes as any)?.list || []
const mailRows = Array.isArray(mailRes) ? mailRes : (mailRes as any)?.list || []
invoiceList.value = invRows.map((row: any) => ({
...row,
phone: row.companyPhone ?? row.phone ?? '',
companyPhone: row.companyPhone ?? row.phone ?? ''
}))
mailList.value = mailRows.map((row: any) => ({
...row,
mailContactName: row.mailContact ?? row.mailContactName ?? '',
mailContactPhone: row.mailPhone ?? row.mailContactPhone ?? ''
}))
const agrRows = Array.isArray(agrRes) ? agrRes : (agrRes as any)?.list || []
agreementList.value = agrRows.map((row: Record<string, any>) => normalizeAgreementRow(row))
} catch {
invoiceList.value = []
mailList.value = []
agreementList.value = []
} finally {
loading.value = false
}
}
function scrollToSection(key: string) {
activeTab.value = key
nextTick(() => {
document
.getElementById(`view-section-${key}`)
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
function handleClosed() {
invoiceList.value = []
mailList.value = []
agreementList.value = []
activeTab.value = 'basic'
}
watch(
() => [props.modelValue, props.channelId] as const,
([visible, id]) => {
if (visible && id) loadSubTables()
}
)
</script>
<style scoped lang="scss">
.channel-view-body {
overflow: visible;
}
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin-bottom: 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #059669;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #059669;
}
}
.kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.kpi-card {
background: #fff;
border-radius: 12px;
padding: 18px 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
border-left: 3px solid #10b981;
transition:
transform 0.2s,
box-shadow 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.1);
}
}
.kpi-label {
font-size: 12px;
color: #94a3b8;
margin-bottom: 6px;
letter-spacing: 0.5px;
}
.kpi-value {
font-size: 16px;
font-weight: 600;
color: #1e293b;
display: flex;
align-items: center;
gap: 6px;
}
.section-card {
border-radius: 12px;
padding: 22px 24px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition:
transform 0.2s,
box-shadow 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&--white {
background: #fff;
}
&--green {
background: #f0fdf4;
}
&--gray {
background: #f8fafc;
}
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
&::before {
content: '';
width: 3px;
height: 18px;
background: #10b981;
border-radius: 2px;
margin-right: 10px;
flex-shrink: 0;
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #1e293b;
line-height: 1.4;
}
}
.data-grid {
display: grid;
gap: 14px 20px;
&--2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
&--4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.data-item {
min-width: 0;
&--full {
grid-column: 1 / -1;
}
}
.data-label {
font-size: 12px;
color: #94a3b8;
margin-bottom: 3px;
}
.data-value {
font-size: 14px;
color: #1e293b;
font-weight: 500;
word-break: break-word;
&--muted {
color: #64748b;
font-weight: 400;
}
&--price {
color: #059669;
font-weight: 700;
}
}
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
line-height: 1.6;
&--green {
background: #ecfdf5;
color: #059669;
}
&--amber {
background: #fffbeb;
color: #d97706;
}
&--gray {
background: #f1f5f9;
color: #64748b;
}
&--red {
background: #fef2f2;
color: #dc2626;
}
&--blue {
background: #eff6ff;
color: #2563eb;
}
}
.attachment-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
display: flex;
flex-direction: column;
gap: 8px;
}
.remark-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
}
.sub-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
.sub-card {
background: #fff;
border-radius: 10px;
padding: 16px 18px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.03);
transition:
transform 0.2s,
box-shadow 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07);
}
}
.sub-card-header {
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.agreement-cards {
display: flex;
flex-direction: column;
gap: 14px;
}
.agreement-card {
background: #fff;
border-radius: 10px;
border: 1px solid #e2e8f0;
overflow: hidden;
transition:
border-color 0.2s,
box-shadow 0.2s;
&:hover {
border-color: #a7f3d0;
box-shadow: 0 2px 12px rgba(16, 185, 129, 0.1);
}
}
.agreement-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.agreement-card__tags {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.agreement-card .data-grid {
padding: 14px 18px;
}
.preview-container {
text-align: center;
}
@media (max-width: 1200px) {
.kpi-strip {
grid-template-columns: repeat(2, 1fr);
}
.data-grid--3,
.data-grid--4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sub-cards {
grid-template-columns: 1fr;
}
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,461 @@
<!-- 体检机构 — 项目套餐(机构内) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="package-panel">
<div class="list-toolbar item-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 item-table-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
max-height="420"
@selection-change="(rows) => (selectedRows = rows)"
>
<el-table-column type="selection" width="55" align="center" header-align="center" />
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column prop="packageName" label="套餐名称" min-width="140" align="center" header-align="center" />
<el-table-column prop="packageCode" label="套餐编码" min-width="120" align="center" header-align="center" />
<el-table-column prop="itemCount" label="项目数" width="80" align="center" header-align="center" />
<el-table-column label="合计费用(元)" width="120" align="center" header-align="center">
<template #default="{ row }">{{
row.totalFee != null ? `¥${row.totalFee}` : '—'
}}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<span :class="['tag', row.status == '1' ? 'success' : 'muted']">
{{ row.status == '1' ? '正常' : '停用' }}
</span>
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="120"
align="center"
show-overflow-tooltip
/>
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="handleEdit(row)"
><el-icon><Edit /></el-icon>编辑</el-button
>
<el-button class="btn-op-item" @click="handleDelete(row)"
><el-icon><Delete /></el-icon>删除</el-button
>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="980px"
class="dialog-form-layout"
align-center
append-to-body
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="package-dialog-form"
scroll-to-error
>
<div class="form-grid-2">
<el-form-item label="套餐名称" prop="packageName">
<el-input v-model="formData.packageName" placeholder="请输入套餐名称" />
</el-form-item>
<el-form-item label="套餐编码" prop="packageCode">
<el-input v-model="formData.packageCode" placeholder="机构内唯一" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio value="1">正常</el-radio>
<el-radio value="0">停用</el-radio>
</el-radio-group>
</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="openProjectSelector">
<el-icon><Plus /></el-icon>添加项目
</el-button>
</div>
<el-table :data="detailItems" size="small" class="detail-table" max-height="280">
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column label="项目名称" min-width="160" align="center">
<template #default="{ row }">
{{ row.projectName }}
<span v-if="row.sourceProjectDeleted" class="form-label-tip">源项目已删除</span>
</template>
</el-table-column>
<el-table-column label="项目费用(元)" min-width="120" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.projectFee"
:precision="2"
:min="0"
:controls="false"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center" header-align="center">
<template #default="{ row }">
<span :class="['tag', row.status == '1' ? 'success' : 'muted']">
{{ row.status == '1' ? '正常' : '停用' }}
</span>
</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="操作" width="260" fixed="right" align="center" header-align="center">
<template #default="{ $index }">
<el-button class="btn-op-item" @click="detailItems.splice($index, 1)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="package-total"
>套餐合计:<strong>¥{{ packageTotalFee.toFixed(2) }}</strong></div
>
</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" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="projectSelectorVisible"
title="添加体检项目"
width="860px"
class="dialog-form-layout"
align-center
append-to-body
destroy-on-close
:close-on-click-modal="false"
>
<el-input
v-model="projectSelectorKeyword"
placeholder="搜索项目名称"
clearable
style="width: 260px; margin-bottom: 12px"
/>
<el-table
:data="filteredProjectList"
max-height="360"
@selection-change="(rows) => (projectSelectorSelected = rows)"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column prop="projectName" label="项目名称" min-width="140" align="center" />
<el-table-column label="项目费用(元)" width="110" align="center">
<template #default="{ row }">{{
row.projectFee != null ? `¥${row.projectFee}` : '—'
}}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<span :class="['tag', row.status == '1' ? 'success' : 'muted']">
{{ row.status == '1' ? '正常' : '停用' }}
</span>
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="120"
align="center"
show-overflow-tooltip
/>
</el-table>
<template #footer>
<el-button class="btn-ghost" @click="projectSelectorVisible = false">取消</el-button>
<el-button
class="btn-main"
type="primary"
:disabled="projectSelectorSelected.length === 0"
@click="confirmProjectSelector"
>
确认添加({{ projectSelectorSelected.length }})
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check, Edit, Delete } from '@element-plus/icons-vue'
import {
fetchCheckupInstitutionPackages,
fetchCheckupInstitutionPackageDetail,
fetchCreateCheckupInstitutionPackage,
fetchUpdateCheckupInstitutionPackage,
fetchDeleteCheckupInstitutionPackage,
fetchCheckupInstitutionProjects
} from '@/api/admin'
const props = defineProps<{
institutionId: string
institutionName: string
}>()
const tableData = ref<any[]>([])
const selectedRows = ref<any[]>([])
const loading = ref(false)
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 formData = reactive({
packageName: '',
packageCode: '',
status: '1',
remark: ''
})
const formRules = {
packageName: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }],
packageCode: [{ required: true, message: '请输入套餐编码', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
const detailItems = ref<any[]>([])
const packageTotalFee = computed(() =>
detailItems.value.reduce((sum, row) => sum + (Number(row.projectFee) || 0), 0)
)
const projectSelectorVisible = ref(false)
const projectSelectorKeyword = ref('')
const projectSelectorList = ref<any[]>([])
const projectSelectorSelected = ref<any[]>([])
const filteredProjectList = computed(() => {
const kw = projectSelectorKeyword.value.trim().toLowerCase()
const used = new Set(detailItems.value.map((r) => r.checkupInstitutionProjectId))
return projectSelectorList.value.filter((row) => {
if (row.status != '1') return false
if (used.has(row.id)) return false
if (!kw) return true
return (row.projectName || '').toLowerCase().includes(kw)
})
})
function parseList(res: unknown) {
return Array.isArray(res) ? res : (res as any)?.list || []
}
async function loadPackages() {
if (!props.institutionId) return
loading.value = true
try {
const res = await fetchCheckupInstitutionPackages(props.institutionId)
tableData.value = parseList(res)
} finally {
loading.value = false
}
}
function resetForm() {
formData.packageName = ''
formData.packageCode = ''
formData.status = '1'
formData.remark = ''
detailItems.value = []
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增套餐'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleEdit(row: any) {
isEdit.value = true
dialogTitle.value = '编辑套餐'
editingId.value = row.id
dialogVisible.value = true
try {
const detail = await fetchCheckupInstitutionPackageDetail(row.id)
const d = (detail as any)?.data ?? detail
formData.packageName = d.packageName || ''
formData.packageCode = d.packageCode || ''
formData.status = d.status != null ? String(d.status) : '1'
formData.remark = d.remark || ''
detailItems.value = (d.items || []).map((it: any) => ({
checkupInstitutionProjectId: it.checkupInstitutionProjectId,
projectName: it.projectName,
projectFee: it.projectFee ?? 0,
status: it.status != null ? String(it.status) : '1',
remark: it.remark || '',
sourceProjectDeleted: !!it.sourceProjectDeleted
}))
} catch {
detailItems.value = []
}
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
const valid = await formRef.value?.validate?.().catch(() => false)
if (!valid) return
if (detailItems.value.length === 0) {
ElMessage.warning('请至少添加一个体检项目')
return
}
submitLoading.value = true
try {
const payload = {
packageName: formData.packageName.trim(),
packageCode: formData.packageCode.trim(),
status: formData.status,
remark: formData.remark?.trim() || undefined,
items: detailItems.value.map((row, idx) => ({
checkupInstitutionProjectId: row.checkupInstitutionProjectId,
projectName: row.projectName,
projectFee: row.projectFee,
remark: row.remark?.trim() || undefined,
orderNum: idx + 1
}))
}
if (isEdit.value && editingId.value) {
await fetchUpdateCheckupInstitutionPackage(editingId.value, payload)
ElMessage.success('已保存')
} else {
await fetchCreateCheckupInstitutionPackage(props.institutionId, payload)
ElMessage.success('已新增')
}
dialogVisible.value = false
await loadPackages()
} finally {
submitLoading.value = false
}
}
async function handleDelete(row: any) {
await ElMessageBox.confirm(`确定删除套餐「${row.packageName}」?`, '提示', { type: 'warning' })
await fetchDeleteCheckupInstitutionPackage(row.id)
ElMessage.success('已删除')
await loadPackages()
}
async function openProjectSelector() {
projectSelectorKeyword.value = ''
projectSelectorSelected.value = []
projectSelectorVisible.value = true
try {
const res = await fetchCheckupInstitutionProjects(props.institutionId)
projectSelectorList.value = parseList(res)
} catch {
projectSelectorList.value = []
}
}
function confirmProjectSelector() {
for (const row of projectSelectorSelected.value) {
detailItems.value.push({
checkupInstitutionProjectId: row.id,
projectName: row.projectName || '',
projectFee: row.projectFee ?? 0,
status: row.status != null ? String(row.status) : '1',
remark: row.remark || '',
sourceProjectDeleted: false
})
}
projectSelectorVisible.value = false
}
watch(
() => props.institutionId,
(id) => {
if (id) loadPackages()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.package-panel {
padding-top: 0;
}
.package-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.detail-section {
margin-top: 16px;
.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;
}
}
.package-total {
margin-top: 12px;
text-align: right;
font-size: 14px;
color: var(--mint-text-muted);
strong {
color: #059669;
font-size: 16px;
}
}
</style>
@@ -0,0 +1,761 @@
<!-- 体检机构 — 项目价格配置 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>体检机构及项目价格</h1>
<p class="page-desc">维护体检机构基础信息、开票信息及项目价格管理。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">机构列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="机构名称/联系人" clearable />
</el-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" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1"
/>
<el-table-column prop="institutionName" label="机构名称" min-width="160" align="center" header-align="center" />
<el-table-column prop="institutionAddress" 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="状态" width="90" align="center" header-align="center">
<template #default="{ row }">
<span :class="['tag', row.status == '1' ? 'success' : 'muted']">
{{ row.status == '1' ? '正常' : '停用' }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="400" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)"><el-icon><View /></el-icon>查看</el-button>
<el-button class="btn-op-item primary" @click="openItemDialog(row)"><el-icon><Setting /></el-icon>项目价格配置</el-button>
<el-button class="btn-op-item" @click="handleEdit(row)"><el-icon><Edit /></el-icon>编辑</el-button>
<el-button class="btn-op-item" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
</template>
</el-table-column>
</el-table>
<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="institutionName">
<el-input v-model="companyForm.institutionName" placeholder="请输入机构名称" />
</el-form-item>
<el-form-item label="机构地址">
<el-input v-model="companyForm.institutionAddress" 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="1120px"
class="dialog-form-layout checkup-item-config-dialog"
align-center
destroy-on-close
:close-on-click-modal="false"
@closed="onItemDialogClosed"
>
<div class="item-dialog-head">
<span class="muted">当前机构:{{ currentInstitutionName }}</span>
</div>
<el-tabs v-model="itemConfigTab" class="item-config-tabs">
<el-tab-pane label="项目列表" name="projects">
<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" width="55" align="center" header-align="center" />
<el-table-column type="index" label="序号" 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="项目费用(元)" width="120" align="center" header-align="center">
<template #default="{ row }">{{ row.projectFee != null ? `¥${row.projectFee}` : '—' }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90" align="center" header-align="center">
<template #default="{ row }">
<span :class="['tag', row.status == '1' ? 'success' : 'muted']">
{{ row.status == '1' ? '正常' : '停用' }}
</span>
</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="操作" 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-tab-pane>
<el-tab-pane label="套餐管理" name="packages">
<CheckupPackagePanel
v-if="itemConfigTab === 'packages' && currentInstitutionId"
:institution-id="currentInstitutionId"
:institution-name="currentInstitutionName"
/>
</el-tab-pane>
</el-tabs>
<!-- 新增/编辑项目嵌套弹窗 -->
<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" />
</el-form-item>
<el-form-item label="项目费用(元)" prop="projectFee">
<el-input-number v-model="itemForm.projectFee" :min="0" :precision="2" style="width: 100%" />
</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="viewInstitutionName"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Close,
Check,
Search,
RefreshLeft,
View,
Setting,
Edit,
Delete,
OfficeBuilding
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import CheckupPackagePanel from './CheckupPackagePanel.vue'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import {
fetchCheckupInstitutionList,
fetchCreateCheckupInstitution,
fetchUpdateCheckupInstitution,
fetchDeleteCheckupInstitution,
fetchCheckupInstitutionInvoices,
fetchCreateCheckupInstitutionInvoice,
fetchUpdateCheckupInstitutionInvoice,
fetchDeleteCheckupInstitutionInvoice,
fetchCheckupInstitutionProjects,
fetchCreateCheckupInstitutionProject,
fetchUpdateCheckupInstitutionProject,
fetchDeleteCheckupInstitutionProject
} from '@/api/admin'
defineOptions({ name: 'PilotCheckupInstitution' })
// ---------------------------------------------------------------------------
// 搜索 & 主表
// ---------------------------------------------------------------------------
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({
institutionName: '',
institutionAddress: '',
contactName: '',
contactPhone: '',
invoiceTitle: '',
taxNo: '',
bankName: '',
bankAccount: '',
tag: 1 as number,
remark: ''
})
const companyFormRules = {
institutionName: [{ required: true, message: '请输入机构名称', trigger: 'blur' }],
tag: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
// ---------------------------------------------------------------------------
// 项目价格配置弹窗
// ---------------------------------------------------------------------------
const itemDialogVisible = ref(false)
const itemDialogTitle = ref('项目价格配置')
const itemConfigTab = ref<'projects' | 'packages'>('projects')
const currentInstitutionId = ref('')
const currentInstitutionName = 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 viewInstitutionName = ref('')
const viewSections = ref<ViewSection[]>([])
// ---------------------------------------------------------------------------
// 主表数据加载
// ---------------------------------------------------------------------------
async function loadData() {
loading.value = true
try {
const res = await fetchCheckupInstitutionList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
tag: searchForm.tag ? Number(searchForm.tag) : undefined, // 后端 list 接口暂无 tag/status 过滤参数
})
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.institutionName = ''
companyForm.institutionAddress = ''
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.institutionName = row.institutionName || ''
companyForm.institutionAddress = row.institutionAddress || ''
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 fetchCheckupInstitutionInvoices(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 = {
institutionName: companyForm.institutionName.trim(),
institutionAddress: companyForm.institutionAddress?.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 fetchUpdateCheckupInstitution(editingId.value, payload)
// 同步开票信息
const invRes = await fetchCheckupInstitutionInvoices(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 fetchUpdateCheckupInstitutionInvoice(invoices[0].id, invoicePayload)
} else if (companyForm.invoiceTitle) {
await fetchCreateCheckupInstitutionInvoice(editingId.value, invoicePayload)
}
ElMessage.success('已保存')
} else {
const newInst = await fetchCreateCheckupInstitution(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 fetchCreateCheckupInstitutionInvoice((newInst as any).id, invoicePayload)
}
ElMessage.success('已新增')
}
companyDialogVisible.value = false
loadData()
} finally {
companySubmitLoading.value = false
}
}
async function handleDelete(row: any) {
await ElMessageBox.confirm(`确定删除「${row.institutionName}」?其下属项目价格将一并删除。`, '提示', {
type: 'warning'
})
await fetchDeleteCheckupInstitution(row.id)
ElMessage.success('已删除')
loadData()
}
// ---------------------------------------------------------------------------
// 查看弹窗
// ---------------------------------------------------------------------------
async function handleView(row: any) {
viewInstitutionName.value = row.institutionName || ''
const sections: ViewSection[] = [
{
title: '基本信息',
fields: [
{ label: '机构名称', value: row.institutionName },
{ label: '机构地址', value: row.institutionAddress, span: true },
{ label: '联系人', value: row.contactName },
{ label: '联系电话', value: row.contactPhone },
{ label: '状态', value: row.status == '1' ? '正常' : '停用' },
{ label: '备注', value: row.remark, span: true }
]
}
]
// 加载开票信息
try {
const invRes = await fetchCheckupInstitutionInvoices(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, tab: 'projects' | 'packages' = 'projects') {
currentInstitutionId.value = row.id
currentInstitutionName.value = row.institutionName || ''
itemPagination.page = 1
itemSelected.value = []
itemConfigTab.value = tab
itemDialogTitle.value = `项目价格配置 — ${row.institutionName}`
itemDialogVisible.value = true
loadItems()
}
async function loadItems() {
if (!currentInstitutionId.value) return
itemLoading.value = true
try {
const res = await fetchCheckupInstitutionProjects(currentInstitutionId.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() {
currentInstitutionId.value = ''
currentInstitutionName.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 fetchUpdateCheckupInstitutionProject(editingItemId.value, payload)
ElMessage.success('已保存')
} else {
await fetchCreateCheckupInstitutionProject(currentInstitutionId.value, payload)
ElMessage.success('已新增')
}
itemFormVisible.value = false
loadItems()
} finally {
itemSubmitLoading.value = false
}
}
async function handleDeleteItem(row: any) {
await ElMessageBox.confirm(`确定删除「${row.projectName}」?`, '提示', { type: 'warning' })
await fetchDeleteCheckupInstitutionProject(row.id)
ElMessage.success('已删除')
loadItems()
}
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-config-tabs {
margin-top: 4px;
:deep(.el-tabs__content) {
min-height: 480px;
}
}
.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.checkup-item-config-dialog.dialog-form-layout {
min-height: min(720px, calc(100vh - 80px));
}
</style>
@@ -0,0 +1,653 @@
<!-- 细胞产品项目价格管理(主子表) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>细胞产品项目价格管理</h1>
<p class="page-desc">维护项目价格体系,关联价格明细。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">体系列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="体系名称/编码" clearable />
</el-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-button>
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</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" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
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="操作" width="320" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)"
><el-icon><View /></el-icon>查看</el-button
>
<el-button class="btn-op-item primary" @click="handleEdit(row)"
><el-icon><Edit /></el-icon>编辑</el-button
>
<el-button class="btn-op-item" @click="handleDelete(row)"
><el-icon><Delete /></el-icon>删除</el-button
>
</template>
</el-table-column>
</el-table>
<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="序号" 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, $index }">
<el-input-number
v-model="row.unitPrice"
:precision="2"
:min="0"
:controls="false"
size="default"
style="width: 100%"
placeholder="请输入单价"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="260" 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" 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 ? `${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"
:meta="viewRow?.createTime || ''"
:sections="viewSections"
/>
</section>
</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 } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchProjectSystemList,
fetchCreateProjectSystem,
fetchUpdateProjectSystem,
fetchDeleteProjectSystem,
fetchProjectSystemItems,
fetchCreateProjectSystemItem,
fetchUpdateProjectSystemItem,
fetchDeleteProjectSystemItem,
fetchCellProductPriceList
} from '@/api/admin'
defineOptions({ name: 'PilotProjectPriceSystem' })
// --- 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' }]
}
// --- 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' ? '正常' : '停用'
},
{ 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 ? item.unitPrice + ' 元' : '-'}`,
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
for (const item of detailItems.value) {
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
}
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)
viewDetailItems.value = (res as any)?.list || (res as any) || []
} catch {
viewDetailItems.value = []
}
viewVisible.value = true
}
// --- Delete ---
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该体系吗?删除后关联的价格明细也将清除。', '提示', { type: 'warning' })
await fetchDeleteProjectSystem(row.id)
ElMessage.success('删除成功')
await loadData()
}
// --- 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
})
}
priceSelectorVisible.value = false
}
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>
@@ -0,0 +1,537 @@
<!-- 耗材包管理 -->
<template>
<div class="page-container">
<header class="page-head">
<h1>耗材包管理</h1>
<p class="page-desc">维护标准与自定义耗材包,配置包内耗材明细与用量。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">耗材包列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="编号/名称"
clearable
@keyup.enter="handleSearch"
/>
</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-button>
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</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"
>
<el-table-column type="selection" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="packCode"
label="编号"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="packName"
label="名称"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
prop="usageScene"
label="使用场景"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
prop="packType"
label="类型"
min-width="130"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="row.packType === 'bzhcb' ? 'primary' : 'warning'" size="small">
{{ row.packType === 'bzhcb' ? '标准耗材包' : '自定义耗材包' }}
</el-tag>
</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' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
<el-button class="btn-op-item" @click="handleDelete(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="dialogTitle"
width="800px"
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="pack-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<!-- 基本信息 -->
<div class="form-section">
<div class="section-title">基本信息</div>
<div class="form-grid-2">
<el-form-item label="编号" prop="packCode">
<el-input v-model="formData.packCode" placeholder="请输入编号" />
</el-form-item>
<el-form-item label="名称" prop="packName">
<el-input v-model="formData.packName" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="使用场景">
<el-input v-model="formData.usageScene" placeholder="请输入使用场景" />
</el-form-item>
<el-form-item label="类型" prop="packType">
<el-radio-group v-model="formData.packType">
<el-radio value="bzhcb">标准耗材包</el-radio>
<el-radio value="zdyhcb">自定义耗材包</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="formData.remark" placeholder="可选" />
</el-form-item>
</div>
</div>
<!-- 耗材明细 -->
<div class="form-section">
<div class="section-title">
<span>耗材明细</span>
<el-button class="btn-main" type="primary" size="small" @click="openAddConsumableDialog">
<el-icon><Plus /></el-icon>
添加耗材
</el-button>
</div>
<el-table :data="formData.items" stripe size="small" class="inner-table">
<el-table-column prop="consumableCode" label="耗材编码" min-width="120" align="center" header-align="center" />
<el-table-column prop="consumableName" label="耗材名称" min-width="120" align="center" header-align="center" />
<el-table-column prop="specModel" label="规格型号" min-width="120" align="center" header-align="center" />
<el-table-column label="标准用量" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.standardUsage" placeholder="用量" size="small" />
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" align="center" header-align="center" />
<el-table-column label="操作" width="80" align="center" header-align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="formData.items.splice($index, 1)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="formData.items.length === 0" description="暂无耗材,点击上方按钮添加" :image-size="48" />
</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" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<!-- 选择耗材内弹窗 -->
<el-dialog
v-model="selectConsumableVisible"
title="选择耗材"
width="700px"
append-to-body
destroy-on-close
:close-on-click-modal="false"
>
<el-input
v-model="consumableKeyword"
placeholder="搜索耗材编码/名称"
clearable
style="margin-bottom: 12px"
@input="filterConsumables"
/>
<el-table
:data="filteredConsumableList"
stripe
size="small"
height="360"
highlight-current-row
@current-change="handleConsumableSelect"
>
<el-table-column prop="consumableCode" label="耗材编码" min-width="120" align="center" header-align="center" />
<el-table-column prop="consumableName" label="耗材名称" min-width="140" align="center" header-align="center" />
<el-table-column prop="specModel" label="规格型号" min-width="120" align="center" header-align="center" />
</el-table>
<template #footer>
<el-button class="btn-ghost" @click="selectConsumableVisible = false">取消</el-button>
<el-button class="btn-main" type="primary" :disabled="!selectedConsumable" @click="confirmAddConsumable">
确定
</el-button>
</template>
</el-dialog>
</section>
</div>
</template>
<script setup lang="ts">
import { Plus, Close, Check } from '@element-plus/icons-vue'
import {
fetchConsumablePackList,
fetchConsumablePackDetail,
fetchCreateConsumablePack,
fetchUpdateConsumablePack,
fetchDeleteConsumablePack,
fetchAllConsumables
} from '@/api/production'
defineOptions({ name: 'ProductionConsumablePack' })
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 formData = reactive({
packCode: '',
packName: '',
usageScene: '',
packType: 'bzhcb',
status: 1,
remark: '',
items: [] as any[]
})
const formRules = {
packCode: [{ required: true, message: '请输入编号', trigger: 'blur' }],
packName: [{ required: true, message: '请输入名称', trigger: 'blur' }],
packType: [{ required: true, message: '请选择类型', trigger: 'change' }]
}
// 选择耗材弹窗
const selectConsumableVisible = ref(false)
const allConsumableList = ref<any[]>([])
const filteredConsumableList = ref<any[]>([])
const consumableKeyword = ref('')
const selectedConsumable = ref<any>(null)
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchConsumablePackList({
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.packCode = ''
formData.packName = ''
formData.usageScene = ''
formData.packType = 'bzhcb'
formData.status = 1
formData.remark = ''
formData.items = []
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增耗材包'
resetForm()
dialogVisible.value = true
nextTick(() => formRef.value?.clearValidate?.())
}
async function handleEdit(row: any) {
isEdit.value = true
dialogTitle.value = '编辑耗材包'
editingId.value = row.id
resetForm()
try {
const detail = await fetchConsumablePackDetail(row.id)
formData.packCode = detail.packCode || ''
formData.packName = detail.packName || ''
formData.usageScene = detail.usageScene || ''
formData.packType = detail.packType || 'bzhcb'
formData.status = detail.status ?? 1
formData.remark = detail.remark || ''
formData.items = (detail.items || []).map((item: any) => ({ ...item }))
} catch {
ElMessage.error('加载详情失败')
}
dialogVisible.value = true
nextTick(() => formRef.value?.clearValidate?.())
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
packCode: formData.packCode.trim(),
packName: formData.packName.trim(),
usageScene: formData.usageScene?.trim() || undefined,
packType: formData.packType,
status: formData.status,
remark: formData.remark?.trim() || undefined,
items: formData.items.map((item) => ({
consumableId: item.consumableId || item.id,
consumableCode: item.consumableCode,
consumableName: item.consumableName,
specModel: item.specModel,
standardUsage: item.standardUsage,
remark: item.remark || ''
}))
}
if (isEdit.value && editingId.value) {
await fetchUpdateConsumablePack(editingId.value, payload)
} else {
await fetchCreateConsumablePack(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该耗材包吗?', '提示', { type: 'warning' })
await fetchDeleteConsumablePack(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' })
for (const id of ids) {
await fetchDeleteConsumablePack(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}
// 选择耗材弹窗
async function openAddConsumableDialog() {
selectedConsumable.value = null
consumableKeyword.value = ''
if (allConsumableList.value.length === 0) {
try {
allConsumableList.value = await fetchAllConsumables()
} catch {
allConsumableList.value = []
}
}
filteredConsumableList.value = [...allConsumableList.value]
selectConsumableVisible.value = true
}
function filterConsumables() {
const kw = consumableKeyword.value.trim().toLowerCase()
if (!kw) {
filteredConsumableList.value = [...allConsumableList.value]
return
}
filteredConsumableList.value = allConsumableList.value.filter(
(c) =>
(c.consumableCode || '').toLowerCase().includes(kw) ||
(c.consumableName || '').toLowerCase().includes(kw)
)
}
function handleConsumableSelect(row: any) {
selectedConsumable.value = row
}
function confirmAddConsumable() {
if (!selectedConsumable.value) return
const c = selectedConsumable.value
// 检查是否已添加
const exists = formData.items.some(
(item) => item.consumableId === c.id || item.consumableCode === c.consumableCode
)
if (exists) {
ElMessage.warning('该耗材已在明细中')
return
}
formData.items.push({
consumableId: c.id,
consumableCode: c.consumableCode || '',
consumableName: c.consumableName || '',
specModel: c.specModel || '',
standardUsage: '',
remark: ''
})
selectConsumableVisible.value = false
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.pack-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.form-section {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
font-size: 15px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.inner-table {
width: 100%;
}
</style>
@@ -0,0 +1,358 @@
<!-- 耗材管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>耗材管理</h1>
<p class="page-desc">维护生产耗材的编码、名称、规格及标准用量等信息。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">耗材列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="耗材编码/名称" clearable />
</el-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-button>
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</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" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="consumableCode" label="耗材编码" min-width="130" align="center" header-align="center" />
<el-table-column prop="consumableName" label="耗材名称" min-width="150" align="center" header-align="center" />
<el-table-column prop="specModel" label="规格型号" min-width="140" align="center" header-align="center" />
<el-table-column prop="standardUsage" label="标准用量" min-width="110" align="center" header-align="center" />
<el-table-column label="描述" min-width="160" align="center" header-align="center">
<template #default="{ row }">
{{ truncateText(row.description, 20) }}
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="180" align="center" header-align="center" />
<el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)"
><el-icon><View /></el-icon>查看</el-button
>
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
</template>
</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="consumable-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="耗材编码" prop="consumableCode">
<el-input v-model="formData.consumableCode" placeholder="请输入耗材编码" />
</el-form-item>
<el-form-item label="耗材名称" prop="consumableName">
<el-input v-model="formData.consumableName" placeholder="请输入耗材名称" />
</el-form-item>
<el-form-item label="规格型号">
<el-input v-model="formData.specModel" placeholder="请输入规格型号" />
</el-form-item>
<el-form-item label="标准用量">
<el-input v-model="formData.standardUsage" placeholder="请输入标准用量" />
</el-form-item>
<el-form-item label="描述" class="form-col-span-2">
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="请输入描述" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<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?.consumableName || '耗材详情'"
:icon="ScaleToOriginal"
:meta="viewRow?.createTime || ''"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check, View, ScaleToOriginal } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchConsumableList,
fetchCreateConsumable,
fetchUpdateConsumable,
fetchDeleteConsumable
} from '@/api/production'
defineOptions({ name: 'ProductionConsumable' })
function truncateText(text: string, max: number) {
if (!text) return ''
return text.length > max ? text.slice(0, max) + '…' : text
}
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({
consumableCode: '',
consumableName: '',
specModel: '',
standardUsage: '',
description: '',
remark: ''
})
const formRules = {
consumableCode: [{ required: true, message: '请输入耗材编码', trigger: 'blur' }],
consumableName: [{ required: true, message: '请输入耗材名称', trigger: 'blur' }]
}
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '耗材编码', value: row.consumableCode },
{ label: '耗材名称', value: row.consumableName },
{ label: '规格型号', value: row.specModel },
{ label: '标准用量', value: row.standardUsage },
{ label: '描述', value: row.description, span: true },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
async function loadData() {
loading.value = true
try {
const res = await fetchConsumableList({
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.consumableCode = ''
formData.consumableName = ''
formData.specModel = ''
formData.standardUsage = ''
formData.description = ''
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.consumableCode = row.consumableCode || ''
formData.consumableName = row.consumableName || ''
formData.specModel = row.specModel || ''
formData.standardUsage = row.standardUsage ?? ''
formData.description = row.description || ''
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
consumableCode: formData.consumableCode.trim(),
consumableName: formData.consumableName.trim(),
specModel: formData.specModel?.trim() || undefined,
standardUsage: formData.standardUsage ?? undefined,
description: formData.description?.trim() || undefined,
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateConsumable(editingId.value, payload)
} else {
await fetchCreateConsumable(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
}
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该耗材吗?', '提示', { type: 'warning' })
await fetchDeleteConsumable(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 fetchDeleteConsumable(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.consumable-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -0,0 +1,322 @@
<!-- 工艺产出物类型 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>工艺产出物类型</h1>
<p class="page-desc">维护工艺产出物的类型编码与名称。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">类型列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="类型编码/名称" clearable />
</el-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-button>
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</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" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="typeCode" label="类型编码" min-width="150" align="center" header-align="center" />
<el-table-column prop="typeName" 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="操作" width="320" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)"
><el-icon><View /></el-icon>查看</el-button
>
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
</template>
</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="output-type-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="请输入类型编码" />
</el-form-item>
<el-form-item label="类型名称" prop="typeName">
<el-input v-model="formData.typeName" placeholder="请输入类型名称" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<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?.typeName || '类型详情'"
:icon="ScaleToOriginal"
:meta="viewRow?.createTime || ''"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check, View, ScaleToOriginal } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchOutputTypeList,
fetchCreateOutputType,
fetchUpdateOutputType,
fetchDeleteOutputType
} from '@/api/production'
defineOptions({ name: 'ProductionOutputType' })
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({
typeCode: '',
typeName: '',
remark: ''
})
const formRules = {
typeCode: [{ required: true, message: '请输入类型编码', trigger: 'blur' }],
typeName: [{ required: true, message: '请输入类型名称', trigger: 'blur' }]
}
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '类型编码', value: row.typeCode },
{ label: '类型名称', value: row.typeName },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
async function loadData() {
loading.value = true
try {
const res = await fetchOutputTypeList({
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.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.typeCode = row.typeCode || ''
formData.typeName = row.typeName || ''
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(),
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateOutputType(editingId.value, payload)
} else {
await fetchCreateOutputType(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
}
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
await fetchDeleteOutputType(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 fetchDeleteOutputType(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.output-type-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -0,0 +1,322 @@
<!-- 工艺工序分类 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>工艺工序分类</h1>
<p class="page-desc">维护工艺工序的分类编码与名称。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">分类列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="分类编码/名称" clearable />
</el-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-button>
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</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" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="categoryCode" label="分类编码" min-width="150" align="center" header-align="center" />
<el-table-column prop="categoryName" 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="操作" width="320" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)"
><el-icon><View /></el-icon>查看</el-button
>
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
</template>
</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="process-category-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="分类编码" prop="categoryCode">
<el-input v-model="formData.categoryCode" placeholder="请输入分类编码" />
</el-form-item>
<el-form-item label="分类名称" prop="categoryName">
<el-input v-model="formData.categoryName" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<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?.categoryName || '分类详情'"
:icon="ScaleToOriginal"
:meta="viewRow?.createTime || ''"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check, View, ScaleToOriginal } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchProcessCategoryList,
fetchCreateProcessCategory,
fetchUpdateProcessCategory,
fetchDeleteProcessCategory
} from '@/api/production'
defineOptions({ name: 'ProductionProcessCategory' })
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({
categoryCode: '',
categoryName: '',
remark: ''
})
const formRules = {
categoryCode: [{ required: true, message: '请输入分类编码', trigger: 'blur' }],
categoryName: [{ required: true, message: '请输入分类名称', trigger: 'blur' }]
}
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '分类编码', value: row.categoryCode },
{ label: '分类名称', value: row.categoryName },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
async function loadData() {
loading.value = true
try {
const res = await fetchProcessCategoryList({
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.categoryCode = ''
formData.categoryName = ''
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.categoryCode = row.categoryCode || ''
formData.categoryName = row.categoryName || ''
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
categoryCode: formData.categoryCode.trim(),
categoryName: formData.categoryName.trim(),
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateProcessCategory(editingId.value, payload)
} else {
await fetchCreateProcessCategory(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
}
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该分类吗?', '提示', { type: 'warning' })
await fetchDeleteProcessCategory(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 fetchDeleteProcessCategory(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.process-category-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -0,0 +1,891 @@
<!-- 工艺库管理 -->
<template>
<div class="page-container">
<header class="page-head">
<h1>工艺库管理</h1>
<p class="page-desc">管理工艺工序及其关联的试剂、耗材与产出物配置。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">工艺列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="工序代码/名称"
clearable
@keyup.enter="handleSearch"
/>
</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-button>
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</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"
>
<el-table-column type="selection" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="processCode"
label="工序代码"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column
prop="processName"
label="工序名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
label="工序分类"
min-width="130"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ categoryMap[row.categoryId] || '-' }}
</template>
</el-table-column>
<el-table-column
prop="description"
label="描述"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="equipmentName"
label="设备名称"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="estimatedDuration"
label="预估耗时"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
<el-button class="btn-op-item" @click="handleDelete(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="dialogTitle"
width="1000px"
class="dialog-form-layout process-dialog"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="process-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<el-tabs v-model="activeTab" type="border-card">
<!-- Tab 1: 基本信息 -->
<el-tab-pane label="基本信息" name="basic">
<div class="form-grid-2">
<el-form-item label="工序代码" prop="processCode">
<el-input v-model="formData.processCode" placeholder="请输入工序代码" />
</el-form-item>
<el-form-item label="工序名称" prop="processName">
<el-input v-model="formData.processName" placeholder="请输入工序名称" />
</el-form-item>
<el-form-item label="工序分类">
<el-select
v-model="formData.categoryId"
placeholder="请选择分类"
clearable
filterable
style="width: 100%"
>
<el-option
v-for="cat in categoryOptions"
:key="cat.id"
:label="cat.categoryName || cat.name"
:value="cat.id"
/>
</el-select>
</el-form-item>
<el-form-item label="设备名称">
<el-input v-model="formData.equipmentName" placeholder="请输入设备名称" />
</el-form-item>
<el-form-item label="预估耗时">
<el-input v-model="formData.estimatedDuration" placeholder="如 30min" />
</el-form-item>
<el-form-item label="关联SOP">
<el-select
v-model="formData.sopIds"
placeholder="请选择SOP文档"
multiple
clearable
filterable
style="width: 100%"
>
<el-option
v-for="sop in sopOptions"
:key="sop.id"
:label="sop.sopName || sop.name"
:value="sop.id"
/>
</el-select>
</el-form-item>
<el-form-item label="描述" class="form-col-span-2">
<el-input
v-model="formData.description"
type="textarea"
:rows="2"
placeholder="请输入描述"
/>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" placeholder="可选" />
</el-form-item>
</div>
</el-tab-pane>
<!-- Tab 2: 试剂关联 -->
<el-tab-pane label="试剂关联" name="reagents">
<div class="tab-toolbar">
<el-button class="btn-main" type="primary" size="small" @click="openAddReagentDialog">
<el-icon><Plus /></el-icon>
添加试剂
</el-button>
</div>
<el-table :data="reagentList" stripe size="small" class="inner-table">
<el-table-column prop="reagentCode" label="试剂编码" min-width="110" align="center" header-align="center" />
<el-table-column prop="reagentName" label="试剂名称" min-width="120" align="center" header-align="center" />
<el-table-column prop="spec" label="规格" min-width="100" align="center" header-align="center" />
<el-table-column label="标准用量" min-width="110" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.standardUsage" placeholder="用量" size="small" />
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" min-width="80" align="center" header-align="center" />
<el-table-column label="关键物料" width="90" align="center" header-align="center">
<template #default="{ row }">
<el-switch v-model="row.isKeyMaterial" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" header-align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="reagentList.splice($index, 1)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="reagentList.length === 0" description="暂无试剂,点击上方按钮添加" :image-size="48" />
</el-tab-pane>
<!-- Tab 3: 耗材关联 -->
<el-tab-pane label="耗材关联" name="consumables">
<div class="tab-toolbar">
<div class="tab-toolbar-left">
<el-button class="btn-main" type="primary" size="small" @click="openAddConsumableDialog">
<el-icon><Plus /></el-icon>
添加耗材
</el-button>
<el-select
v-model="selectedPackId"
placeholder="从耗材包导入"
clearable
filterable
size="small"
style="width: 200px"
@change="handleImportPack"
>
<el-option
v-for="pack in packOptions"
:key="pack.id"
:label="pack.packName"
:value="pack.id"
/>
</el-select>
</div>
</div>
<el-table :data="consumableList" stripe size="small" class="inner-table">
<el-table-column prop="consumableCode" label="耗材编码" min-width="110" align="center" header-align="center" />
<el-table-column prop="consumableName" label="耗材名称" min-width="120" align="center" header-align="center" />
<el-table-column prop="specModel" label="规格型号" min-width="110" align="center" header-align="center" />
<el-table-column label="标准用量" min-width="110" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.standardUsage" placeholder="用量" size="small" />
</template>
</el-table-column>
<el-table-column label="必填" width="80" align="center" header-align="center">
<template #default="{ row }">
<el-switch v-model="row.isRequired" />
</template>
</el-table-column>
<el-table-column label="备注" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" header-align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="consumableList.splice($index, 1)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="consumableList.length === 0" description="暂无耗材,点击上方按钮添加" :image-size="48" />
</el-tab-pane>
<!-- Tab 4: 产出物 -->
<el-tab-pane label="产出物" name="outputs">
<div class="tab-toolbar">
<el-button class="btn-main" type="primary" size="small" @click="addOutputRow">
<el-icon><Plus /></el-icon>
添加产出物
</el-button>
</div>
<el-table :data="outputList" stripe size="small" class="inner-table">
<el-table-column label="产出物类型" min-width="150" align="center" header-align="center">
<template #default="{ row }">
<el-select v-model="row.outputTypeId" placeholder="选择类型" size="small" filterable>
<el-option
v-for="ot in outputTypeOptions"
:key="ot.id"
:label="ot.typeName || ot.name"
:value="ot.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="产出物名称" min-width="150" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.outputName" placeholder="名称" size="small" />
</template>
</el-table-column>
<el-table-column label="产出物条码" min-width="150" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.outputBarcode" placeholder="条码" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" header-align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="outputList.splice($index, 1)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="outputList.length === 0" description="暂无产出物,点击上方按钮添加" :image-size="48" />
</el-tab-pane>
</el-tabs>
</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="handleSubmit">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<!-- 选择试剂内弹窗 -->
<el-dialog
v-model="selectReagentVisible"
title="选择试剂"
width="700px"
append-to-body
destroy-on-close
:close-on-click-modal="false"
>
<el-input
v-model="reagentKeyword"
placeholder="搜索试剂编码/名称"
clearable
style="margin-bottom: 12px"
@input="filterReagents"
/>
<el-table
:data="filteredReagentList"
stripe
size="small"
height="360"
highlight-current-row
@current-change="handleReagentSelect"
>
<el-table-column prop="reagentCode" label="试剂编码" min-width="120" align="center" header-align="center" />
<el-table-column prop="reagentName" label="试剂名称" min-width="140" align="center" header-align="center" />
<el-table-column prop="spec" label="规格" min-width="100" align="center" header-align="center" />
<el-table-column prop="unit" label="单位" min-width="80" align="center" header-align="center" />
</el-table>
<template #footer>
<el-button class="btn-ghost" @click="selectReagentVisible = false">取消</el-button>
<el-button class="btn-main" type="primary" :disabled="!selectedReagent" @click="confirmAddReagent">
确定
</el-button>
</template>
</el-dialog>
<!-- 选择耗材内弹窗 -->
<el-dialog
v-model="selectConsumableVisible"
title="选择耗材"
width="700px"
append-to-body
destroy-on-close
:close-on-click-modal="false"
>
<el-input
v-model="consumableKeyword"
placeholder="搜索耗材编码/名称"
clearable
style="margin-bottom: 12px"
@input="filterConsumables"
/>
<el-table
:data="filteredConsumableList"
stripe
size="small"
height="360"
highlight-current-row
@current-change="handleConsumableRowSelect"
>
<el-table-column prop="consumableCode" label="耗材编码" min-width="120" align="center" header-align="center" />
<el-table-column prop="consumableName" label="耗材名称" min-width="140" align="center" header-align="center" />
<el-table-column prop="specModel" label="规格型号" min-width="120" align="center" header-align="center" />
</el-table>
<template #footer>
<el-button class="btn-ghost" @click="selectConsumableVisible = false">取消</el-button>
<el-button class="btn-main" type="primary" :disabled="!selectedConsumable" @click="confirmAddConsumable">
确定
</el-button>
</template>
</el-dialog>
</section>
</div>
</template>
<script setup lang="ts">
import { Plus, Close, Check } from '@element-plus/icons-vue'
import {
fetchProcessList,
fetchProcessDetail,
fetchCreateProcess,
fetchUpdateProcess,
fetchDeleteProcess,
fetchAllProcessCategories,
fetchAllSopDocuments,
fetchAllReagents,
fetchAllConsumables,
fetchAllOutputTypes,
fetchAllConsumablePacks
} from '@/api/production'
defineOptions({ name: 'ProductionProcess' })
// 搜索 & 列表
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 activeTab = ref('basic')
// 下拉选项
const categoryOptions = ref<any[]>([])
const sopOptions = ref<any[]>([])
const outputTypeOptions = ref<any[]>([])
const packOptions = ref<any[]>([])
const allReagentOptions = ref<any[]>([])
const allConsumableOptions = ref<any[]>([])
// 列表中的分类映射
const categoryMap = computed(() => {
const map: Record<string, string> = {}
categoryOptions.value.forEach((c: any) => {
map[c.id] = c.categoryName || c.name || ''
})
return map
})
// 表单数据
const formData = reactive({
processCode: '',
processName: '',
categoryId: '',
description: '',
equipmentName: '',
estimatedDuration: '',
sopIds: [] as string[],
remark: ''
})
const formRules = {
processCode: [{ required: true, message: '请输入工序代码', trigger: 'blur' }],
processName: [{ required: true, message: '请输入工序名称', trigger: 'blur' }]
}
// 子表数据
const reagentList = ref<any[]>([])
const consumableList = ref<any[]>([])
const outputList = ref<any[]>([])
// 选择试剂弹窗
const selectReagentVisible = ref(false)
const filteredReagentList = ref<any[]>([])
const reagentKeyword = ref('')
const selectedReagent = ref<any>(null)
// 选择耗材弹窗
const selectConsumableVisible = ref(false)
const filteredConsumableList = ref<any[]>([])
const consumableKeyword = ref('')
const selectedConsumable = ref<any>(null)
// 耗材包导入
const selectedPackId = ref<string | undefined>(undefined)
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchProcessList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
// 加载所有下拉参考数据
async function loadReferenceData() {
try {
const [cats, sops, reagents, consumables, outputTypes, packs] = await Promise.all([
fetchAllProcessCategories(),
fetchAllSopDocuments(),
fetchAllReagents(),
fetchAllConsumables(),
fetchAllOutputTypes(),
fetchAllConsumablePacks()
])
categoryOptions.value = cats || []
sopOptions.value = sops || []
allReagentOptions.value = reagents || []
allConsumableOptions.value = consumables || []
outputTypeOptions.value = outputTypes || []
packOptions.value = packs || []
} catch {
/* ignore */
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
pagination.page = 1
loadData()
}
function resetForm() {
formData.processCode = ''
formData.processName = ''
formData.categoryId = ''
formData.description = ''
formData.equipmentName = ''
formData.estimatedDuration = ''
formData.sopIds = []
formData.remark = ''
reagentList.value = []
consumableList.value = []
outputList.value = []
editingId.value = null
activeTab.value = 'basic'
selectedPackId.value = undefined
}
async function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增工艺'
resetForm()
await loadReferenceData()
dialogVisible.value = true
nextTick(() => formRef.value?.clearValidate?.())
}
async function handleEdit(row: any) {
isEdit.value = true
dialogTitle.value = '编辑工艺'
editingId.value = row.id
resetForm()
await loadReferenceData()
try {
const detail = await fetchProcessDetail(row.id)
formData.processCode = detail.processCode || ''
formData.processName = detail.processName || ''
formData.categoryId = detail.categoryId || ''
formData.description = detail.description || ''
formData.equipmentName = detail.equipmentName || ''
formData.estimatedDuration = detail.estimatedDuration || ''
formData.sopIds = detail.sopIds || []
formData.remark = detail.remark || ''
reagentList.value = (detail.reagents || []).map((r: any) => ({ ...r }))
consumableList.value = (detail.consumables || []).map((c: any) => ({ ...c }))
outputList.value = (detail.outputs || []).map((o: any) => ({ ...o }))
} catch {
ElMessage.error('加载详情失败')
}
dialogVisible.value = true
nextTick(() => formRef.value?.clearValidate?.())
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
processCode: formData.processCode.trim(),
processName: formData.processName.trim(),
categoryId: formData.categoryId || undefined,
description: formData.description?.trim() || undefined,
equipmentName: formData.equipmentName?.trim() || undefined,
estimatedDuration: formData.estimatedDuration?.trim() || undefined,
sopIds: formData.sopIds,
remark: formData.remark?.trim() || undefined,
reagents: reagentList.value.map((r) => ({
reagentId: r.reagentId || r.id,
reagentCode: r.reagentCode,
reagentName: r.reagentName,
spec: r.spec,
standardUsage: r.standardUsage,
unit: r.unit,
isKeyMaterial: r.isKeyMaterial || false
})),
consumables: consumableList.value.map((c) => ({
consumableId: c.consumableId || c.id,
consumableCode: c.consumableCode,
consumableName: c.consumableName,
specModel: c.specModel,
standardUsage: c.standardUsage,
isRequired: c.isRequired || false,
remark: c.remark || ''
})),
outputs: outputList.value.map((o) => ({
outputTypeId: o.outputTypeId,
outputName: o.outputName,
outputBarcode: o.outputBarcode
}))
}
if (isEdit.value && editingId.value) {
await fetchUpdateProcess(editingId.value, payload)
} else {
await fetchCreateProcess(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该工艺吗?', '提示', { type: 'warning' })
await fetchDeleteProcess(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' })
for (const id of ids) {
await fetchDeleteProcess(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}
// =============== 试剂弹窗 ===============
function openAddReagentDialog() {
selectedReagent.value = null
reagentKeyword.value = ''
filteredReagentList.value = [...allReagentOptions.value]
selectReagentVisible.value = true
}
function filterReagents() {
const kw = reagentKeyword.value.trim().toLowerCase()
if (!kw) {
filteredReagentList.value = [...allReagentOptions.value]
return
}
filteredReagentList.value = allReagentOptions.value.filter(
(r) =>
(r.reagentCode || '').toLowerCase().includes(kw) ||
(r.reagentName || '').toLowerCase().includes(kw)
)
}
function handleReagentSelect(row: any) {
selectedReagent.value = row
}
function confirmAddReagent() {
if (!selectedReagent.value) return
const r = selectedReagent.value
const exists = reagentList.value.some(
(item) => item.reagentId === r.id || item.reagentCode === r.reagentCode
)
if (exists) {
ElMessage.warning('该试剂已在列表中')
return
}
reagentList.value.push({
reagentId: r.id,
reagentCode: r.reagentCode || '',
reagentName: r.reagentName || '',
spec: r.spec || '',
standardUsage: '',
unit: r.unit || '',
isKeyMaterial: false
})
selectReagentVisible.value = false
}
// =============== 耗材弹窗 ===============
function openAddConsumableDialog() {
selectedConsumable.value = null
consumableKeyword.value = ''
filteredConsumableList.value = [...allConsumableOptions.value]
selectConsumableVisible.value = true
}
function filterConsumables() {
const kw = consumableKeyword.value.trim().toLowerCase()
if (!kw) {
filteredConsumableList.value = [...allConsumableOptions.value]
return
}
filteredConsumableList.value = allConsumableOptions.value.filter(
(c) =>
(c.consumableCode || '').toLowerCase().includes(kw) ||
(c.consumableName || '').toLowerCase().includes(kw)
)
}
function handleConsumableRowSelect(row: any) {
selectedConsumable.value = row
}
function confirmAddConsumable() {
if (!selectedConsumable.value) return
const c = selectedConsumable.value
const exists = consumableList.value.some(
(item) => item.consumableId === c.id || item.consumableCode === c.consumableCode
)
if (exists) {
ElMessage.warning('该耗材已在列表中')
return
}
consumableList.value.push({
consumableId: c.id,
consumableCode: c.consumableCode || '',
consumableName: c.consumableName || '',
specModel: c.specModel || '',
standardUsage: '',
isRequired: false,
remark: ''
})
selectConsumableVisible.value = false
}
// =============== 从耗材包导入 ===============
async function handleImportPack(packId: string | undefined) {
if (!packId) return
// 在 packOptions 中找到对应的包,然后查看它的 items
// 由于 packOptions 来自 fetchAllConsumablePacks,可能不含 items
// 需要用 fetchConsumablePackDetail 加载详情
try {
const { fetchConsumablePackDetail } = await import('@/api/production')
const detail = await fetchConsumablePackDetail(packId)
const packItems = detail.items || []
let added = 0
for (const item of packItems) {
const exists = consumableList.value.some(
(c) => c.consumableId === item.consumableId || c.consumableCode === item.consumableCode
)
if (!exists) {
consumableList.value.push({
consumableId: item.consumableId,
consumableCode: item.consumableCode || '',
consumableName: item.consumableName || '',
specModel: item.specModel || '',
standardUsage: item.standardUsage || '',
isRequired: false,
remark: ''
})
added++
}
}
ElMessage.success(`导入成功,新增 ${added} 项耗材`)
} catch {
ElMessage.error('导入耗材包失败')
}
selectedPackId.value = undefined
}
// =============== 产出物 ===============
function addOutputRow() {
outputList.value.push({
outputTypeId: '',
outputName: '',
outputBarcode: ''
})
}
// 初始化加载列表 & 分类数据(用于列表列的分类名映射)
onMounted(async () => {
await loadData()
// 静默加载分类数据用于列表展示
try {
categoryOptions.value = (await fetchAllProcessCategories()) || []
} catch {
/* ignore */
}
})
</script>
<style scoped lang="scss">
.process-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.process-dialog {
:deep(.el-dialog__body) {
max-height: calc(100vh - 200px);
overflow-y: auto;
}
}
.tab-toolbar {
display: flex;
align-items: center;
margin-bottom: 12px;
.tab-toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
}
.inner-table {
width: 100%;
}
.form-col-span-2 {
grid-column: 1 / -1;
}
</style>
@@ -0,0 +1,379 @@
<!-- 试剂管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>试剂管理</h1>
<p class="page-desc">维护生产试剂的编码、名称、规格、标准用量及单位等信息。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">试剂列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="试剂编码/名称" clearable />
</el-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-button>
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</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" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="reagentCode" label="试剂编码" min-width="130" align="center" header-align="center" />
<el-table-column prop="reagentName" label="试剂名称" min-width="150" align="center" header-align="center" />
<el-table-column prop="spec" label="规格" min-width="120" align="center" header-align="center" />
<el-table-column prop="standardUsage" label="标准用量" min-width="110" align="center" header-align="center" />
<el-table-column prop="unit" label="单位" min-width="90" align="center" header-align="center" />
<el-table-column label="描述" min-width="160" align="center" header-align="center">
<template #default="{ row }">
{{ truncateText(row.description, 20) }}
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="180" align="center" header-align="center" />
<el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)"
><el-icon><View /></el-icon>查看</el-button
>
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
</template>
</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="reagent-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="试剂编码" prop="reagentCode">
<el-input v-model="formData.reagentCode" placeholder="请输入试剂编码" />
</el-form-item>
<el-form-item label="试剂名称" prop="reagentName">
<el-input v-model="formData.reagentName" placeholder="请输入试剂名称" />
</el-form-item>
<el-form-item label="规格">
<el-input v-model="formData.spec" placeholder="请输入规格" />
</el-form-item>
<el-form-item label="标准用量">
<el-input v-model="formData.standardUsage" placeholder="请输入标准用量" />
</el-form-item>
<el-form-item label="单位">
<el-select v-model="formData.unit" placeholder="请选择单位" clearable filterable>
<el-option
v-for="item in unitOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="formData.description" placeholder="请输入描述" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<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?.reagentName || '试剂详情'"
:icon="ScaleToOriginal"
:meta="viewRow?.createTime || ''"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check, View, ScaleToOriginal } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchReagentList,
fetchCreateReagent,
fetchUpdateReagent,
fetchDeleteReagent
} from '@/api/production'
import { fetchItemUnitList } from '@/api/admin'
defineOptions({ name: 'ProductionReagent' })
function truncateText(text: string, max: number) {
if (!text) return ''
return text.length > max ? text.slice(0, max) + '…' : text
}
const unitOptions = ref<{ label: string; value: string }[]>([])
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({
reagentCode: '',
reagentName: '',
spec: '',
standardUsage: '',
unit: '',
description: '',
remark: ''
})
const formRules = {
reagentCode: [{ required: true, message: '请输入试剂编码', trigger: 'blur' }],
reagentName: [{ required: true, message: '请输入试剂名称', trigger: 'blur' }]
}
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '试剂编码', value: row.reagentCode },
{ label: '试剂名称', value: row.reagentName },
{ label: '规格', value: row.spec },
{ label: '标准用量', value: row.standardUsage },
{ label: '单位', value: row.unit },
{ label: '描述', value: row.description, span: true },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
async function loadData() {
loading.value = true
try {
const res = await fetchReagentList({
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.reagentCode = ''
formData.reagentName = ''
formData.spec = ''
formData.standardUsage = ''
formData.unit = ''
formData.description = ''
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.reagentCode = row.reagentCode || ''
formData.reagentName = row.reagentName || ''
formData.spec = row.spec || ''
formData.standardUsage = row.standardUsage ?? ''
formData.unit = row.unit || ''
formData.description = row.description || ''
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
reagentCode: formData.reagentCode.trim(),
reagentName: formData.reagentName.trim(),
spec: formData.spec?.trim() || undefined,
standardUsage: formData.standardUsage ?? undefined,
unit: formData.unit?.trim() || undefined,
description: formData.description?.trim() || undefined,
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateReagent(editingId.value, payload)
} else {
await fetchCreateReagent(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
}
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该试剂吗?', '提示', { type: 'warning' })
await fetchDeleteReagent(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 fetchDeleteReagent(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
onMounted(async () => {
loadData()
const res = await fetchItemUnitList({ page: 1, pageSize: 100 })
unitOptions.value = (res.list || []).map((u: any) => ({ label: u.unitName, value: u.unitName }))
})
</script>
<style scoped lang="scss">
.reagent-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -0,0 +1,492 @@
<!-- SOP文档 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>SOP文档</h1>
<p class="page-desc">管理标准作业程序文档,支持附件上传与版本管理。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">SOP列表</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="SOP编号/名称" 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-button>
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</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>
新增SOP
</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" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="sopCode" label="SOP编号" min-width="130" align="center" header-align="center" />
<el-table-column prop="sopName" label="SOP文档名称" min-width="180" align="center" header-align="center" />
<el-table-column prop="versionNo" label="版本号" min-width="100" align="center" header-align="center" />
<el-table-column label="附件数" min-width="90" align="center" header-align="center">
<template #default="{ row }">
{{ row.sopAttachments ? row.sopAttachments.split(',').length : 0 }}
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="180" align="center" header-align="center" />
<el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)"
><el-icon><View /></el-icon>查看</el-button
>
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
</template>
</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="sop-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="SOP编号" prop="sopCode">
<el-input v-model="formData.sopCode" placeholder="请输入SOP编号" />
</el-form-item>
<el-form-item label="SOP文档名称" prop="sopName">
<el-input v-model="formData.sopName" placeholder="请输入SOP文档名称" />
</el-form-item>
<el-form-item label="版本号">
<el-input v-model="formData.versionNo" placeholder="请输入版本号" />
</el-form-item>
<el-form-item label="附件" class="form-col-span-2">
<div class="upload-card-row">
<div
v-for="(url, idx) in attachmentList"
:key="idx"
class="upload-card-item"
>
<el-icon class="upload-card-icon"><Document /></el-icon>
<span class="upload-card-name" :title="getFileName(url)">{{ getFileName(url) }}</span>
<el-icon class="upload-card-remove" @click="removeAttachment(idx)"><Close /></el-icon>
</div>
<el-upload
:auto-upload="false"
:show-file-list="false"
@change="handleFileChange"
accept="*"
>
<div class="upload-card-trigger">
<el-icon><Plus /></el-icon>
<span>上传</span>
</div>
</el-upload>
</div>
<div class="upload-card-hint">支持上传多个附件文件。</div>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" placeholder="可选" />
</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?.sopName || 'SOP详情'"
:icon="ScaleToOriginal"
:meta="viewRow?.createTime || ''"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check, View, ScaleToOriginal, Document } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchSopList,
fetchCreateSop,
fetchUpdateSop,
fetchDeleteSop
} from '@/api/production'
import { fetchUploadAttachment } from '@/api/business'
defineOptions({ name: 'ProductionSop' })
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('新增SOP')
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({
sopCode: '',
sopName: '',
versionNo: '',
sopAttachments: '',
remark: ''
})
const attachmentList = computed(() => {
if (!formData.sopAttachments) return []
return formData.sopAttachments.split(',').filter(Boolean)
})
const formRules = {
sopCode: [{ required: true, message: '请输入SOP编号', trigger: 'blur' }],
sopName: [{ required: true, message: '请输入SOP文档名称', trigger: 'blur' }]
}
function getFileName(url: string) {
if (!url) return ''
const parts = url.split('/')
return decodeURIComponent(parts[parts.length - 1] || '')
}
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
const attachCount = row.sopAttachments ? row.sopAttachments.split(',').filter(Boolean).length : 0
const attachLinks = row.sopAttachments
? row.sopAttachments
.split(',')
.filter(Boolean)
.map((url: string) => getFileName(url))
.join('、')
: '无'
return [
{
title: '基本信息',
fields: [
{ label: 'SOP编号', value: row.sopCode },
{ label: 'SOP文档名称', value: row.sopName },
{ label: '版本号', value: row.versionNo },
{ label: '附件数', value: String(attachCount) },
{ label: '附件列表', value: attachLinks, span: true },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
async function handleFileChange(uploadFile: any) {
const file = uploadFile?.raw
if (!file) return
try {
const fd = new FormData()
fd.append('business_type', 'sop')
fd.append('business_id', editingId.value || 'temp')
fd.append('file', file)
const res: any = await fetchUploadAttachment(fd)
if (res?.fileUrl) {
const current = formData.sopAttachments ? formData.sopAttachments.split(',').filter(Boolean) : []
current.push(res.fileUrl)
formData.sopAttachments = current.join(',')
}
} catch {
ElMessage.error('文件上传失败')
}
}
function removeAttachment(idx: number) {
const current = formData.sopAttachments.split(',').filter(Boolean)
current.splice(idx, 1)
formData.sopAttachments = current.join(',')
}
async function loadData() {
loading.value = true
try {
const res = await fetchSopList({
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.sopCode = ''
formData.sopName = ''
formData.versionNo = ''
formData.sopAttachments = ''
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增SOP'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleEdit(row: any) {
isEdit.value = true
dialogTitle.value = '编辑SOP'
editingId.value = row.id
formData.sopCode = row.sopCode || ''
formData.sopName = row.sopName || ''
formData.versionNo = row.versionNo || ''
formData.sopAttachments = row.sopAttachments || ''
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
sopCode: formData.sopCode.trim(),
sopName: formData.sopName.trim(),
versionNo: formData.versionNo?.trim() || undefined,
sopAttachments: formData.sopAttachments || undefined,
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateSop(editingId.value, payload)
} else {
await fetchCreateSop(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
}
async function handleDelete(row: any) {
await ElMessageBox.confirm('确定要删除该SOP文档吗?', '提示', { type: 'warning' })
await fetchDeleteSop(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} 个SOP文档吗?`, '提示', { type: 'warning' })
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteSop(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.sop-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.upload-card-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
}
.upload-card-item {
width: 120px;
height: 96px;
border: 1px solid #d1d5db;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
padding: 8px;
background: #f9fafb;
}
.upload-card-icon {
font-size: 24px;
color: #6b7280;
margin-bottom: 4px;
}
.upload-card-name {
font-size: 12px;
color: #374151;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.upload-card-remove {
position: absolute;
top: 4px;
right: 4px;
font-size: 14px;
color: #ef4444;
cursor: pointer;
}
.upload-card-trigger {
width: 120px;
height: 96px;
border: 1px dashed #a7f3d0;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
color: #6b7280;
transition: border-color 0.2s;
&:hover {
border-color: #10b981;
color: #10b981;
}
.el-icon {
font-size: 20px;
margin-bottom: 4px;
}
span {
font-size: 12px;
}
}
.upload-card-hint {
font-size: 12px;
color: #9ca3af;
margin-top: 4px;
}
</style>
@@ -0,0 +1,339 @@
<!-- 捐赠协议单审核 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>捐赠协议审核</h1>
<p class="page-desc">审核与管理科研捐赠协议数据。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">捐赠协议审核</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="捐赠编号/母亲姓名" clearable />
</el-form-item>
<el-form-item label="审核状态">
<el-select
v-model="searchForm.auditStatus"
clearable
placeholder="全部"
style="width: 100%"
>
<el-option label="审核通过" value="shtg" />
<el-option label="审核驳回" value="shth" />
</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-ghost" :disabled="!selectedRows.length" @click="handleBatchAudit('shtg')">
<el-icon><CircleCheck /></el-icon>
批量审核通过
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchAudit('shth')">
<el-icon><CircleClose /></el-icon>
批量审核驳回
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchStatusChange('activate')">
批量生效
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchStatusChange('void')">
批量作废
</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" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="donationNo"
label="捐赠编号"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="motherName"
label="母亲姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="auditStatus"
label="审核状态"
width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="row.auditStatus === 'shtg' ? 'success' : row.auditStatus === 'shth' ? 'danger' : 'info'"
size="small"
>
{{ auditStatusLabel(String(row.auditStatus || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="auditRemark"
label="审核备注"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="auditTime"
label="审核时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="260"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)"
><el-icon><View /></el-icon>查看</el-button
>
<el-button class="btn-op-item" @click="openAuditDialog(row)"
><el-icon><CircleCheck /></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>
<!-- 查看弹窗 -->
<DonationViewDialog
v-model="viewVisible"
:donation-id="viewDonationId"
:list-row="viewRow"
show-audit-history
/>
<!-- 审核弹窗 -->
<DonationAuditDialog
v-model="auditDialogVisible"
:donation-id="auditDonationId"
:list-row="auditRow"
@saved="onAuditSubmitted"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import {
View,
Search,
RefreshLeft,
CircleCheck,
CircleClose
} from '@element-plus/icons-vue'
import DonationViewDialog from '../list/DonationViewDialog.vue'
import DonationAuditDialog from '../list/DonationAuditDialog.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchResearchDonationAuditList,
fetchSubmitDonationAudit,
fetchDonationStatusChange,
type ResearchDonationRow
} from '@/api/research-donation'
defineOptions({ name: 'ResearchDonationAudit' })
/* ====== 标签映射 ====== */
function auditStatusLabel(s: string) {
if (s === 'shtg') return '审核通过'
if (s === 'shth') return '审核驳回'
return s || '—'
}
/* ====== 列表 ====== */
type AuditRow = Record<string, unknown>
const searchForm = reactive<{ keyword: string; auditStatus: string }>({
keyword: '',
auditStatus: ''
})
const tableData = ref<AuditRow[]>([])
const selectedRows = ref<AuditRow[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const handleSelectionChange = (rows: AuditRow[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchResearchDonationAuditList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
auditStatus: searchForm.auditStatus || 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.auditStatus = ''
pagination.page = 1
loadData()
}
/* ====== 查看弹窗 ====== */
const viewVisible = ref(false)
const viewDonationId = ref<string | null>(null)
const viewRow = ref<AuditRow | null>(null)
function handleView(row: AuditRow) {
viewDonationId.value = String(row.id)
viewRow.value = row
viewVisible.value = true
}
/* ====== 审核 ====== */
const auditDialogVisible = ref(false)
const auditDonationId = ref<string | null>(null)
const auditRow = ref<AuditRow | null>(null)
function openAuditDialog(row: AuditRow) {
auditDonationId.value = String(row.id)
auditRow.value = row
auditDialogVisible.value = true
}
function onAuditSubmitted() {
selectedRows.value = []
loadData()
}
/* ====== 批量审核 ====== */
async function handleBatchAudit(status: string) {
if (!selectedRows.value.length) {
ElMessage.warning('请先选择要操作的记录')
return
}
const label = status === 'shtg' ? '审核通过' : '审核驳回'
await ElMessageBox.confirm(
`确认将选中的 ${selectedRows.value.length} 条记录标记为「${label}」?`,
'批量审核',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const rows = [...selectedRows.value]
await withFullscreenLoading(async () => {
for (const row of rows) {
await fetchSubmitDonationAudit(String(row.id), { auditStatus: status })
}
ElMessage.success(`批量${label}成功`)
selectedRows.value = []
loadData()
}, fullscreenLoadingText.batchAudit)
}
/* ====== 批量状态变更 ====== */
async function handleBatchStatusChange(action: 'activate' | 'void') {
if (!selectedRows.value.length) {
ElMessage.warning('请先选择要操作的记录')
return
}
const label = action === 'activate' ? '生效' : '作废'
const targetStatus = action === 'activate' ? 'sxz' : 'yzf'
await ElMessageBox.confirm(
`确认将选中的 ${selectedRows.value.length} 条记录标记为「${label}」?`,
`批量${label}`,
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const rows = [...selectedRows.value]
await withFullscreenLoading(async () => {
for (const row of rows) {
await fetchDonationStatusChange(String(row.id), { donationStatus: targetStatus })
}
ElMessage.success(`批量${label}成功`)
selectedRows.value = []
loadData()
}, fullscreenLoadingText.save)
}
onMounted(() => {
loadData()
})
</script>
@@ -0,0 +1,133 @@
<!-- 捐赠协议单 — 审核弹窗 -->
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
style="backdrop-filter: none"
@update:model-value="emit('update:modelValue', $event)"
@closed="resetForm"
>
<p class="audit-target-hint">
捐赠编号:<strong>{{ listRow?.donationNo || '—' }}</strong>
</p>
<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 { Check, Close } from '@element-plus/icons-vue'
import { fetchSubmitDonationAudit } from '@/api/research-donation'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
defineOptions({ name: 'DonationAuditDialog' })
const props = defineProps<{
modelValue: boolean
donationId: string | null
listRow?: Record<string, unknown> | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): 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(() => '捐赠协议单审核')
function resetForm() {
form.auditStatus = 'shtg'
form.auditRemark = ''
formRef.value?.clearValidate()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || !props.donationId) return
const payload = {
auditStatus: form.auditStatus,
auditRemark: form.auditRemark.trim() || undefined
}
submitting.value = true
try {
await withFullscreenLoading(
async () => {
await fetchSubmitDonationAudit(props.donationId!, payload)
emit('update:modelValue', false)
emit('saved')
},
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>
@@ -0,0 +1,680 @@
<!-- 捐赠协议单 - 新增/编辑弹窗 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑捐赠协议单' : '新增捐赠协议单'"
width="1200px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
style="backdrop-filter: none"
>
<el-tabs v-model="activeTab" class="donation-form-tabs">
<!-- ====== Tab 1: 基本信息 ====== -->
<el-tab-pane label="基本信息" name="basic">
<el-form
ref="basicFormRef"
:model="formData"
:rules="basicRules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="捐赠编号" prop="donationNo">
<el-input v-model="formData.donationNo" placeholder="请输入捐赠编号" />
</el-form-item>
<el-form-item label="捐赠日期">
<el-date-picker
v-model="formData.donationDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择捐赠日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="来源类型">
<el-select v-model="formData.sourceType" placeholder="请选择" :disabled="isEdit" style="width: 100%">
<el-option label="独立新建" value="direct" />
<el-option label="从存储转来" value="from_storage" />
</el-select>
</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>
</el-tab-pane>
<!-- ====== Tab 2: 当事人信息 ====== -->
<el-tab-pane label="当事人信息" name="party">
<el-form
ref="partyFormRef"
:model="formData.partyCustomer"
label-position="top"
>
<h4 class="section-sub-title">母亲信息</h4>
<div class="form-grid-2">
<el-form-item label="母亲姓名">
<el-input v-model="formData.partyCustomer.motherName" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="formData.partyCustomer.motherPhone" placeholder="请输入电话" />
</el-form-item>
<el-form-item label="身份证号">
<el-input v-model="formData.partyCustomer.motherIdCard" placeholder="请输入身份证号" />
</el-form-item>
<el-form-item label="出生年月">
<el-date-picker
v-model="formData.partyCustomer.motherBirthYm"
type="month"
value-format="YYYY-MM"
placeholder="请选择"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="民族">
<el-input v-model="formData.partyCustomer.motherEthnic" placeholder="请输入民族" />
</el-form-item>
<el-form-item label="地址">
<el-input v-model="formData.partyCustomer.motherAddress" placeholder="请输入地址" />
</el-form-item>
</div>
<h4 class="section-sub-title">父亲信息</h4>
<div class="form-grid-2">
<el-form-item label="父亲姓名">
<el-input v-model="formData.partyCustomer.fatherName" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="formData.partyCustomer.fatherPhone" placeholder="请输入电话" />
</el-form-item>
<el-form-item label="身份证号">
<el-input v-model="formData.partyCustomer.fatherIdCard" placeholder="请输入身份证号" />
</el-form-item>
</div>
<h4 class="section-sub-title">新生儿信息</h4>
<div class="form-grid-2">
<el-form-item label="新生儿姓名">
<el-input v-model="formData.partyCustomer.newbornName" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="身份证号">
<el-input v-model="formData.partyCustomer.newbornIdCard" placeholder="请输入身份证号" />
</el-form-item>
<el-form-item label="出生时间">
<el-date-picker
v-model="formData.partyCustomer.newbornDeliveryTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="性别">
<el-select v-model="formData.partyCustomer.newbornSex" placeholder="请选择" style="width: 100%">
<el-option label="男" value="male" />
<el-option label="女" value="female" />
</el-select>
</el-form-item>
<el-form-item label="分娩方式">
<el-select v-model="formData.partyCustomer.deliveryMode" placeholder="请选择" style="width: 100%">
<el-option label="顺产" value="natural" />
<el-option label="剖宫产" value="cesarean" />
</el-select>
</el-form-item>
<el-form-item label="胎次">
<el-input-number
v-model="formData.partyCustomer.birthOrder"
:min="1"
:max="20"
placeholder="胎次"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="出生体重(g)">
<el-input-number
v-model="formData.partyCustomer.newbornWeightGram"
:min="0"
:max="99999"
placeholder="克"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="Apgar 1分钟">
<el-input-number
v-model="formData.partyCustomer.apgar1min"
:min="0"
:max="10"
placeholder="0-10"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="Apgar 5分钟">
<el-input-number
v-model="formData.partyCustomer.apgar5min"
:min="0"
:max="10"
placeholder="0-10"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="采集人员">
<el-input v-model="formData.partyCustomer.collectorUserId" placeholder="请选择采集人员" />
</el-form-item>
</div>
</el-form>
</el-tab-pane>
<!-- ====== Tab 3: 采集信息 ====== -->
<el-tab-pane label="采集信息" name="collection">
<el-form
ref="collectionFormRef"
:model="formData.collectionInfo"
label-position="top"
>
<div class="form-grid-2">
<el-form-item label="区域">
<el-input v-model="formData.collectionInfo.regionId" placeholder="请选择区域" />
</el-form-item>
<el-form-item label="医院">
<el-input v-model="formData.collectionInfo.hospitalId" placeholder="请选择医院" />
</el-form-item>
<el-form-item label="医护人员">
<el-input v-model="formData.collectionInfo.medicalStaff" placeholder="请输入医护人员姓名" />
</el-form-item>
<el-form-item label="业务员">
<el-input v-model="formData.collectionInfo.salesUserId" placeholder="请选择业务员" />
</el-form-item>
<el-form-item label="样本采集时间">
<el-date-picker
v-model="formData.collectionInfo.sampleCollectTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="母血采集时间">
<el-date-picker
v-model="formData.collectionInfo.maternalBloodCollectTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择"
style="width: 100%"
/>
</el-form-item>
</div>
</el-form>
</el-tab-pane>
<!-- ====== Tab 4: 样本信息 ====== -->
<el-tab-pane label="样本信息" name="sample">
<div class="sample-toolbar">
<el-button type="primary" class="btn-main" @click="addSample">
<el-icon><Plus /></el-icon>
添加样本
</el-button>
</div>
<div v-for="(sample, index) in formData.samples" :key="index" class="sample-block">
<div class="sample-block-header">
<span class="sample-block-title">样本 {{ index + 1 }}</span>
<el-button type="danger" link @click="removeSample(index)">
<el-icon><Delete /></el-icon>
移除
</el-button>
</div>
<el-form :model="sample" label-position="top">
<div class="form-grid-2">
<el-form-item label="样本类型">
<el-input v-model="sample.sampleTypeId" placeholder="请选择样本类型" />
</el-form-item>
<el-form-item label="样本编码">
<el-input v-model="sample.sampleCode" placeholder="请输入样本编码" />
</el-form-item>
<el-form-item label="采集量">
<el-input-number
v-model="sample.collectAmount"
:min="0"
placeholder="采集量"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="单位">
<el-input v-model="sample.unitId" placeholder="请选择单位" />
</el-form-item>
<el-form-item label="存储年限">
<el-input-number
v-model="sample.storageYears"
:min="0"
placeholder="年"
style="width: 100%"
/>
</el-form-item>
</div>
<h5 class="section-sub-title">运输信息(选填)</h5>
<div class="form-grid-2">
<el-form-item label="运输方式">
<el-select v-model="sample.transportType" placeholder="请选择" clearable style="width: 100%">
<el-option label="快递" value="express" />
<el-option label="专人运送" value="dedicated" />
</el-select>
</el-form-item>
<el-form-item label="快递公司">
<el-input v-model="sample.expressCompanyId" placeholder="请选择快递公司" />
</el-form-item>
<el-form-item label="运单号">
<el-input v-model="sample.waybillNo" placeholder="请输入运单号" />
</el-form-item>
<el-form-item label="运费">
<el-input-number
v-model="sample.expressFee"
:min="0"
:precision="2"
placeholder="运费金额"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="承运人姓名">
<el-input v-model="sample.carrierName" placeholder="请输入承运人姓名" />
</el-form-item>
<el-form-item label="承运人电话">
<el-input v-model="sample.carrierPhone" placeholder="请输入承运人电话" />
</el-form-item>
</div>
</el-form>
</div>
<el-empty v-if="!formData.samples.length" description="暂未添加样本" :image-size="64" />
</el-tab-pane>
<!-- ====== Tab 5: 附件 ====== -->
<el-tab-pane label="附件" name="attachment">
<div class="attachment-section">
<h4 class="section-sub-title">
知情同意书 <span class="text-danger">*</span>
</h4>
<el-upload
action="#"
:auto-upload="false"
:on-change="(file: UploadFile, fileList: UploadFile[]) => handleConsentChange(fileList)"
:on-remove="() => handleConsentRemove()"
accept="image/*,.pdf"
multiple
>
<el-button type="primary" class="btn-main">
<el-icon><Upload /></el-icon>
上传知情同意书
</el-button>
</el-upload>
<p class="form-label-tip">支持图片和PDF格式,可上传多份。</p>
<div v-if="consentPreviewUrls.length" class="attachment-preview-list">
<span v-for="(url, idx) in consentPreviewUrls" :key="idx" class="attachment-preview-item">
{{ extractFileName(url) }}
<el-button type="danger" link size="small" @click="removeConsentFile(idx)">
<el-icon><Close /></el-icon>
</el-button>
</span>
</div>
</div>
<div class="attachment-section">
<h4 class="section-sub-title">其他附件</h4>
<el-upload
action="#"
:auto-upload="false"
:on-change="(file: UploadFile, fileList: UploadFile[]) => handleAttachmentChange(fileList)"
:on-remove="() => handleAttachmentRemove()"
accept="image/*,.pdf,.doc,.docx"
multiple
>
<el-button class="btn-ghost">
<el-icon><Upload /></el-icon>
上传其他附件
</el-button>
</el-upload>
<p class="form-label-tip">支持图片、PDF、Word格式。</p>
<div v-if="attachmentPreviewUrls.length" class="attachment-preview-list">
<span v-for="(url, idx) in attachmentPreviewUrls" :key="idx" class="attachment-preview-item">
{{ extractFileName(url) }}
<el-button type="danger" link size="small" @click="removeAttachmentFile(idx)">
<el-icon><Close /></el-icon>
</el-button>
</span>
</div>
</div>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button class="btn-ghost" @click="visible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Plus, Delete, Close, Check, Upload } from '@element-plus/icons-vue'
import type { UploadFile } from 'element-plus'
import {
fetchCreateResearchDonation,
fetchUpdateResearchDonation,
fetchResearchDonationDetail
} from '@/api/research-donation'
defineOptions({ name: 'DonationFormDialog' })
const props = defineProps<{
modelValue: boolean
editId?: string | null
listRow?: Record<string, unknown> | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'saved': []
}>()
/* ====== 双向绑定 visible ====== */
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const isEdit = computed(() => !!props.editId)
/* ====== 状态 ====== */
const activeTab = ref('basic')
const submitLoading = ref(false)
const basicFormRef = ref()
const partyFormRef = ref()
const collectionFormRef = ref()
/* ====== 表单数据 ====== */
function createEmptySample() {
return {
sampleTypeId: '',
sampleCode: '',
collectAmount: 0,
unitId: '',
storageYears: 0,
transportType: '',
expressCompanyId: '',
waybillNo: '',
expressFee: 0,
carrierName: '',
carrierPhone: ''
}
}
function createEmptyFormData() {
return {
donationNo: '',
donationDate: '',
sourceType: 'direct',
remark: '',
partyCustomer: {
motherName: '',
motherPhone: '',
motherIdCard: '',
motherBirthYm: '',
motherEthnic: '',
motherAddress: '',
fatherName: '',
fatherPhone: '',
fatherIdCard: '',
newbornName: '',
newbornIdCard: '',
newbornDeliveryTime: '',
newbornSex: '',
deliveryMode: '',
birthOrder: 1,
newbornWeightGram: 0,
apgar1min: 0,
apgar5min: 0,
collectorUserId: ''
},
collectionInfo: {
regionId: '',
hospitalId: '',
medicalStaff: '',
salesUserId: '',
sampleCollectTime: '',
maternalBloodCollectTime: ''
},
samples: [] as Array<Record<string, unknown>>,
consentFormUrls: '',
attachmentUrls: ''
}
}
const formData = reactive(createEmptyFormData())
const consentPreviewUrls = ref<string[]>([])
const attachmentPreviewUrls = ref<string[]>([])
/* ====== 基本信息校验规则 ====== */
const basicRules = {
donationNo: [{ required: true, message: '请输入捐赠编号', trigger: 'blur' }]
}
/* ====== 样本操作 ====== */
function addSample() {
formData.samples.push(createEmptySample())
}
function removeSample(index: number) {
formData.samples.splice(index, 1)
}
/* ====== 附件操作 ====== */
function handleConsentChange(_fileList: UploadFile[]) {
// TODO: 实际上传到 MinIO 后获取 URL,此处占位
}
function handleConsentRemove() {
// TODO: 同步移除对应 URL
}
function removeConsentFile(index: number) {
consentPreviewUrls.value.splice(index, 1)
formData.consentFormUrls = consentPreviewUrls.value.join(',')
}
function handleAttachmentChange(_fileList: UploadFile[]) {
// TODO: 实际上传到 MinIO 后获取 URL,此处占位
}
function handleAttachmentRemove() {
// TODO: 同步移除对应 URL
}
function removeAttachmentFile(index: number) {
attachmentPreviewUrls.value.splice(index, 1)
formData.attachmentUrls = attachmentPreviewUrls.value.join(',')
}
function extractFileName(url: string): string {
if (!url) return ''
const parts = url.split('/')
return parts[parts.length - 1] || url
}
/* ====== 提交 ====== */
async function handleSubmit() {
// 校验基本信息
try {
await basicFormRef.value?.validate()
} catch {
activeTab.value = 'basic'
return
}
// 校验知情同意书
if (!formData.consentFormUrls) {
ElMessage.warning('请上传知情同意书')
activeTab.value = 'attachment'
return
}
submitLoading.value = true
try {
const payload = { ...toRaw(formData) }
if (isEdit.value && props.editId) {
await fetchUpdateResearchDonation(props.editId, payload)
} else {
await fetchCreateResearchDonation(payload)
}
emit('saved')
visible.value = false
} finally {
submitLoading.value = false
}
}
/* ====== 弹窗打开时:填充或重置 ====== */
watch(
() => props.modelValue,
async (open) => {
if (!open) return
activeTab.value = 'basic'
if (props.editId) {
// 编辑模式:从详情接口拉取完整数据
try {
const res = await fetchResearchDonationDetail(props.editId)
const d = res.donation || {}
const p = res.party || null
const c = res.collection || null
const s = res.samples || []
formData.donationNo = String(d.donationNo || '')
formData.donationDate = String(d.donationDate || '')
formData.sourceType = String(d.sourceType || 'direct')
formData.remark = String(d.remark || '')
if (p) {
Object.keys(p).forEach((key) => {
if (key in formData.partyCustomer) {
;(formData.partyCustomer as Record<string, unknown>)[key] = p[key] ?? ''
}
})
}
if (c) {
Object.keys(c).forEach((key) => {
if (key in formData.collectionInfo) {
;(formData.collectionInfo as Record<string, unknown>)[key] = c[key] ?? ''
}
})
}
formData.samples = s.length
? s.map((item) => ({
...createEmptySample(),
...item
}))
: []
const consentStr = String(d.consentFormUrls || '')
formData.consentFormUrls = consentStr
consentPreviewUrls.value = consentStr ? consentStr.split(',').filter(Boolean) : []
const attachStr = String(d.attachmentUrls || '')
formData.attachmentUrls = attachStr
attachmentPreviewUrls.value = attachStr ? attachStr.split(',').filter(Boolean) : []
} catch {
ElMessage.error('获取详情失败')
}
} else {
// 新增模式:重置
Object.assign(formData, createEmptyFormData())
consentPreviewUrls.value = []
attachmentPreviewUrls.value = []
}
},
{ immediate: true }
)
</script>
<style lang="scss" scoped>
.donation-form-tabs {
:deep(.el-tabs__header) {
margin-bottom: 16px;
}
}
.section-sub-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 20px 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
&:first-child {
margin-top: 0;
}
}
.sample-toolbar {
margin-bottom: 16px;
}
.sample-block {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
background: var(--el-bg-color);
.sample-block-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.sample-block-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.attachment-section {
margin-bottom: 24px;
}
.attachment-preview-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.attachment-preview-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
font-size: 12px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
}
.text-danger {
color: var(--el-color-danger);
}
</style>
@@ -0,0 +1,228 @@
<!-- 捐赠协议单 — 状态变更弹窗(生效 / 过期 / 作废 / 转细胞存储) -->
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
style="backdrop-filter: none"
@update:model-value="emit('update:modelValue', $event)"
@closed="resetForm"
>
<p class="status-change-hint">
捐赠编号:<strong>{{ listRow?.donationNo || '—' }}</strong>
</p>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="biz-dialog-form"
@submit.prevent
>
<el-form-item label="变更操作" prop="operationType">
<el-radio-group v-model="form.operationType" @change="handleOperationChange">
<el-radio
v-for="op in availableOperations"
:key="op.value"
:value="op.value"
>
{{ op.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-alert
v-if="form.operationType === 'zckc'"
type="warning"
:closable="false"
class="convert-alert"
>
转为细胞存储后将创建新的细胞存储协议,需补填缴费信息。
</el-alert>
<el-form-item :label="timeLabel" prop="changeTime">
<el-date-picker
v-model="form.changeTime"
type="datetime"
placeholder="请选择时间"
style="width: 100%"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item label="描述">
<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 { Check, Close } from '@element-plus/icons-vue'
import { fetchDonationStatusChange } from '@/api/research-donation'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
type OperationType = 'activate' | 'expire' | 'void' | 'zckc'
defineOptions({ name: 'DonationStatusChangeDialog' })
const props = defineProps<{
modelValue: boolean
donationId: string | null
listRow?: Record<string, unknown> | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const OPERATION_CONFIG: Record<
OperationType,
{
label: string
timeLabel: string
targetStatus: string
}
> = {
activate: {
label: '生效',
timeLabel: '生效时间',
targetStatus: 'sxz'
},
expire: {
label: '过期',
timeLabel: '过期时间',
targetStatus: 'ygq'
},
void: {
label: '作废',
timeLabel: '作废时间',
targetStatus: 'yzf'
},
zckc: {
label: '转细胞存储',
timeLabel: '转换时间',
targetStatus: 'zckc'
}
}
const currentStatus = computed(() => String(props.listRow?.donationStatus || ''))
const availableOperations = computed(() => {
const ops: Array<{ value: OperationType; label: string }> = []
const s = currentStatus.value
if (s === 'dsx') {
ops.push({ value: 'activate', label: '生效' })
ops.push({ value: 'void', label: '作废' })
} else if (s === 'sxz') {
ops.push({ value: 'expire', label: '过期' })
ops.push({ value: 'void', label: '作废' })
ops.push({ value: 'zckc', label: '转细胞存储' })
}
return ops
})
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
operationType: '' as OperationType | '',
changeTime: '',
changeDesc: ''
})
const rules: FormRules = {
operationType: [{ required: true, message: '请选择变更操作', trigger: 'change' }],
changeTime: [{ required: true, message: '请选择时间', trigger: 'change' }]
}
const config = computed(() => {
if (!form.operationType) return null
return OPERATION_CONFIG[form.operationType]
})
const timeLabel = computed(() => config.value?.timeLabel || '时间')
const dialogTitle = computed(() => '捐赠协议单 — 状态变更')
function handleOperationChange() {
form.changeTime = ''
form.changeDesc = ''
}
function resetForm() {
form.operationType = ''
form.changeTime = ''
form.changeDesc = ''
formRef.value?.clearValidate()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || !props.donationId || !form.operationType) return
const cfg = OPERATION_CONFIG[form.operationType]
const payload = {
donationStatus: cfg.targetStatus,
time: form.changeTime,
desc: form.changeDesc.trim() || undefined
}
submitting.value = true
try {
await withFullscreenLoading(
async () => {
await fetchDonationStatusChange(props.donationId!, payload)
emit('update:modelValue', false)
emit('saved')
},
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;
}
}
.convert-alert {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,975 @@
<!-- 捐赠协议单 — 只读查看弹窗 -->
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="1600px"
class="dialog-form-layout donation-view-dialog"
align-center
destroy-on-close
:close-on-click-modal="false"
style="backdrop-filter: none"
@update:model-value="emit('update:modelValue', $event)"
@closed="handleClosed"
>
<div v-loading="loading" class="donation-view-body">
<div class="detail-tabs">
<div
v-for="tab in formTabs"
:key="tab.key"
class="detail-tab"
:class="{ active: activeTab === tab.key }"
@click="scrollToSection(tab.key)"
>
{{ tab.label }}
</div>
</div>
<template v-if="detail">
<!-- KPI 摘要条 -->
<div class="kpi-strip">
<div class="kpi-card">
<div class="kpi-label">捐赠编号</div>
<div class="kpi-value">{{ dash(donation?.donationNo) }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">状态</div>
<div class="kpi-value">
<span class="badge" :class="statusBadgeClass(String(donation?.donationStatus || ''))">
{{ donationStatusLabel(String(donation?.donationStatus || '')) }}
</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">母亲姓名</div>
<div class="kpi-value">{{ dash(party?.motherName) }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">捐赠日期</div>
<div class="kpi-value">{{ dash(donation?.donationDate) }}</div>
</div>
</div>
<!-- 基本信息 -->
<div id="view-section-basic" class="section-card section-card--white">
<div class="section-title"><h3>基本信息</h3></div>
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">捐赠编号</div>
<div class="data-value">{{ dash(donation?.donationNo) }}</div>
</div>
<div class="data-item">
<div class="data-label">捐赠日期</div>
<div class="data-value">{{ dash(donation?.donationDate) }}</div>
</div>
<div class="data-item">
<div class="data-label">状态</div>
<div class="data-value">
<span class="badge" :class="statusBadgeClass(String(donation?.donationStatus || ''))">
{{ donationStatusLabel(String(donation?.donationStatus || '')) }}
</span>
</div>
</div>
<div class="data-item">
<div class="data-label">来源类型</div>
<div class="data-value">{{ sourceTypeLabel(String(donation?.sourceType || '')) }}</div>
</div>
<div class="data-item">
<div class="data-label">创建时间</div>
<div class="data-value">{{ dash(donation?.createTime) }}</div>
</div>
</div>
<!-- 状态变更记录 -->
<div v-if="hasAnyStatusChange" class="status-change-section">
<div class="status-change-title">状态变更记录</div>
<div class="data-grid data-grid--2">
<template v-if="donation?.effectiveTime">
<div class="data-item">
<div class="data-label">生效时间</div>
<div class="data-value">{{ dash(donation.effectiveTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">生效描述</div>
<div class="data-value data-value--muted">{{ dash(donation.effectiveDesc) }}</div>
</div>
</template>
<template v-if="donation?.expireTime">
<div class="data-item">
<div class="data-label">过期时间</div>
<div class="data-value">{{ dash(donation.expireTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">过期描述</div>
<div class="data-value data-value--muted">{{ dash(donation.expireDesc) }}</div>
</div>
</template>
<template v-if="donation?.voidTime">
<div class="data-item">
<div class="data-label">作废时间</div>
<div class="data-value">{{ dash(donation.voidTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">作废描述</div>
<div class="data-value data-value--muted">{{ dash(donation.voidDesc) }}</div>
</div>
</template>
<template v-if="donation?.convertTime">
<div class="data-item">
<div class="data-label">转细胞存储时间</div>
<div class="data-value">{{ dash(donation.convertTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">转细胞存储描述</div>
<div class="data-value data-value--muted">{{ dash(donation.convertDesc) }}</div>
</div>
</template>
</div>
</div>
<!-- 备注 -->
<div v-if="donation?.remark" class="remark-row">
<div class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(donation?.remark) }}</div>
</div>
</div>
</div>
<!-- 当事人信息 -->
<div id="view-section-party" class="section-card section-card--green">
<div class="section-title"><h3>当事人信息</h3></div>
<div class="client-sub-cards">
<!-- 母亲信息 -->
<div class="client-sub-card">
<div class="sub-card-header"><span class="sub-card-icon">🤱</span><h4>母亲信息</h4></div>
<div class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">母亲姓名</div>
<div class="data-value">{{ dash(party?.motherName) }}</div>
</div>
<div class="data-item">
<div class="data-label">联系电话</div>
<div class="data-value">{{ dash(party?.motherPhone) }}</div>
</div>
<div class="data-item">
<div class="data-label">身份证号</div>
<div class="data-value">{{ dash(party?.motherIdCard) }}</div>
</div>
<div class="data-item">
<div class="data-label">出生年月</div>
<div class="data-value">{{ dash(party?.motherBirthYm) }}</div>
</div>
<div class="data-item">
<div class="data-label">民族</div>
<div class="data-value">{{ dash(party?.motherEthnic) }}</div>
</div>
</div>
<div class="sub-card-full-row">
<div class="data-label">地址</div>
<div class="data-value data-value--muted">{{ dash(party?.motherAddress) }}</div>
</div>
</div>
<!-- 父亲信息 -->
<div class="client-sub-card">
<div class="sub-card-header"><span class="sub-card-icon">👨</span><h4>父亲信息</h4></div>
<div class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">父亲姓名</div>
<div class="data-value">{{ dash(party?.fatherName) }}</div>
</div>
<div class="data-item">
<div class="data-label">联系电话</div>
<div class="data-value">{{ dash(party?.fatherPhone) }}</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">身份证号</div>
<div class="data-value">{{ dash(party?.fatherIdCard) }}</div>
</div>
</div>
</div>
<!-- 新生儿信息 -->
<div class="client-sub-card">
<div class="sub-card-header"><span class="sub-card-icon">👶</span><h4>新生儿信息</h4></div>
<div class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">新生儿姓名</div>
<div class="data-value">{{ dash(party?.newbornName) }}</div>
</div>
<div class="data-item">
<div class="data-label">身份证号</div>
<div class="data-value">{{ dash(party?.newbornIdCard) }}</div>
</div>
<div class="data-item">
<div class="data-label">出生时间</div>
<div class="data-value">{{ dash(party?.newbornDeliveryTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">性别</div>
<div class="data-value">{{ newbornSexLabel(String(party?.newbornSex || '')) }}</div>
</div>
<div class="data-item">
<div class="data-label">分娩方式</div>
<div class="data-value">{{ deliveryModeLabel(String(party?.deliveryMode || '')) }}</div>
</div>
<div class="data-item">
<div class="data-label">胎次</div>
<div class="data-value">{{ dash(party?.birthOrder) }}</div>
</div>
<div class="data-item">
<div class="data-label">出生体重(g)</div>
<div class="data-value">{{ dash(party?.newbornWeightGram) }}</div>
</div>
<div class="data-item">
<div class="data-label">Apgar 1分钟</div>
<div class="data-value">{{ dash(party?.apgar1min) }}</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">Apgar 5分钟</div>
<div class="data-value">{{ dash(party?.apgar5min) }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 采集信息 -->
<div id="view-section-collection" class="section-card section-card--white">
<div class="section-title"><h3>采集信息</h3></div>
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">区域</div>
<div class="data-value">{{ dash(collection?.regionName || collection?.regionId) }}</div>
</div>
<div class="data-item">
<div class="data-label">医院</div>
<div class="data-value">{{ dash(collection?.hospitalName || collection?.hospitalId) }}</div>
</div>
<div class="data-item">
<div class="data-label">医护人员</div>
<div class="data-value">{{ dash(collection?.medicalStaff) }}</div>
</div>
<div class="data-item">
<div class="data-label">业务员</div>
<div class="data-value">{{ dash(collection?.salesUserName || collection?.salesUserId) }}</div>
</div>
<div class="data-item">
<div class="data-label">样本采集时间</div>
<div class="data-value">{{ dash(collection?.sampleCollectTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">母血采集时间</div>
<div class="data-value">{{ dash(collection?.maternalBloodCollectTime) }}</div>
</div>
</div>
</div>
<!-- 样本信息 -->
<div id="view-section-sample" class="section-card section-card--white">
<div class="section-title"><h3>样本信息</h3></div>
<div v-if="samples.length" class="sample-cards">
<div
v-for="(row, sIdx) in samples"
:key="row.id || sIdx"
class="sample-card"
>
<div class="sample-card__header">
<div class="sample-card__title">
<span class="sample-card__seq">{{ sIdx + 1 }}</span>
{{ dash(row.sampleTypeName || row.sampleTypeId) }}
</div>
</div>
<div class="sample-card__body">
<div class="sample-card__section">
<div class="sample-card__section-label">样本信息</div>
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">样本编码</div>
<div class="data-value data-value--code">{{ dash(row.sampleCode) }}</div>
</div>
<div class="data-item">
<div class="data-label">采集量</div>
<div class="data-value">{{ dash(row.collectAmount) }} {{ dash(row.unitName || row.unitId) }}</div>
</div>
<div class="data-item">
<div class="data-label">存储年限(年)</div>
<div class="data-value">{{ dash(row.storageYears) }}</div>
</div>
<div v-if="row.remark" class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(row.remark) }}</div>
</div>
</div>
</div>
<!-- 运输信息 -->
<div v-if="row.transportType" class="sample-card__section sample-card__section--transport">
<div class="sample-card__section-label">运输信息</div>
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">运输方式</div>
<div class="data-value">{{ transportTypeLabel(String(row.transportType || '')) }}</div>
</div>
<div class="data-item">
<div class="data-label">快递公司</div>
<div class="data-value">{{ dash(row.expressCompanyName || row.expressCompanyId) }}</div>
</div>
<div class="data-item">
<div class="data-label">运单号</div>
<div class="data-value">{{ dash(row.waybillNo) }}</div>
</div>
<div class="data-item">
<div class="data-label">运费(元)</div>
<div class="data-value">{{ row.expressFee ? `¥${row.expressFee}` : '—' }}</div>
</div>
<div class="data-item">
<div class="data-label">承运人姓名</div>
<div class="data-value">{{ dash(row.carrierName) }}</div>
</div>
<div class="data-item">
<div class="data-label">承运人电话</div>
<div class="data-value">{{ dash(row.carrierPhone) }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<el-empty v-else description="暂无样本" :image-size="48" />
</div>
<!-- 附件 -->
<div id="view-section-attachment" class="section-card section-card--white">
<div class="section-title"><h3>附件</h3></div>
<!-- 知情同意书 -->
<div class="attachment-row" v-if="consentUrls.length">
<div class="data-label">知情同意书</div>
<div class="attachment-list">
<a
v-for="(url, idx) in consentUrls"
:key="'consent-' + idx"
:href="String(url)"
target="_blank"
class="attachment-link"
>
<el-icon><Document /></el-icon>
{{ extractFileName(String(url)) }}
</a>
</div>
</div>
<!-- 其他附件 -->
<div class="attachment-row" v-if="otherAttachmentUrls.length">
<div class="data-label">其他附件</div>
<div class="attachment-list">
<a
v-for="(url, idx) in otherAttachmentUrls"
:key="'other-' + idx"
:href="String(url)"
target="_blank"
class="attachment-link"
>
<el-icon><Document /></el-icon>
{{ extractFileName(String(url)) }}
</a>
</div>
</div>
<el-empty
v-if="!consentUrls.length && !otherAttachmentUrls.length"
description="暂无附件"
:image-size="48"
/>
</div>
<!-- 审核记录 -->
<div v-if="showAuditHistory" id="view-section-audit" class="section-card section-card--white">
<div class="section-title"><h3>审核记录</h3></div>
<el-table
v-loading="auditLoading"
class="list-table sub-view-table"
:data="auditRecords"
stripe
empty-text="暂无审核记录"
>
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="审核时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
prop="auditStatus"
label="审核状态"
width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<span
class="tag"
:class="String(row.auditStatus) === 'shtg' ? 'success' : 'warning'"
>
{{ auditStatusLabel(String(row.auditStatus || '')) }}
</span>
</template>
</el-table-column>
<el-table-column
prop="auditRemark"
label="审核备注"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="auditorUserName"
label="审核人"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ dash(row.auditorUserName || row.auditorUserId) }}</template>
</el-table-column>
</el-table>
</div>
</template>
</div>
<template #footer>
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>
关闭
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Close, Document } from '@element-plus/icons-vue'
import {
fetchResearchDonationDetail,
fetchResearchDonationAuditList,
type ResearchDonationRow
} from '@/api/research-donation'
defineOptions({ name: 'DonationViewDialog' })
const props = withDefaults(
defineProps<{
modelValue: boolean
donationId: string | null
listRow?: ResearchDonationRow | null
showAuditHistory?: boolean
}>(),
{ showAuditHistory: false }
)
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
}>()
const baseFormTabs = [
{ key: 'basic', label: '基本信息' },
{ key: 'party', label: '当事人信息' },
{ key: 'collection', label: '采集信息' },
{ key: 'sample', label: '样本信息' },
{ key: 'attachment', label: '附件' }
] as const
const formTabs = computed(() => {
const tabs: Array<{ key: string; label: string }> = [...baseFormTabs]
if (props.showAuditHistory) {
tabs.push({ key: 'audit', label: '审核记录' })
}
return tabs
})
const loading = ref(false)
const auditLoading = ref(false)
const detail = ref<Record<string, any> | null>(null)
const auditRecords = ref<Array<Record<string, unknown>>>([])
const activeTab = ref('basic')
const donation = computed(() => detail.value?.donation as Record<string, any> | undefined)
const party = computed(() => detail.value?.party as Record<string, any> | null)
const collection = computed(() => detail.value?.collection as Record<string, any> | null)
const samples = computed(() => {
const rows = (detail.value?.samples as Array<Record<string, any>>) || []
return [...rows].sort((a, b) => {
const oa = Number(a.orderNum ?? 0)
const ob = Number(b.orderNum ?? 0)
if (oa !== ob) return oa - ob
return String(a.createTime || '').localeCompare(String(b.createTime || ''))
})
})
const consentUrls = computed(() => {
const str = String(donation.value?.consentFormUrls || '')
return str ? str.split(',').filter(Boolean) : []
})
const otherAttachmentUrls = computed(() => {
const str = String(donation.value?.attachmentUrls || '')
return str ? str.split(',').filter(Boolean) : []
})
const dialogTitle = computed(() => {
const no = donation.value?.donationNo || props.listRow?.donationNo
return no ? `查看捐赠协议单 — ${no}` : '查看捐赠协议单'
})
const hasAnyStatusChange = computed(() => {
if (!donation.value) return false
return !!(
donation.value.effectiveTime ||
donation.value.expireTime ||
donation.value.voidTime ||
donation.value.convertTime
)
})
function dash(val: unknown) {
if (val === null || val === undefined || val === '') return '—'
return String(val)
}
function donationStatusLabel(s: string) {
const m: Record<string, string> = {
dsx: '待生效',
sxz: '生效中',
ygq: '已过期',
yzf: '已作废',
zckc: '转细胞存储'
}
return m[s] || s || '—'
}
function statusBadgeClass(s: string): string {
const m: Record<string, string> = {
dsx: 'badge--amber',
sxz: 'badge--green',
ygq: 'badge--gray',
yzf: 'badge--red',
zckc: 'badge--purple'
}
return m[s] || 'badge--gray'
}
function sourceTypeLabel(s: string) {
if (s === 'direct') return '独立新建'
if (s === 'from_storage') return '从存储转来'
return s || '—'
}
function newbornSexLabel(s: string) {
if (s === 'male') return '男'
if (s === 'female') return '女'
return s || '—'
}
function deliveryModeLabel(s: string) {
if (s === 'natural') return '顺产'
if (s === 'cesarean') return '剖宫产'
return s || '—'
}
function transportTypeLabel(s: string) {
if (s === 'express') return '快递'
if (s === 'dedicated') return '专人运送'
return s || '—'
}
function auditStatusLabel(s: string) {
if (s === 'shtg') return '审核通过'
if (s === 'shth') return '审核退回'
return s || '—'
}
function extractFileName(url: string): string {
if (!url) return ''
const parts = url.split('/')
return parts[parts.length - 1] || url
}
function scrollToSection(key: string) {
activeTab.value = key
const el = document.getElementById(`view-section-${key}`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
async function loadDetail() {
if (!props.donationId) return
loading.value = true
try {
detail.value = await fetchResearchDonationDetail(props.donationId)
} finally {
loading.value = false
}
}
async function loadAuditRecords() {
if (!props.donationId || !props.showAuditHistory) return
auditLoading.value = true
try {
const res = await fetchResearchDonationAuditList({
page: 1,
pageSize: 100,
keyword: props.donationId
})
auditRecords.value = res.list || []
} catch {
auditRecords.value = []
} finally {
auditLoading.value = false
}
}
function handleClosed() {
detail.value = null
auditRecords.value = []
activeTab.value = 'basic'
}
watch(
() => props.modelValue,
(open) => {
if (open && props.donationId) {
loadDetail()
if (props.showAuditHistory) loadAuditRecords()
}
}
)
</script>
<style scoped lang="scss">
.donation-view-body {
max-height: calc(100vh - 100px);
overflow-y: auto;
padding-right: 4px;
}
.detail-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
padding-bottom: 8px;
position: sticky;
top: 0;
background: #fff;
z-index: 1;
}
.detail-tab {
padding: 6px 16px;
font-size: 13px;
color: var(--mint-text-muted, #64748b);
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
&:hover {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
&.active {
color: #fff;
background: var(--el-color-primary);
font-weight: 500;
}
}
.kpi-strip {
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.kpi-card {
flex: 1;
background: #fff;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 12px 16px;
}
.kpi-label {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
margin-bottom: 4px;
}
.kpi-value {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.section-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
&--white {
background: #fff;
}
&--green {
background: #f0fdf4;
border-color: #bbf7d0;
}
}
.section-title {
margin-bottom: 16px;
h3 {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
padding-left: 10px;
border-left: 3px solid var(--el-color-primary);
}
}
.data-grid {
display: grid;
gap: 12px 24px;
&--2 {
grid-template-columns: repeat(2, 1fr);
}
&--3 {
grid-template-columns: repeat(3, 1fr);
}
}
.data-item {
&--full {
grid-column: 1 / -1;
}
}
.data-label {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
margin-bottom: 4px;
}
.data-value {
font-size: 14px;
color: var(--el-text-color-primary);
word-break: break-all;
&--muted {
color: var(--mint-text-muted, #64748b);
}
&--code {
font-family: 'SF Mono', monospace;
letter-spacing: 0.5px;
}
}
.client-sub-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.client-sub-card {
background: #fff;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 16px;
}
.sub-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
h4 {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.sub-card-icon {
font-size: 18px;
}
.sub-card-full-row {
margin-top: 8px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
&--green {
background: #dcfce7;
color: #166534;
}
&--amber {
background: #fef3c7;
color: #92400e;
}
&--gray {
background: #f1f5f9;
color: #475569;
}
&--red {
background: #fee2e2;
color: #991b1b;
}
&--purple {
background: #f3e8ff;
color: #6b21a8;
}
}
.tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
&.success {
background: #dcfce7;
color: #166534;
}
&.warning {
background: #fef3c7;
color: #92400e;
}
}
.status-change-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed var(--el-border-color-lighter);
}
.status-change-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 12px;
}
.remark-row {
margin-top: 12px;
}
.sample-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.sample-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
overflow: hidden;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
}
&__title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
display: flex;
align-items: center;
gap: 6px;
}
&__seq {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--el-color-primary);
color: #fff;
font-size: 12px;
font-weight: 500;
}
&__body {
padding: 16px;
}
&__section {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
&__section-label {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 10px;
padding-left: 8px;
border-left: 2px solid var(--el-color-primary-light-5);
}
}
.attachment-row {
margin-bottom: 16px;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.attachment-link {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
font-size: 12px;
color: var(--el-color-primary);
text-decoration: none;
background: #fff;
transition: all 0.2s;
&:hover {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
}
</style>
@@ -0,0 +1,404 @@
<!-- 捐赠协议单 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<header class="page-head">
<h1>捐赠协议单</h1>
<p class="page-desc">查看与管理科研捐赠协议单主数据。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">捐赠协议单列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="捐赠编号/备注" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="searchForm.donationStatus"
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="zckc" />
</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 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" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="donationNo"
label="捐赠编号"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="donationDate"
label="捐赠日期"
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="donationStatus"
label="状态"
width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="statusTagType(String(row.donationStatus || ''))" disable-transitions>
{{ donationStatusLabel(String(row.donationStatus || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="sourceType"
label="来源类型"
width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{
sourceTypeLabel(String(row.sourceType || ''))
}}</template>
</el-table-column>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
width="320"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)"
><el-icon><View /></el-icon>查看</el-button
>
<el-button class="btn-op-item primary" @click="handleEdit(row)"
><el-icon><Edit /></el-icon>编辑</el-button
>
<el-button class="btn-op-item" @click="handleAudit(row)"
><el-icon><CircleCheck /></el-icon>审核</el-button
>
<el-button class="btn-op-item" @click="handleStatusChange(row)"
><el-icon><Switch /></el-icon>状态变更</el-button
>
<el-button class="btn-op-item" @click="handleDelete(row)"
><el-icon><Delete /></el-icon>删除</el-button
>
</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>
<!-- 新增/编辑弹窗 -->
<DonationFormDialog
v-model="formDialogVisible"
:edit-id="formDialogEditId"
:list-row="formDialogRow"
@saved="onFormSaved"
/>
<!-- 查看弹窗 -->
<DonationViewDialog
v-model="viewVisible"
:donation-id="viewDonationId"
:list-row="viewRow"
show-audit-history
/>
<!-- 审核弹窗 -->
<DonationAuditDialog
v-model="auditVisible"
:donation-id="auditDonationId"
:list-row="auditRow"
@saved="onAuditSaved"
/>
<!-- 状态变更弹窗 -->
<DonationStatusChangeDialog
v-model="statusChangeVisible"
:donation-id="statusChangeDonationId"
:list-row="statusChangeRow"
@saved="onStatusChangeSaved"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
View,
Edit,
Delete,
Search,
RefreshLeft,
CircleCheck,
Switch
} from '@element-plus/icons-vue'
import DonationFormDialog from './DonationFormDialog.vue'
import DonationViewDialog from './DonationViewDialog.vue'
import DonationAuditDialog from './DonationAuditDialog.vue'
import DonationStatusChangeDialog from './DonationStatusChangeDialog.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchResearchDonationList,
fetchDeleteResearchDonation,
type ResearchDonationRow
} from '@/api/research-donation'
defineOptions({ name: 'ResearchDonationList' })
/* ====== 标签映射 ====== */
function donationStatusLabel(s: string) {
const m: Record<string, string> = {
dsx: '待生效',
sxz: '生效中',
ygq: '已过期',
yzf: '已作废',
zckc: '转细胞存储'
}
return m[s] || s || '-'
}
function statusTagType(s: string) {
const m: Record<string, string> = {
dsx: 'info',
sxz: 'success',
ygq: 'warning',
yzf: 'danger',
zckc: 'primary'
}
return m[s] || 'info'
}
function sourceTypeLabel(s: string) {
if (s === 'direct') return '独立新建'
if (s === 'from_storage') return '从存储转来'
return s || '-'
}
/* ====== 列表 ====== */
const searchForm = reactive<{ keyword: string; donationStatus: string }>({
keyword: '',
donationStatus: ''
})
const tableData = ref<ResearchDonationRow[]>([])
const selectedRows = ref<ResearchDonationRow[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const handleSelectionChange = (rows: ResearchDonationRow[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchResearchDonationList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
donationStatus: searchForm.donationStatus || 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.donationStatus = ''
pagination.page = 1
loadData()
}
/* ====== 查看弹窗 ====== */
const viewVisible = ref(false)
const viewDonationId = ref<string | null>(null)
const viewRow = ref<ResearchDonationRow | null>(null)
function handleView(row: ResearchDonationRow) {
viewDonationId.value = String(row.id)
viewRow.value = row
viewVisible.value = true
}
/* ====== 新增/编辑弹窗 ====== */
const formDialogVisible = ref(false)
const formDialogEditId = ref<string | null>(null)
const formDialogRow = ref<ResearchDonationRow | null>(null)
function handleAdd() {
formDialogEditId.value = null
formDialogRow.value = null
formDialogVisible.value = true
}
function handleEdit(row: ResearchDonationRow) {
formDialogEditId.value = row.id as string
formDialogRow.value = row
formDialogVisible.value = true
}
function onFormSaved() {
loadData()
}
/* ====== 审核弹窗 ====== */
const auditVisible = ref(false)
const auditDonationId = ref<string | null>(null)
const auditRow = ref<ResearchDonationRow | null>(null)
function handleAudit(row: ResearchDonationRow) {
auditDonationId.value = String(row.id)
auditRow.value = row
auditVisible.value = true
}
function onAuditSaved() {
loadData()
}
/* ====== 状态变更弹窗 ====== */
const statusChangeVisible = ref(false)
const statusChangeDonationId = ref<string | null>(null)
const statusChangeRow = ref<ResearchDonationRow | null>(null)
function handleStatusChange(row: ResearchDonationRow) {
statusChangeDonationId.value = String(row.id)
statusChangeRow.value = row
statusChangeVisible.value = true
}
function onStatusChangeSaved() {
loadData()
}
/* ====== 删除 ====== */
function handleDelete(row: ResearchDonationRow) {
ElMessageBox.confirm(`确认删除捐赠编号「${row.donationNo}」?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await fetchDeleteResearchDonation(String(row.id))
loadData()
})
.catch(() => {})
}
async function handleBatchDelete() {
if (!selectedRows.value.length) return
await ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录?`, '批量删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const rows = [...selectedRows.value]
await withFullscreenLoading(async () => {
for (const row of rows) {
await fetchDeleteResearchDonation(String(row.id))
}
ElMessage.success('批量删除成功')
selectedRows.value = []
loadData()
}, fullscreenLoadingText.batchDelete)
}
onMounted(() => {
loadData()
})
</script>