feat(cell-storage): 储存证质检字段级质检/生产权限
按方案 A 增加租户级 ACL(字段+人员),详情返回可编辑字段,保存改为服务端 merge;弹窗 footer 提供权限配置入口。关联 #93。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
9dc6812393
commit
529f36eac6
16 files changed
+1210
-32
No files matched your search
@@ -12,6 +12,20 @@ export type SampleStorageCertQcDetail = {
|
||||
filledFlag: boolean
|
||||
payload: Record<string, unknown>
|
||||
prefill?: Record<string, unknown>
|
||||
editableFieldKeys?: string[]
|
||||
canConfigureAcl?: boolean
|
||||
aclStrict?: boolean
|
||||
}
|
||||
|
||||
export type StorageCertQcAclScope = 'qc' | 'production'
|
||||
|
||||
export type StorageCertQcAclDetail = {
|
||||
scope: StorageCertQcAclScope
|
||||
fieldKeys: string[]
|
||||
userIds: string[]
|
||||
users: Array<{ id: string; nickName: string; userName: string }>
|
||||
configuredFlag: boolean
|
||||
aclStrict: boolean
|
||||
}
|
||||
|
||||
export function fetchSampleStorageCertQc(sampleCode: string) {
|
||||
@@ -29,3 +43,19 @@ export function saveSampleStorageCertQc(
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchSampleStorageCertQcAcl(scope: StorageCertQcAclScope) {
|
||||
return request.get({
|
||||
url: `/web/admin/sample-storage-cert-qc-acl/${encodeURIComponent(scope)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function saveSampleStorageCertQcAcl(
|
||||
scope: StorageCertQcAclScope,
|
||||
data: { fieldKeys: string[]; userIds: string[] }
|
||||
) {
|
||||
return request.put({
|
||||
url: `/web/admin/sample-storage-cert-qc-acl/${encodeURIComponent(scope)}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="720px"
|
||||
class="dialog-form-layout storage-cert-qc-acl-dialog"
|
||||
align-center
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
modal-class="protocol-dialog-mask"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<div v-loading="loading" class="storage-cert-qc-acl-dialog__body">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="勾选可编辑字段" name="fields">
|
||||
<div v-for="(group, gi) in fieldGroups" :key="gi" class="field-block">
|
||||
<div class="field-block__title">{{ group.title }}</div>
|
||||
<el-checkbox-group v-model="selectedKeys">
|
||||
<el-checkbox
|
||||
v-for="field in group.fields"
|
||||
:key="field.key"
|
||||
:label="field.key"
|
||||
class="field-check"
|
||||
>
|
||||
{{ field.label }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="配置人员" name="users">
|
||||
<el-select
|
||||
v-model="selectedUserIds"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
reserve-keyword
|
||||
:remote-method="searchUsers"
|
||||
:loading="userLoading"
|
||||
placeholder="输入姓名或账号搜索"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="u in userOptions"
|
||||
:key="u.value"
|
||||
:label="u.label"
|
||||
:value="u.value"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-tip">仅名单内用户可修改本权限勾选的字段。</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:visible', false)">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||
<el-icon><Check /></el-icon>
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fetchUserList } from '@/api/admin'
|
||||
import {
|
||||
fetchSampleStorageCertQcAcl,
|
||||
saveSampleStorageCertQcAcl,
|
||||
type StorageCertQcAclScope
|
||||
} from '@/api/sample-storage-cert-qc'
|
||||
import { STORAGE_CERT_QC_GROUPS } from '@/utils/storage-cert-qc-fields'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
scope: StorageCertQcAclScope
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', v: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const userLoading = ref(false)
|
||||
const activeTab = ref('fields')
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const selectedUserIds = ref<string[]>([])
|
||||
const userOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
const dialogTitle = computed(() => (props.scope === 'qc' ? '质检权限' : '生产权限'))
|
||||
|
||||
const fieldGroups = computed(() => {
|
||||
const map = new Map<string, { title: string; fields: Array<{ key: string; label: string }> }>()
|
||||
for (const groups of Object.values(STORAGE_CERT_QC_GROUPS)) {
|
||||
for (const group of groups) {
|
||||
const item = map.get(group.title) || { title: group.title, fields: [] }
|
||||
const seen = new Set(item.fields.map((f) => f.key))
|
||||
for (const field of group.fields) {
|
||||
if (seen.has(field.key)) continue
|
||||
seen.add(field.key)
|
||||
item.fields.push({ key: field.key, label: field.label })
|
||||
}
|
||||
map.set(group.title, item)
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
})
|
||||
|
||||
function upsertUserOption(id: string, label: string) {
|
||||
if (!id) return
|
||||
if (userOptions.value.some((u) => u.value === id)) return
|
||||
userOptions.value.push({ value: id, label: label || id })
|
||||
}
|
||||
|
||||
async function searchUsers(keyword: string) {
|
||||
userLoading.value = true
|
||||
try {
|
||||
const res = await fetchUserList({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
status: '1',
|
||||
keyword: keyword || undefined
|
||||
})
|
||||
const list = (res as any)?.list || []
|
||||
for (const row of list) {
|
||||
const id = String(row.id || '')
|
||||
const name = String(row.nickName || row.userName || row.userNo || id)
|
||||
const account = String(row.userName || row.userNo || '')
|
||||
upsertUserOption(id, account ? `${name}(${account})` : name)
|
||||
}
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
activeTab.value = 'fields'
|
||||
try {
|
||||
const detail = (await fetchSampleStorageCertQcAcl(props.scope)) as any
|
||||
selectedKeys.value = Array.isArray(detail?.fieldKeys) ? [...detail.fieldKeys] : []
|
||||
selectedUserIds.value = Array.isArray(detail?.userIds) ? [...detail.userIds] : []
|
||||
userOptions.value = []
|
||||
for (const u of detail?.users || []) {
|
||||
const id = String(u.id || '')
|
||||
const name = String(u.nickName || u.userName || id)
|
||||
const account = String(u.userName || '')
|
||||
upsertUserOption(id, account ? `${name}(${account})` : name)
|
||||
}
|
||||
if (!userOptions.value.length) {
|
||||
await searchUsers('')
|
||||
}
|
||||
} catch {
|
||||
selectedKeys.value = []
|
||||
selectedUserIds.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await saveSampleStorageCertQcAcl(props.scope, {
|
||||
fieldKeys: [...selectedKeys.value],
|
||||
userIds: [...selectedUserIds.value]
|
||||
})
|
||||
ElMessage.success('保存成功')
|
||||
emit('saved')
|
||||
emit('update:visible', false)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.scope] as const,
|
||||
([vis]) => {
|
||||
if (vis) loadDetail()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.storage-cert-qc-acl-dialog__body {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.field-block {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field-block__title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary-dark-2, #059669);
|
||||
}
|
||||
|
||||
.field-check {
|
||||
display: flex;
|
||||
margin-right: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
</style>
|
||||
@@ -54,7 +54,7 @@
|
||||
'form-col-span-3': field.span === 3
|
||||
}"
|
||||
>
|
||||
<template v-if="mode === 'view'">
|
||||
<template v-if="mode === 'view' || !isFieldEditable(field.key)">
|
||||
<div
|
||||
class="readonly-box"
|
||||
:class="{ 'is-positive': field.control === 'select_yny' && isPositiveQual(form[field.key]) }"
|
||||
@@ -112,32 +112,56 @@
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="mode === 'edit'">
|
||||
<el-button @click="emit('update:visible', false)">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
<el-button v-else type="primary" @click="emit('update:visible', false)">
|
||||
<el-icon><Check /></el-icon>
|
||||
关闭
|
||||
</el-button>
|
||||
<div class="storage-cert-qc-dialog__footer">
|
||||
<div class="footer-left">
|
||||
<template v-if="canConfigureAcl">
|
||||
<el-button @click="openAcl('qc')">
|
||||
<el-icon><Setting /></el-icon>
|
||||
质检权限
|
||||
</el-button>
|
||||
<el-button @click="openAcl('production')">
|
||||
<el-icon><Setting /></el-icon>
|
||||
生产权限
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<template v-if="mode === 'edit' && canSaveAny">
|
||||
<el-button @click="emit('update:visible', false)">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
<el-button v-else type="primary" @click="emit('update:visible', false)">
|
||||
<el-icon><Check /></el-icon>
|
||||
关闭
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<StorageCertQcAclDialog
|
||||
v-model:visible="aclVisible"
|
||||
:scope="aclScope"
|
||||
@saved="loadDetail"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
import { Check, Close, Setting } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
fetchSampleStorageCertQc,
|
||||
saveSampleStorageCertQc,
|
||||
type SampleStorageCertQcDetail
|
||||
type SampleStorageCertQcDetail,
|
||||
type StorageCertQcAclScope
|
||||
} from '@/api/sample-storage-cert-qc'
|
||||
import StorageCertQcAclDialog from '@/components/business/storage-cert-qc-acl-dialog/index.vue'
|
||||
import {
|
||||
STORAGE_CERT_QC_GROUPS,
|
||||
isPerinatalQualField,
|
||||
@@ -166,6 +190,10 @@
|
||||
const filledFlag = ref(false)
|
||||
const typeKey = ref<StorageCertQcTypeKey | null>(null)
|
||||
const form = reactive<Record<string, string>>({})
|
||||
const editableKeySet = ref<Set<string>>(new Set())
|
||||
const canConfigureAcl = ref(false)
|
||||
const aclVisible = ref(false)
|
||||
const aclScope = ref<StorageCertQcAclScope>('qc')
|
||||
const identity = reactive({
|
||||
labFlowCode: '',
|
||||
sampleCode: '',
|
||||
@@ -174,7 +202,7 @@
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
props.mode === 'edit' ? '填写储存证质检信息' : '查看储存证的质检信息'
|
||||
props.mode === 'edit' && canSaveAny.value ? '填写储存证质检信息' : '查看储存证的质检信息'
|
||||
)
|
||||
|
||||
const codeFieldLabel = computed(() => {
|
||||
@@ -184,6 +212,19 @@
|
||||
|
||||
const groups = computed(() => (typeKey.value ? STORAGE_CERT_QC_GROUPS[typeKey.value] || [] : []))
|
||||
|
||||
const canSaveAny = computed(
|
||||
() => props.mode === 'edit' && editableKeySet.value.size > 0
|
||||
)
|
||||
|
||||
function isFieldEditable(key: string) {
|
||||
return props.mode === 'edit' && editableKeySet.value.has(key)
|
||||
}
|
||||
|
||||
function openAcl(scope: StorageCertQcAclScope) {
|
||||
aclScope.value = scope
|
||||
aclVisible.value = true
|
||||
}
|
||||
|
||||
function displayValue(key: string) {
|
||||
const v = form[key]
|
||||
if (v == null || String(v).trim() === '') return '—'
|
||||
@@ -213,6 +254,9 @@
|
||||
identity.motherName = detail.motherName || ''
|
||||
identity.sampleTypeName = detail.sampleTypeName || props.sampleTypeName || ''
|
||||
filledFlag.value = !!detail.filledFlag
|
||||
canConfigureAcl.value = !!detail.canConfigureAcl
|
||||
const keys = Array.isArray(detail.editableFieldKeys) ? detail.editableFieldKeys : []
|
||||
editableKeySet.value = new Set(keys.map((k) => String(k)))
|
||||
const resolved =
|
||||
(detail.sampleTypeKey as StorageCertQcTypeKey | null) ||
|
||||
resolveStorageCertQcTypeKey(identity.sampleTypeName)
|
||||
@@ -234,6 +278,8 @@
|
||||
} catch {
|
||||
typeKey.value = resolveStorageCertQcTypeKey(props.sampleTypeName)
|
||||
filledFlag.value = false
|
||||
editableKeySet.value = new Set()
|
||||
canConfigureAcl.value = false
|
||||
resetFormKeys(typeKey.value, {})
|
||||
identity.labFlowCode = ''
|
||||
identity.sampleCode = code
|
||||
@@ -249,6 +295,10 @@
|
||||
ElMessage.warning('无法保存:样本类型不支持或缺少样本编码')
|
||||
return
|
||||
}
|
||||
if (!canSaveAny.value) {
|
||||
ElMessage.warning('当前无字段编辑权限')
|
||||
return
|
||||
}
|
||||
const payload: Record<string, unknown> = {}
|
||||
for (const group of groups.value) {
|
||||
for (const field of group.fields) {
|
||||
@@ -283,6 +333,21 @@
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.storage-cert-qc-dialog__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer-left,
|
||||
.footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ctx-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@@ -405,5 +470,15 @@
|
||||
.form-col-span-3 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.storage-cert-qc-dialog__footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.footer-left,
|
||||
.footer-right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user