feat(lab-sample): 实验室样本处理三表台账与部门字段 ACL

按造血/单个核/间充质聚合展示,部门员工只改本部门、主管可改全部,并支持筛选与 Excel 导出;同步纠正采集接受人串权与严格菜单守卫。
This commit is contained in:
zlwu committed 2026-11-12 12:48:05 +08:00
1 parent 58b252ce6b
commit 5fe1193a2c
22 files changed
+2136 -5

No files matched your search

@@ -0,0 +1,74 @@
import request from '@/utils/http'
export type LabSampleDataset = 'hsc' | 'ubmc' | 'msc'
export interface LabSampleFieldMeta {
key: string
label: string
dept: 'market' | 'production' | 'qc'
source: string
}
export interface LabSampleAcl {
canList?: boolean
isManager?: boolean
editableDepts?: string[]
deptFlags?: Record<string, boolean>
}
export function fetchLabSampleMeta(dataset: LabSampleDataset) {
return request.get<{
dataset: LabSampleDataset
label: string
fields: LabSampleFieldMeta[]
acl: LabSampleAcl
}>({
url: `/web/admin/lab-sample-processing/meta/${dataset}`
})
}
export function fetchLabSampleList(
dataset: LabSampleDataset,
params: {
keyword?: string
dateFrom?: string
dateTo?: string
page?: number
pageSize?: number
}
) {
return request.get<{
list: Record<string, any>[]
total: number
page: number
pageSize: number
fields: LabSampleFieldMeta[]
acl: LabSampleAcl
}>({
url: `/web/admin/lab-sample-processing/${dataset}/list`,
params
})
}
export function updateLabSampleRow(
dataset: LabSampleDataset,
sampleCode: string,
payload: Record<string, any>
) {
return request.put<Record<string, any>>({
url: `/web/admin/lab-sample-processing/${dataset}/${encodeURIComponent(sampleCode)}`,
data: { payload }
})
}
export function exportLabSampleUrl(
dataset: LabSampleDataset,
params: { keyword?: string; dateFrom?: string; dateTo?: string }
) {
const q = new URLSearchParams()
if (params.keyword) q.set('keyword', params.keyword)
if (params.dateFrom) q.set('dateFrom', params.dateFrom)
if (params.dateTo) q.set('dateTo', params.dateTo)
const qs = q.toString()
return `/api/web/admin/lab-sample-processing/${dataset}/export${qs ? `?${qs}` : ''}`
}
+53
View File
@@ -1313,6 +1313,59 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
]
},
// ═══════════════════════════════════════════════════════════════════════════
// 3b. 实验室样本处理数据 (menu:lab-sample-processing)
// ═══════════════════════════════════════════════════════════════════════════
{
id: 'TOP0000000000000000000000000000L1',
name: 'LabSampleProcessing',
path: '/lab-sample-processing',
component: '/index/index',
meta: {
title: '实验室样本处理数据',
icon: 'ri:test-tube-line',
keepAlive: false,
isTopMenu: true,
menuCode: 'menu:lab-sample-processing',
roles: ['admin']
},
children: [
{
id: 'LAB00000000000000000000000000001',
name: 'LabSampleProcessingHsc',
path: 'hsc',
component: '/lab-sample-processing/hsc/index',
meta: {
title: '造血干细胞数据',
keepAlive: true,
menuCode: 'lab:sample-processing:list'
}
},
{
id: 'LAB00000000000000000000000000002',
name: 'LabSampleProcessingUbmc',
path: 'ubmc',
component: '/lab-sample-processing/ubmc/index',
meta: {
title: '单个核数据',
keepAlive: true,
menuCode: 'lab:sample-processing:list'
}
},
{
id: 'LAB00000000000000000000000000003',
name: 'LabSampleProcessingMsc',
path: 'msc',
component: '/lab-sample-processing/msc/index',
meta: {
title: '脐带间充质干细胞数据',
keepAlive: true,
menuCode: 'lab:sample-processing:list'
}
}
]
},
// ═══════════════════════════════════════════════════════════════════════════
// 4. 运营支撑 (menu:operations)
// 二级菜单:系统基础信息、运营支撑
@@ -0,0 +1,329 @@
<!-- 实验室样本处理数据 — 共享列表页 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">{{ pageTitle }}</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="样本编码/姓名"
clearable
/>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<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">{{ pagination.total }}</span> 条
</div>
<el-button class="btn-main" type="primary" :loading="exporting" @click="handleExport">
<el-icon><Download /></el-icon>
导出
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
size="small"
row-key="id"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
v-for="col in visibleColumns"
:key="col.key"
:prop="col.key"
:label="col.label"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="操作"
fixed="right"
min-width="260"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button
v-if="(acl.editableDepts || []).length"
class="btn-ghost"
link
type="primary"
@click="openEdit(row)"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<span v-else class="text-muted">只读</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData"
@current-change="loadData"
/>
</div>
</section>
<el-dialog
v-model="editVisible"
class="dialog-form-layout"
:title="`编辑 — ${editRow?.sampleCode || ''}`"
width="860px"
align-center
destroy-on-close
append-to-body
>
<el-form label-position="top" class="form-grid-2" v-loading="saving">
<template v-for="group in editGroups" :key="group.dept">
<div class="form-col-span-2 dept-title">{{ group.label }}</div>
<el-form-item v-for="f in group.fields" :key="f.key" :label="f.label">
<el-input
v-model="editForm[f.key]"
:disabled="!canEditDept(f.dept)"
clearable
/>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="editVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="saving" @click="submitEdit">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Check, Close, Download, Edit, RefreshLeft, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
fetchLabSampleList,
updateLabSampleRow,
type LabSampleAcl,
type LabSampleDataset,
type LabSampleFieldMeta
} from '@/api/lab-sample-processing'
const props = defineProps<{
dataset: LabSampleDataset
title: string
}>()
const pageTitle = computed(() => props.title)
const loading = ref(false)
const saving = ref(false)
const exporting = ref(false)
const tableData = ref<Record<string, any>[]>([])
const fields = ref<LabSampleFieldMeta[]>([])
const acl = ref<LabSampleAcl>({})
const searchForm = reactive({ keyword: '' })
const dateRange = ref<string[] | null>(null)
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const editVisible = ref(false)
const editRow = ref<Record<string, any> | null>(null)
const editForm = reactive<Record<string, any>>({})
const deptLabel: Record<string, string> = {
market: '市场字段',
production: '生产字段',
qc: '质检字段'
}
const visibleColumns = computed(() => fields.value)
const editGroups = computed(() => {
const groups = ['market', 'production', 'qc'].map((dept) => ({
dept,
label: deptLabel[dept] || dept,
fields: fields.value.filter((f) => f.dept === dept)
}))
return groups.filter((g) => g.fields.length > 0)
})
function canEditDept(dept: string) {
const list = acl.value.editableDepts || []
return list.includes(dept)
}
async function loadData() {
loading.value = true
try {
const res = await fetchLabSampleList(props.dataset, {
keyword: searchForm.keyword || undefined,
dateFrom: dateRange.value?.[0],
dateTo: dateRange.value?.[1],
page: pagination.page,
pageSize: pagination.pageSize
})
tableData.value = res?.list || []
pagination.total = res?.total || 0
fields.value = res?.fields || []
acl.value = res?.acl || {}
} catch (e: any) {
ElMessage.error(e?.message || '加载失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
void loadData()
}
function handleReset() {
searchForm.keyword = ''
dateRange.value = null
handleSearch()
}
function openEdit(row: Record<string, any>) {
editRow.value = row
Object.keys(editForm).forEach((k) => delete editForm[k])
for (const f of fields.value) {
editForm[f.key] = row[f.key] ?? ''
}
editVisible.value = true
}
async function submitEdit() {
if (!editRow.value?.sampleCode) return
const editableKeys = fields.value
.filter((f) => canEditDept(f.dept))
.map((f) => f.key)
if (!editableKeys.length) {
ElMessage.warning('当前账号无可编辑字段')
return
}
const payload: Record<string, any> = {}
for (const key of editableKeys) {
payload[key] = editForm[key]
}
saving.value = true
try {
await updateLabSampleRow(props.dataset, editRow.value.sampleCode, payload)
ElMessage.success('保存成功')
editVisible.value = false
await loadData()
} catch (e: any) {
ElMessage.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleExport() {
exporting.value = true
try {
const res = await fetchLabSampleList(props.dataset, {
keyword: searchForm.keyword || undefined,
dateFrom: dateRange.value?.[0],
dateTo: dateRange.value?.[1],
page: 1,
pageSize: 5000
})
const cols = res?.fields?.length ? res.fields : fields.value
const rows = res?.list || []
const ExcelJS = (await import('exceljs')).default
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet(props.title || props.dataset)
sheet.addRow(cols.map((c) => c.label))
for (const row of rows) {
sheet.addRow(cols.map((c) => row[c.key] ?? ''))
}
const header = sheet.getRow(1)
header.font = { bold: true }
const buffer = await workbook.xlsx.writeBuffer()
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `${props.title || props.dataset}.xlsx`
a.click()
URL.revokeObjectURL(a.href)
ElMessage.success(`已导出 ${rows.length} 条`)
} catch (e: any) {
ElMessage.error(e?.message || '导出失败')
} finally {
exporting.value = false
}
}
onMounted(() => {
void loadData()
})
</script>
<style scoped lang="scss">
.dept-title {
font-weight: 600;
color: var(--el-color-primary);
margin: 4px 0 8px;
}
.text-muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>
@@ -0,0 +1,7 @@
<template>
<LabSampleProcessingPage dataset="hsc" title="造血干细胞数据" />
</template>
<script setup lang="ts">
import LabSampleProcessingPage from '../LabSampleProcessingPage.vue'
</script>
@@ -0,0 +1,7 @@
<template>
<LabSampleProcessingPage dataset="msc" title="脐带间充质干细胞数据" />
</template>
<script setup lang="ts">
import LabSampleProcessingPage from '../LabSampleProcessingPage.vue'
</script>
@@ -0,0 +1,7 @@
<template>
<LabSampleProcessingPage dataset="ubmc" title="单个核数据" />
</template>
<script setup lang="ts">
import LabSampleProcessingPage from '../LabSampleProcessingPage.vue'
</script>