fix: 协议子资源单事务保存,并补齐样本编码/OA/质检等近期能力

锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-26 11:02:25 +08:00
1 parent 19bc66ba45
commit 5dad1bb0e3
142 files changed
+95584 -65717

No files matched your search

@@ -213,6 +213,7 @@
:unit-options="unitOptions"
:unit-options-loading="unitLoading"
:show-price="true"
:sample-payments="formData.samplePayments"
@price-change="onSamplePriceChange"
@search-sample-code="remoteSearchSampleCodes"
@search-sample-type="remoteSearchSampleTypes"
@@ -372,6 +372,7 @@
parsePreferentialPrice,
resolvePerSamplePaymentDefaults,
samplePaymentHasConfirmedTransfer,
samplePaymentHasLockedTransfer,
schedulePaidArrears,
shouldAutoFillLastPeriodDue,
shouldReplaceContractWithPreferentialPrice
@@ -569,19 +570,13 @@
}
}
function paymentHasLockedTransfer(pay: Record<string, any>) {
return (pay.schedules || []).some((s: Record<string, any>) =>
(s.transfers || []).some((t: Record<string, any>) => t.payStatus === 'yjf')
)
}
/** 优惠价变更时同步合同;打开编辑不覆盖库内金额(仅合同为空时补齐) */
function applyPreferentialPriceToPayment(
pay: Record<string, any>,
sample: Record<string, any>,
force = false
): Record<string, any> {
if (paymentHasLockedTransfer(pay)) return pay
if (samplePaymentHasLockedTransfer(pay)) return pay
const price = parsePreferentialPrice(sample)
if (price == null) return pay
if (!force && !shouldReplaceContractWithPreferentialPrice(pay.contractAmount, price)) {
@@ -1114,6 +1109,26 @@
next.push(emptyDjPayment(sample))
}
}
// 锁定缴费不可因样本删改/换码从提交列表中消失(对齐后端「不能省略或删除」)
const keptCodes = new Set(next.map((p) => String(p.sampleCode || '')))
const keptIds = new Set(
next.map((p) => String(p.id || p.samplePaymentId || '')).filter(Boolean)
)
let restoredLocked = false
for (const pay of existing) {
if (!samplePaymentHasLockedTransfer(pay)) continue
const id = String(pay.id || pay.samplePaymentId || '')
const code = String(pay.sampleCode || '')
if ((id && keptIds.has(id)) || (code && keptCodes.has(code))) continue
next.push(pay)
restoredLocked = true
}
if (restoredLocked) {
ElMessage.warning(
'存在锁定缴费方案的样本不可删除或改码,请使用客户服务「更正编码」'
)
}
lastPreferentialByCode.value = nextPriceMap
const same =
next.length === existing.length &&
@@ -0,0 +1,217 @@
<template>
<el-dialog
v-model="visible"
title="更正样本编码"
width="640px"
class="dialog-form-layout"
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
@closed="onClosed"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid-2">
<el-form-item label="当前样本编码" class="form-col-span-2">
<el-input :model-value="oldSampleCode" disabled />
</el-form-item>
<el-form-item label="新样本编码" prop="newSampleCode" class="form-col-span-2">
<el-input
v-model="form.newSampleCode"
placeholder="请输入正确的样本编码"
clearable
maxlength="32"
/>
</el-form-item>
<el-form-item label="更正原因" prop="reason" class="form-col-span-2">
<el-input
v-model="form.reason"
type="textarea"
:rows="2"
placeholder="例如:客户填错编码"
maxlength="512"
show-word-limit
/>
</el-form-item>
</el-form>
<div v-if="preview" class="preview-block">
<div v-if="!preview.ok" class="preview-blockers">
<div v-for="(b, i) in preview.blockers" :key="i" class="blocker-line">{{ b }}</div>
</div>
<template v-else>
<div class="preview-summary">
预计影响 <strong>{{ preview.totalAffectedRows }}</strong> 行(含源表)
</div>
<el-table :data="preview.impacts" size="small" stripe class="preview-table" max-height="220">
<el-table-column prop="label" label="业务表" min-width="160" />
<el-table-column prop="table" label="表名" min-width="200" show-overflow-tooltip />
<el-table-column prop="count" label="行数" min-width="72" align="center" />
</el-table>
<ul class="preview-hints">
<li>实验室入库编码不会修改</li>
<li>细胞产品码不会自动修改(同名产品码:旧 {{ hintOld }} / 新 {{ hintNew }})</li>
</ul>
</template>
</div>
<template #footer>
<el-button @click="visible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button :loading="previewing" @click="handlePreview">
<el-icon><View /></el-icon>
预览影响
</el-button>
<el-button
type="primary"
:loading="applying"
:disabled="!preview?.ok"
@click="handleApply"
>
<el-icon><Check /></el-icon>
确认更正
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close, View } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
fetchApplySampleCodeCorrection,
fetchPreviewSampleCodeCorrection,
type SampleCodeCorrectionPreview,
type SampleCodeCorrectionSource
} from '@/api/sample-code-correction'
const props = defineProps<{
modelValue: boolean
receptionSource: SampleCodeCorrectionSource
oldSampleCode: string
}>()
const emit = defineEmits<{
'update:modelValue': [boolean]
success: []
}>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const formRef = ref<FormInstance>()
const form = reactive({
newSampleCode: '',
reason: ''
})
const rules: FormRules = {
newSampleCode: [{ required: true, message: '请输入新样本编码', trigger: 'blur' }],
reason: [{ required: true, message: '请填写更正原因', trigger: 'blur' }]
}
const preview = ref<SampleCodeCorrectionPreview | null>(null)
const previewing = ref(false)
const applying = ref(false)
const hintOld = computed(() => preview.value?.hints?.sameProductCodeAsOld ?? 0)
const hintNew = computed(() => preview.value?.hints?.sameProductCodeAsNew ?? 0)
watch(
() => props.modelValue,
(open) => {
if (open) {
form.newSampleCode = ''
form.reason = ''
preview.value = null
}
}
)
function onClosed() {
preview.value = null
}
async function handlePreview() {
const ok = await formRef.value?.validate().catch(() => false)
if (!ok) return
previewing.value = true
try {
const data = await fetchPreviewSampleCodeCorrection({
receptionSource: props.receptionSource,
oldSampleCode: props.oldSampleCode,
newSampleCode: form.newSampleCode.trim(),
reason: form.reason.trim()
})
preview.value = data
if (!data.ok) {
ElMessage.warning(data.blockers?.[0] || '无法更正')
}
} catch (e: any) {
ElMessage.error(e?.message || '预览失败')
} finally {
previewing.value = false
}
}
async function handleApply() {
if (!preview.value?.ok) {
ElMessage.warning('请先预览并通过校验')
return
}
try {
await ElMessageBox.confirm(
`确认将样本编码「${props.oldSampleCode}」更正为「${form.newSampleCode.trim()}」?将级联更新 ${preview.value.totalAffectedRows} 行相关数据。`,
'确认更正',
{ type: 'warning', confirmButtonText: '确认更正', cancelButtonText: '取消' }
)
} catch {
return
}
applying.value = true
try {
await fetchApplySampleCodeCorrection({
receptionSource: props.receptionSource,
oldSampleCode: props.oldSampleCode,
newSampleCode: form.newSampleCode.trim(),
reason: form.reason.trim()
})
ElMessage.success('样本编码已更正')
visible.value = false
emit('success')
} catch (e: any) {
ElMessage.error(e?.message || '更正失败')
} finally {
applying.value = false
}
}
</script>
<style scoped lang="scss">
.preview-block {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-lighter);
}
.preview-blockers {
color: var(--el-color-danger);
font-size: 13px;
line-height: 1.6;
}
.preview-summary {
margin-bottom: 8px;
font-size: 13px;
color: var(--mint-text, #334155);
}
.preview-hints {
margin: 8px 0 0;
padding-left: 18px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.6;
}
</style>
@@ -393,17 +393,24 @@
</el-table-column>
</template>
<el-table-column label="操作" min-width="280" fixed="right" align="center" header-align="center">
<el-table-column label="操作" min-width="360" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button
class="btn-op-item"
:disabled="!row.sampleCode"
@click="openCodeCorrection(row)"
>
<el-icon><EditPen /></el-icon>更正编码
</el-button>
<el-button
v-if="isCellStorage && supportsStorageCertQc(row.sampleTypeName)"
class="btn-op-item"
@click="openStorageCertQcView(row)"
@click="openStorageCertQcEdit(row)"
>
<el-icon><Document /></el-icon>查看储存证的质检信息
<el-icon><Document /></el-icon>填写储存证质检信息
</el-button>
</template>
</el-table-column>
@@ -447,10 +454,16 @@
/>
<StorageCertQcDialog
v-model:visible="storageCertQcVisible"
mode="view"
mode="edit"
:sample-code="storageCertQcSampleCode"
:sample-type-name="storageCertQcSampleTypeName"
/>
<SampleCodeCorrectionDialog
v-model="codeCorrectionVisible"
:reception-source="activeSource"
:old-sample-code="codeCorrectionOldCode"
@success="loadData"
/>
</div>
</template>
@@ -460,6 +473,7 @@
ChatDotRound,
Collection,
Document,
EditPen,
Files,
Location,
Phone,
@@ -480,6 +494,7 @@
import DonationViewDialog from '@/views/research-donation/list/DonationViewDialog.vue'
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import SampleInfoQuickRegisterDialog from './SampleInfoQuickRegisterDialog.vue'
import SampleCodeCorrectionDialog from './SampleCodeCorrectionDialog.vue'
import {
donationStatusDisplayLabel,
donationStatusTagType,
@@ -535,6 +550,8 @@
const storageCertQcVisible = ref(false)
const storageCertQcSampleCode = ref('')
const storageCertQcSampleTypeName = ref('')
const codeCorrectionVisible = ref(false)
const codeCorrectionOldCode = ref('')
function sexLabel(sex: unknown) {
const s = String(sex || '').trim()
@@ -543,16 +560,26 @@
return s || '—'
}
function openStorageCertQcView(row: Record<string, unknown>) {
function openStorageCertQcEdit(row: Record<string, unknown>) {
storageCertQcSampleCode.value = String(row.sampleCode || '')
storageCertQcSampleTypeName.value = String(row.sampleTypeName || '')
if (!storageCertQcSampleCode.value) {
ElMessage.warning('缺少样本编码,无法查看')
ElMessage.warning('缺少样本编码,无法填写')
return
}
storageCertQcVisible.value = true
}
function openCodeCorrection(row: Record<string, unknown>) {
const code = String(row.sampleCode || '').trim()
if (!code) {
ElMessage.warning('缺少样本编码,无法更正')
return
}
codeCorrectionOldCode.value = code
codeCorrectionVisible.value = true
}
function statusLabel(row: Record<string, unknown>) {
if (isCellStorage.value) {
return protocolStatusDisplayLabel(String(row.protocolStatus || ''), String(row.voidType || ''))
@@ -74,7 +74,12 @@
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.appName || row.title || '-' }}</template>
<template #default="{ row }">
<span class="mention-flow-name">
<span v-if="!row.isRead" class="mention-unread-dot" aria-hidden="true" />
{{ row.appName || row.title || '-' }}
</span>
</template>
</el-table-column>
<el-table-column
prop="initiatorUserName"
@@ -116,6 +121,13 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="阅读状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.isRead ? 'info' : 'danger'">
{{ row.isRead ? '已读' : '未读' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="260" fixed="right" align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="openDetail(row)">
@@ -156,9 +168,14 @@
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Printer, RefreshLeft, Search, View } from '@element-plus/icons-vue'
import { fetchOaInstanceDetail, fetchOaTasks } from '@/api/oaApproval'
import {
fetchMarkOaMentionRead,
fetchOaInstanceDetail,
fetchOaTasks
} from '@/api/oaApproval'
import { useUserStore } from '@/store/modules/user'
import { exportOaApprovalPdf } from '@/utils/oa-approval-print'
import { refreshOaMenuBadges } from '@/utils/oa-menu-badge'
defineOptions({ name: 'OaApprovalMention' })
@@ -211,9 +228,18 @@
return map[s] || s
}
function openDetail(row: any) {
const id = row.id
async function openDetail(row: any) {
const id = String(row?.id || '').trim()
if (!id) return
if (!row.isRead) {
try {
await fetchMarkOaMentionRead(id)
row.isRead = true
} catch {
/* 仍允许进入详情 */
}
void refreshOaMenuBadges()
}
router.push({ path: `/system-manage/oa-approval/instances/${id}` })
}
@@ -289,6 +315,29 @@
{ debounce: 300, deep: true }
)
onMounted(load)
onActivated(load)
onMounted(() => {
void load()
void refreshOaMenuBadges()
})
onActivated(() => {
void load()
void refreshOaMenuBadges()
})
</script>
<style scoped lang="scss">
.mention-flow-name {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
}
.mention-unread-dot {
flex-shrink: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--el-color-danger);
}
</style>
@@ -0,0 +1,405 @@
<!-- 细胞产品基础信息维护 · 包装 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="cell-product-package-tab">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="包装编码/名称" clearable />
</el-form-item>
</div>
<div class="filter-actions-row">
<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-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="packageCode"
label="包装编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="packageName"
label="包装名称"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown
trigger="hover"
@command="(cmd: string) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="package-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="包装编码" prop="packageCode">
<el-input v-model="formData.packageCode" placeholder="请输入包装编码" />
</el-form-item>
<el-form-item label="包装名称" prop="packageName">
<el-input v-model="formData.packageName" placeholder="请输入包装名称" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="formData.remark" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.packageName || '包装详情'"
:icon="Box"
:code="viewRow?.packageCode"
code-label="包装编码"
:sections="viewSections"
/>
</div>
</template>
<script setup lang="ts">
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Close,
Check,
View,
Edit,
Delete,
Box,
RefreshLeft,
Search,
More
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCellProductPackageList,
fetchCreateCellProductPackage,
fetchUpdateCellProductPackage,
fetchDeleteCellProductPackage
} from '@/api/admin'
defineOptions({ name: 'CellProductPackageTab' })
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<Api.CellProductPackage.CellProductPackageItem[]>([])
const selectedRows = ref<Api.CellProductPackage.CellProductPackageItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const dialogTitle = ref('新增包装')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
const viewVisible = ref(false)
const viewRow = ref<Api.CellProductPackage.CellProductPackageItem | null>(null)
const formData = reactive({
packageCode: '',
packageName: '',
remark: ''
})
const formRules = {
packageCode: [{ required: true, message: '请输入包装编码', trigger: 'blur' }],
packageName: [{ required: true, message: '请输入包装名称', trigger: 'blur' }]
}
const handleSelectionChange = (rows: Api.CellProductPackage.CellProductPackageItem[]) => {
selectedRows.value = rows
}
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '包装编码', value: row.packageCode },
{ label: '包装名称', value: row.packageName },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
async function loadData() {
loading.value = true
try {
const res = await fetchCellProductPackageList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
pagination.page = 1
loadData()
}
function resetForm() {
formData.packageCode = ''
formData.packageName = ''
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增包装'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleEdit(row: Api.CellProductPackage.CellProductPackageItem) {
isEdit.value = true
dialogTitle.value = '编辑包装'
editingId.value = row.id
formData.packageCode = row.packageCode || ''
formData.packageName = row.packageName || ''
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
packageCode: formData.packageCode.trim(),
packageName: formData.packageName.trim(),
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateCellProductPackage(editingId.value, payload)
} else {
await fetchCreateCellProductPackage(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
function handleView(row: Api.CellProductPackage.CellProductPackageItem) {
viewRow.value = row
viewVisible.value = true
}
function handleDropdownCommand(cmd: string, row: Api.CellProductPackage.CellProductPackageItem) {
if (cmd === 'edit') handleEdit(row)
else if (cmd === 'delete') handleDelete(row)
}
async function handleDelete(row: Api.CellProductPackage.CellProductPackageItem) {
try {
await ElMessageBox.confirm('确定要删除该包装吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteCellProductPackage(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个包装吗?`, '提示', {
type: 'warning'
})
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteCellProductPackage(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
defineExpose({ handleAdd, loadData })
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.package-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -1,214 +1,165 @@
<!-- 细胞产品基础信息维护 · 价格体系 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div :class="layout === 'kanban' ? 'kanban-col-body' : 'cell-product-price-tab'">
<template v-if="layout === 'kanban'">
<div class="kanban-col-search">
<el-input v-model="searchForm.keyword" placeholder="项目名称/编码" clearable />
</div>
<div class="kanban-list" v-loading="loading">
<article
v-for="row in tableData"
:key="row.id"
class="entity-card"
@dblclick="handleView(row)"
<div class="cell-product-price-tab">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="项目名称/编码" clearable />
</el-form-item>
</div>
<div class="filter-actions-row">
<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"
>
<div class="entity-card-del" @click.stop>
<el-button class="btn-op-item" title="删除" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div class="entity-card-body">
<h3 class="entity-card-title">{{ row.projectName }}</h3>
<div class="entity-card-meta">{{ row.projectCode }}</div>
<div v-if="row.cellTypeName || row.specText" class="entity-card-tags">
<span v-if="row.cellTypeName" class="tag success">{{ row.cellTypeName }}</span>
<span v-if="row.specText" class="tag muted">{{ row.specText }}</span>
</div>
<div class="entity-card-ft" @click.stop>
<span
class="entity-card-ft-val"
:class="{ 'entity-card-ft-val--muted': row.unitPrice == null }"
>
{{ row.unitPrice != null ? `${formatMoneyDisplay(row.unitPrice)} 元` : '未定价' }}
</span>
<div class="entity-card-ft-actions">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
</div>
</div>
</div>
</article>
<div v-if="!loading && !tableData.length" class="kanban-empty">
<span>暂无价格数据</span>
<span class="kanban-empty__hint">点击列头「新增」添加产品价格</span>
</div>
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</template>
</div>
<template v-else>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="项目名称/编码" clearable />
</el-form-item>
</div>
<div class="filter-actions-row">
<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-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="projectName"
label="项目名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="projectCode"
label="项目编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="cellTypeName"
label="细胞类型名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="specText"
label="规格"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="unitPrice"
label="单价"
min-width="100"
align="center"
header-align="center"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="projectName"
label="项目名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="projectCode"
label="项目编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="cellTypeName"
label="细胞类型名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="specText"
label="规格"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="unitPrice"
label="单价"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ row.unitPrice != null ? `${formatMoneyDisplay(row.unitPrice)} 元` : '-' }}
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center">
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
<template #default="{ row }">
{{ row.unitPrice != null ? `${formatMoneyDisplay(row.unitPrice)} 元` : '-' }}
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown trigger="hover" @command="(cmd) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<el-dropdown trigger="hover" @command="(cmd) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</template>
</div>
<el-dialog
v-model="dialogVisible"
@@ -343,11 +294,6 @@
defineOptions({ name: 'CellProductPriceTab' })
const props = withDefaults(defineProps<{ layout?: 'table' | 'kanban' }>(), { layout: 'table' })
const emit = defineEmits<{ (e: 'meta-change', payload: { total: number }): void }>()
const KANBAN_PAGE_SIZE = 100
// --- Search ---
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
@@ -484,7 +430,6 @@
})
tableData.value = (res as any)?.list || []
pagination.total = (res as any)?.total ?? 0
emit('meta-change', { total: pagination.total })
} finally {
loading.value = false
}
@@ -652,35 +597,11 @@
defineExpose({ handleAdd, loadData })
onMounted(() => {
if (props.layout === 'kanban') {
pagination.pageSize = KANBAN_PAGE_SIZE
}
loadData()
})
</script>
<style scoped lang="scss">
.kanban-col-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.kanban-col-search {
padding: 8px 10px 0;
flex-shrink: 0;
}
.kanban-list {
padding: 10px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
@import './cell-product-kanban.scss';
.price-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
@@ -1,185 +1,143 @@
<!-- 细胞产品基础信息维护 · 规格 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div :class="layout === 'kanban' ? 'kanban-col-body' : 'cell-product-spec-tab'">
<template v-if="layout === 'kanban'">
<div class="kanban-col-search">
<el-input v-model="searchForm.keyword" placeholder="规格编码/名称" clearable />
</div>
<div class="kanban-list" v-loading="loading">
<article
v-for="row in tableData"
:key="row.id"
class="entity-card"
@dblclick="handleView(row)"
<div class="cell-product-spec-tab">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="规格编码/名称" clearable />
</el-form-item>
</div>
<div class="filter-actions-row">
<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"
>
<div class="entity-card-del" @click.stop>
<el-button class="btn-op-item" title="删除" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div class="entity-card-body">
<h3 class="entity-card-title">{{ row.specName }}</h3>
<div class="entity-card-meta">{{ row.specCode }}</div>
<div v-if="row.remark" class="entity-card-tags">
<span class="tag muted">{{ row.remark }}</span>
</div>
<div class="entity-card-ft" @click.stop>
<div class="entity-card-ft-actions">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
</div>
</div>
</div>
</article>
<div v-if="!loading && !tableData.length" class="kanban-empty">
<span>暂无规格数据</span>
<span class="kanban-empty__hint">点击列头「新增」添加产品规格</span>
</div>
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</template>
</div>
<template v-else>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="规格编码/名称" clearable />
</el-form-item>
</div>
<div class="filter-actions-row">
<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-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="specCode"
label="规格编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="specName"
label="规格名称"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="specCode"
label="规格编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="specName"
label="规格名称"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center">
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown
trigger="hover"
@command="(cmd: string) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<el-dropdown
trigger="hover"
@command="(cmd: string) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</template>
</div>
<el-dialog
v-model="dialogVisible"
@@ -263,11 +221,6 @@
defineOptions({ name: 'CellProductSpecTab' })
const props = withDefaults(defineProps<{ layout?: 'table' | 'kanban' }>(), { layout: 'table' })
const emit = defineEmits<{ (e: 'meta-change', payload: { total: number }): void }>()
const KANBAN_PAGE_SIZE = 100
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<Api.CellProductSpec.CellProductSpecItem[]>([])
const selectedRows = ref<Api.CellProductSpec.CellProductSpecItem[]>([])
@@ -322,7 +275,6 @@
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
emit('meta-change', { total: pagination.total })
} finally {
loading.value = false
}
@@ -440,35 +392,11 @@
defineExpose({ handleAdd, loadData })
onMounted(() => {
if (props.layout === 'kanban') {
pagination.pageSize = KANBAN_PAGE_SIZE
}
loadData()
})
</script>
<style scoped lang="scss">
.kanban-col-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.kanban-col-search {
padding: 8px 10px 0;
flex-shrink: 0;
}
.kanban-list {
padding: 10px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
@import './cell-product-kanban.scss';
.spec-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
@@ -0,0 +1,410 @@
<!-- 细胞产品基础信息维护 · 类型 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="cell-product-type-tab">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="类型编码/名称" clearable />
</el-form-item>
</div>
<div class="filter-actions-row">
<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-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="productTypeCode"
label="类型编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="productTypeName"
label="类型名称"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown
trigger="hover"
@command="(cmd: string) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="type-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="类型编码" prop="productTypeCode">
<el-input v-model="formData.productTypeCode" placeholder="请输入类型编码" />
</el-form-item>
<el-form-item label="类型名称" prop="productTypeName">
<el-input v-model="formData.productTypeName" placeholder="请输入类型名称" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="formData.remark" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.productTypeName || '类型详情'"
:icon="Grid"
:code="viewRow?.productTypeCode"
code-label="类型编码"
:sections="viewSections"
/>
</div>
</template>
<script setup lang="ts">
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Close,
Check,
View,
Edit,
Delete,
Grid,
RefreshLeft,
Search,
More
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCellProductTypeList,
fetchCreateCellProductType,
fetchUpdateCellProductType,
fetchDeleteCellProductType
} from '@/api/admin'
defineOptions({ name: 'CellProductTypeTab' })
const emit = defineEmits<{ (e: 'changed'): void }>()
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<Api.CellProductType.CellProductTypeItem[]>([])
const selectedRows = ref<Api.CellProductType.CellProductTypeItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const dialogTitle = ref('新增类型')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
const viewVisible = ref(false)
const viewRow = ref<Api.CellProductType.CellProductTypeItem | null>(null)
const formData = reactive({
productTypeCode: '',
productTypeName: '',
remark: ''
})
const formRules = {
productTypeCode: [{ required: true, message: '请输入类型编码', trigger: 'blur' }],
productTypeName: [{ required: true, message: '请输入类型名称', trigger: 'blur' }]
}
const handleSelectionChange = (rows: Api.CellProductType.CellProductTypeItem[]) => {
selectedRows.value = rows
}
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '类型编码', value: row.productTypeCode },
{ label: '类型名称', value: row.productTypeName },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
async function loadData() {
loading.value = true
try {
const res = await fetchCellProductTypeList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
pagination.page = 1
loadData()
}
function resetForm() {
formData.productTypeCode = ''
formData.productTypeName = ''
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增类型'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleEdit(row: Api.CellProductType.CellProductTypeItem) {
isEdit.value = true
dialogTitle.value = '编辑类型'
editingId.value = row.id
formData.productTypeCode = row.productTypeCode || ''
formData.productTypeName = row.productTypeName || ''
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
productTypeCode: formData.productTypeCode.trim(),
productTypeName: formData.productTypeName.trim(),
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateCellProductType(editingId.value, payload)
} else {
await fetchCreateCellProductType(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
emit('changed')
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
function handleView(row: Api.CellProductType.CellProductTypeItem) {
viewRow.value = row
viewVisible.value = true
}
function handleDropdownCommand(cmd: string, row: Api.CellProductType.CellProductTypeItem) {
if (cmd === 'edit') handleEdit(row)
else if (cmd === 'delete') handleDelete(row)
}
async function handleDelete(row: Api.CellProductType.CellProductTypeItem) {
try {
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteCellProductType(row.id)
ElMessage.success('删除成功')
await loadData()
emit('changed')
}, fullscreenLoadingText.delete)
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个类型吗?`, '提示', {
type: 'warning'
})
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteCellProductType(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
emit('changed')
}, fullscreenLoadingText.batchDelete)
}
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
defineExpose({ handleAdd, loadData })
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.type-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -1,516 +1,80 @@
<!-- 细胞产品基础信息维护 · 看板模式 P04 -->
<!-- 细胞产品基础信息维护 · 多 Tab 列表 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container cell-product-kanban-page">
<div class="page-container">
<section class="panel">
<section class="cell-product-stats">
<div class="stat-card">
<div class="stat-icon">
<img src="/money.png" alt="" />
</div>
<div class="stat-main">
<div class="stat-k">产品价格</div>
<div class="stat-v">{{ priceTotal }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<img src="/lx.png" alt="" />
</div>
<div class="stat-main">
<div class="stat-k">产品类型</div>
<div class="stat-v">{{ typeTotal }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<img src="/gg.png" alt="" />
</div>
<div class="stat-main">
<div class="stat-k">产品规格</div>
<div class="stat-v">{{ specTotal }}</div>
</div>
</div>
</section>
<div class="kanban">
<!-- 列 1:产品价格 -->
<div class="kanban-col">
<div class="kanban-col-hd">
<span class="kanban-col-title">
细胞产品价格
<span class="kanban-count">{{ priceTotal }}</span>
</span>
<el-button
class="btn-main kanban-add-btn"
type="primary"
@click="priceTabRef?.handleAdd()"
>
<el-icon><Plus /></el-icon>
新增
</el-button>
</div>
<CellProductPriceTab ref="priceTabRef" layout="kanban" @meta-change="onPriceMetaChange" />
</div>
<!-- 列 2:产品类型 -->
<div class="kanban-col">
<div class="kanban-col-hd">
<span class="kanban-col-title">
细胞产品类型
<span class="kanban-count">{{ typeTotal }}</span>
</span>
<el-button class="btn-main kanban-add-btn" type="primary" @click="handleTypeAdd">
<el-icon><Plus /></el-icon>
新增
</el-button>
</div>
<div class="kanban-col-search">
<el-input
v-model="typeSearchForm.keyword"
placeholder="类型编码/名称"
clearable
@keyup.enter="handleTypeSearch"
/>
</div>
<div class="kanban-list" v-loading="typeLoading">
<article
v-for="row in typeTableData"
:key="row.id"
class="entity-card"
@dblclick="handleTypeView(row)"
>
<div class="entity-card-del" @click.stop>
<el-button class="btn-op-item" title="删除" @click="handleTypeDelete(row)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div class="entity-card-body">
<h3 class="entity-card-title">{{ row.productTypeName }}</h3>
<div class="entity-card-meta">{{ row.productTypeCode }}</div>
<div v-if="row.remark" class="entity-card-tags">
<span class="tag muted">{{ row.remark }}</span>
</div>
<div class="entity-card-ft" @click.stop>
<div class="entity-card-ft-actions">
<el-button class="btn-op-item" @click="handleTypeView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="handleTypeEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
</div>
</div>
</div>
</article>
<div v-if="!typeLoading && !typeTableData.length" class="kanban-empty">
<span>暂无类型数据</span>
<span class="kanban-empty__hint">点击右上角「新增」添加产品类型</span>
</div>
</div>
</div>
<!-- 列 3:产品规格 -->
<div class="kanban-col">
<div class="kanban-col-hd">
<span class="kanban-col-title">
细胞产品规格
<span class="kanban-count">{{ specTotal }}</span>
</span>
<el-button
class="btn-main kanban-add-btn"
type="primary"
@click="specTabRef?.handleAdd()"
>
<el-icon><Plus /></el-icon>
新增
</el-button>
</div>
<CellProductSpecTab ref="specTabRef" layout="kanban" @meta-change="onSpecMetaChange" />
</div>
</div>
<!-- 类型弹窗 -->
<el-dialog
v-model="typeDialogVisible"
:title="typeDialogTitle"
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="typeFormRef"
:model="typeFormData"
:rules="typeFormRules"
label-position="top"
class="type-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="类型编码" prop="productTypeCode">
<el-input v-model="typeFormData.productTypeCode" placeholder="请输入类型编码" />
</el-form-item>
<el-form-item label="类型名称" prop="productTypeName">
<el-input v-model="typeFormData.productTypeName" placeholder="请输入类型名称" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="typeFormData.remark" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="typeDialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
@click="handleTypeSubmit"
:loading="typeSubmitLoading"
>
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="typeViewVisible"
:title="typeViewRow?.productTypeName || '类型详情'"
:icon="Grid"
:code="typeViewRow?.productTypeCode"
code-label="类型编码"
:sections="typeViewSections"
/>
<el-tabs v-model="activeTab" class="panel-pill-tabs panel-pill-tabs--lg">
<el-tab-pane label="细胞产品价格" name="price">
<CellProductPriceTab ref="priceTabRef" />
</el-tab-pane>
<el-tab-pane label="细胞产品类型" name="type" lazy>
<CellProductTypeTab @changed="onTypeChanged" />
</el-tab-pane>
<el-tab-pane label="细胞产品规格" name="spec" lazy>
<CellProductSpecTab />
</el-tab-pane>
<el-tab-pane label="产品包装" name="package" lazy>
<CellProductPackageTab />
</el-tab-pane>
</el-tabs>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check, View, Edit, Delete, Grid } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCellProductTypeList,
fetchCreateCellProductType,
fetchUpdateCellProductType,
fetchDeleteCellProductType
} from '@/api/admin'
import CellProductSpecTab from './CellProductSpecTab.vue'
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import CellProductPriceTab from './CellProductPriceTab.vue'
import CellProductTypeTab from './CellProductTypeTab.vue'
import CellProductSpecTab from './CellProductSpecTab.vue'
import CellProductPackageTab from './CellProductPackageTab.vue'
defineOptions({ name: 'PilotCellProductType' })
const KANBAN_PAGE_SIZE = 100
const TAB_KEYS = ['price', 'type', 'spec', 'package'] as const
type TabKey = (typeof TAB_KEYS)[number]
const specTabRef = ref<InstanceType<typeof CellProductSpecTab> | null>(null)
const route = useRoute()
const router = useRouter()
function normalizeTab(raw: unknown): TabKey {
const v = Array.isArray(raw) ? raw[0] : raw
if (v === 'type' || v === 'spec' || v === 'price' || v === 'package') return v
return 'price'
}
const activeTab = ref<TabKey>(normalizeTab(route.query.tab))
const priceTabRef = ref<InstanceType<typeof CellProductPriceTab> | null>(null)
const specTotal = ref(0)
const priceTotal = ref(0)
function syncTabToQuery(tab: TabKey) {
if (route.query.tab === tab) return
router.replace({
query: {
...route.query,
tab
}
})
}
const typeSearchForm = reactive<{ keyword: string }>({ keyword: '' })
const typeTableData = ref<Api.CellProductType.CellProductTypeItem[]>([])
const typeLoading = ref(false)
const typeTotal = ref(0)
const typeDialogVisible = ref(false)
const typeDialogTitle = ref('新增类型')
const typeIsEdit = ref(false)
const typeSubmitLoading = ref(false)
const typeFormRef = ref()
const typeEditingId = ref<string | null>(null)
const typeViewVisible = ref(false)
const typeViewRow = ref<Api.CellProductType.CellProductTypeItem | null>(null)
function onTypeChanged() {
priceTabRef.value?.loadData?.()
}
const typeFormData = reactive({
productTypeCode: '',
productTypeName: '',
remark: ''
watch(activeTab, (tab) => {
syncTabToQuery(tab)
})
const typeFormRules = {
productTypeCode: [{ required: true, message: '请输入类型编码', trigger: 'blur' }],
productTypeName: [{ required: true, message: '请输入类型名称', trigger: 'blur' }]
}
const typeViewSections = computed(() => {
if (!typeViewRow.value) return []
const row = typeViewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '类型编码', value: row.productTypeCode },
{ label: '类型名称', value: row.productTypeName },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
function onSpecMetaChange(payload: { total: number }) {
specTotal.value = payload.total
}
function onPriceMetaChange(payload: { total: number }) {
priceTotal.value = payload.total
}
async function loadTypeData() {
typeLoading.value = true
try {
const res = await fetchCellProductTypeList({
page: 1,
pageSize: KANBAN_PAGE_SIZE,
keyword: typeSearchForm.keyword || undefined
})
typeTableData.value = res.list || []
typeTotal.value = res.total ?? 0
} finally {
typeLoading.value = false
watch(
() => route.query.tab,
(tab) => {
const next = normalizeTab(tab)
if (activeTab.value !== next) activeTab.value = next
}
}
function handleTypeSearch() {
loadTypeData()
}
function resetTypeForm() {
typeFormData.productTypeCode = ''
typeFormData.productTypeName = ''
typeFormData.remark = ''
typeEditingId.value = null
}
function handleTypeAdd() {
typeIsEdit.value = false
typeDialogTitle.value = '新增类型'
resetTypeForm()
typeDialogVisible.value = true
typeFormRef.value?.clearValidate?.()
}
function handleTypeEdit(row: Api.CellProductType.CellProductTypeItem) {
typeIsEdit.value = true
typeDialogTitle.value = '编辑类型'
typeEditingId.value = row.id
typeFormData.productTypeCode = row.productTypeCode || ''
typeFormData.productTypeName = row.productTypeName || ''
typeFormData.remark = row.remark || ''
typeDialogVisible.value = true
typeFormRef.value?.clearValidate?.()
}
async function handleTypeSubmit() {
await typeFormRef.value?.validate?.()
typeSubmitLoading.value = true
try {
const payload = {
productTypeCode: typeFormData.productTypeCode.trim(),
productTypeName: typeFormData.productTypeName.trim(),
remark: typeFormData.remark?.trim() || undefined
}
if (typeIsEdit.value && typeEditingId.value) {
await fetchUpdateCellProductType(typeEditingId.value, payload)
} else {
await fetchCreateCellProductType(payload)
}
ElMessage.success(typeIsEdit.value ? '更新成功' : '创建成功')
typeDialogVisible.value = false
await loadTypeData()
priceTabRef.value?.loadData?.()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
typeSubmitLoading.value = false
}
}
function handleTypeView(row: Api.CellProductType.CellProductTypeItem) {
typeViewRow.value = row
typeViewVisible.value = true
}
async function handleTypeDelete(row: Api.CellProductType.CellProductTypeItem) {
try {
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteCellProductType(row.id)
ElMessage.success('删除成功')
await loadTypeData()
priceTabRef.value?.loadData?.()
}, fullscreenLoadingText.delete)
}
watchDebounced(
() => typeSearchForm.keyword,
() => {
loadTypeData()
},
{ debounce: 300 }
)
onMounted(() => {
loadTypeData()
const next = normalizeTab(route.query.tab)
activeTab.value = next
syncTabToQuery(next)
})
</script>
<style scoped lang="scss">
.cell-product-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 16px 16px 0;
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
min-height: 72px;
padding: 12px 16px;
border: 1px solid #d1fae5;
border-radius: 10px;
background: linear-gradient(180deg, var(--primary-bg, #ecfdf5) 0%, #fff 100%);
}
.stat-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
img {
width: 24px;
height: 24px;
object-fit: contain;
display: block;
}
}
.stat-main {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.stat-k {
font-size: 14px;
font-weight: 600;
color: var(--mint-text-muted, #334155);
}
.stat-v {
font-size: 22px;
font-weight: 700;
color: var(--primary-dark, #059669);
line-height: 1.1;
}
.kanban {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 16px;
align-items: stretch;
}
.kanban-col {
min-width: 0;
background: var(--table-head, #f8fafc);
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: 10px;
display: flex;
flex-direction: column;
min-height: calc(100vh - 280px);
max-height: calc(100vh - 200px);
}
.kanban-col-hd {
padding: 10px 12px;
border-bottom: 1px solid var(--mint-border, #d9e8e2);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
}
.kanban-col-title {
font-size: 13px;
font-weight: 600;
color: var(--mint-text-muted, #334155);
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.kanban-count {
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface, #fff);
color: var(--mint-text-muted, #64748b);
border: 1px solid var(--mint-border, #d9e8e2);
}
.kanban-add-btn {
flex-shrink: 0;
height: 32px;
padding: 0 10px;
font-size: 13px;
}
.kanban-col-search {
padding: 8px 10px 0;
flex-shrink: 0;
}
.kanban-list {
padding: 10px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
@import './cell-product-kanban.scss';
.type-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
@media (max-width: 1100px) {
.kanban {
grid-template-columns: 1fr;
}
.kanban-col {
min-height: 320px;
max-height: 420px;
}
.cell-product-stats {
grid-template-columns: 1fr;
}
}
</style>
@@ -528,23 +528,29 @@
</p>
</div>
</div>
<div v-if="!isEdit" class="cell-product-toolbar">
<div class="cell-product-toolbar">
<div class="section-subtitle">细胞产品</div>
<el-button type="primary" :disabled="!canAddCellProduct" @click="addSaleProductLine">
<el-button
v-if="!isEdit"
type="primary"
:disabled="!canAddCellProduct"
@click="addSaleProductLine"
>
<el-icon><Plus /></el-icon>
添加产品
</el-button>
</div>
<el-table
v-if="!isEdit"
:data="saleProductLines"
stripe
class="cell-product-table"
style="width: 100%"
empty-text="暂无交付计划"
>
<el-table-column label="细胞产品" min-width="180">
<template #default="{ row, $index }">
<el-select
v-if="!isEdit"
v-model="row.priceId"
filterable
clearable
@@ -559,28 +565,55 @@
:value="p.priceId"
/>
</el-select>
<span v-else>{{
formatSaleProductDisplay(row) || '—'
}}</span>
</template>
</el-table-column>
<el-table-column label="规格" min-width="100">
<template #default="{ row }">{{ row.specText || '—' }}</template>
</el-table-column>
<el-table-column label="产品包装" min-width="140">
<template #default="{ row }">
<el-select
v-if="!isEdit"
v-model="row.packageId"
filterable
clearable
placeholder="选择包装"
class="cell-product-table__control"
@change="onSalePackageSelect(row, $event)"
>
<el-option
v-for="pkg in packageOptions"
:key="pkg.id"
:label="pkg.packageName"
:value="pkg.id"
/>
</el-select>
<span v-else>{{ row.packageText || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="参考单价(元)" min-width="120" align="right">
<template #default="{ row }">{{ formatMoneyDisplay(row.agreementPrice, 2) }}</template>
</el-table-column>
<el-table-column label="实际单价(元)" min-width="130">
<template #default="{ row }">
<DecimalInput
v-if="!isEdit"
v-model="row.actualPrice"
:min="0"
:precision="2"
style="width: 100%"
@change="syncSaleLineAmount(row)"
/>
<span v-else>{{ formatMoneyDisplay(row.actualPrice, 2) }}</span>
</template>
</el-table-column>
<el-table-column label="份数" min-width="90">
<template #default="{ row }">
<el-input-number
v-if="!isEdit"
v-model="row.quantity"
:min="1"
:max="9999"
@@ -588,30 +621,35 @@
style="width: 100%"
@change="syncSaleLineAmount(row)"
/>
<span v-else>{{ row.quantity ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column :label="bizConfig.pickupTimeLabel" min-width="180">
<template #default="{ row }">
<el-date-picker
v-if="!isEdit"
v-model="row.pickupTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择交付时间"
style="width: 100%"
/>
<span v-else>{{ row.pickupTime || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="金额(元)" min-width="120">
<template #default="{ row }">
<DecimalInput
v-if="!isEdit"
v-model="row.amount"
:min="0"
:precision="2"
style="width: 100%"
/>
<span v-else>{{ formatMoneyDisplay(row.amount, 2) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" align="center">
<el-table-column v-if="!isEdit" label="操作" min-width="80" align="center">
<template #default="{ $index }">
<el-button link type="danger" @click="removeSaleProductLine($index)">
<el-icon><Delete /></el-icon>
@@ -1195,6 +1233,7 @@
fetchChannelAgreements,
fetchChannelAgreementPrices,
fetchCellProductPriceList,
fetchCellProductPackageList,
fetchDictDataOptions,
fetchCustomerList,
fetchCreateCustomer
@@ -1290,6 +1329,8 @@
projectName: string
cellTypeName: string
specText: string
packageId: string
packageText: string
agreementPrice: number
actualPrice: number
quantity: number
@@ -1299,6 +1340,7 @@
let saleLineSeq = 0
const saleProductLines = ref<SaleProductLine[]>([])
const packageOptions = ref<Api.CellProductPackage.CellProductPackageItem[]>([])
const formRules = reactive({
applicationName: [{ required: true, message: '请输入申请单名称', trigger: 'blur' }],
@@ -1538,6 +1580,21 @@
return !!selectedServiceOrder.value && agreementPriceOptions.value.length > 0
})
const loadPackageOptions = async () => {
try {
const res: any = await fetchCellProductPackageList({ page: 1, pageSize: 100 })
packageOptions.value = res?.list || []
} catch {
packageOptions.value = []
}
}
const onSalePackageSelect = (line: SaleProductLine, packageId: string) => {
const pkg = packageOptions.value.find((p) => p.id === packageId)
line.packageId = packageId || ''
line.packageText = pkg?.packageName || ''
}
const addSaleProductLine = () => {
saleProductLines.value.push({
_localId: ++saleLineSeq,
@@ -1546,6 +1603,8 @@
projectName: '',
cellTypeName: '',
specText: '',
packageId: '',
packageText: '',
agreementPrice: 0,
actualPrice: 0,
quantity: 1,
@@ -2677,6 +2736,13 @@
return `${name}(${cellType}、${spec}、¥${price})`
}
const formatSaleProductDisplay = (row: SaleProductLine) => {
const name = row.projectName || row.projectCode || ''
const cellType = row.cellTypeName || ''
if (name && cellType) return `${name}(${cellType})`
return name || cellType
}
// ── 弹窗打开 ───────────────────────────────────────────────────────────
const handleOpen = () => {
resetForm()
@@ -2686,6 +2752,9 @@
if (isClinical.value || isBasicExperiment.value) {
loadClinicalCellProductPrices()
}
if (isBasicExperiment.value) {
loadPackageOptions()
}
if (isBasicExperiment.value && !isEdit.value) {
addSaleProductLine()
}
@@ -2878,6 +2947,8 @@
projectName: cp.projectName || '',
cellTypeName: cp.cellTypeName || s.cellType || '',
specText: cp.specText || s.specText || '',
packageId: cp.packageId || s.packageId || '',
packageText: cp.packageText || s.packageText || '',
agreementPrice: cp.agreementPrice ?? 0,
actualPrice: cp.actualPrice ?? 0,
quantity: cp.quantity || 1,
@@ -2922,6 +2993,10 @@
ElMessage.warning(`第 ${i + 1} 行请选择细胞产品`)
return
}
if (!line.packageId) {
ElMessage.warning(`第 ${i + 1} 行请选择产品包装`)
return
}
if (!line.pickupTime) {
ElMessage.warning(`第 ${i + 1} 行请填写提货时间`)
return
@@ -3030,6 +3105,8 @@
projectName: line.projectName,
cellTypeName: line.cellTypeName,
specText: line.specText,
packageId: line.packageId,
packageText: line.packageText,
agreementPrice: line.agreementPrice,
actualPrice: line.actualPrice,
quantity: line.quantity || 1,
@@ -234,7 +234,10 @@
: '质检工单派发成功'
)
emit('success', { sampleCode: props.workOrderRow.sampleCode })
handleClose()
resetForm()
if (productionTasks.value.length === 1) {
form.taskId = productionTasks.value[0].taskId
}
} finally {
submitting.value = false
}
@@ -72,16 +72,16 @@
>
<el-icon><View /></el-icon>查看
</el-button>
<el-button
v-if="canSaveTaskSet(row)"
class="btn-op-item"
@click="emit('command', 'save-set', row)"
>
<el-icon><FolderAdd /></el-icon>保存为模版
</el-button>
<el-button class="btn-op-item" @click="emit('view-qc', row)">
<el-icon><DataLine /></el-icon>查看质检数据
</el-button>
<el-button
v-if="showStorageCertQcButton(row)"
class="btn-op-item"
@click="openStorageCertQcDialog(row)"
>
<el-icon><Document /></el-icon>填写储存证质检信息
</el-button>
<el-tooltip
v-if="!isMineMode && rowCardMeta(row).allowDispatchQc"
:content="dispatchQcTooltip(row)"
@@ -112,32 +112,42 @@
>
<el-icon><Check /></el-icon>完成工单
</el-button>
<el-button
v-if="showCardComplete(row) && canVoidWorkOrder(row)"
class="btn-op-item"
type="danger"
plain
@click="emit('void', row)"
>
<el-icon><Close /></el-icon>作废工单
</el-button>
<el-dropdown
v-if="!isMineMode && hasMoreActions(row)"
v-if="hasMoreActions(row)"
trigger="hover"
@command="(cmd) => emit('command', cmd, row)"
@command="(cmd) => handleMoreCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="canDispatchTask(row)" command="view">
<el-dropdown-item
v-if="!isMineMode && canDispatchTask(row)"
command="view"
>
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item v-if="canEditWorkOrder(row)" command="edit">
<el-dropdown-item
v-if="!isMineMode && canEditWorkOrder(row)"
command="edit"
>
<el-icon><EditPen /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item v-if="canDeleteWorkOrder(row)" command="delete" divided>
<el-dropdown-item v-if="canSaveTaskSet(row)" command="save-set">
<el-icon><FolderAdd /></el-icon>保存为模版
</el-dropdown-item>
<el-dropdown-item
v-if="showCardComplete(row) && canVoidWorkOrder(row)"
command="void"
>
<el-icon><Close /></el-icon>作废工单
</el-dropdown-item>
<el-dropdown-item
v-if="!isMineMode && canDeleteWorkOrder(row)"
command="delete"
divided
>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
@@ -201,6 +211,10 @@
<dt>规格</dt>
<dd>{{ product.specText || row.specText || '—' }}</dd>
</div>
<div class="production-work-order-card__cell-field">
<dt>产品包装</dt>
<dd>{{ product.packageText || row.packageText || '—' }}</dd>
</div>
<div class="production-work-order-card__cell-field">
<dt>数量</dt>
<dd>{{ product.quantity != null ? product.quantity : '—' }}</dd>
@@ -222,10 +236,12 @@
</div>
</div>
<p v-else class="production-work-order-card__cells-fallback">
<template v-if="row.cellType || row.specText">
<template v-if="row.cellType || row.specText || row.packageText">
<span v-if="row.cellType">细胞类型:{{ row.cellType }}</span>
<span v-if="row.cellType && row.specText"> · </span>
<span v-if="row.specText">规格:{{ row.specText }}</span>
<span v-if="(row.cellType || row.specText) && row.packageText"> · </span>
<span v-if="row.packageText">包装:{{ row.packageText }}</span>
</template>
<template v-else>暂无细胞产品明细</template>
</p>
@@ -297,6 +313,12 @@
@current-change="(v) => emit('page-change', v)"
/>
</div>
<StorageCertQcDialog
v-model:visible="storageCertQcVisible"
mode="edit"
:sample-code="storageCertQcSampleCode"
:sample-type-name="storageCertQcSampleTypeName"
/>
</div>
</template>
@@ -320,6 +342,8 @@
import type { OutboundQcDetailProduct } from '@/api/qc-work-order'
import type { ProductionWorkOrderCardMetaConfig } from '@/utils/production-work-order-card-meta'
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import StorageCertQcDialog from '@/components/business/storage-cert-qc-dialog/index.vue'
import { supportsStorageCertQc } from '@/utils/storage-cert-qc-fields'
import {
getCardMetaConfigForLegacyTab,
resolveCardMetaForRow
@@ -384,6 +408,26 @@
}
const expandedFeeIds = ref<Set<string>>(new Set())
const storageCertQcVisible = ref(false)
const storageCertQcSampleCode = ref('')
const storageCertQcSampleTypeName = ref('')
function showStorageCertQcButton(row: ProductionWorkOrderItem) {
const tabKey = resolveEffectiveRowTabKey(listTabKey.value, row)
if (tabKey !== 'inbound_cell_storage') return false
return supportsStorageCertQc(String(row.sampleTypeName || ''))
}
function openStorageCertQcDialog(row: ProductionWorkOrderItem) {
const code = String(row.sampleCode || '').trim()
if (!code) {
ElMessage.warning('缺少样本编码,无法填写')
return
}
storageCertQcSampleCode.value = code
storageCertQcSampleTypeName.value = String(row.sampleTypeName || '')
storageCertQcVisible.value = true
}
function isFeeExpanded(id: string) {
return expandedFeeIds.value.has(id)
@@ -573,7 +617,25 @@
}
function hasMoreActions(row: ProductionWorkOrderItem) {
return canDispatchTask(row) || canEditWorkOrder(row) || canDeleteWorkOrder(row) || true
if (isMineMode.value) {
return showCardComplete(row) && canVoidWorkOrder(row)
}
return (
canDispatchTask(row) ||
canEditWorkOrder(row) ||
canSaveTaskSet(row) ||
(showCardComplete(row) && canVoidWorkOrder(row)) ||
canDeleteWorkOrder(row) ||
true
)
}
function handleMoreCommand(cmd: string, row: ProductionWorkOrderItem) {
if (cmd === 'void') {
emit('void', row)
return
}
emit('command', cmd, row)
}
</script>
@@ -186,7 +186,7 @@
<!-- 搜索框 -->
<el-input
v-model="searchKeyword"
placeholder="搜索产品编码或名称"
placeholder="搜索产品编码、名称或储物格位置"
clearable
style="margin-bottom: 12px"
@input="handleSearch"
@@ -402,6 +402,7 @@
(cp) =>
(cp.productCode || '').toLowerCase().includes(keyword) ||
(cp.productName || '').toLowerCase().includes(keyword) ||
(cp.storageSlotName || '').toLowerCase().includes(keyword) ||
(cp.label || '').toLowerCase().includes(keyword)
)
})
@@ -44,16 +44,6 @@
}}</span>
<div class="work-order-task-step-sublist__ops">
<el-button
v-if="canEnterStep(row)"
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="handleEnterStep(row)"
>
<el-icon><VideoPlay /></el-icon>进入执行
</el-button>
<el-button
v-else
class="btn-op-item primary"
type="primary"
size="small"
@@ -85,7 +75,7 @@
</template>
<script setup lang="ts">
import { VideoPlay, View } from '@element-plus/icons-vue'
import { View } from '@element-plus/icons-vue'
import {
fetchProductionTaskDetail,
type ProductionTaskOperateScope
@@ -128,7 +118,7 @@
const tableData = ref<Record<string, any>[]>([])
const loading = ref(false)
/** 我的生产任务:展示任务下全部工艺步骤;本人负责步骤高亮,进入执行仍仅限本人步骤 */
/** 我的生产任务:展示任务下全部工艺步骤;本人负责步骤高亮 */
const visibleSteps = computed(() => tableData.value)
function assigneeDisplayName(raw: string): string {
@@ -176,12 +166,6 @@
return (row.assigneeUserIds || []).includes(uid)
}
function canEnterStep(row: Record<string, any>) {
if (row.stepStatus === 'ywc') return false
if (isMineMode.value) return isStepAssignee(row)
return true
}
async function loadData() {
const taskId = props.task?.id
if (!taskId) {
@@ -224,10 +208,6 @@
executionDialogVisible.value = true
}
function handleEnterStep(row: Record<string, any>) {
openExecution(row, false)
}
function handleViewStep(row: Record<string, any>) {
openExecution(row, true)
}
@@ -65,6 +65,14 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="packageText"
label="产品包装"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="quantity"
label="数量"
@@ -101,6 +101,10 @@
<dt>规格</dt>
<dd>{{ product.specText || row.specText || '—' }}</dd>
</div>
<div class="qc-work-order-card__cell-field">
<dt>产品包装</dt>
<dd>{{ product.packageText || row.packageText || '—' }}</dd>
</div>
<div class="qc-work-order-card__cell-field">
<dt>数量</dt>
<dd>{{ product.quantity != null ? product.quantity : '—' }}</dd>
@@ -122,10 +126,12 @@
</div>
</div>
<p v-else class="qc-work-order-card__cells-fallback">
<template v-if="row.cellType || row.specText">
<template v-if="row.cellType || row.specText || row.packageText">
<span v-if="row.cellType">细胞类型:{{ row.cellType }}</span>
<span v-if="row.cellType && row.specText"> · </span>
<span v-if="row.specText">规格:{{ row.specText }}</span>
<span v-if="(row.cellType || row.specText) && row.packageText"> · </span>
<span v-if="row.packageText">包装:{{ row.packageText }}</span>
</template>
<template v-else>暂无细胞产品明细</template>
</p>
@@ -339,9 +345,12 @@
const sampleSpecialDialogVisible = ref(false)
const storageCertQcVisible = ref(false)
const showStorageCertQcButton = computed(() =>
supportsStorageCertQc(String(props.row?.sampleTypeName || ''))
)
const showStorageCertQcButton = computed(() => {
// 科研捐赠质检不填写储存证质检信息(质检工单 / 我的质检任务共用本卡片)
const tabKey = resolveEffectiveRowTabKey(props.cardMetaConfig.tabKey, props.row)
if (tabKey === 'inbound_research_donation') return false
return supportsStorageCertQc(String(props.row?.sampleTypeName || ''))
})
const sampleSpecialMarkTarget = computed(() =>
resolveInboundSampleSpecialMarkTargetFromRow(props.row)
@@ -7,6 +7,7 @@ import {
groupMineTasksByWorkOrder,
filterMineEntriesByWorkOrderSearch,
sortMineEntriesForInboundLabStorage,
sortMineEntriesForTemporary,
paginateMineEntries,
buildMineTableRows
} from '@/utils/qc-mine-task-groups'
@@ -23,6 +24,10 @@ import {
} from '@/utils/qc-work-order-tabs'
import { toAllTabListQuery } from '@/utils/qc-work-order-category'
import { getCardMetaConfig } from '@/utils/qc-work-order-card-meta'
import {
DEFAULT_DONATION_PROTOCOL_STATUS_FILTER,
joinProtocolStatusFilter
} from '@/utils/donation-protocol-status-filter'
import { joinWorkOrderStatusFilter } from '@/utils/status-tag-registry'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import { fetchQcInspectionBatchesBatch, type QcInspectionBatch } from '@/utils/qc-inspection-batch'
@@ -52,6 +57,7 @@ export function useQcWorkOrderTabPage(
qcCategory: '',
keyword: '',
workOrderStatus: [...DEFAULT_WORK_ORDER_STATUS_FILTER],
protocolStatus: [...DEFAULT_DONATION_PROTOCOL_STATUS_FILTER],
triggerSource: '',
sampleTypeId: '',
taskStatus: '',
@@ -122,6 +128,10 @@ export function useQcWorkOrderTabPage(
workOrderStatus: searchForm.workOrderStatus.length
? [...searchForm.workOrderStatus]
: undefined,
protocolStatus:
tabKey.value === 'inbound_research_donation'
? [...searchForm.protocolStatus]
: undefined,
qcCategory: searchForm.qcCategory || undefined,
triggerSource:
shouldApplyTriggerSourceFilter(tabKey.value, tab.listLayout) && searchForm.triggerSource
@@ -148,6 +158,8 @@ export function useQcWorkOrderTabPage(
entries = filterMineEntriesByWorkOrderSearch(entries, buildMineWorkOrderSearchFilters())
if (isInboundSampleQcTab(tabKey.value)) {
entries = sortMineEntriesForInboundLabStorage(entries)
} else if (tabKey.value === 'temporary') {
entries = sortMineEntriesForTemporary(entries)
}
const pageResult = paginateMineEntries(entries, pagination.page, pagination.pageSize)
const tableModel = buildMineTableRows(pageResult.items)
@@ -159,6 +171,12 @@ export function useQcWorkOrderTabPage(
}
const tab = tabConfig.value
if (tabKey.value === 'inbound_research_donation' && !searchForm.protocolStatus.length) {
tableData.value = []
presetBatchesByWorkOrderId.value = undefined
pagination.total = 0
return
}
const timeParams =
isAllQcWorkOrderTab(tabKey.value) || !tabUsesInfusionTimeFilter(tab)
? {
@@ -177,6 +195,10 @@ export function useQcWorkOrderTabPage(
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
workOrderStatus: joinWorkOrderStatusFilter(searchForm.workOrderStatus),
protocolStatus:
tabKey.value === 'inbound_research_donation'
? joinProtocolStatusFilter(searchForm.protocolStatus)
: undefined,
triggerSource:
shouldApplyTriggerSourceFilter(tabKey.value, tab.listLayout) && searchForm.triggerSource
? searchForm.triggerSource
@@ -211,6 +233,7 @@ export function useQcWorkOrderTabPage(
searchForm.qcCategory = ''
searchForm.keyword = ''
searchForm.workOrderStatus = [...DEFAULT_WORK_ORDER_STATUS_FILTER]
searchForm.protocolStatus = [...DEFAULT_DONATION_PROTOCOL_STATUS_FILTER]
searchForm.triggerSource = ''
searchForm.sampleTypeId = ''
searchForm.taskStatus = ''
@@ -40,6 +40,22 @@
/>
</el-select>
</el-form-item>
<el-form-item v-if="tabKey === 'inbound_research_donation'" label="协议状态">
<el-select
v-model="searchForm.protocolStatus"
placeholder="全部"
clearable
multiple
style="width: 100%"
>
<el-option
v-for="opt in DONATION_PROTOCOL_STATUS_FILTER_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="!isAllTab && (tabConfig.listLayout !== 'standalone' || tabKey === 'temporary')"
label="工单来源"
@@ -170,6 +186,22 @@
/>
</el-select>
</el-form-item>
<el-form-item v-if="tabKey === 'inbound_research_donation'" label="协议状态">
<el-select
v-model="searchForm.protocolStatus"
placeholder="全部"
clearable
multiple
style="width: 100%"
>
<el-option
v-for="opt in DONATION_PROTOCOL_STATUS_FILTER_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="任务状态">
<el-select
v-model="searchForm.taskStatus"
@@ -359,6 +391,7 @@
QC_WORK_ORDER_CATEGORY_OPTIONS,
QC_ALL_TAB_TRIGGER_SOURCE_OPTIONS
} from '@/utils/qc-work-order-category'
import { DONATION_PROTOCOL_STATUS_FILTER_OPTIONS } from '@/utils/donation-protocol-status-filter'
import { QC_WORK_ORDER_STATUS_FILTER_OPTIONS } from '@/utils/status-tag-registry'
import QcWorkOrderCardList from '../QcWorkOrderCardList.vue'
import { QC_WORK_ORDER_VIEW_DIALOG_COMPONENTS } from '../view-dialogs/view-dialog-registry'
@@ -40,6 +40,14 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="packageText"
label="产品包装"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="quantity"
label="数量"