This commit is contained in:
zlwu committed 2026-05-17 23:08:38 +08:00
1 parent 135dd550b6
commit 2a4767a014
76 files changed
+25160 -2306

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日趋势|今日各区域|组织|组织管理|角色列表|菜单列表|字典类型|字典数据|站点列表|职位列表|快递公司列表|流程配置|流程列表|待办列表|已办列表|申请列表|客户列表|细胞样本类型列表|采集地区列表|采集协议号列表|样本编码列表|医院列表|协议单列表|审核记录列表|缴费确认列表|退费确认列表|分期预警列表|请假列表|洁净室列表|库位树|预约列表|缴费信息列表|样本接收列表|接收检查项列表|公司列表|项目列表|工单列表|任务列表)/
]
const refreshPanelTitleIcons = () => {
+26
View File
@@ -0,0 +1,26 @@
import request from '@/utils/http'
export type QcItemParam = {
id?: string
paramName: string
paramCode: string
paramUnit?: string
paramType: string
requiredFlag: string
orderNum?: number
}
export function fetchQcItemParams(itemSource: string, itemId: string) {
return request.get<QcItemParam[]>({
url: '/web/admin/qc-item-params',
params: { itemSource, itemId }
})
}
export function fetchSaveQcItemParams(itemSource: string, itemId: string, params: QcItemParam[]) {
return request.put<QcItemParam[]>({
url: '/web/admin/qc-item-params',
params: { itemSource, itemId },
data: { params }
})
}
+74
View File
@@ -0,0 +1,74 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type QcLabTestItem = {
id: string
itemCode: string
itemName: string
description?: string
status: string
orderNum: number
remark?: string
createTime?: string
updateTime?: string
}
export function fetchQcLabTestItemList(params?: {
page?: number
pageSize?: number
keyword?: string
status?: string
}) {
return request.get<PageList<QcLabTestItem>>({
url: '/web/admin/qc-lab-test-items',
params
})
}
export function fetchQcLabTestItemsActive() {
return request.get<QcLabTestItem[]>({
url: '/web/admin/qc-lab-test-items/active'
})
}
export function fetchCreateQcLabTestItem(data: {
itemCode: string
itemName: string
description?: string
status?: string
orderNum?: number
remark?: string
}) {
return request.post<QcLabTestItem>({
url: '/web/admin/qc-lab-test-items',
data
})
}
export function fetchUpdateQcLabTestItem(
id: string,
data: Partial<{
itemCode: string
itemName: string
description: string
status: string
orderNum: number
remark: string
}>
) {
return request.put<QcLabTestItem>({
url: `/web/admin/qc-lab-test-items/${id}`,
data
})
}
export function fetchDeleteQcLabTestItem(id: string) {
return request.del({ url: `/web/admin/qc-lab-test-items/${id}` })
}
+26
View File
@@ -0,0 +1,26 @@
import request from '@/utils/http'
export type QcTaskResultItem = {
paramId?: string
paramName: string
paramCode?: string
paramUnit?: string
paramType: string
paramValue: string
}
export function fetchQcTaskResults(taskId: string) {
return request.get<QcTaskResultItem[]>({
url: `/web/admin/qc-tasks/${taskId}/results`
})
}
export function fetchCompleteTaskWithResult(
taskId: string,
data: { results: QcTaskResultItem[]; thirdPartyAttachments?: string }
) {
return request.post({
url: `/web/admin/qc-tasks/${taskId}/complete-with-result`,
data
})
}
+80
View File
@@ -0,0 +1,80 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type QcTask = {
id: string
workOrderId: string
workOrderNo?: string
taskNo: string
taskType: string
itemCode: string
itemName: string
companyId?: string
companyName?: string
feeAmount?: number
taskStatus: string
thirdPartyAttachments?: string
completedTime?: string
completedBy?: string
remark?: string
createTime?: string
updateTime?: string
}
export type QcTaskDetail = QcTask & {
protocolNo?: string
sampleCode?: string
attachmentFiles?: Array<{ id?: string; fileUrl: string; fileName?: string }>
results?: Array<{
paramName: string
paramUnit?: string
paramType: string
paramValue: string
}>
}
export function fetchQcTaskList(params?: {
page?: number
pageSize?: number
keyword?: string
taskType?: string
taskStatus?: string
workOrderId?: string
}) {
return request.get<PageList<QcTask>>({
url: '/web/admin/qc-tasks',
params
})
}
export function fetchQcTaskDetail(id: string) {
return request.get<QcTaskDetail>({
url: `/web/admin/qc-tasks/${id}`
})
}
export function fetchStartQcTask(id: string) {
return request.post<QcTask>({
url: `/web/admin/qc-tasks/${id}/start`
})
}
export function fetchCompleteQcTask(id: string) {
return request.post<QcTask>({
url: `/web/admin/qc-tasks/${id}/complete`
})
}
export function fetchUpdateQcTaskAttachments(id: string, thirdPartyAttachments: string) {
return request.put<QcTask>({
url: `/web/admin/qc-tasks/${id}/attachments`,
data: { thirdPartyAttachments }
})
}
@@ -0,0 +1,92 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type QcThirdPartyCompany = {
id: string
companyCode: string
companyName: string
contactName?: string
contactPhone?: string
address?: string
invoiceTitle?: string
taxId?: string
bankName?: string
bankAccount?: string
status: string
orderNum: number
remark?: string
createTime?: string
updateTime?: string
}
export function fetchQcThirdPartyCompanyList(params?: {
page?: number
pageSize?: number
keyword?: string
status?: string
}) {
return request.get<PageList<QcThirdPartyCompany>>({
url: '/web/admin/qc-third-party-companies',
params
})
}
export function fetchQcThirdPartyCompanyOptions() {
return request.get<Array<{ id: string; companyCode: string; companyName: string }>>({
url: '/web/admin/qc-third-party-companies/options'
})
}
export function fetchCreateQcThirdPartyCompany(data: {
companyCode: string
companyName: string
contactName?: string
contactPhone?: string
address?: string
invoiceTitle?: string
taxId?: string
bankName?: string
bankAccount?: string
status?: string
orderNum?: number
remark?: string
}) {
return request.post<QcThirdPartyCompany>({
url: '/web/admin/qc-third-party-companies',
data
})
}
export function fetchUpdateQcThirdPartyCompany(
id: string,
data: Partial<{
companyCode: string
companyName: string
contactName: string
contactPhone: string
address: string
invoiceTitle: string
taxId: string
bankName: string
bankAccount: string
status: string
orderNum: number
remark: string
}>
) {
return request.put<QcThirdPartyCompany>({
url: `/web/admin/qc-third-party-companies/${id}`,
data
})
}
export function fetchDeleteQcThirdPartyCompany(id: string) {
return request.del({ url: `/web/admin/qc-third-party-companies/${id}` })
}
+80
View File
@@ -0,0 +1,80 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type QcThirdPartyItem = {
id: string
companyId: string
companyName?: string
itemCode: string
itemName: string
feeAmount: number
status: string
orderNum: number
remark?: string
createTime?: string
updateTime?: string
}
export function fetchQcThirdPartyItemList(params?: {
page?: number
pageSize?: number
keyword?: string
status?: string
companyId?: string
}) {
return request.get<PageList<QcThirdPartyItem>>({
url: '/web/admin/qc-third-party-items',
params
})
}
export function fetchQcThirdPartyItemsActive(companyId?: string) {
return request.get<QcThirdPartyItem[]>({
url: '/web/admin/qc-third-party-items/active',
params: companyId ? { companyId } : undefined
})
}
export function fetchCreateQcThirdPartyItem(data: {
companyId: string
itemCode: string
itemName: string
feeAmount: number
status?: string
orderNum?: number
remark?: string
}) {
return request.post<QcThirdPartyItem>({
url: '/web/admin/qc-third-party-items',
data
})
}
export function fetchUpdateQcThirdPartyItem(
id: string,
data: Partial<{
companyId: string
itemCode: string
itemName: string
feeAmount: number
status: string
orderNum: number
remark: string
}>
) {
return request.put<QcThirdPartyItem>({
url: `/web/admin/qc-third-party-items/${id}`,
data
})
}
export function fetchDeleteQcThirdPartyItem(id: string) {
return request.del({ url: `/web/admin/qc-third-party-items/${id}` })
}
+80
View File
@@ -0,0 +1,80 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type QcWorkOrder = {
id: string
workOrderNo: string
receptionId: string
protocolSampleId: string
collectionProtocolId: string
protocolNo: string
sampleCode: string
workOrderStatus: string
hasLab: boolean
hasThirdParty: boolean
configuredTime?: string
configuredBy?: string
thirdPartyFeeTotal?: number
remark?: string
createTime?: string
updateTime?: string
}
export type QcWorkOrderDetail = QcWorkOrder & {
reception?: {
receptionTime?: string
handlerName?: string
acceptConclusion?: string
}
labItems?: Array<{ id: string; labTestItemId: string; itemCode: string; itemName: string }>
thirdPartyItems?: Array<{
id: string
thirdPartyItemId: string
companyId: string
companyName: string
itemCode: string
itemName: string
feeAmount: number
}>
tasks?: Array<Record<string, unknown>>
}
export function fetchQcWorkOrderList(params?: {
page?: number
pageSize?: number
keyword?: string
workOrderStatus?: string
}) {
return request.get<PageList<QcWorkOrder>>({
url: '/web/admin/qc-work-orders',
params
})
}
export function fetchQcWorkOrderDetail(id: string) {
return request.get<QcWorkOrderDetail>({
url: `/web/admin/qc-work-orders/${id}`
})
}
export function fetchConfigureQcWorkOrder(
id: string,
data: {
hasLab: boolean
hasThirdParty: boolean
labItemIds: string[]
thirdPartyItemIds: string[]
}
) {
return request.post<QcWorkOrder>({
url: `/web/admin/qc-work-orders/${id}/configure`,
data
})
}
@@ -0,0 +1,73 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type SampleReceptionCheckItem = {
id: string
itemCode: string
itemName: string
status: string
orderNum: number
remark?: string
createTime?: string
updateTime?: string
}
export function fetchSampleReceptionCheckItemList(params?: {
page?: number
pageSize?: number
keyword?: string
status?: string
}) {
return request.get<PageList<SampleReceptionCheckItem>>({
url: '/web/admin/sample-reception-check-items',
params
})
}
export function fetchSampleReceptionCheckItemsActive() {
return request.get<SampleReceptionCheckItem[]>({
url: '/web/admin/sample-reception-check-items/active'
})
}
export function fetchCreateSampleReceptionCheckItem(data: {
itemCode: string
itemName: string
status?: string
orderNum?: number
remark?: string
}) {
return request.post<SampleReceptionCheckItem>({
url: '/web/admin/sample-reception-check-items',
data
})
}
export function fetchUpdateSampleReceptionCheckItem(
id: string,
data: {
itemCode?: string
itemName?: string
status?: string
orderNum?: number
remark?: string
}
) {
return request.put<SampleReceptionCheckItem>({
url: `/web/admin/sample-reception-check-items/${id}`,
data
})
}
export function fetchDeleteSampleReceptionCheckItem(id: string) {
return request.del({
url: `/web/admin/sample-reception-check-items/${id}`
})
}
+65
View File
@@ -0,0 +1,65 @@
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export function fetchSampleReceptionPendingList(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/sample-receptions/pending',
params
})
}
export function fetchSampleReceptionReceivedList(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
return request.get<PageList<Record<string, unknown>>>({
url: '/web/admin/sample-receptions/received',
params
})
}
export function fetchSampleReceptionDetail(receptionId: string) {
return request.get<Record<string, unknown>>({
url: `/web/admin/sample-receptions/${receptionId}`
})
}
export type SampleReceptionCheckResult = {
id?: string
checkItemId?: string
itemCode: string
itemName: string
checked: boolean
orderNum?: number
}
export function fetchSampleReceptionSubmit(data: {
protocolSampleId: string
receptionTime: string
handlerUserId?: string
handlerName?: string
receiveTemperature?: string
volumeMl?: number
netWeightG?: number
checks: Array<{ checkItemId: string; checked: boolean }>
acceptConclusion: 'js' | 'bj'
receiveAttachments?: string
remark?: string
}) {
return request.post({
url: '/web/admin/sample-receptions',
data
})
}
@@ -486,6 +486,7 @@ body {
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
}
.list-toolbar,
@@ -1,7 +1,7 @@
<!-- 财务确认弹窗:附件上传 + 财务备注(对齐采集协议单附件 UI) -->
<template>
<div class="protocol-finance-confirm-fields">
<el-form-item label="财务备注" class="form-col-span-2">
<el-form-item :label="remarkLabel" class="form-col-span-2">
<el-input
:model-value="financeRemark"
type="textarea"
@@ -10,7 +10,7 @@
@update:model-value="emit('update:financeRemark', $event)"
/>
</el-form-item>
<el-form-item label="财务确认附件" class="form-col-span-2">
<el-form-item :label="attachmentLabel" class="form-col-span-2">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
@@ -94,8 +94,14 @@
financeRemark: string
attachments: FinanceAttachmentItem[]
uploadFolder?: string
remarkLabel?: string
attachmentLabel?: string
}>(),
{ uploadFolder: 'protocol-finance-confirm' }
{
uploadFolder: 'protocol-finance-confirm',
remarkLabel: '财务备注',
attachmentLabel: '财务确认附件'
}
)
const emit = defineEmits<{
@@ -144,6 +150,14 @@
emit('update:attachments', next)
}
/** Mutable snapshot used to avoid race conditions when multiple files are picked at once */
let _pendingList: FinanceAttachmentItem[] | null = null
function _getList(): FinanceAttachmentItem[] {
if (_pendingList) return _pendingList
return props.attachments
}
async function handleAttachmentChange(uploadFile: { raw?: File }) {
const file = uploadFile.raw
if (!file) return
@@ -152,7 +166,7 @@
ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
return
}
const tempId = `temp_${Date.now()}`
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const uploading: FinanceAttachmentItem = {
id: tempId,
fileName: file.name,
@@ -160,29 +174,38 @@
fileType: file.type || '',
_uploading: true
}
const listWithTemp = [...props.attachments, uploading]
// Append to the latest snapshot so concurrent calls don't overwrite each other
const current = _getList()
const listWithTemp = [...current, uploading]
_pendingList = listWithTemp
emit('update:attachments', listWithTemp)
const idx = listWithTemp.length - 1
try {
const fd = new FormData()
fd.append('folder', props.uploadFolder)
fd.append('file', file)
const res = await fetchUploadAttachment(fd)
const updated = [...listWithTemp]
updated[idx] = {
id: res.id || tempId,
fileName: file.name || res.fileName,
fileUrl: res.fileUrl,
fileType: res.fileType || file.type || '',
_uploading: false
}
// Read from latest snapshot (NOT the closure-captured listWithTemp) to avoid
// overwriting sibling uploads that may have already completed while this one was in-flight
const latest = _pendingList || props.attachments
const updated = latest.map((a) =>
a.id === tempId
? {
id: res.id || tempId,
fileName: file.name || res.fileName,
fileUrl: res.fileUrl,
fileType: res.fileType || file.type || '',
_uploading: false
}
: a
)
_pendingList = updated
emit('update:attachments', updated)
} catch {
ElMessage.error('文件上传失败')
emit(
'update:attachments',
props.attachments.filter((a) => a.id !== tempId)
)
const latest = _pendingList || props.attachments
const remaining = latest.filter((a) => a.id !== tempId)
_pendingList = remaining.length > 0 ? remaining : null
emit('update:attachments', remaining)
}
}
</script>
@@ -203,7 +226,7 @@
}
.upload-card {
width: 120px;
width: 140px;
height: 96px;
border: 1px dashed #a7f3d0;
border-radius: 8px;
@@ -236,18 +259,26 @@
display: block;
font-size: 11px;
color: #94a3b8;
line-height: 1.4;
line-height: 1;
margin-top: 8px;
}
.attachment-item {
width: 120px;
width: 140px;
display: flex;
flex-direction: column;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #10b981;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
}
&.is-uploading {
opacity: 0.6;
@@ -263,58 +294,84 @@
align-items: center;
justify-content: center;
background: #f8fafc;
overflow: hidden;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.attachment-preview__icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
&--video {
color: #10b981;
background: #ede9fe;
color: #7c3aed;
}
&--doc {
background: #e0f2fe;
color: #0284c7;
}
}
.attachment-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
flex-direction: column;
flex: 1;
padding: 6px 8px;
min-height: 32px;
}
.attachment-name {
flex: 1;
min-width: 0;
font-size: 12px;
color: #334155;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: #334155;
line-height: 1.4;
word-break: break-all;
min-height: calc(11px * 1.4 * 2);
}
.attachment-actions {
display: flex;
gap: 4px;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 4px;
flex-shrink: 0;
transition: opacity 0.15s;
}
.action-btn {
width: 22px;
height: 22px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #64748b;
cursor: pointer;
color: #94a3b8;
font-size: 16px;
background: #f1f5f9;
transition:
background 0.15s,
color 0.15s;
&:hover {
color: #10b981;
background: #10b981;
color: #fff;
}
}
</style>
@@ -46,7 +46,7 @@
<dd>{{ formatPaymentDueDate(detail?.installmentDueDate) }}</dd>
</div>
<div class="view-kv">
<dt>应缴费金额</dt>
<dt>应缴费金额(元)</dt>
<dd>{{ formatPaymentAmount(detail?.dueAmount) }}</dd>
</div>
<div class="view-kv">
@@ -54,7 +54,7 @@
<dd>{{ transferTypeLabel(String(detail?.transferType || '')) }}</dd>
</div>
<div class="view-kv">
<dt>转账金额</dt>
<dt>转账金额(元)</dt>
<dd>{{ formatPaymentAmount(detail?.transferAmount) }}</dd>
</div>
<div class="view-kv">
@@ -80,7 +80,7 @@
<h4 v-if="refundApplyTitle" class="detail-block-title">{{ refundApplyTitle }}</h4>
<dl class="view-kv-grid view-kv-grid--4">
<div class="view-kv">
<dt>退费金额</dt>
<dt>退费金额(元)</dt>
<dd>{{ formatPaymentAmount(detail?.refundAmount) }}</dd>
</div>
<div v-if="detail?.refundRemark" class="view-kv view-kv-span-2">
+167
View File
@@ -19,6 +19,97 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'dashboard'
}
},
{
id: '100',
name: 'System',
path: '/system',
component: '/index/index',
meta: {
title: '系统管理',
icon: 'ri:settings-3-line',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '101',
name: 'Tenant',
path: 'tenant',
component: '/system/tenant/index',
meta: { title: '租户管理', keepAlive: true, menuCode: 'system:tenant:list' }
},
{
id: '102',
name: 'Dict',
path: 'dict',
component: '/system/dict/index',
meta: { title: '字典管理', keepAlive: true, menuCode: 'system:dict:list' }
},
{
id: '103',
name: 'Dept',
path: 'dept',
component: '/system/dept/index',
meta: { title: '组织管理', keepAlive: true, menuCode: 'system:dept:list' }
},
{
id: '104',
name: 'User',
path: 'user',
component: '/system/user/index',
meta: { title: '用户管理', keepAlive: true, menuCode: 'system:user:list' }
},
{
id: '10000000000000000000000000000008',
name: 'UserDetail',
path: 'user/detail',
component: '/system/user/detail',
meta: { title: '用户详情', isHide: true, keepAlive: true }
},
{
id: '105',
name: 'Role',
path: 'role',
component: '/system/role/index',
meta: { title: '角色管理', keepAlive: true, menuCode: 'system:role:list' }
},
{
id: '106',
name: 'Menu',
path: 'menu',
component: '/system/menu/index',
meta: { title: '菜单管理', keepAlive: true, menuCode: 'system:menu:list' }
},
{
id: '107',
name: 'Site',
path: 'site',
component: '/system/site/index',
meta: { title: '站点管理', keepAlive: true, menuCode: 'system:site:list' }
},
{
id: '108',
name: 'Post',
path: 'post',
component: '/system/post/index',
meta: { title: '职位管理', keepAlive: true, menuCode: 'system:post:list' }
},
{
id: '109',
name: 'Unit',
path: 'unit',
component: '/system/unit/index',
meta: { title: '物品单位', keepAlive: true, menuCode: 'system:unit:list' }
},
{
id: '110',
name: 'Express',
path: 'express',
component: '/system/express/index',
meta: { title: '快递公司', keepAlive: true, menuCode: 'system:express:list' }
}
]
},
{
id: '60000000000000000000000000000001',
name: 'Operations',
@@ -159,6 +250,82 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
}
]
},
{
id: '70000000000000000000000000000001',
name: 'QualityInspection',
path: '/quality-inspection',
component: '/index/index',
meta: {
title: '质量检测',
icon: 'ri:shield-check-line',
keepAlive: false,
roles: ['admin']
},
children: [
{
id: '70000000000000000000000000000002',
name: 'QualityInspectionSampleReception',
path: 'sample-reception',
component: '/quality-inspection/sample-reception/index',
meta: { title: '样本接收', keepAlive: true, menuCode: 'biz:sampleReception:list' }
},
{
id: '70000000000000000000000000000003',
name: 'QualityInspectionSampleReceptionCheckItem',
path: 'sample-reception-check-item',
component: '/quality-inspection/sample-reception-check-item/index',
meta: {
title: '接收检查项配置',
keepAlive: true,
menuCode: 'biz:sampleReceptionCheckItem:list'
}
},
{
id: '70000000000000000000000000000004',
name: 'QualityInspectionThirdPartyCompany',
path: 'third-party-company',
component: '/quality-inspection/third-party-company/index',
meta: {
title: '第三方检测公司及项目',
keepAlive: true,
menuCode: 'biz:qcThirdPartyCompany:list'
}
},
{
id: '70000000000000000000000000000006',
name: 'QualityInspectionLabTestItem',
path: 'lab-test-item',
component: '/quality-inspection/lab-test-item/index',
meta: {
title: '实验室自检项目',
keepAlive: true,
menuCode: 'biz:qcLabTestItem:list'
}
},
{
id: '70000000000000000000000000000007',
name: 'QualityInspectionQcWorkOrder',
path: 'qc-work-order',
component: '/quality-inspection/qc-work-order/index',
meta: {
title: '质检工单',
keepAlive: true,
menuCode: 'biz:qcWorkOrder:list'
}
},
{
id: '70000000000000000000000000000008',
name: 'QualityInspectionQcTask',
path: 'qc-task',
component: '/quality-inspection/qc-task/index',
meta: {
title: '质检任务',
keepAlive: true,
menuCode: 'biz:qcTask:list'
}
}
]
},
{
id: '30000000000000000000000000000001',
name: 'Workflow',
+4
View File
@@ -542,6 +542,8 @@ declare namespace Api {
id: string
protocolNo: string
issued: number
/** 是否已在采集协议单主表占用(与号池「是否下发」无关) */
usedInProtocol?: boolean
issuedUserId: string
issuedUserName: string
issuedTime: string
@@ -559,6 +561,8 @@ declare namespace Api {
sampleCode: string
sampleCodeType: string
issued: number
/** 是否已在协议单样本表中占用(与号池「是否下发」无关) */
usedInProtocol?: boolean
issuedUserId: string
issuedUserName: string
issuedTime: string
@@ -0,0 +1,135 @@
/** 分期缴费:期数对齐、应缴日/金额计算(与 ProtocolFormDialog 逻辑一致) */
export function addMonthsYmd(dateStr: string, months: number): string {
const compact = String(dateStr || '').replace(/-/g, '')
if (compact.length !== 8) return ''
const y = parseInt(compact.slice(0, 4), 10)
const m = parseInt(compact.slice(4, 6), 10) - 1
const d = parseInt(compact.slice(6, 8), 10)
const date = new Date(y, m + months, d)
const yy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
return `${yy}${mm}${dd}`
}
export function calcInstallmentDueAmounts(
contractAmount: number | null | undefined,
count: number
): number[] {
if (contractAmount == null || !count || count < 1) return []
const totalCents = Math.round(Number(contractAmount) * 100)
const baseCents = Math.floor(totalCents / count)
const amounts: number[] = []
let sumCents = 0
for (let i = 0; i < count; i++) {
if (i === count - 1) {
amounts.push((totalCents - sumCents) / 100)
} else {
amounts.push(baseCents / 100)
sumCents += baseCents
}
}
return amounts
}
type PaymentLike = Record<string, unknown> | null | undefined
type DetailLike = Record<string, any>
/**
* 分期模式下按 installmentCount 补齐缺失期次(保留已有明细数据),仅用于编辑表单。
*/
export function alignFqPaymentDetailsForDisplay(
payment: PaymentLike,
details: DetailLike[]
): DetailLike[] {
if (!payment || payment.payMode !== 'fq') return details
const count = Number(payment.installmentCount)
if (!count || count < 1) return details
const rows = [...details]
const byNo = new Map<number, DetailLike>()
const unnumbered: DetailLike[] = []
for (const row of rows) {
const no = row.installmentNo
if (no != null && no !== '') {
byNo.set(Number(no), row)
} else {
unnumbered.push(row)
}
}
const needed = new Set(Array.from({ length: count }, (_, i) => i + 1))
const have = new Set(byNo.keys())
let missing = false
for (const n of needed) {
if (!have.has(n)) {
missing = true
break
}
}
if (!missing && rows.length >= count) {
return sortFqPaymentDetails(rows).slice(0, count)
}
const contract = payment.contractAmount != null ? Number(payment.contractAmount) : null
const amounts = calcInstallmentDueAmounts(contract, count)
const startDate = String(payment.installmentStartDate || '')
const interval = Number(payment.installmentIntervalMonths || 0)
const result: DetailLike[] = []
for (let i = 0; i < count; i++) {
const no = i + 1
if (byNo.has(no)) {
result.push(byNo.get(no)!)
continue
}
if (i < unnumbered.length) {
const raw = { ...unnumbered[i], installmentNo: no }
if (!raw.installmentDueDate && startDate && interval) {
raw.installmentDueDate = addMonthsYmd(startDate, i * interval)
}
result.push(raw)
continue
}
result.push({
installmentNo: no,
installmentDueDate:
startDate && interval ? addMonthsYmd(startDate, i * interval) : '',
dueAmount: amounts[i] ?? null,
payStatus: 'dfj',
transferType: '',
transferAmount: null,
attachments: []
})
}
return result
}
export function sortFqPaymentDetails(details: DetailLike[]): DetailLike[] {
return [...details].sort((a, b) => {
const na = a.installmentNo != null && a.installmentNo !== '' ? Number(a.installmentNo) : 9999
const nb = b.installmentNo != null && b.installmentNo !== '' ? Number(b.installmentNo) : 9999
if (na !== nb) return na - nb
return Number(a.orderNum || 0) - Number(b.orderNum || 0)
})
}
/** 编辑表单:将缴费明细行数与分期期数对齐(不覆盖已有行) */
export function syncFqPaymentDetailRowCount(
payment: {
payMode: string
installmentCount: number | null
contractAmount: number | null
installmentStartDate: string
installmentIntervalMonths: number | null
},
paymentDetails: DetailLike[]
): void {
if (payment.payMode !== 'fq') return
const count = Number(payment.installmentCount)
if (!count || count < 1) return
const aligned = alignFqPaymentDetailsForDisplay(payment, paymentDetails)
paymentDetails.splice(0, paymentDetails.length, ...aligned)
}
@@ -78,5 +78,5 @@ export function formatPaymentAmount(v: unknown) {
if (v == null || v === '') return '-'
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
return '¥' + n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
+25
View File
@@ -0,0 +1,25 @@
export function workOrderStatusLabel(v: string) {
const m: Record<string, string> = {
dpz: '待配置',
ypz: '已配置',
ywc: '已完成'
}
return m[v] || v || '—'
}
export function taskTypeLabel(v: string) {
const m: Record<string, string> = {
sysz: '实验室自检',
dsfjc: '第三方检测'
}
return m[v] || v || '—'
}
export function taskStatusLabel(v: string) {
const m: Record<string, string> = {
dzx: '待执行',
jxz: '进行中',
ywc: '已完成'
}
return m[v] || v || '—'
}
@@ -34,11 +34,12 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-button v-if="hasAuth('add')" class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增类型
</el-button>
<el-button
v-if="hasAuth('delete')"
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@@ -116,8 +117,8 @@
>
<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>
<el-button v-if="hasAuth('edit')" class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="hasAuth('delete')" class="btn-op-item" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -218,6 +219,8 @@
fetchDeleteCellSampleType
} from '@/api/admin'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { useAuth } from '@/hooks/core/useAuth'
const { hasAuth } = useAuth()
defineOptions({ name: 'BizCellSampleType' })
@@ -34,11 +34,11 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-button v-if="hasAuth('add')" class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增协议号
</el-button>
<el-button class="btn-ghost" @click="openBatchGenerate">
<el-button v-if="hasAuth('add')" class="btn-ghost" @click="openBatchGenerate">
批量生成
</el-button>
<el-button
@@ -56,6 +56,7 @@
批量收回
</el-button>
<el-button
v-if="hasAuth('delete')"
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@@ -137,8 +138,8 @@
>
<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>
<el-button v-if="hasAuth('edit')" class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="hasAuth('delete')" class="btn-op-item" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -376,6 +377,8 @@
fetchBatchIssueProtocolNos,
fetchBatchRevokeProtocolNos
} from '@/api/admin'
import { useAuth } from '@/hooks/core/useAuth'
const { hasAuth } = useAuth()
defineOptions({ name: 'BizCollectionProtocolNo' })
@@ -34,11 +34,12 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-button v-if="hasAuth('add')" class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增地区
</el-button>
<el-button
v-if="hasAuth('delete')"
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@@ -113,8 +114,8 @@
<el-button class="btn-op-item primary" @click="openHospitalDialog(row)"
>地区医院配置</el-button
>
<el-button class="btn-op-item" @click="handleEdit(row)">编辑</el-button>
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
<el-button v-if="hasAuth('edit')" class="btn-op-item" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="hasAuth('delete')" class="btn-op-item" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -345,6 +346,8 @@
fetchUpdateHospital,
fetchDeleteHospital
} from '@/api/admin'
import { useAuth } from '@/hooks/core/useAuth'
const { hasAuth } = useAuth()
defineOptions({ name: 'BizCollectionRegion' })
@@ -38,11 +38,12 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-button v-if="hasAuth('add')" class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增客户
</el-button>
<el-button
v-if="hasAuth('delete')"
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@@ -125,8 +126,8 @@
>
<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>
<el-button v-if="hasAuth('edit')" class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="hasAuth('delete')" class="btn-op-item" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -235,6 +236,8 @@
fetchDeleteCustomer
} from '@/api/admin'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { useAuth } from '@/hooks/core/useAuth'
const { hasAuth } = useAuth()
defineOptions({ name: 'BizCustomer' })
@@ -34,11 +34,11 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-button v-if="hasAuth('add')" class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增样本编码
</el-button>
<el-button class="btn-ghost" @click="openBatchGenerate">
<el-button v-if="hasAuth('add')" class="btn-ghost" @click="openBatchGenerate">
批量生成
</el-button>
<el-button
@@ -56,6 +56,7 @@
批量收回
</el-button>
<el-button
v-if="hasAuth('delete')"
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@@ -146,8 +147,8 @@
>
<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>
<el-button v-if="hasAuth('edit')" class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="hasAuth('delete')" class="btn-op-item" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -391,6 +392,8 @@
fetchBatchIssueSampleCodes,
fetchBatchRevokeSampleCodes
} from '@/api/admin'
import { useAuth } from '@/hooks/core/useAuth'
const { hasAuth } = useAuth()
defineOptions({ name: 'BizSampleCode' })
@@ -560,7 +560,7 @@
if (v == null || v === '') return '-'
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
return '¥' + n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
async function loadData() {
@@ -60,6 +60,18 @@
<el-icon><CircleCheck /></el-icon>
审核
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="openStatusChangeDialog('activate')">
协议生效
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="openStatusChangeDialog('expire')">
协议过期
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="openStatusChangeDialog('void')">
协议作废
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="openStatusChangeDialog('toResearch')">
协议转科研
</el-button>
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>
批量删除
@@ -261,6 +273,14 @@
@submitted="onAuditSubmitted"
/>
<!-- 状态变更弹窗(生效/过期/作废/转科研) -->
<ProtocolStatusChangeDialog
v-model="statusChangeDialogVisible"
:action-type="statusChangeAction"
:targets="statusChangeTargets"
@submitted="onStatusChangeSubmitted"
/>
<!-- 查看弹窗 -->
<ProtocolViewDialog
v-model="viewVisible"
@@ -288,6 +308,10 @@
import ProtocolAuditDialog, {
type ProtocolAuditTarget
} from '../protocol/ProtocolAuditDialog.vue'
import ProtocolStatusChangeDialog, {
type StatusChangeAction,
type StatusChangeTarget
} from '../protocol/ProtocolStatusChangeDialog.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCollectionProtocolList,
@@ -417,6 +441,36 @@
loadData()
}
/* ====== 状态变更(生效/过期/作废/转科研) ====== */
const statusChangeDialogVisible = ref(false)
const statusChangeAction = ref<StatusChangeAction>('activate')
const statusChangeTargets = ref<StatusChangeTarget[]>([])
function openStatusChangeDialog(action: StatusChangeAction, row?: CollectionProtocolRow) {
const rows = row ? [row] : selectedRows.value
if (!rows.length) {
const labels: Record<StatusChangeAction, string> = {
activate: '协议生效',
expire: '协议过期',
void: '协议作废',
toResearch: '协议转科研'
}
ElMessage.warning(`请先选择要操作的协议单`)
return
}
statusChangeAction.value = action
statusChangeTargets.value = rows.map((r) => ({
id: String(r.id),
protocolNo: r.protocolNo != null ? String(r.protocolNo) : undefined
}))
statusChangeDialogVisible.value = true
}
function onStatusChangeSubmitted() {
selectedRows.value = []
loadData()
}
/* ====== 删除 ====== */
function handleDelete(row: CollectionProtocolRow) {
ElMessageBox.confirm(`确认删除协议号「${row.protocolNo}」?`, '删除确认', {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,222 @@
<!-- 采集协议单 — 状态变更弹窗(协议生效 / 协议过期 / 协议作废 / 协议转科研) -->
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="resetForm"
>
<p v-if="targets.length > 1" class="status-change-hint">
将对已选
<strong>{{ targets.length }}</strong> 条协议单执行<strong>{{ actionLabel }}</strong
>操作,每条各记一次变更。
</p>
<p v-else-if="targets.length === 1" class="status-change-hint">
协议号:<strong>{{ targets[0].protocolNo || '—' }}</strong>
</p>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="biz-dialog-form"
@submit.prevent
>
<el-form-item :label="timeLabel" prop="changeTime">
<el-date-picker
v-model="form.changeTime"
type="datetime"
placeholder="请选择时间"
style="width: 100%"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item :label="descLabel">
<el-input
v-model="form.changeDesc"
type="textarea"
:rows="3"
placeholder="可选"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import { Check, Close } from '@element-plus/icons-vue'
import { fetchUpdateCollectionProtocol } from '@/api/collection-protocol'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
export type StatusChangeAction = 'activate' | 'expire' | 'void' | 'toResearch'
export type StatusChangeTarget = {
id: string
protocolNo?: string
}
defineOptions({ name: 'ProtocolStatusChangeDialog' })
const props = defineProps<{
modelValue: boolean
actionType: StatusChangeAction
targets: StatusChangeTarget[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'submitted'): void
}>()
const ACTION_CONFIG: Record<
StatusChangeAction,
{
label: string
timeLabel: string
descLabel: string
status: string
timeKey: string
descKey: string
}
> = {
activate: {
label: '协议生效',
timeLabel: '协议生效时间',
descLabel: '协议生效描述',
status: 'sxz',
timeKey: 'effectiveTime',
descKey: 'effectiveDesc'
},
expire: {
label: '协议过期',
timeLabel: '协议过期时间',
descLabel: '协议过期描述',
status: 'ygq',
timeKey: 'expireTime',
descKey: 'expireDesc'
},
void: {
label: '协议作废',
timeLabel: '协议作废时间',
descLabel: '协议作废描述',
status: 'yzf',
timeKey: 'voidTime',
descKey: 'voidDesc'
},
toResearch: {
label: '协议转科研',
timeLabel: '协议转科研时间',
descLabel: '协议转科研描述',
status: 'zky',
timeKey: 'toResearchTime',
descKey: 'toResearchDesc'
}
}
const config = computed(() => ACTION_CONFIG[props.actionType])
const actionLabel = computed(() => config.value.label)
const timeLabel = computed(() => config.value.timeLabel)
const descLabel = computed(() => config.value.descLabel)
const dialogTitle = computed(() => {
const label = config.value.label
return props.targets.length > 1 ? `批量${label}(${props.targets.length} 条)` : label
})
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
changeTime: '',
changeDesc: ''
})
const rules: FormRules = {
changeTime: [{ required: true, message: '请选择时间', trigger: 'change' }]
}
function resetForm() {
form.changeTime = ''
form.changeDesc = ''
formRef.value?.clearValidate()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || !props.targets.length) return
const cfg = config.value
const 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 {
await withFullscreenLoading(
async () => {
let ok = 0
const failed: string[] = []
for (const row of rows) {
try {
await fetchUpdateCollectionProtocol(String(row.id), payload)
ok += 1
} catch {
failed.push(row.protocolNo || String(row.id))
}
}
if (isBatch) {
if (failed.length) {
ElMessage.warning(`${cfg.label}成功 ${ok} 条,失败 ${failed.length} 条`)
} else {
ElMessage.success(`批量${cfg.label}成功,共 ${ok} 条`)
}
}
if (ok > 0) {
emit('update:modelValue', false)
emit('submitted')
}
},
isBatch ? fullscreenLoadingText.batchAudit : fullscreenLoadingText.save
)
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
.status-change-hint {
margin: 0 0 16px;
font-size: 13px;
line-height: 1.5;
color: var(--mint-text-muted, #64748b);
strong {
color: #1e293b;
font-weight: 600;
}
}
</style>
File diff suppressed because it is too large. Load diff
@@ -228,7 +228,9 @@
<el-table-column prop="enterTime" label="进入时间" min-width="160" align="center" header-align="center" />
<el-table-column prop="purpose" label="目的" min-width="120" align="center" header-align="center" show-overflow-tooltip />
<el-table-column prop="leaveTime" label="离开时间" min-width="160" align="center" header-align="center" />
<el-table-column prop="bodyTemp" label="体温(℃)" width="90" align="center" header-align="center" />
<el-table-column prop="bodyTemp" label="体温(℃)" width="90" align="center" header-align="center">
<template #default="{ row }">{{ row.bodyTemp != null ? row.bodyTemp + '℃' : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleViewLog(row)"
@@ -795,7 +797,7 @@
{ label: '进入时间', value: row.enterTime },
{ label: '离开时间', value: row.leaveTime },
{ label: '目的', value: row.purpose, span: true },
{ label: '体温(℃)', value: row.bodyTemp ? `${row.bodyTemp}` : '' },
{ label: '体温(℃)', value: row.bodyTemp != null ? `${row.bodyTemp}℃` : '' },
{ label: '备注', value: row.remark, span: true }
]
}
@@ -0,0 +1,343 @@
<!-- 质量检测 — 实验室自检项目 -->
<!-- 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.status" 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="(r: QcLabTestItem[]) => (selectedRows.value = r)"
@row-dblclick="handleView"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="68" align="center" :index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1" />
<el-table-column prop="itemCode" label="项目编码" min-width="140" align="center" />
<el-table-column prop="itemName" label="项目名称" min-width="160" align="center" />
<el-table-column prop="description" label="描述" min-width="200" align="center" show-overflow-tooltip />
<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 label="操作" width="320" fixed="right" 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="(v) => ((pagination.page = 1), (pagination.pageSize = v), loadData())"
@current-change="(v) => ((pagination.page = v), loadData())"
/>
</div>
</div>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" class="dialog-form-layout" align-center destroy-on-close :close-on-click-modal="false">
<el-form ref="formRef" :model="formData" :rules="formRules" label-position="top" class="biz-dialog-form">
<div class="form-grid-2">
<el-form-item label="项目编码" prop="itemCode">
<el-input v-model="formData.itemCode" :disabled="isEdit" />
</el-form-item>
<el-form-item label="项目名称" prop="itemName">
<el-input v-model="formData.itemName" />
</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="描述" class="form-col-span-2">
<el-input v-model="formData.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="2" />
</el-form-item>
</div>
<!-- 参数配置 -->
<div class="form-section" style="margin-top: 8px">
<div class="section-title">参数配置</div>
<div class="param-row param-row-header">
<span style="flex: 2">参数名称</span>
<span style="flex: 1.5">参数编码</span>
<span style="flex: 1">单位</span>
<span style="flex: 1">类型</span>
<span style="width: 40px; text-align: center">必填</span>
<span style="width: 32px"></span>
</div>
<div v-if="itemParams.length === 0" style="text-align: center; padding: 8px 0; color: #94a3b8; font-size: 13px">
暂无参数,点击下方按钮添加
</div>
<div v-for="(param, idx) in itemParams" :key="idx" class="param-row">
<el-input v-model="param.paramName" placeholder="参数名称" style="flex: 2" />
<el-input v-model="param.paramCode" placeholder="参数编码" style="flex: 1.5" />
<el-input v-model="param.paramUnit" placeholder="单位" style="flex: 1" />
<el-select v-model="param.paramType" style="flex: 1">
<el-option label="文本" value="text" />
<el-option label="数字" value="number" />
</el-select>
<el-switch v-model="param.requiredFlag" active-value="1" inactive-value="0" />
<el-button class="btn-op-item" @click="removeParam(idx)"><el-icon><Delete /></el-icon></el-button>
</div>
<el-button type="primary" plain style="margin-top: 8px" @click="addParam"><el-icon><Plus /></el-icon>添加参数</el-button>
</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>
<SystemViewDialog
v-model:visible="viewVisible"
title="查看项目"
:icon="Notebook"
: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, Edit, Delete, Notebook } from '@element-plus/icons-vue'
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
import {
fetchQcLabTestItemList,
fetchCreateQcLabTestItem,
fetchUpdateQcLabTestItem,
fetchDeleteQcLabTestItem,
type QcLabTestItem
} from '@/api/qc-lab-test-item'
import { fetchQcItemParams, fetchSaveQcItemParams, type QcItemParam } from '@/api/qc-item-param'
defineOptions({ name: 'QualityInspectionLabTestItem' })
const searchForm = reactive({ keyword: '', status: '' })
const tableData = ref<QcLabTestItem[]>([])
const selectedRows = ref<QcLabTestItem[]>([])
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 itemParams = ref<QcItemParam[]>([])
const formData = reactive({ itemCode: '', itemName: '', description: '', status: '1', remark: '' })
const viewVisible = ref(false)
const viewSections = ref<ViewSection[]>([])
const formRules = {
itemCode: [{ required: true, message: '请输入项目编码', trigger: 'blur' }],
itemName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
function addParam() {
itemParams.value.push({
paramName: '',
paramCode: '',
paramUnit: '',
paramType: 'text',
requiredFlag: '0'
})
}
function removeParam(index: number) {
itemParams.value.splice(index, 1)
}
async function loadData() {
loading.value = true
try {
const res = await fetchQcLabTestItemList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
status: searchForm.status || 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.status = ''
pagination.page = 1
loadData()
}
function resetForm() {
Object.assign(formData, { itemCode: '', itemName: '', description: '', status: '1', remark: '' })
editingId.value = null
itemParams.value = []
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增项目'
resetForm()
dialogVisible.value = true
}
function handleEdit(row: QcLabTestItem) {
isEdit.value = true
dialogTitle.value = '编辑项目'
editingId.value = row.id
Object.assign(formData, {
itemCode: row.itemCode,
itemName: row.itemName,
description: row.description || '',
status: row.status || '1',
remark: row.remark || ''
})
fetchQcItemParams('lab', row.id).then((res) => {
itemParams.value = res || []
}).catch(() => {
itemParams.value = []
})
dialogVisible.value = true
}
async function handleSubmit() {
if (!(await formRef.value?.validate?.().catch(() => false))) return
submitLoading.value = true
try {
const payload = { ...formData, remark: formData.remark || undefined, description: formData.description || undefined }
let savedId = editingId.value
if (isEdit.value && editingId.value) {
await fetchUpdateQcLabTestItem(editingId.value, payload)
ElMessage.success('已保存')
} else {
const created = await fetchCreateQcLabTestItem(payload)
savedId = created?.id || ''
ElMessage.success('已新增')
}
dialogVisible.value = false
if (savedId && itemParams.value.length > 0) {
await fetchSaveQcItemParams('lab', savedId, itemParams.value).catch(() => {})
}
loadData()
} finally {
submitLoading.value = false
}
}
function handleView(row: QcLabTestItem) {
viewSections.value = [
{
title: '基本信息',
fields: [
{ label: '项目编码', value: row.itemCode },
{ label: '项目名称', value: row.itemName },
{ label: '状态', value: row.status === '1' ? '启用' : '停用' }
]
},
{
title: '详细信息',
fields: [
{ label: '描述', value: row.description, span: true },
{ label: '备注', value: row.remark, span: true }
]
}
]
viewVisible.value = true
}
async function handleDelete(row: QcLabTestItem) {
await ElMessageBox.confirm(`确定删除「${row.itemName}」?`, '提示', { type: 'warning' })
await fetchDeleteQcLabTestItem(row.id)
ElMessage.success('已删除')
loadData()
}
onMounted(() => loadData())
</script>
<style scoped lang="scss">
.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;
}
.param-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.param-row-header {
font-size: 12px;
color: #64748b;
font-weight: 500;
padding-bottom: 4px;
border-bottom: 1px solid #e2e8f0;
margin-bottom: 10px;
}
</style>
@@ -0,0 +1,382 @@
<!-- 质量检测 — 质检任务 -->
<!-- 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.taskType" placeholder="全部" clearable style="width: 100%">
<el-option label="实验室自检" value="sysz" />
<el-option label="第三方检测" value="dsfjc" />
</el-select>
</el-form-item>
<el-form-item label="任务状态">
<el-select v-model="searchForm.taskStatus" placeholder="全部" clearable style="width: 100%">
<el-option label="待执行" value="dzx" />
<el-option label="进行中" value="jxz" />
<el-option label="已完成" value="ywc" />
</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-body list-wrap">
<el-table class="list-table" :data="tableData" v-loading="loading" stripe @row-dblclick="handleView">
<el-table-column type="index" label="序号" width="68" align="center" :index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1" />
<el-table-column prop="taskNo" label="任务号" min-width="150" align="center" />
<el-table-column prop="workOrderNo" label="工单号" min-width="140" align="center" />
<el-table-column prop="taskType" label="类型" width="110" align="center">
<template #default="{ row }">
<span :class="['tag', row.taskType === 'sysz' ? 'success' : 'muted']">{{ taskTypeLabel(row.taskType) }}</span>
</template>
</el-table-column>
<el-table-column prop="itemName" label="检测项目" min-width="160" align="center" />
<el-table-column prop="companyName" label="第三方公司" min-width="120" align="center">
<template #default="{ row }">{{ row.companyName || '—' }}</template>
</el-table-column>
<el-table-column prop="taskStatus" label="状态" width="100" align="center">
<template #default="{ row }">
<span :class="['tag', row.taskStatus === 'ywc' ? 'success' : row.taskStatus === 'jxz' ? 'warning' : 'muted']">
{{ taskStatusLabel(row.taskStatus) }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right" align="center">
<template #default="{ row }">
<template v-if="row.taskType === 'sysz' && row.taskStatus === 'dzx'">
<el-button class="btn-op-item primary" @click="handleStart(row)"><el-icon><VideoPlay /></el-icon>开始</el-button>
</template>
<template v-if="row.taskType === 'sysz' && row.taskStatus === 'jxz'">
<el-button class="btn-op-item primary" @click="openResult(row)"><el-icon><EditPen /></el-icon>填写结果</el-button>
</template>
<template v-if="row.taskType === 'dsfjc' && row.taskStatus !== 'ywc'">
<el-button class="btn-op-item" @click="openAttach(row)"><el-icon><Upload /></el-icon>上传材料</el-button>
<el-button class="btn-op-item primary" @click="openResult(row)"><el-icon><EditPen /></el-icon>填写结果</el-button>
</template>
<el-button class="btn-op-item" @click="handleView(row)"><el-icon><View /></el-icon>查看</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
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="attachVisible" title="上传第三方检测材料" width="720px" class="dialog-form-layout" align-center destroy-on-close :close-on-click-modal="false">
<ProtocolFinanceConfirmFields
v-model:finance-remark="attachRemark"
v-model:attachments="attachments"
remark-label="备注"
attachment-label="检测材料"
upload-folder="qc-third-party"
/>
<template #footer>
<el-button class="btn-ghost" @click="attachVisible = false"><el-icon><Close /></el-icon>取消</el-button>
<el-button class="btn-main" type="primary" :loading="attachLoading" @click="saveAttachments"><el-icon><Check /></el-icon>保存</el-button>
</template>
</el-dialog>
<el-dialog
v-model="resultVisible"
title="填写检测结果"
width="720px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top" class="biz-dialog-form">
<div class="form-section">
<div class="section-title">检测参数</div>
<div v-if="resultParams.length === 0" style="text-align: center; padding: 8px 0; color: #94a3b8; font-size: 13px">
该项目暂无配置参数
</div>
<div class="form-grid-2">
<el-form-item v-for="(param, idx) in resultParams" :key="idx" :label="param.paramName + (param.paramUnit ? `(${param.paramUnit})` : '')">
<el-input v-if="param.paramType === 'text'" v-model="param.paramValue" placeholder="请输入" />
<el-input-number v-else v-model="param.paramValue" :precision="2" style="width: 100%" placeholder="请输入" />
</el-form-item>
</div>
</div>
<div v-if="resultTaskType === 'dsfjc'" style="margin-top: 12px">
<ProtocolFinanceConfirmFields
v-model:finance-remark="resultRemark"
v-model:attachments="resultAttachments"
remark-label="备注"
attachment-label="检测材料"
upload-folder="qc-third-party"
/>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="resultVisible = false"><el-icon><Close /></el-icon>取消</el-button>
<el-button class="btn-main" type="primary" :loading="resultLoading" @click="submitResult"><el-icon><Check /></el-icon>提交并完成</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
title="任务详情"
:icon="Tickets"
:meta="viewDetail?.taskNo || ''"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Check, Search, RefreshLeft, VideoPlay, Upload, View, Tickets, EditPen } from '@element-plus/icons-vue'
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import {
financeAttachmentsToUrlString,
type FinanceAttachmentItem
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
import {
fetchQcTaskList,
fetchQcTaskDetail,
fetchStartQcTask,
fetchCompleteQcTask,
fetchUpdateQcTaskAttachments,
type QcTask,
type QcTaskDetail
} from '@/api/qc-task'
import { fetchQcTaskResults, fetchCompleteTaskWithResult, type QcTaskResultItem } from '@/api/qc-task-result'
import { taskTypeLabel, taskStatusLabel } from '@/utils/qc-labels'
defineOptions({ name: 'QualityInspectionQcTask' })
const searchForm = reactive({ keyword: '', taskType: '', taskStatus: '' })
const tableData = ref<QcTask[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const attachVisible = ref(false)
const attachLoading = ref(false)
const attachTaskId = ref('')
const attachRemark = ref('')
const attachments = ref<FinanceAttachmentItem[]>([])
const resultVisible = ref(false)
const resultLoading = ref(false)
const resultTaskId = ref('')
const resultTaskType = ref('')
const resultParams = ref<QcTaskResultItem[]>([])
const resultRemark = ref('')
const resultAttachments = ref<FinanceAttachmentItem[]>([])
const viewVisible = ref(false)
const viewDetail = ref<QcTaskDetail | null>(null)
const viewSections = computed<ViewSection[]>(() => {
const d = viewDetail.value
if (!d) return []
const resultSection =
(d as any).results && (d as any).results.length > 0
? [
{
title: '检测结果',
fields: (d as any).results.map((r: any) => ({
label: r.paramName + (r.paramUnit ? `(${r.paramUnit})` : ''),
value: r.paramValue || '—'
}))
}
]
: []
return [
{
title: '基本信息',
fields: [
{ label: '任务号', value: d.taskNo },
{ label: '工单号', value: d.workOrderNo },
{ label: '类型', value: taskTypeLabel(d.taskType) },
{ label: '检测项目', value: d.itemName },
{ label: '第三方公司', value: d.companyName || '—' },
{ label: '状态', value: taskStatusLabel(d.taskStatus) }
]
},
...resultSection
]
})
async function loadData() {
loading.value = true
try {
const res = await fetchQcTaskList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
taskType: searchForm.taskType || undefined,
taskStatus: searchForm.taskStatus || 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.taskType = ''
searchForm.taskStatus = ''
pagination.page = 1
loadData()
}
async function handleStart(row: QcTask) {
await fetchStartQcTask(row.id)
ElMessage.success('已开始')
loadData()
}
async function handleComplete(row: QcTask) {
await fetchCompleteQcTask(row.id)
ElMessage.success('已完成')
loadData()
}
function openAttach(row: QcTask) {
attachTaskId.value = row.id
attachRemark.value = ''
attachments.value = []
attachVisible.value = true
}
async function saveAttachments() {
if (attachments.value.some((a) => a._uploading)) {
ElMessage.warning('附件正在上传,请稍候')
return
}
attachLoading.value = true
try {
await fetchUpdateQcTaskAttachments(
attachTaskId.value,
financeAttachmentsToUrlString(attachments.value)
)
ElMessage.success('已保存')
attachVisible.value = false
loadData()
} finally {
attachLoading.value = false
}
}
async function handleView(row: QcTask) {
const detail = await fetchQcTaskDetail(row.id)
try {
const results = await fetchQcTaskResults(row.id)
;(detail as any).results = results || []
} catch {
;(detail as any).results = []
}
viewDetail.value = detail
viewVisible.value = true
}
async function openResult(row: QcTask) {
resultTaskId.value = row.id
resultTaskType.value = row.taskType
resultRemark.value = ''
resultAttachments.value = []
resultLoading.value = true
try {
const results = await fetchQcTaskResults(row.id)
if (results && results.length > 0) {
resultParams.value = results
} else {
resultParams.value = []
}
} catch {
resultParams.value = []
} finally {
resultLoading.value = false
}
resultVisible.value = true
}
async function submitResult() {
if (resultAttachments.value.some((a) => a._uploading)) {
ElMessage.warning('附件正在上传,请稍候')
return
}
resultLoading.value = true
try {
const data: { results: QcTaskResultItem[]; thirdPartyAttachments?: string } = {
results: resultParams.value.filter((p) => p.paramValue !== '' && p.paramValue !== undefined)
}
if (resultTaskType.value === 'dsfjc') {
data.thirdPartyAttachments = financeAttachmentsToUrlString(resultAttachments.value)
}
await fetchCompleteTaskWithResult(resultTaskId.value, data)
ElMessage.success('已完成')
resultVisible.value = false
loadData()
} finally {
resultLoading.value = false
}
}
onMounted(() => loadData())
</script>
<style scoped lang="scss">
.form-section {
background: #fafafa;
border-radius: 8px;
padding: 20px;
}
.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;
}
.biz-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -0,0 +1,305 @@
<!-- 质量检测 — 质检工单 -->
<!-- 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.workOrderStatus" placeholder="全部" clearable style="width: 100%">
<el-option label="待配置" value="dpz" />
<el-option label="已配置" value="ypz" />
<el-option label="已完成" value="ywc" />
</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-body list-wrap">
<el-table class="list-table" :data="tableData" v-loading="loading" stripe @row-dblclick="handleView">
<el-table-column type="index" label="序号" width="68" align="center" :index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1" />
<el-table-column prop="workOrderNo" label="工单号" min-width="150" align="center" />
<el-table-column prop="protocolNo" label="协议号" min-width="140" align="center" />
<el-table-column prop="sampleCode" label="样本编码" min-width="120" align="center" />
<el-table-column prop="workOrderStatus" label="状态" width="100" align="center">
<template #default="{ row }">
<span :class="['tag', row.workOrderStatus === 'ywc' ? 'success' : row.workOrderStatus === 'ypz' ? 'warning' : 'muted']">
{{ workOrderStatusLabel(row.workOrderStatus) }}
</span>
</template>
</el-table-column>
<el-table-column label="检测方式" min-width="160" align="center">
<template #default="{ row }">
<span v-if="row.hasLab" class="tag success">实验室</span>
<span v-if="row.hasThirdParty" class="tag muted" style="margin-left: 4px">第三方</span>
<span v-if="!row.hasLab && !row.hasThirdParty">—</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center" />
<el-table-column label="操作" width="280" fixed="right" align="center">
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)"><el-icon><View /></el-icon>查看</el-button>
<el-button v-if="row.workOrderStatus === 'dpz'" class="btn-op-item primary" @click="handleConfigure(row)"><el-icon><Setting /></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"
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="configureVisible" title="配置检测" width="800px" class="dialog-form-layout" align-center destroy-on-close :close-on-click-modal="false">
<el-form label-position="top" class="biz-dialog-form">
<el-form-item label="检测方式">
<el-checkbox v-model="configureForm.hasLab">实验室自检</el-checkbox>
<el-checkbox v-model="configureForm.hasThirdParty" style="margin-left: 16px">第三方检测</el-checkbox>
</el-form-item>
<el-form-item v-if="configureForm.hasLab" label="实验室自检项目">
<el-checkbox-group v-model="configureForm.labItemIds" class="qc-config-check-group">
<el-checkbox v-for="item in labOptions" :key="item.id" :value="item.id">{{ item.itemName }}({{ item.itemCode }})</el-checkbox>
</el-checkbox-group>
<p v-if="!labOptions.length" class="hint-muted">暂无启用的实验室项目</p>
</el-form-item>
<el-form-item v-if="configureForm.hasThirdParty" label="第三方检测项目">
<el-checkbox-group v-model="configureForm.thirdPartyItemIds" class="qc-config-check-group">
<div v-for="group in thirdOptionsGrouped" :key="group.companyId" class="qc-company-group">
<div class="qc-company-group-title">{{ group.companyName }}</div>
<div class="qc-company-group-items">
<el-checkbox v-for="item in group.items" :key="item.id" :value="item.id">
{{ item.itemName }}({{ item.itemCode }})¥{{ item.feeAmount }}
</el-checkbox>
</div>
</div>
</el-checkbox-group>
<p v-if="!thirdOptions.length" class="hint-muted">暂无启用的第三方项目</p>
</el-form-item>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="configureVisible = false"><el-icon><Close /></el-icon>取消</el-button>
<el-button class="btn-main" type="primary" :loading="configureLoading" @click="submitConfigure"><el-icon><Check /></el-icon>确认</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
title="工单详情"
:icon="Document"
:meta="detail?.workOrderNo || ''"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, Check, Search, RefreshLeft, View, Setting, Document } from '@element-plus/icons-vue'
import { fetchQcLabTestItemsActive, type QcLabTestItem } from '@/api/qc-lab-test-item'
import { fetchQcThirdPartyItemsActive, type QcThirdPartyItem } from '@/api/qc-third-party-item'
import {
fetchQcWorkOrderList,
fetchQcWorkOrderDetail,
fetchConfigureQcWorkOrder,
type QcWorkOrder,
type QcWorkOrderDetail
} from '@/api/qc-work-order'
import { workOrderStatusLabel } from '@/utils/qc-labels'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
defineOptions({ name: 'QualityInspectionQcWorkOrder' })
const searchForm = reactive({ keyword: '', workOrderStatus: '' })
const tableData = ref<QcWorkOrder[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const configureVisible = ref(false)
const configureLoading = ref(false)
const configuringId = ref('')
const labOptions = ref<QcLabTestItem[]>([])
const thirdOptions = ref<QcThirdPartyItem[]>([])
const thirdOptionsGrouped = computed(() => {
const map = new Map<string, { companyId: string; companyName: string; items: QcThirdPartyItem[] }>()
for (const item of thirdOptions.value) {
const cid = item.companyId
if (!map.has(cid)) {
map.set(cid, { companyId: cid, companyName: item.companyName || '', items: [] })
}
map.get(cid)!.items.push(item)
}
return Array.from(map.values())
})
const configureForm = reactive({
hasLab: false,
hasThirdParty: false,
labItemIds: [] as string[],
thirdPartyItemIds: [] as string[]
})
const viewVisible = ref(false)
const detail = ref<QcWorkOrderDetail | null>(null)
const viewSections = computed<ViewSection[]>(() => {
if (!detail.value) return []
const d = detail.value
return [
{
title: '基本信息',
fields: [
{ label: '工单号', value: d.workOrderNo },
{ label: '协议号', value: d.protocolNo },
{ label: '样本编码', value: d.sampleCode },
{ label: '状态', value: workOrderStatusLabel(d.workOrderStatus) }
]
},
{
title: '实验室项目',
fields: [
{
label: '检测项目',
value: d.labItems?.map((li) => li.itemName + '(' + li.itemCode + ')').join('、') || '无',
span: true
}
]
},
{
title: '第三方项目',
fields: [
{
label: '检测项目',
value:
d.thirdPartyItems?.map((li) => li.companyName + ' — ' + li.itemName + '(¥' + li.feeAmount + ')').join('、') ||
'无',
span: true
}
]
}
]
})
async function loadData() {
loading.value = true
try {
const res = await fetchQcWorkOrderList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
workOrderStatus: searchForm.workOrderStatus || 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.workOrderStatus = ''
pagination.page = 1
loadData()
}
async function handleConfigure(row: QcWorkOrder) {
configuringId.value = row.id
configureForm.hasLab = false
configureForm.hasThirdParty = false
configureForm.labItemIds = []
configureForm.thirdPartyItemIds = []
labOptions.value = (await fetchQcLabTestItemsActive()) || []
thirdOptions.value = (await fetchQcThirdPartyItemsActive()) || []
configureVisible.value = true
}
async function submitConfigure() {
if (!configureForm.hasLab && !configureForm.hasThirdParty) {
ElMessage.warning('请至少选择一种检测方式')
return
}
configureLoading.value = true
try {
await fetchConfigureQcWorkOrder(configuringId.value, { ...configureForm })
ElMessage.success('配置成功,已生成质检任务')
configureVisible.value = false
loadData()
} finally {
configureLoading.value = false
}
}
async function handleView(row: QcWorkOrder) {
detail.value = await fetchQcWorkOrderDetail(row.id)
viewVisible.value = true
}
onMounted(() => loadData())
</script>
<style scoped lang="scss">
.qc-config-check-group {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 16px;
}
.qc-company-group {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px 14px;
grid-column: 1 / -1;
}
.qc-company-group-title {
font-size: 13px;
font-weight: 500;
color: #334155;
padding-bottom: 6px;
border-bottom: 1px dashed #e2e8f0;
margin-bottom: 6px;
}
.qc-company-group-items {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 16px;
}
.hint-muted {
margin: 0;
font-size: 13px;
color: var(--mint-text-muted, #64748b);
}
</style>
@@ -0,0 +1,309 @@
<!-- 质量检测 — 样本接收检查项配置 -->
<!-- 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.status" 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">
<span class="filter-hint">停用后新登记不再展示,历史接收记录仍按快照展示。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增检查项
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
>
<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="itemCode" label="检查项编码" min-width="140" align="center" header-align="center" />
<el-table-column prop="itemName" label="检查项名称" min-width="160" align="center" header-align="center" />
<el-table-column prop="orderNum" label="排序" width="80" align="center" header-align="center" />
<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="createTime" label="创建时间" min-width="170" 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="640px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="biz-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="检查项编码" prop="itemCode">
<el-input v-model="formData.itemCode" placeholder="租户内唯一,如 packaging" :disabled="isEdit" />
</el-form-item>
<el-form-item label="检查项名称" prop="itemName">
<el-input v-model="formData.itemName" placeholder="如:包装" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="formData.orderNum" :min="0" :max="9999" style="width: 100%" />
</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="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
</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 } from '@element-plus/icons-vue'
import {
fetchSampleReceptionCheckItemList,
fetchCreateSampleReceptionCheckItem,
fetchUpdateSampleReceptionCheckItem,
fetchDeleteSampleReceptionCheckItem,
type SampleReceptionCheckItem
} from '@/api/sample-reception-check-item'
defineOptions({ name: 'QualityInspectionSampleReceptionCheckItem' })
const searchForm = reactive({ keyword: '', status: '' as string })
const tableData = ref<SampleReceptionCheckItem[]>([])
const selectedRows = ref<SampleReceptionCheckItem[]>([])
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({
itemCode: '',
itemName: '',
orderNum: 0,
status: '1',
remark: ''
})
const formRules = {
itemCode: [{ required: true, message: '请输入检查项编码', trigger: 'blur' }],
itemName: [{ required: true, message: '请输入检查项名称', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
const handleSelectionChange = (rows: SampleReceptionCheckItem[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchSampleReceptionCheckItemList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
status: searchForm.status || 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.status = ''
pagination.page = 1
loadData()
}
function resetForm() {
formData.itemCode = ''
formData.itemName = ''
formData.orderNum = 0
formData.status = '1'
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增检查项'
resetForm()
dialogVisible.value = true
}
function handleEdit(row: SampleReceptionCheckItem) {
isEdit.value = true
dialogTitle.value = '编辑检查项'
editingId.value = row.id
formData.itemCode = row.itemCode
formData.itemName = row.itemName
formData.orderNum = row.orderNum ?? 0
formData.status = row.status || '1'
formData.remark = row.remark || ''
dialogVisible.value = true
}
async function handleSubmit() {
const valid = await formRef.value?.validate?.().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
const payload = {
itemCode: formData.itemCode,
itemName: formData.itemName,
orderNum: formData.orderNum,
status: formData.status,
remark: formData.remark || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateSampleReceptionCheckItem(editingId.value, payload)
ElMessage.success('已保存')
} else {
await fetchCreateSampleReceptionCheckItem(payload)
ElMessage.success('已新增')
}
dialogVisible.value = false
loadData()
} finally {
submitLoading.value = false
}
}
async function handleDelete(row: SampleReceptionCheckItem) {
await ElMessageBox.confirm(`确定删除检查项「${row.itemName}」?`, '提示', { type: 'warning' })
await fetchDeleteSampleReceptionCheckItem(row.id)
ElMessage.success('已删除')
loadData()
}
async function handleBatchDelete() {
if (!selectedRows.value.length) return
await ElMessageBox.confirm(`确定删除选中的 ${selectedRows.value.length} 项?`, '提示', {
type: 'warning'
})
for (const row of selectedRows.value) {
await fetchDeleteSampleReceptionCheckItem(row.id)
}
ElMessage.success('已删除')
selectedRows.value = []
loadData()
}
onMounted(() => loadData())
</script>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,723 @@
<!-- 质量检测 — 第三方检测公司及项目 -->
<!-- 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.status" 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" />
<el-table-column
type="index"
label="序号"
width="68"
align="center"
:index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1"
/>
<el-table-column prop="companyCode" label="公司编码" min-width="120" align="center" />
<el-table-column prop="companyName" label="公司名称" min-width="160" align="center" />
<el-table-column prop="contactName" label="联系人" min-width="100" align="center" />
<el-table-column prop="contactPhone" label="联系电话" min-width="120" align="center" />
<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 label="操作" width="400" fixed="right" 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="companyCode">
<el-input v-model="companyForm.companyCode" :disabled="companyIsEdit" placeholder="租户内唯一" />
</el-form-item>
<el-form-item label="公司名称" prop="companyName">
<el-input v-model="companyForm.companyName" />
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="companyForm.contactName" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="companyForm.contactPhone" />
</el-form-item>
<el-form-item label="地址" class="form-col-span-2">
<el-input v-model="companyForm.address" />
</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.taxId" 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>
<div class="form-grid-2">
<el-form-item label="状态" prop="status">
<el-radio-group v-model="companyForm.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="companyForm.remark" type="textarea" :rows="2" />
</el-form-item>
</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="880px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@closed="onItemDialogClosed"
>
<div class="item-dialog-head">
<span class="muted">当前公司:{{ currentCompanyName }}</span>
</div>
<div class="list-toolbar item-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ itemSelected.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAddItem">
<el-icon><Plus /></el-icon>新增项目
</el-button>
</div>
</div>
<div class="list-body list-wrap item-table-wrap">
<el-table
class="list-table"
:data="itemTable"
v-loading="itemLoading"
stripe
row-key="id"
max-height="360"
@selection-change="onItemSelectionChange"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="68" align="center" />
<el-table-column prop="itemCode" label="项目编码" min-width="120" align="center" />
<el-table-column prop="itemName" label="项目名称" min-width="160" align="center" />
<el-table-column prop="feeAmount" label="费用(元)" width="110" align="center">
<template #default="{ row }">{{ row.feeAmount != null ? `¥${row.feeAmount}` : '—' }}</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 label="操作" width="260" fixed="right" align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="handleEditItem(row)"><el-icon><Edit /></el-icon>编辑</el-button>
<el-button class="btn-op-item" @click="handleDeleteItem(row)"><el-icon><Delete /></el-icon>删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="itemPagination.page"
:page-size="itemPagination.pageSize"
:total="itemPagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(v) => ((itemPagination.page = 1), (itemPagination.pageSize = v), loadItems())"
@current-change="(v) => ((itemPagination.page = v), loadItems())"
/>
</div>
</div>
<el-dialog
v-model="itemFormVisible"
:title="itemFormTitle"
width="640px"
class="dialog-form-layout"
align-center
append-to-body
destroy-on-close
:close-on-click-modal="false"
>
<el-form ref="itemFormRef" :model="itemForm" :rules="itemFormRules" label-position="top" class="biz-dialog-form">
<div class="form-grid-2">
<el-form-item label="项目编码" prop="itemCode">
<el-input v-model="itemForm.itemCode" :disabled="itemIsEdit" />
</el-form-item>
<el-form-item label="项目名称" prop="itemName">
<el-input v-model="itemForm.itemName" />
</el-form-item>
<el-form-item label="费用(元)" prop="feeAmount">
<el-input-number v-model="itemForm.feeAmount" :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>
<div class="form-section" style="margin-top: 8px">
<div class="section-title">参数配置</div>
<div class="param-row param-row-header">
<span style="flex: 2">参数名称</span>
<span style="flex: 1.5">参数编码</span>
<span style="flex: 1">单位</span>
<span style="flex: 1">类型</span>
<span style="width: 40px; text-align: center">必填</span>
<span style="width: 32px"></span>
</div>
<div v-if="itemParams.length === 0" style="text-align: center; padding: 8px 0; color: #94a3b8; font-size: 13px">
暂无参数,点击下方按钮添加
</div>
<div v-for="(param, idx) in itemParams" :key="idx" class="param-row">
<el-input v-model="param.paramName" placeholder="参数名称" style="flex: 2" />
<el-input v-model="param.paramCode" placeholder="参数编码" style="flex: 1.5" />
<el-input v-model="param.paramUnit" placeholder="单位" style="flex: 1" />
<el-select v-model="param.paramType" style="flex: 1">
<el-option label="文本" value="text" />
<el-option label="数字" value="number" />
</el-select>
<el-switch v-model="param.requiredFlag" active-value="1" inactive-value="0" />
<el-button class="btn-op-item" @click="removeItemParam(idx)"><el-icon><Delete /></el-icon></el-button>
</div>
<el-button type="primary" plain style="margin-top: 8px" @click="addItemParam"><el-icon><Plus /></el-icon>添加参数</el-button>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="itemFormVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="itemSubmitLoading" @click="submitItem">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
<template #footer>
<el-button class="btn-ghost" @click="itemDialogVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
title="查看公司"
:icon="OfficeBuilding"
:meta="viewCompanyName"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { 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 type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import {
fetchQcThirdPartyCompanyList,
fetchCreateQcThirdPartyCompany,
fetchUpdateQcThirdPartyCompany,
fetchDeleteQcThirdPartyCompany,
type QcThirdPartyCompany
} from '@/api/qc-third-party-company'
import {
fetchQcThirdPartyItemList,
fetchCreateQcThirdPartyItem,
fetchUpdateQcThirdPartyItem,
fetchDeleteQcThirdPartyItem,
type QcThirdPartyItem
} from '@/api/qc-third-party-item'
import { fetchQcItemParams, fetchSaveQcItemParams, type QcItemParam } from '@/api/qc-item-param'
defineOptions({ name: 'QualityInspectionThirdPartyCompany' })
const searchForm = reactive({ keyword: '', status: '' })
const tableData = ref<QcThirdPartyCompany[]>([])
const selectedRows = ref<QcThirdPartyCompany[]>([])
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 editingCompanyId = ref<string | null>(null)
const companyForm = reactive({
companyCode: '',
companyName: '',
contactName: '',
contactPhone: '',
address: '',
invoiceTitle: '',
taxId: '',
bankName: '',
bankAccount: '',
status: '1',
remark: ''
})
const companyFormRules = {
companyCode: [{ required: true, message: '请输入公司编码', trigger: 'blur' }],
companyName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
const itemDialogVisible = ref(false)
const itemDialogTitle = ref('检测项目配置')
const currentCompanyId = ref('')
const currentCompanyName = ref('')
const itemTable = ref<QcThirdPartyItem[]>([])
const itemSelected = ref<QcThirdPartyItem[]>([])
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 itemParams = ref<QcItemParam[]>([])
const itemForm = reactive({
itemCode: '',
itemName: '',
feeAmount: 0,
status: '1',
remark: ''
})
const itemFormRules = {
itemCode: [{ required: true, message: '请输入项目编码', trigger: 'blur' }],
itemName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
feeAmount: [{ required: true, message: '请输入费用', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
function handleSelectionChange(rows: QcThirdPartyCompany[]) {
selectedRows.value = rows
}
function onItemSelectionChange(rows: QcThirdPartyItem[]) {
itemSelected.value = rows
}
function addItemParam() {
itemParams.value.push({
paramName: '',
paramCode: '',
paramUnit: '',
paramType: 'text',
requiredFlag: '0'
})
}
function removeItemParam(index: number) {
itemParams.value.splice(index, 1)
}
async function loadData() {
loading.value = true
try {
const res = await fetchQcThirdPartyCompanyList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
status: searchForm.status || 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.status = ''
pagination.page = 1
loadData()
}
function resetCompanyForm() {
companyForm.companyCode = ''
companyForm.companyName = ''
companyForm.contactName = ''
companyForm.contactPhone = ''
companyForm.address = ''
companyForm.invoiceTitle = ''
companyForm.taxId = ''
companyForm.bankName = ''
companyForm.bankAccount = ''
companyForm.status = '1'
companyForm.remark = ''
editingCompanyId.value = null
}
function handleAdd() {
companyIsEdit.value = false
companyDialogTitle.value = '新增公司'
resetCompanyForm()
companyDialogVisible.value = true
}
function handleEdit(row: QcThirdPartyCompany) {
companyIsEdit.value = true
companyDialogTitle.value = '编辑公司'
editingCompanyId.value = row.id
companyForm.companyCode = row.companyCode
companyForm.companyName = row.companyName
companyForm.contactName = row.contactName || ''
companyForm.contactPhone = row.contactPhone || ''
companyForm.address = row.address || ''
companyForm.invoiceTitle = row.invoiceTitle || ''
companyForm.taxId = row.taxId || ''
companyForm.bankName = row.bankName || ''
companyForm.bankAccount = row.bankAccount || ''
companyForm.status = row.status || '1'
companyForm.remark = row.remark || ''
companyDialogVisible.value = true
}
async function submitCompany() {
const valid = await companyFormRef.value?.validate?.().catch(() => false)
if (!valid) return
companySubmitLoading.value = true
try {
const payload = { ...companyForm, remark: companyForm.remark || undefined }
if (companyIsEdit.value && editingCompanyId.value) {
await fetchUpdateQcThirdPartyCompany(editingCompanyId.value, payload)
ElMessage.success('已保存')
} else {
await fetchCreateQcThirdPartyCompany(payload)
ElMessage.success('已新增')
}
companyDialogVisible.value = false
loadData()
} finally {
companySubmitLoading.value = false
}
}
async function handleDelete(row: QcThirdPartyCompany) {
await ElMessageBox.confirm(`确定删除「${row.companyName}」?其下属检测项目将一并删除。`, '提示', {
type: 'warning'
})
await fetchDeleteQcThirdPartyCompany(row.id)
ElMessage.success('已删除')
loadData()
}
const viewVisible = ref(false)
const viewCompanyName = ref('')
const viewSections = ref<ViewSection[]>([])
function handleView(row: QcThirdPartyCompany) {
viewCompanyName.value = row.companyName || ''
viewSections.value = [
{
title: '基本信息',
fields: [
{ label: '公司编码', value: row.companyCode },
{ label: '公司名称', value: row.companyName },
{ label: '联系人', value: row.contactName },
{ label: '联系电话', value: row.contactPhone },
{ label: '地址', value: row.address, span: true },
{ label: '状态', value: row.status === '1' ? '启用' : '停用' },
{ label: '备注', value: row.remark, span: true }
]
},
{
title: '开票信息',
fields: [
{ label: '发票抬头', value: row.invoiceTitle },
{ label: '纳税人识别号', value: row.taxId },
{ label: '开户银行', value: row.bankName },
{ label: '银行账号', value: row.bankAccount }
]
}
]
viewVisible.value = true
}
function openItemDialog(row: QcThirdPartyCompany) {
currentCompanyId.value = row.id
currentCompanyName.value = row.companyName || ''
itemPagination.page = 1
itemSelected.value = []
itemDialogTitle.value = `检测项目配置 — ${row.companyName}`
itemDialogVisible.value = true
loadItems()
}
async function loadItems() {
if (!currentCompanyId.value) return
itemLoading.value = true
try {
const res = await fetchQcThirdPartyItemList({
page: itemPagination.page,
pageSize: itemPagination.pageSize,
companyId: currentCompanyId.value
})
itemTable.value = res.list || []
itemPagination.total = res.total ?? 0
} finally {
itemLoading.value = false
}
}
function onItemDialogClosed() {
currentCompanyId.value = ''
currentCompanyName.value = ''
itemTable.value = []
}
function resetItemForm() {
itemForm.itemCode = ''
itemForm.itemName = ''
itemForm.feeAmount = 0
itemForm.status = '1'
itemForm.remark = ''
editingItemId.value = null
itemParams.value = []
}
function handleAddItem() {
itemIsEdit.value = false
itemFormTitle.value = '新增项目'
resetItemForm()
itemParams.value = []
itemFormVisible.value = true
}
function handleEditItem(row: QcThirdPartyItem) {
itemIsEdit.value = true
itemFormTitle.value = '编辑项目'
editingItemId.value = row.id
itemForm.itemCode = row.itemCode
itemForm.itemName = row.itemName
itemForm.feeAmount = row.feeAmount ?? 0
itemForm.status = row.status || '1'
itemForm.remark = row.remark || ''
fetchQcItemParams('third_party', row.id).then((res) => {
itemParams.value = res || []
}).catch(() => {
itemParams.value = []
})
itemFormVisible.value = true
}
async function submitItem() {
const valid = await itemFormRef.value?.validate?.().catch(() => false)
if (!valid) return
itemSubmitLoading.value = true
try {
const payload = {
companyId: currentCompanyId.value,
itemCode: itemForm.itemCode,
itemName: itemForm.itemName,
feeAmount: itemForm.feeAmount,
status: itemForm.status,
remark: itemForm.remark || undefined
}
let savedId = editingItemId.value
if (itemIsEdit.value && editingItemId.value) {
await fetchUpdateQcThirdPartyItem(editingItemId.value, payload)
ElMessage.success('已保存')
} else {
const created = await fetchCreateQcThirdPartyItem(payload)
savedId = created?.id || ''
ElMessage.success('已新增')
}
if (savedId && itemParams.value.length > 0) {
await fetchSaveQcItemParams('third_party', savedId, itemParams.value).catch(() => {})
}
itemFormVisible.value = false
loadItems()
} finally {
itemSubmitLoading.value = false
}
}
async function handleDeleteItem(row: QcThirdPartyItem) {
await ElMessageBox.confirm(`确定删除「${row.itemName}」?`, '提示', { type: 'warning' })
await fetchDeleteQcThirdPartyItem(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-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;
}
.param-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.param-row-header {
font-size: 12px;
color: #64748b;
font-weight: 500;
padding-bottom: 4px;
border-bottom: 1px solid #e2e8f0;
margin-bottom: 10px;
}
</style>
+26 -4
View File
@@ -179,6 +179,11 @@
<el-option label="停用" value="0" />
</el-select>
</el-form-item>
<el-form-item label="角色" style="grid-column: 1 / -1">
<el-select v-model="formData.roleIds" multiple placeholder="请选择角色" style="width: 100%">
<el-option v-for="item in roleOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="备注" style="grid-column: 1 / -1">
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
@@ -218,9 +223,20 @@
fetchUpdateUser,
fetchDeleteUser,
fetchResetPassword,
fetchDeptTree
fetchDeptTree,
fetchRoleOptions
} from '@/api/admin'
const roleOptions = ref<Array<{ label: string; value: string }>>([])
async function loadRoleOptions() {
try {
roleOptions.value = (await fetchRoleOptions()) || []
} catch {
roleOptions.value = []
}
}
defineOptions({ name: 'SystemUser' })
const deptKeyword = ref('')
@@ -251,7 +267,8 @@
avatar: '',
status: '1',
orderNum: 0,
remark: ''
remark: '',
roleIds: [] as string[]
})
const formRules = {
@@ -327,6 +344,7 @@
formData.status = '1'
formData.orderNum = 0
formData.remark = ''
formData.roleIds = []
editingId.value = null
}
@@ -356,6 +374,7 @@
formData.status = row.status || '1'
formData.orderNum = row.orderNum ?? 0
formData.remark = row.remark || ''
formData.roleIds = (row as any).roleIds || []
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
@@ -373,7 +392,8 @@
sex: formData.sex,
status: formData.status,
orderNum: formData.orderNum,
remark: formData.remark?.trim() || undefined
remark: formData.remark?.trim() || undefined,
roleIds: formData.roleIds.length ? formData.roleIds : undefined
})
} else {
await fetchCreateUser({
@@ -386,7 +406,8 @@
sex: formData.sex,
status: formData.status,
orderNum: formData.orderNum,
remark: formData.remark?.trim() || undefined
remark: formData.remark?.trim() || undefined,
roleIds: formData.roleIds.length ? formData.roleIds : undefined
})
}
dialogVisible.value = false
@@ -445,6 +466,7 @@
onMounted(() => {
loadDeptTree()
loadData()
loadRoleOptions()
})
</script>