This commit is contained in:
zlwu committed 2026-06-16 17:10:01 +08:00
1 parent a9667f00cc
commit 817dfff43c
174 files changed
+17383 -14508

No files matched your search

@@ -30,7 +30,7 @@
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">支持模糊查询;默认展示近 30 天内创建的客户。</span>
<span class="filter-hint">支持模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
@@ -274,22 +274,8 @@
defineOptions({ name: 'BizCustomer' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultCreateTimeRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const createTimeRange = ref<[string, string] | null>(getDefaultCreateTimeRange())
const createTimeRange = ref<[string, string] | null>(null)
const tableData = ref<Api.BizCustomer.CustomerItem[]>([])
const selectedRows = ref<Api.BizCustomer.CustomerItem[]>([])
const loading = ref(false)
@@ -371,7 +357,7 @@
function handleReset() {
searchForm.keyword = ''
createTimeRange.value = getDefaultCreateTimeRange()
createTimeRange.value = null
pagination.page = 1
loadData()
}
@@ -417,6 +417,7 @@
if (sourceType === 'manual_in') return 'success'
if (sourceType === 'manual_out') return 'warning'
if (sourceType === 'manual_discard') return 'danger'
if (sourceType === 'outbound_handover') return 'primary'
return 'info'
}
@@ -424,6 +425,7 @@
if (sourceType === 'manual_in') return '手动入库'
if (sourceType === 'manual_out') return '手动出库'
if (sourceType === 'manual_discard') return '手动废弃'
if (sourceType === 'outbound_handover') return '出库交接'
return sourceType || '-'
}
@@ -486,37 +486,17 @@
const emit = defineEmits<{ actionDone: [] }>()
const filterHint =
'支持客户姓名、身份证号、新生儿姓名、新生儿身份证号模糊检索;默认展示应缴费时间前 15 天至后 15 天内的明细。'
'支持客户姓名、身份证号、新生儿姓名、新生儿身份证号模糊检索。'
function formatPaymentAmount(v: unknown): string {
if (v == null || v === '') return '-'
return '¥' + formatMoneyDisplay(v)
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultInstallmentDueDateRange(): [string, string] {
const today = new Date()
const start = new Date(today)
const end = new Date(today)
start.setDate(start.getDate() - 15)
end.setDate(end.getDate() + 15)
return [formatLocalDate(start), formatLocalDate(end)]
}
const pendingSearchForm = reactive({ keyword: '' })
const confirmedSearchForm = reactive({ keyword: '' })
const pendingInstallmentDueDateRange = ref<[string, string] | null>(
getDefaultInstallmentDueDateRange()
)
const confirmedInstallmentDueDateRange = ref<[string, string] | null>(
getDefaultInstallmentDueDateRange()
)
const pendingInstallmentDueDateRange = ref<[string, string] | null>(null)
const confirmedInstallmentDueDateRange = ref<[string, string] | null>(null)
const pendingData = ref<Record<string, unknown>[]>([])
const confirmedData = ref<Record<string, unknown>[]>([])
@@ -605,7 +585,7 @@
function handlePendingReset() {
pendingSearchForm.keyword = ''
pendingInstallmentDueDateRange.value = getDefaultInstallmentDueDateRange()
pendingInstallmentDueDateRange.value = null
pendingPagination.page = 1
loadPending()
}
@@ -617,7 +597,7 @@
function handleConfirmedReset() {
confirmedSearchForm.keyword = ''
confirmedInstallmentDueDateRange.value = getDefaultInstallmentDueDateRange()
confirmedInstallmentDueDateRange.value = null
confirmedPagination.page = 1
loadConfirmed()
}
@@ -527,37 +527,17 @@
}
const filterHint =
'支持客户姓名、身份证号、新生儿姓名、新生儿身份证号模糊检索;默认展示应缴费时间前 15 天至后 15 天内的明细。'
'支持客户姓名、身份证号、新生儿姓名、新生儿身份证号模糊检索。'
function formatPaymentAmount(v: unknown): string {
if (v == null || v === '') return '-'
return '¥' + formatMoneyDisplay(v)
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultInstallmentDueDateRange(): [string, string] {
const today = new Date()
const start = new Date(today)
const end = new Date(today)
start.setDate(start.getDate() - 15)
end.setDate(end.getDate() + 15)
return [formatLocalDate(start), formatLocalDate(end)]
}
const pendingSearchForm = reactive({ keyword: '' })
const confirmedSearchForm = reactive({ keyword: '' })
const pendingInstallmentDueDateRange = ref<[string, string] | null>(
getDefaultInstallmentDueDateRange()
)
const confirmedInstallmentDueDateRange = ref<[string, string] | null>(
getDefaultInstallmentDueDateRange()
)
const pendingInstallmentDueDateRange = ref<[string, string] | null>(null)
const confirmedInstallmentDueDateRange = ref<[string, string] | null>(null)
const pendingData = ref<Record<string, unknown>[]>([])
const confirmedData = ref<Record<string, unknown>[]>([])
@@ -659,7 +639,7 @@
function handlePendingReset() {
pendingSearchForm.keyword = ''
pendingInstallmentDueDateRange.value = getDefaultInstallmentDueDateRange()
pendingInstallmentDueDateRange.value = null
pendingPagination.page = 1
loadPending()
}
@@ -671,7 +651,7 @@
function handleConfirmedReset() {
confirmedSearchForm.keyword = ''
confirmedInstallmentDueDateRange.value = getDefaultInstallmentDueDateRange()
confirmedInstallmentDueDateRange.value = null
confirmedPagination.page = 1
loadConfirmed()
}
@@ -39,7 +39,7 @@
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>支持按姓名模糊检索;待办状态默认未办保险,满月日期默认近 30 天。</span
>支持按姓名模糊检索;待办状态默认未办保险。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
@@ -477,25 +477,8 @@
defineOptions({ name: 'CellStorageFullmoonTodoTab' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultFullmoonDateRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
// ---------------------------------------------------------------------------
// 搜索 & 主表
// ---------------------------------------------------------------------------
const searchForm = reactive({ keyword: '', todoStatus: '0' })
const fullmoonDateRange = ref<[string, string] | null>(getDefaultFullmoonDateRange())
const fullmoonDateRange = ref<[string, string] | null>(null)
const tableData = ref<any[]>([])
const tableRef = ref()
const selectedRows = ref<any[]>([])
@@ -670,7 +653,7 @@
function handleReset() {
searchForm.keyword = ''
searchForm.todoStatus = '0'
fullmoonDateRange.value = getDefaultFullmoonDateRange()
fullmoonDateRange.value = null
pagination.page = 1
loadData()
}
@@ -36,8 +36,7 @@
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>关键词、转账方式、应缴费时间对五列看板均生效;默认应缴费时间为当天前 10 天至后 20
天。</span
>关键词、转账方式、应缴费时间对五列看板均生效。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
@@ -389,23 +388,8 @@
return { list: [], total: 0, page: 1, pageSize: 10, loading: false }
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultInstallmentDueDateRange(): [string, string] {
const start = new Date()
const end = new Date()
start.setDate(start.getDate() - 10)
end.setDate(end.getDate() + 20)
return [formatLocalDate(start), formatLocalDate(end)]
}
const searchForm = reactive({ keyword: '', transferType: '' })
const installmentDueDateRange = ref<[string, string] | null>(getDefaultInstallmentDueDateRange())
const installmentDueDateRange = ref<[string, string] | null>(null)
const columnStates = reactive<Record<PayStatusKey, KanbanColumnState>>({
dfj: createColumnState(),
jfqrz: createColumnState(),
@@ -680,7 +664,7 @@
function handleReset() {
searchForm.keyword = ''
searchForm.transferType = ''
installmentDueDateRange.value = getDefaultInstallmentDueDateRange()
installmentDueDateRange.value = null
loadAllColumns(true)
}
@@ -52,7 +52,7 @@
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>列表为概要信息,详情见弹窗;默认展示近 30 天内创建的协议单。</span
>列表为概要信息,详情见弹窗。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
@@ -468,26 +468,12 @@
}))
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultCreateTimeRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
/* ====== 列表 ====== */
const searchForm = reactive<{ keyword: string; protocolStatus: string }>({
keyword: '',
protocolStatus: ''
})
const createTimeRange = ref<[string, string] | null>(getDefaultCreateTimeRange())
const createTimeRange = ref<[string, string] | null>(null)
const sampleCollectTimeRange = ref<[string, string] | null>(null)
const tableData = ref<CollectionProtocolRow[]>([])
const selectedRows = ref<CollectionProtocolRow[]>([])
@@ -535,7 +521,7 @@
function handleReset() {
searchForm.keyword = ''
searchForm.protocolStatus = ''
createTimeRange.value = getDefaultCreateTimeRange()
createTimeRange.value = null
sampleCollectTimeRange.value = null
pagination.page = 1
loadData()
@@ -46,7 +46,7 @@
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>展示全部协议样本(每个样本一条);支持按姓名、样本编码检索;默认展示近 30 天内创建的样本。</span
>展示全部协议样本(每个样本一条);支持按姓名、样本编码检索。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
@@ -241,22 +241,8 @@
return row.qcStatus === 'tg' || row.qcStatus === 'btg'
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultSampleTimeRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
const searchForm = reactive({ keyword: '', qcStatus: '', smsNotified: '' })
const sampleTimeRange = ref<[string, string] | null>(getDefaultSampleTimeRange())
const sampleTimeRange = ref<[string, string] | null>(null)
const tableData = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
@@ -292,7 +278,7 @@
function handleReset() {
Object.assign(searchForm, { keyword: '', qcStatus: '', smsNotified: '' })
sampleTimeRange.value = getDefaultSampleTimeRange()
sampleTimeRange.value = null
handleSearch()
}
@@ -44,7 +44,7 @@
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>支持按姓名、样本编码模糊检索;默认展示近 30 天内接收的样本。</span
>支持按姓名、样本编码模糊检索。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
@@ -239,22 +239,8 @@
defineOptions({ name: 'CellStorageSampleReceptionInfoTab' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultReceptionTimeRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
const searchForm = reactive({ keyword: '', acceptConclusion: '', smsNotified: '' })
const receptionTimeRange = ref<[string, string] | null>(getDefaultReceptionTimeRange())
const receptionTimeRange = ref<[string, string] | null>(null)
const tableData = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
@@ -290,7 +276,7 @@
function handleReset() {
Object.assign(searchForm, { keyword: '', acceptConclusion: '', smsNotified: '' })
receptionTimeRange.value = getDefaultReceptionTimeRange()
receptionTimeRange.value = null
handleSearch()
}
@@ -33,7 +33,7 @@
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>支持按姓名模糊检索;制作状态默认未制作,入库日期默认近 30 天。</span
>支持按姓名模糊检索;制作状态默认未制作。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
@@ -380,25 +380,8 @@
defineOptions({ name: 'CellStorageStorageCertTab' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultStorageInDateRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
// ---------------------------------------------------------------------------
// 搜索 & 主表
// ---------------------------------------------------------------------------
const searchForm = reactive({ keyword: '', makeStatus: '0' })
const storageInDateRange = ref<[string, string] | null>(getDefaultStorageInDateRange())
const storageInDateRange = ref<[string, string] | null>(null)
const tableData = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
@@ -490,7 +473,7 @@
function handleReset() {
searchForm.keyword = ''
searchForm.makeStatus = '0'
storageInDateRange.value = getDefaultStorageInDateRange()
storageInDateRange.value = null
pagination.page = 1
loadData()
}
@@ -44,7 +44,7 @@
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>支持按姓名、样本编码模糊检索;默认展示近 30 天内入库的样本。</span
>支持按姓名、样本编码模糊检索。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
@@ -230,22 +230,8 @@
defineOptions({ name: 'CellStorageStorageEntryInfoTab' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultStorageTimeRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
const searchForm = reactive({ keyword: '', storageStatus: '', smsNotified: '' })
const storageTimeRange = ref<[string, string] | null>(getDefaultStorageTimeRange())
const storageTimeRange = ref<[string, string] | null>(null)
const tableData = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
@@ -281,7 +267,7 @@
function handleReset() {
Object.assign(searchForm, { keyword: '', storageStatus: '', smsNotified: '' })
storageTimeRange.value = getDefaultStorageTimeRange()
storageTimeRange.value = null
handleSearch()
}
@@ -106,141 +106,3 @@
}
})
</script>
<style scoped lang="scss">
.pill-tab-nav {
padding: 16px 20px 0;
display: flex;
flex-direction: column;
align-items: center;
}
.pill-tab-nav__rail {
display: flex;
align-items: center;
width: 70%;
max-width: 100%;
margin-inline: auto;
background: var(--surface, #fff);
border: 1px solid var(--border, #e2e8f0);
border-radius: 10px;
padding: 6px;
position: relative;
overflow: hidden;
}
.pill-tab-nav__progress {
position: absolute;
top: 6px;
bottom: 6px;
left: 6px;
background: var(--primary, #10b981);
border: 1px solid var(--primary-dark, #059669);
border-radius: 8px;
box-shadow: 0 1px 3px rgb(16 185 129 / 28%);
transition:
transform 0.25s ease,
width 0.25s ease;
z-index: 0;
}
.pill-tab-nav__step {
flex: 1;
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
min-height: 44px;
padding: 8px 10px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 8px;
transition: color 0.2s ease;
}
.pill-tab-nav__index {
width: 22px;
height: 22px;
border-radius: 50%;
background: #e2e8f0;
color: var(--mint-text-muted, #334155);
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s ease;
}
.pill-tab-nav__text {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
text-align: left;
}
.pill-tab-nav__title {
font-size: 13px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
white-space: nowrap;
}
.pill-tab-nav__desc {
font-size: 11px;
color: var(--mint-text-muted, #334155);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.pill-tab-nav__step.is-active .pill-tab-nav__index {
background: #fff;
color: var(--primary-dark, #059669);
}
.pill-tab-nav__step.is-active .pill-tab-nav__title,
.pill-tab-nav__step.is-active .pill-tab-nav__desc {
color: #fff;
}
.pill-tab-nav__step.is-done .pill-tab-nav__index {
background: var(--primary-bg, #ecfdf5);
color: var(--primary-dark, #059669);
border: 1px solid var(--mint-border, #c5e0d4);
}
.pill-tab-nav__step.is-done .pill-tab-nav__title {
color: var(--text, #1e293b);
}
@media (width <= 960px) {
.pill-tab-nav__desc {
display: none;
}
.pill-tab-nav__text {
align-items: center;
}
}
@media (width <= 720px) {
.pill-tab-nav {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
align-items: stretch;
}
.pill-tab-nav__rail {
width: 100%;
min-width: 680px;
}
}
</style>
@@ -2783,42 +2783,6 @@
}
}
/* ---- Sticky Tab 导航(与查看弹窗 detail-tabs 一致) ---- */
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin: 0 28px 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #065f46;
}
}
/* ═══════════════════════════════════════════
SECTION CARDS
═══════════════════════════════════════════ */
@@ -1936,48 +1936,6 @@
}
}
/* ---- Sticky Tab 导航 ---- */
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin-bottom: 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #065f46;
}
&.is-disabled {
cursor: default;
opacity: 0.65;
pointer-events: none;
}
}
/* ---- KPI 摘要条(统一薄荷绿通栏 + 图标徽章) ---- */
.kpi-strip {
display: grid;
@@ -2104,16 +2062,12 @@
&--banner {
margin: -22px -24px 18px;
padding: 10px 24px;
border-bottom: none;
border-bottom: 1px solid rgba(16, 185, 129, 0.12);
border-radius: 12px 12px 0 0;
background: var(--primary, #10b981);
&::before {
display: none;
}
background: var(--primary-bg, #ecfdf5);
h3 {
color: #fff;
color: var(--primary-dark, #059669);
}
}
}
@@ -56,9 +56,7 @@
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>列表为概要信息,详情见弹窗;默认展示近 30 天内创建的协议单。</span
>
<span class="filter-hint">列表为概要信息,详情见弹窗。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
@@ -316,26 +314,12 @@
defineOptions({ name: 'CellStorageProtocol' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultCreateTimeRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
/* ====== 列表 ====== */
const searchForm = reactive<{ keyword: string; protocolStatus: string }>({
keyword: '',
protocolStatus: ''
})
const createTimeRange = ref<[string, string] | null>(getDefaultCreateTimeRange())
const createTimeRange = ref<[string, string] | null>(null)
const sampleCollectTimeRange = ref<[string, string] | null>(null)
const tableData = ref<CollectionProtocolRow[]>([])
const selectedRows = ref<CollectionProtocolRow[]>([])
@@ -374,7 +358,7 @@
function handleReset() {
searchForm.keyword = ''
searchForm.protocolStatus = ''
createTimeRange.value = getDefaultCreateTimeRange()
createTimeRange.value = null
sampleCollectTimeRange.value = null
pagination.page = 1
loadData()
@@ -101,141 +101,3 @@
}
})
</script>
<style scoped lang="scss">
.pill-tab-nav {
padding: 16px 20px 0;
display: flex;
flex-direction: column;
align-items: center;
}
.pill-tab-nav__rail {
display: flex;
align-items: center;
width: 70%;
max-width: 100%;
margin-inline: auto;
background: var(--surface, #fff);
border: 1px solid var(--border, #e2e8f0);
border-radius: 10px;
padding: 6px;
position: relative;
overflow: hidden;
}
.pill-tab-nav__progress {
position: absolute;
top: 6px;
bottom: 6px;
left: 6px;
background: var(--primary, #10b981);
border: 1px solid var(--primary-dark, #059669);
border-radius: 8px;
box-shadow: 0 1px 3px rgb(16 185 129 / 28%);
transition:
transform 0.25s ease,
width 0.25s ease;
z-index: 0;
}
.pill-tab-nav__step {
flex: 1;
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
min-height: 44px;
padding: 8px 10px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 8px;
transition: color 0.2s ease;
}
.pill-tab-nav__index {
width: 22px;
height: 22px;
border-radius: 50%;
background: #e2e8f0;
color: var(--mint-text-muted, #334155);
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s ease;
}
.pill-tab-nav__text {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
text-align: left;
}
.pill-tab-nav__title {
font-size: 13px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
white-space: nowrap;
}
.pill-tab-nav__desc {
font-size: 11px;
color: var(--mint-text-muted, #334155);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.pill-tab-nav__step.is-active .pill-tab-nav__index {
background: #fff;
color: var(--primary-dark, #059669);
}
.pill-tab-nav__step.is-active .pill-tab-nav__title,
.pill-tab-nav__step.is-active .pill-tab-nav__desc {
color: #fff;
}
.pill-tab-nav__step.is-done .pill-tab-nav__index {
background: var(--primary-bg, #ecfdf5);
color: var(--primary-dark, #059669);
border: 1px solid var(--mint-border, #c5e0d4);
}
.pill-tab-nav__step.is-done .pill-tab-nav__title {
color: var(--text, #1e293b);
}
@media (width <= 960px) {
.pill-tab-nav__desc {
display: none;
}
.pill-tab-nav__text {
align-items: center;
}
}
@media (width <= 720px) {
.pill-tab-nav {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
align-items: stretch;
}
.pill-tab-nav__rail {
width: 100%;
min-width: 680px;
}
}
</style>
@@ -31,7 +31,7 @@
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>按预约编号、来访单位、联系人模糊查询;默认展示未来 30 天内的到访预约。</span
>按预约编号、来访单位、联系人模糊查询。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
@@ -288,22 +288,8 @@
[key: string]: any
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultVisitDateRange(): [string, string] {
const start = new Date()
const end = new Date()
end.setDate(end.getDate() + 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const visitDateRange = ref<[string, string] | null>(getDefaultVisitDateRange())
const visitDateRange = ref<[string, string] | null>(null)
const tableData = ref<AppointmentRow[]>([])
const selectedRows = ref<AppointmentRow[]>([])
const loading = ref(false)
@@ -417,7 +403,7 @@
function handleReset() {
searchForm.keyword = ''
visitDateRange.value = getDefaultVisitDateRange()
visitDateRange.value = null
pagination.page = 1
loadData()
}
@@ -58,7 +58,7 @@
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">默认展示近 30 天内创建的流水。</span>
<span class="filter-hint">支持按流水类型、状态等条件检索。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
@@ -644,27 +644,7 @@
done: []
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultLast30DaysRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
const FINANCE_TABS = [
{ name: 'pending', label: '待处理' },
{ name: 'done', label: '已处理' }
] as const
const activeTab = ref('pending')
const createTimeRange = ref<[string, string] | null>(getDefaultLast30DaysRange())
const createTimeRange = ref<[string, string] | null>(null)
const searchForm = reactive({
txnType: '' as string,
txnStatus: ['dqr'] as string[],
@@ -716,7 +696,7 @@
searchForm.txnType = ''
searchForm.txnStatus = [...TAB_STATUS_MAP[activeTab.value]]
searchForm.keyword = ''
createTimeRange.value = getDefaultLast30DaysRange()
createTimeRange.value = null
pagination.page = 1
loadData()
}
@@ -5,7 +5,7 @@
v-if="rows.length"
:data="rows"
stripe
class="list-table inner-table"
class="list-table"
:max-height="maxHeight"
>
<el-table-column
@@ -27,6 +27,30 @@
<span class="amount-text">¥{{ formatAmount(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column
v-if="showBalance"
label="变动前余额(元)"
min-width="130"
align="right"
header-align="center"
>
<template #default="{ row }">
<span v-if="row.txnStatus === 'yqr'">¥{{ formatAmount(row.balanceBefore) }}</span>
<span v-else class="void-info muted">—</span>
</template>
</el-table-column>
<el-table-column
v-if="showBalance"
label="变动后余额(元)"
min-width="130"
align="right"
header-align="center"
>
<template #default="{ row }">
<span v-if="row.txnStatus === 'yqr'">¥{{ formatAmount(row.balanceAfter) }}</span>
<span v-else class="void-info muted">—</span>
</template>
</el-table-column>
<el-table-column label="状态" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="txnStatusTagType(row.txnStatus)" size="small">
@@ -70,10 +94,12 @@
defineProps<{
rows: Record<string, any>[]
maxHeight?: number | string
showBalance?: boolean
}>(),
{
rows: () => [],
maxHeight: 360
maxHeight: 360,
showBalance: false
}
)
@@ -48,7 +48,7 @@
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">默认展示近 30 天内创建的订单;渠道名称输入后自动检索。</span>
<span class="filter-hint">渠道名称输入后自动检索。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
@@ -1084,21 +1084,7 @@
}
// ── 搜索 ──────────────────────────────────────────────────────────────
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultLast30DaysRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
const createTimeRange = ref<[string, string] | null>(getDefaultLast30DaysRange())
const createTimeRange = ref<[string, string] | null>(null)
const searchForm = reactive<{
orderType: string
@@ -1153,7 +1139,7 @@
searchForm.orderType = ''
searchForm.orderStatus = []
searchForm.keyword = ''
createTimeRange.value = getDefaultLast30DaysRange()
createTimeRange.value = null
pagination.page = 1
loadData()
}
@@ -123,58 +123,74 @@
</div>
</div>
<div id="view-section-invoice" class="section-card section-card--green">
<div id="view-section-invoice" class="section-card section-card--white">
<div class="section-title"><h3>开票信息</h3></div>
<div v-if="invoiceList.length" class="sub-cards">
<div v-for="(row, idx) in invoiceList" :key="row.id || idx" class="sub-card">
<div class="sub-card-header">
<h4>{{ row.companyName || '未命名' }}</h4>
</div>
<div class="data-grid data-grid--2">
<div v-if="row.taxNo" class="data-item">
<div class="data-label">税号</div>
<div class="data-value">{{ dash(row.taxNo) }}</div>
</div>
<div v-if="row.phone || row.companyPhone" class="data-item">
<div class="data-label">电话</div>
<div class="data-value">{{ dash(row.phone || row.companyPhone) }}</div>
</div>
<div v-if="row.companyAddress" class="data-item data-item--full">
<div class="data-label">单位地址</div>
<div class="data-value data-value--muted">{{ dash(row.companyAddress) }}</div>
</div>
<div v-if="row.bankName" class="data-item">
<div class="data-label">开户银行</div>
<div class="data-value">{{ dash(row.bankName) }}</div>
</div>
<div v-if="row.bankAccount" class="data-item">
<div class="data-label">银行账号</div>
<div class="data-value">{{ dash(row.bankAccount) }}</div>
</div>
</div>
</div>
<div v-if="invoiceList.length" class="list-body list-wrap">
<el-table
:data="invoiceList"
class="list-table"
stripe
row-key="id"
max-height="360"
>
<el-table-column type="index" label="序号" min-width="68" />
<el-table-column
prop="companyName"
label="单位名称"
min-width="160"
show-overflow-tooltip
/>
<el-table-column prop="taxNo" label="税号" min-width="140" show-overflow-tooltip />
<el-table-column
prop="companyAddress"
label="单位地址"
min-width="200"
show-overflow-tooltip
/>
<el-table-column label="电话" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ dash(row.phone || row.companyPhone) }}
</template>
</el-table-column>
<el-table-column
prop="bankName"
label="开户银行"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
prop="bankAccount"
label="银行账号"
min-width="160"
show-overflow-tooltip
/>
</el-table>
</div>
<el-empty v-else description="暂无开票信息" :image-size="48" />
</div>
<div id="view-section-mail" class="section-card section-card--white">
<div class="section-title"><h3>邮寄地址</h3></div>
<div v-if="mailList.length" class="sub-cards">
<div v-for="(row, idx) in mailList" :key="row.id || idx" class="sub-card">
<div class="sub-card-header">
<h4>{{ row.mailAddress || '未命名' }}</h4>
</div>
<div class="data-grid data-grid--2">
<div v-if="row.mailContactName || row.mailContact" class="data-item">
<div class="data-label">联系人</div>
<div class="data-value">{{ dash(row.mailContactName || row.mailContact) }}</div>
</div>
<div v-if="row.mailContactPhone || row.mailPhone" class="data-item">
<div class="data-label">联系电话</div>
<div class="data-value">{{ dash(row.mailContactPhone || row.mailPhone) }}</div>
</div>
</div>
</div>
<div v-if="mailList.length" class="list-body list-wrap">
<el-table :data="mailList" class="list-table" stripe row-key="id" max-height="360">
<el-table-column type="index" label="序号" min-width="68" />
<el-table-column
prop="mailAddress"
label="邮寄地址"
min-width="220"
show-overflow-tooltip
/>
<el-table-column label="联系人" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ dash(row.mailContactName || row.mailContact) }}
</template>
</el-table-column>
<el-table-column label="联系电话" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
{{ dash(row.mailContactPhone || row.mailPhone) }}
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else description="暂无邮寄地址" :image-size="48" />
</div>
@@ -187,70 +203,50 @@
¥ {{ accountLoading ? '—' : formatAmount(accountData?.balanceAmount) }}
</div>
</div>
<div class="account-quotas-head">
<h4 class="account-quotas-title">产品份数</h4>
<div class="account-subsection-head">
<h4 class="account-subsection-title">账户流水</h4>
</div>
<div v-loading="accountTxnLoading" class="list-body list-wrap channel-view-txn-wrap">
<ServiceOrderTxnTable :rows="accountTxnRows" max-height="320" show-balance />
<div v-if="accountTxnTotal > accountTxnPageSize" class="pagination list-pager-bar">
<el-pagination
:current-page="accountTxnPage"
:page-size="accountTxnPageSize"
:total="accountTxnTotal"
layout="total, prev, pager, next"
@current-change="loadAccountTxns"
/>
</div>
</div>
<div class="account-subsection-head account-subsection-head--spaced">
<h4 class="account-subsection-title">产品份数</h4>
</div>
<div v-if="quotasLoading" v-loading="true" style="min-height: 120px"></div>
<div
v-else-if="productQuotas.length"
class="list-body list-wrap account-quota-list-wrap"
>
<div v-else-if="productQuotas.length" class="list-body list-wrap">
<el-table
:data="productQuotas"
class="list-table account-quota-table"
class="list-table"
stripe
row-key="id"
max-height="360"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column type="index" label="序号" min-width="68" />
<el-table-column
prop="projectName"
label="项目名称"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="cellTypeName"
label="细胞类型"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="specText"
label="规格"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="totalQty"
label="总份数"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="usedQty"
label="已用份数"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="remainingQty"
label="剩余份数"
min-width="100"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column prop="specText" label="规格" min-width="150" show-overflow-tooltip />
<el-table-column prop="totalQty" label="总份数" min-width="100" align="right" />
<el-table-column prop="usedQty" label="已用份数" min-width="100" align="right" />
<el-table-column prop="remainingQty" label="剩余份数" min-width="100" align="right" />
</el-table>
</div>
<el-empty v-else description="暂无产品份数数据" :image-size="48" />
@@ -298,66 +294,41 @@
</div>
<div
v-if="(agreementPricesMap[agr.id] || []).length > 0"
class="list-body list-wrap agreement-price-list-wrap"
class="list-body list-wrap"
>
<el-table
:data="agreementPricesMap[agr.id] || []"
class="list-table agreement-price-table"
class="list-table"
stripe
row-key="id"
max-height="360"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column type="index" label="序号" min-width="68" />
<el-table-column
prop="projectName"
label="项目名称"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="projectCode"
label="项目编码"
min-width="100"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="cellTypeName"
label="细胞类型"
min-width="100"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="specText"
label="规格"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
label="原价(元)"
min-width="100"
align="center"
header-align="center"
>
<el-table-column prop="specText" label="规格" min-width="100" show-overflow-tooltip />
<el-table-column label="原价(元)" min-width="100" align="right">
<template #default="{ row: priceRow }">
{{ formatPrice(priceRow.originalPrice) }}
</template>
</el-table-column>
<el-table-column
label="协议价(元)"
min-width="100"
align="center"
header-align="center"
>
<el-table-column label="协议价(元)" min-width="100" align="right">
<template #default="{ row: priceRow }">
<span class="data-value--price">{{
formatPrice(priceRow.agreementPrice)
@@ -368,8 +339,6 @@
prop="remark"
label="备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
/>
</el-table>
@@ -427,6 +396,7 @@
import { computed, nextTick, ref, watch } from 'vue'
import { Calendar, Close, CollectionTag, OfficeBuilding, User } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import ServiceOrderTxnTable from '../channel-service-order/ServiceOrderTxnTable.vue'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import { formatMoneyDisplay } from '@/utils/money-format'
import {
@@ -445,7 +415,8 @@
fetchChannelAgreements,
fetchChannelAgreementPrices,
fetchChannelAccount,
fetchChannelProductQuotas
fetchChannelProductQuotas,
fetchServiceOrderTxnList
} from '@/api/admin'
const props = defineProps<{
@@ -477,6 +448,11 @@
const accountLoading = ref(false)
const quotasLoading = ref(false)
const accountLoaded = ref(false)
const accountTxnRows = ref<Record<string, any>[]>([])
const accountTxnLoading = ref(false)
const accountTxnPage = ref(1)
const accountTxnPageSize = ref(10)
const accountTxnTotal = ref(0)
const channel = computed(() => props.listRow || null)
@@ -559,25 +535,59 @@
const formatAmount = (v: unknown): string => formatMoneyDisplay(v as any, 2)
async function loadAccountTxns(page = 1) {
if (!props.channelId) return
accountTxnLoading.value = true
try {
const res = await fetchServiceOrderTxnList({
channelId: props.channelId,
page,
pageSize: accountTxnPageSize.value
})
const data = (res as any)?.data ?? res
accountTxnRows.value = Array.isArray(data?.list) ? data.list : []
accountTxnTotal.value = Number(data?.total) || 0
accountTxnPage.value = page
} catch {
accountTxnRows.value = []
accountTxnTotal.value = 0
} finally {
accountTxnLoading.value = false
}
}
async function loadAccountData() {
if (!props.channelId || accountLoaded.value) return
accountLoading.value = true
quotasLoading.value = true
accountTxnLoading.value = true
try {
const [accRes, quotaRes] = await Promise.all([
const [accRes, quotaRes, txnRes] = await Promise.all([
fetchChannelAccount(props.channelId),
fetchChannelProductQuotas(props.channelId)
fetchChannelProductQuotas(props.channelId),
fetchServiceOrderTxnList({
channelId: props.channelId,
page: 1,
pageSize: accountTxnPageSize.value
})
])
accountData.value = (accRes as any)?.data ?? accRes ?? null
const quotaRows = Array.isArray(quotaRes) ? quotaRes : (quotaRes as any)?.list || []
productQuotas.value = quotaRows
const txnData = (txnRes as any)?.data ?? txnRes
accountTxnRows.value = Array.isArray(txnData?.list) ? txnData.list : []
accountTxnTotal.value = Number(txnData?.total) || 0
accountTxnPage.value = 1
accountLoaded.value = true
} catch {
accountData.value = null
productQuotas.value = []
accountTxnRows.value = []
accountTxnTotal.value = 0
} finally {
accountLoading.value = false
quotasLoading.value = false
accountTxnLoading.value = false
}
}
@@ -652,6 +662,9 @@
agreementPricesMap.value = {}
accountData.value = null
productQuotas.value = []
accountTxnRows.value = []
accountTxnTotal.value = 0
accountTxnPage.value = 1
accountLoaded.value = false
activeTab.value = 'basic'
}
@@ -672,69 +685,30 @@
overflow: visible;
}
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin-bottom: 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #065f46;
}
}
/* KPI 摘要条(对齐 ProtocolViewDialog:统一薄荷绿通栏 + 图标徽章) */
/* KPI 摘要条 */
.kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
margin-bottom: 24px;
padding: 6px;
background: linear-gradient(135deg, #e8f4ee 0%, #f8fafc 52%, #e8f4ee 100%);
border: 1px solid #a8d4c4;
padding: 18px 20px;
background: linear-gradient(180deg, #ecfdf5 0%, #fff 100%);
border: none;
border-radius: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85);
box-shadow: none;
}
.kpi-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
padding: 8px 16px;
background: transparent;
border-radius: 8px;
transition: background 0.2s;
&:not(:last-child) {
border-right: 1px solid rgba(168, 212, 196, 0.75);
}
&:hover {
background: rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.35);
}
}
@@ -746,8 +720,8 @@
height: 40px;
flex-shrink: 0;
border-radius: 10px;
background: #fff;
border: 1px solid #a8d4c4;
background: rgba(255, 255, 255, 0.72);
border: none;
color: #065f46;
font-size: 18px;
}
@@ -797,10 +771,6 @@
&--white {
background: #fff;
}
&--green {
background: #f0fdf4;
}
}
.section-title {
@@ -892,44 +862,6 @@
border-top: 1px dashed #e2e8f0;
}
.sub-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
.sub-card {
background: #fff;
border-radius: 10px;
padding: 16px 18px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.03);
transition:
transform 0.2s,
box-shadow 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07);
}
}
.sub-card-header {
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.agreement-cards {
display: flex;
flex-direction: column;
@@ -1000,40 +932,16 @@
}
}
.agreement-price-list-wrap {
margin-bottom: 0;
border-radius: 8px;
background: #fff;
}
.agreement-price-empty {
padding: 20px 0;
}
.agreement-price-table.list-table {
border: none !important;
box-shadow: none !important;
.channel-view-txn-wrap {
margin-bottom: 20px;
}
&::before,
&::after {
display: none !important;
}
:deep(.el-table__inner-wrapper) {
border: none !important;
&::before {
display: none !important;
}
}
:deep(.el-table__cell) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td.el-table__cell) {
border-bottom: none !important;
}
.channel-view-txn-wrap .pagination.list-pager-bar {
margin-top: 0;
}
.preview-container {
@@ -1065,11 +973,15 @@
letter-spacing: 0.02em;
}
.account-quotas-head {
.account-subsection-head {
margin-bottom: 10px;
&--spaced {
margin-top: 4px;
}
}
.account-quotas-title {
.account-subsection-title {
margin: 0;
font-size: 14px;
font-weight: 600;
@@ -1088,38 +1000,6 @@
}
}
.account-quota-list-wrap {
margin-bottom: 0;
border-radius: 8px;
background: #fff;
}
.account-quota-table.list-table {
border: none !important;
box-shadow: none !important;
&::before,
&::after {
display: none !important;
}
:deep(.el-table__inner-wrapper) {
border: none !important;
&::before {
display: none !important;
}
}
:deep(.el-table__cell) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td.el-table__cell) {
border-bottom: none !important;
}
}
@media (max-width: 1200px) {
.kpi-strip {
grid-template-columns: repeat(2, 1fr);
@@ -1138,10 +1018,6 @@
.data-grid--4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sub-cards {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
@@ -2504,42 +2504,6 @@
}
}
/* ---- Sticky Tab 导航 ---- */
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin: 0 28px 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #065f46;
}
}
/* ═══════════════════════════════════════════
SECTION CARDS
═══════════════════════════════════════════ */
@@ -4,7 +4,7 @@
<el-dialog
:model-value="modelValue"
title="出库交接"
width="1120px"
width="1280px"
class="dialog-form-layout outbound-handover-dialog"
align-center
append-to-body
@@ -36,7 +36,7 @@
<section class="section-card handover-form-section">
<h3 class="section-title section-title--mint-bar">交接信息</h3>
<el-form label-position="top" class="biz-dialog-form">
<div class="form-grid-2">
<div class="form-grid-3">
<el-form-item label="交接人" required>
<el-select
v-model="handoverUserId"
@@ -92,7 +92,7 @@
clearable
/>
</el-form-item>
<el-form-item label="备注">
<el-form-item label="备注" class="form-col-span-2">
<el-input
v-model="handoverRemark"
type="textarea"
@@ -48,7 +48,14 @@
</template>
<template #qc-info>
<SampleQcInfoCards :qc-info="detail?.qcInfo" hide-sample-context />
<SampleQcInfoGroupedPanel
:gate-qc-info="detail?.qcInfo"
:process-qc-info-list="detail?.processQcInfoList"
gate-section-label="计划质检(出库门禁)"
process-section-label="生产过程质检"
:show-section-label="false"
hide-sample-context
/>
</template>
<template #outbound-material>
@@ -236,7 +243,7 @@
type TagType
} from '@/components/business/system-view-dialog/index.vue'
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
import SampleQcInfoCards from '@/components/business/sample-qc-info-cards/index.vue'
import SampleQcInfoGroupedPanel from '@/components/business/sample-qc-info-grouped-panel/index.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import ClinicalSchedulePaymentDetailView from '@/components/business/clinical-schedule-payment-detail/index.vue'
import { fetchInfusionScheduleMgmtDetail } from '@/api/admin'
@@ -49,7 +49,13 @@
max-height="280"
empty-text="请从细胞产品库勾选"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="productCode"
label="产品编码"
@@ -64,14 +70,20 @@
/>
<el-table-column prop="productTypeName" label="产品类型" min-width="100" />
<el-table-column prop="specName" label="规格" min-width="90" />
<el-table-column prop="passage" label="代次" min-width="70" align="center" />
<el-table-column
prop="passage"
label="代次"
min-width="70"
align="center"
header-align="center"
/>
<el-table-column
prop="storageSlotName"
label="储物格"
min-width="140"
show-overflow-tooltip
/>
<el-table-column label="状态" min-width="90" align="center">
<el-table-column label="状态" min-width="90" align="center" header-align="center">
<template #default="{ row }">
<el-tag
v-if="row.productStatus"
@@ -83,9 +95,15 @@
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" align="center" fixed="right">
<el-table-column
label="操作"
min-width="100"
align="center"
header-align="center"
fixed="right"
>
<template #default="{ $index }">
<el-button type="danger" link size="small" @click="removeCellRow($index)">
<el-button type="danger" link @click="removeCellRow($index)">
<el-icon><Delete /></el-icon>
删除
</el-button>
@@ -95,84 +113,6 @@
</div>
</section>
<!-- 使用的物料 -->
<section class="section-card">
<div class="section-head">
<h3 class="section-title section-title--mint-bar">使用的物料</h3>
<el-button type="primary" size="small" class="btn-main" @click="addMaterialRow">
<el-icon><Plus /></el-icon>
添加物料
</el-button>
</div>
<el-table
v-if="materialRows.length"
:data="materialRows"
:row-key="(row) => row._rowKey"
stripe
class="list-table inner-table"
max-height="280"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column label="物料名称" min-width="180">
<template #default="{ row, $index }">
<el-select
v-model="row.materialId"
filterable
remote
reserve-keyword
clearable
placeholder="输入物料名称检索"
:loading="materialOptionsLoading"
:remote-method="remoteSearchMaterials"
class="inner-cell-input"
@visible-change="(visible) => visible && remoteSearchMaterials('')"
@change="(v) => onMaterialSelect($index, v)"
>
<el-option
v-for="opt in materialOptions"
:key="opt.id"
:label="opt.label"
:value="opt.id"
:disabled="isMaterialOptionDisabled(opt.id, $index)"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="规格型号" min-width="120">
<template #default="{ row }">{{ row.specModel || '—' }}</template>
</el-table-column>
<el-table-column label="计划用量" min-width="130">
<template #default="{ row }">
<el-input-number
v-model="row.qty"
:min="0.0001"
:precision="4"
:step="1"
controls-position="right"
class="inner-cell-input"
/>
</template>
</el-table-column>
<el-table-column label="单位" min-width="72" align="center">
<template #default="{ row }">{{ row.unit || '—' }}</template>
</el-table-column>
<el-table-column label="当前库存" min-width="100" align="center">
<template #default="{ row }">
{{ stockQtyDisplay(row.materialId) }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button type="danger" link size="small" @click="removeMaterialRow($index)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<p v-else class="section-empty-hint">点击「添加物料」从质检物料库勾选并填写计划用量</p>
</section>
<!-- 纸质材料 -->
<section class="section-card">
<div class="section-head">
@@ -182,48 +122,55 @@
添加纸质材料
</el-button>
</div>
<el-table
v-if="paperRows.length"
:data="paperRows"
:row-key="(row) => row._rowKey"
stripe
class="list-table inner-table"
max-height="280"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column label="名称" min-width="200">
<template #default="{ row }">
<el-select
v-model="row.paperName"
filterable
allow-create
default-first-option
clearable
placeholder="请选择或输入材料名称"
class="inner-cell-input"
>
<el-option
v-for="opt in PAPER_NAME_OPTIONS"
:key="opt"
:label="opt"
:value="opt"
<div class="list-body list-wrap inner-table-wrap">
<el-table
:data="paperRows"
:row-key="(row) => row._rowKey"
stripe
class="list-table inner-table"
max-height="280"
empty-text="点击「添加纸质材料」逐条填写名称、份数并上传附件;仅作台账留存,不扣库存"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column label="名称" min-width="200">
<template #default="{ row }">
<el-select
v-model="row.paperName"
filterable
allow-create
default-first-option
clearable
placeholder="请选择或输入材料名称"
class="inner-cell-input inner-cell-select"
>
<el-option
v-for="opt in PAPER_NAME_OPTIONS"
:key="opt"
:label="opt"
:value="opt"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="份数" min-width="120">
<template #default="{ row }">
<el-input-number
v-model="row.copies"
:min="1"
:precision="0"
:step="1"
controls-position="right"
class="inner-cell-input"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="份数" min-width="120">
<template #default="{ row }">
<el-input-number
v-model="row.copies"
:min="1"
:precision="0"
:step="1"
controls-position="right"
class="inner-cell-input"
/>
</template>
</el-table-column>
<el-table-column label="附件" min-width="320">
</template>
</el-table-column>
<el-table-column label="附件" min-width="320">
<template #default="{ row, $index }">
<div class="paper-att-cards">
<div
@@ -304,16 +251,22 @@
</div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button type="danger" link size="small" @click="removePaperRow($index)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<p v-else class="section-empty-hint">点击「添加纸质材料」逐条填写名称、份数并上传附件;仅作台账留存,不扣库存</p>
<el-table-column
label="操作"
min-width="100"
align="center"
header-align="center"
fixed="right"
>
<template #default="{ $index }">
<el-button type="danger" link @click="removePaperRow($index)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</section>
<el-form label-position="top" class="biz-dialog-form">
@@ -366,7 +319,7 @@
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="list-body list-wrap">
<div class="list-body list-wrap inner-table-wrap">
<el-table
ref="cellSelectTableRef"
class="list-table"
@@ -381,14 +334,21 @@
type="selection"
min-width="45"
align="center"
header-align="center"
:selectable="isCellRowSelectable"
/>
<el-table-column prop="productCode" label="产品编码" min-width="180" show-overflow-tooltip />
<el-table-column prop="productName" label="产品名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="productTypeName" label="产品类型" min-width="90" />
<el-table-column prop="passage" label="代次" min-width="70" align="center" />
<el-table-column
prop="passage"
label="代次"
min-width="70"
align="center"
header-align="center"
/>
<el-table-column prop="storageSlotName" label="储物格" min-width="140" show-overflow-tooltip />
<el-table-column label="状态" min-width="90" align="center">
<el-table-column label="状态" min-width="90" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="cellProductStatusTagType(row.productStatus) as any" size="small">
{{ cellProductStatusLabel(row.productStatus) }}
@@ -400,8 +360,17 @@
</el-table>
</div>
<template #footer>
<el-button @click="cellSelectVisible = false">取消</el-button>
<el-button type="primary" :disabled="!cellSelectedRows.length" @click="confirmCellSelect">
<el-button class="btn-ghost" @click="cellSelectVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
:disabled="!cellSelectedRows.length"
@click="confirmCellSelect"
>
<el-icon><Check /></el-icon>
确定(已选 {{ cellSelectedRows.length }} 项)
</el-button>
</template>
@@ -449,7 +418,6 @@
} from '@element-plus/icons-vue'
import { fetchUploadAttachment } from '@/api/business'
import { fetchCellProductList } from '@/api/cell-product'
import { fetchQcMaterialList, type QcMaterialOption } from '@/api/qc-material'
import {
fetchOutboundReleaseMaterial,
saveOutboundReleaseMaterial,
@@ -480,13 +448,9 @@
_uploading?: boolean
}
type MaterialRow = {
_rowKey: string
type PreservedQcMaterial = {
materialId: string
materialName: string
specModel: string
unit: string
qty?: number
qty: number
remark?: string
}
@@ -518,10 +482,8 @@
const submitting = ref(false)
const formRemark = ref('')
const cellRows = ref<OutboundReleaseCellRow[]>([])
const materialRows = ref<MaterialRow[]>([])
const preservedQcMaterials = ref<PreservedQcMaterial[]>([])
const paperRows = ref<PaperRow[]>([])
const materialOptions = ref<QcMaterialOption[]>([])
const materialOptionsLoading = ref(false)
const cellSelectVisible = ref(false)
const cellSearchKeyword = ref('')
@@ -536,12 +498,7 @@
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
let materialRowSeq = 0
let paperRowSeq = 0
function nextMaterialRowKey() {
materialRowSeq += 1
return `mr_${Date.now()}_${materialRowSeq}`
}
function nextPaperRowKey() {
paperRowSeq += 1
return `pr_${Date.now()}_${paperRowSeq}`
@@ -575,101 +532,6 @@
)
}
function mapMaterialListToOptions(list: Array<Record<string, any>>): QcMaterialOption[] {
return (list || []).map((m) => ({
id: String(m.id || ''),
label: `${m.materialName || ''}${m.materialCode ? ` (${m.materialCode})` : ''}`.trim(),
materialCode: String(m.materialCode || ''),
materialName: String(m.materialName || ''),
specModel: String(m.specModel || ''),
unit: String(m.unit || ''),
stockQty: Number(m.stockQty ?? 0)
}))
}
function ensureMaterialsInOptions(rows: MaterialRow[]) {
const merged = [...materialOptions.value]
for (const row of rows) {
const id = String(row.materialId || '')
if (!id || merged.some((o) => o.id === id)) continue
merged.push({
id,
label: row.materialName || id,
materialCode: '',
materialName: row.materialName || '',
specModel: row.specModel || '',
unit: row.unit || '',
stockQty: 0
})
}
materialOptions.value = merged
}
async function remoteSearchMaterials(query: string) {
materialOptionsLoading.value = true
try {
const res = await fetchQcMaterialList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
materialOptions.value = mapMaterialListToOptions(res.list || [])
ensureMaterialsInOptions(materialRows.value)
} catch {
materialOptions.value = []
ensureMaterialsInOptions(materialRows.value)
} finally {
materialOptionsLoading.value = false
}
}
function stockQtyDisplay(materialId: string) {
const opt = materialOptions.value.find((o) => o.id === materialId)
if (!opt || opt.stockQty == null) return '—'
return String(opt.stockQty)
}
function isMaterialOptionDisabled(materialId: string, currentIndex: number) {
return materialRows.value.some((r, i) => i !== currentIndex && r.materialId === materialId)
}
function onMaterialSelect(index: number, id: string) {
const row = materialRows.value[index]
if (!row) return
if (!id) {
row.materialId = ''
row.materialName = ''
row.specModel = ''
row.unit = ''
return
}
if (isMaterialOptionDisabled(id, index)) {
ElMessage.warning('该物料已在列表中,请勿重复添加')
row.materialId = ''
return
}
const opt = materialOptions.value.find((o) => o.id === id)
row.materialId = id
row.materialName = opt?.materialName || ''
row.specModel = opt?.specModel || ''
row.unit = opt?.unit || ''
}
function addMaterialRow() {
materialRows.value.push({
_rowKey: nextMaterialRowKey(),
materialId: '',
materialName: '',
specModel: '',
unit: '',
qty: undefined
})
}
function removeMaterialRow(index: number) {
materialRows.value.splice(index, 1)
}
function addPaperRow() {
paperRows.value.push({
_rowKey: nextPaperRowKey(),
@@ -800,17 +662,14 @@
const data = await fetchOutboundReleaseMaterial(props.scheduleId)
formRemark.value = data.remark || ''
cellRows.value = (data.cells || []).map((c) => ({ ...c }))
materialRows.value = (data.qcMaterials || []).map((m) => ({
_rowKey: nextMaterialRowKey(),
materialId: m.qcMaterialId || '',
materialName: m.materialName || '',
specModel: m.specModel || '',
unit: m.unit || '',
qty: m.qty,
remark: m.remark || ''
}))
preservedQcMaterials.value = (data.qcMaterials || [])
.map((m) => ({
materialId: m.qcMaterialId || '',
qty: Number(m.qty),
remark: m.remark || undefined
}))
.filter((m) => m.materialId && Number.isFinite(m.qty) && m.qty > 0)
paperRows.value = mapPaperRowsFromApi(data.paperMaterials, data.paperAttachments)
await remoteSearchMaterials('')
} finally {
loading.value = false
}
@@ -826,7 +685,7 @@
function resetState() {
cellRows.value = []
materialRows.value = []
preservedQcMaterials.value = []
paperRows.value = []
formRemark.value = ''
cellSelectVisible.value = false
@@ -883,18 +742,6 @@
return
}
for (const row of materialRows.value) {
if (!row.materialId) {
ElMessage.warning('请为每条物料选择名称')
return
}
const qty = row.qty
if (qty == null || !Number.isFinite(Number(qty)) || Number(qty) <= 0) {
ElMessage.warning('物料计划用量须大于 0')
return
}
}
for (const row of paperRows.value) {
if (!row.paperName?.trim()) {
ElMessage.warning('请填写每条纸质材料的名称')
@@ -924,11 +771,7 @@
cellProductId: String(c.cellProductId || ''),
remark: c.remark
})),
qcMaterials: materialRows.value.map((m) => ({
materialId: m.materialId,
qty: Number(m.qty),
remark: m.remark
}))
qcMaterials: preservedQcMaterials.value
})
ElMessage.success('出库物料准备已保存')
emit('update:modelValue', false)
@@ -995,10 +838,81 @@
color: #1e293b;
}
.section-empty-hint {
margin: 0;
font-size: 13px;
color: var(--mint-text-muted, #334155);
.section-title--mint-bar {
display: flex;
align-items: center;
&::before {
content: '';
width: 3px;
height: 18px;
background: var(--primary, #047857);
border-radius: 2px;
margin-right: 10px;
flex-shrink: 0;
}
}
.inner-table-wrap.list-body.list-wrap {
border-top: 1px solid var(--mint-border, #e2e8f0);
border-radius: 0;
overflow: hidden;
:deep(.el-table) {
--el-table-header-bg-color: var(--mint-table-head, #f8fafc);
--el-table-border-color: var(--mint-border, #e2e8f0);
--el-table-row-hover-bg-color: var(--mint-row-hover, #f0fdf4);
--el-table-text-color: var(--mint-text, #0f172a);
--el-table-header-text-color: var(--mint-text-muted, #64748b);
font-size: 14px;
}
:deep(.el-table th.el-table__cell) {
font-size: 12px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
}
:deep(.el-table td.el-table__cell) {
font-size: 14px;
font-weight: 500;
color: var(--mint-text, #0f172a);
}
:deep(.el-empty) {
padding: 24px 0;
}
}
.inner-table {
width: 100%;
:deep(td.el-table__cell) {
padding-top: 8px;
padding-bottom: 8px;
}
:deep(.inner-cell-input) {
width: 100%;
.el-input__wrapper {
min-height: 36px;
padding-top: 2px;
padding-bottom: 2px;
}
.el-input-number .el-input__wrapper {
min-height: 36px;
}
}
:deep(.inner-cell-select) {
width: 100%;
.el-select__wrapper {
min-height: 36px;
}
}
}
.inner-cell-input {
@@ -0,0 +1,417 @@
<!-- 出库放行审核 · 卡片列表 -->
<template>
<div class="list-body list-wrap outbound-release-review-cards-body">
<div v-loading="loading" class="outbound-release-review-cards-wrap">
<article
v-for="(row, idx) in rows"
:key="String(row.scheduleId || row.id || idx)"
class="outbound-release-review-card"
@dblclick="emit('card-dblclick', row)"
>
<header class="outbound-release-review-card__head">
<div class="outbound-release-review-card__head-main">
<div class="outbound-release-review-card__title-row">
<el-checkbox
:model-value="isSelected(row)"
class="outbound-release-review-card__checkbox"
@change="(val) => emit('toggle-select', row, Boolean(val))"
@click.stop
/>
<span class="outbound-release-review-card__index">{{ cardIndex(idx) }}</span>
<span class="outbound-release-review-card__code">{{ row.applicationNo || '—' }}</span>
<el-tag v-if="row.bizType" :type="bizTypeTagType(row.bizType) as any" size="small">
{{ row.bizTypeLabel || bizTypeLabel(row.bizType) }}
</el-tag>
<el-tag
v-if="row.scheduleStatus"
:type="scheduleStatusTagType(row.scheduleStatus)"
size="small"
>
{{ scheduleStatusLabel(row.scheduleStatus) }}
</el-tag>
<el-tag v-if="row.qcStatus" :type="qcStatusTagType(row.qcStatus)" size="small">
{{ qcStatusLabel(row.qcStatus) }}
</el-tag>
<el-tag
v-if="row.reviewConclusion"
:type="storageReleaseConclusionTagType(row.reviewConclusion)"
size="small"
>
{{ storageReleaseConclusionLabel(row.reviewConclusion) }}
</el-tag>
<el-tag v-else type="info" size="small">待审核</el-tag>
</div>
<dl class="outbound-release-review-card__meta">
<div class="outbound-release-review-card__meta-item">
<dt>患者姓名</dt>
<dd>{{ row.patientName || '—' }}</dd>
</div>
<div class="outbound-release-review-card__meta-item">
<dt>{{ pickupTimeLabel }}</dt>
<dd>{{ row.infusionTime || '—' }}</dd>
</div>
<div class="outbound-release-review-card__meta-item">
<dt>审核人</dt>
<dd>{{ row.reviewByName || '—' }}</dd>
</div>
<div class="outbound-release-review-card__meta-item">
<dt>审核时间</dt>
<dd>{{ row.reviewTime || '—' }}</dd>
</div>
</dl>
</div>
<section class="outbound-release-review-card__cells">
<div class="outbound-release-review-card__cells-title">需求细胞</div>
<div v-if="cellProducts(row).length" class="outbound-release-review-card__cell-list">
<div
v-for="(product, pi) in cellProducts(row)"
:key="`${product.projectCode || product.projectName || pi}`"
class="outbound-release-review-card__cell-item"
>
<dl class="outbound-release-review-card__cell-fields">
<div class="outbound-release-review-card__cell-field">
<dt>项目编码</dt>
<dd>{{ product.projectCode || '—' }}</dd>
</div>
<div class="outbound-release-review-card__cell-field">
<dt>项目名称</dt>
<dd>{{ product.projectName || '—' }}</dd>
</div>
<div class="outbound-release-review-card__cell-field">
<dt>细胞类型</dt>
<dd>{{ product.cellTypeName || row.cellType || '—' }}</dd>
</div>
<div class="outbound-release-review-card__cell-field">
<dt>规格</dt>
<dd>{{ product.specText || row.specText || '—' }}</dd>
</div>
<div class="outbound-release-review-card__cell-field">
<dt>数量</dt>
<dd>{{ product.quantity != null ? product.quantity : '—' }}</dd>
</div>
<div class="outbound-release-review-card__cell-field">
<dt>产品状态</dt>
<dd>
<el-tag
v-if="product.productStatus"
:type="productStatusTagType(product.productStatus) as any"
size="small"
>
{{ productStatusLabel(product.productStatus) }}
</el-tag>
<span v-else>—</span>
</dd>
</div>
</dl>
</div>
</div>
<p v-else class="outbound-release-review-card__cells-fallback">
<template v-if="row.cellType || row.specText">
<span v-if="row.cellType">细胞类型:{{ row.cellType }}</span>
<span v-if="row.cellType && row.specText"> · </span>
<span v-if="row.specText">规格:{{ row.specText }}</span>
</template>
<template v-else>暂无细胞需求明细</template>
</p>
</section>
<div class="outbound-release-review-card__ops">
<el-button class="btn-op-item" @click.stop="emit('view', row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click.stop="emit('material', row)">
<el-icon><Box /></el-icon>出库物料
</el-button>
<el-button class="btn-op-item" @click.stop="emit('review', row)">
<el-icon><CircleCheck /></el-icon>审核
</el-button>
</div>
</header>
</article>
<el-empty
v-if="!loading && !rows.length"
class="outbound-release-review-cards-empty"
description="暂无出库放行审核记录"
:image-size="72"
/>
</div>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="page"
:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(v) => emit('page-size-change', v)"
@current-change="(v) => emit('page-change', v)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { View, CircleCheck, Box } from '@element-plus/icons-vue'
import type { OutboundReleaseReview } from '@/api/outbound-release-review'
import type { OutboundQcDetailProduct } from '@/api/qc-work-order'
import {
storageReleaseConclusionLabel,
storageReleaseConclusionTagType,
scheduleStatusLabel,
scheduleStatusTagType,
qcStatusLabel,
qcStatusTagType,
bizTypeLabel,
bizTypeTagType,
productStatusLabel,
productStatusTagType
} from '@/utils/status-tag-registry'
defineOptions({ name: 'OutboundReleaseReviewCardList' })
const props = defineProps<{
rows: OutboundReleaseReview[]
loading: boolean
page: number
pageSize: number
total: number
selectedIds: string[]
pickupTimeLabel: string
}>()
const emit = defineEmits<{
view: [row: OutboundReleaseReview]
material: [row: OutboundReleaseReview]
review: [row: OutboundReleaseReview]
'toggle-select': [row: OutboundReleaseReview, checked: boolean]
'card-dblclick': [row: OutboundReleaseReview]
'page-change': [page: number]
'page-size-change': [size: number]
}>()
function rowId(row: OutboundReleaseReview) {
return String(row.scheduleId || row.id || '')
}
function isSelected(row: OutboundReleaseReview) {
return props.selectedIds.includes(rowId(row))
}
function cardIndex(i: number) {
return (props.page - 1) * props.pageSize + i + 1
}
function cellProducts(row: OutboundReleaseReview): OutboundQcDetailProduct[] {
return row.products?.length ? row.products : []
}
</script>
<style scoped lang="scss">
.outbound-release-review-cards-body {
border-top: 1px solid var(--mint-border, #e2e8f0);
}
.outbound-release-review-cards-wrap {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 14px;
min-height: 120px;
padding: 16px;
background: #fff;
}
.outbound-release-review-card {
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
overflow: hidden;
box-shadow:
0 1px 3px rgba(15, 23, 42, 0.06),
0 1px 2px rgba(15, 23, 42, 0.04);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover {
border-color: #a8d4c4;
box-shadow: 0 4px 12px rgba(4, 120, 87, 0.1);
}
}
.outbound-release-review-card__head {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 16px;
background: #fff;
}
.outbound-release-review-card__head-main {
min-width: 0;
}
.outbound-release-review-card__title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.outbound-release-review-card__checkbox {
flex-shrink: 0;
margin-right: -4px;
}
.outbound-release-review-card__index {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
min-width: 20px;
}
.outbound-release-review-card__code {
font-size: 15px;
font-weight: 700;
color: #0f172a;
word-break: break-all;
}
.outbound-release-review-card__meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
margin: 0;
}
.outbound-release-review-card__meta-item {
min-width: 0;
dt {
margin: 0 0 2px;
font-size: 11px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
}
dd {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #1e293b;
line-height: 1.45;
word-break: break-word;
}
}
.outbound-release-review-card__cells {
padding: 12px 14px;
border: 1px solid #d1fae5;
border-radius: 8px;
background: linear-gradient(180deg, #ecfdf5 0%, #fff 100%);
}
.outbound-release-review-card__cells-title {
display: flex;
align-items: center;
margin-bottom: 10px;
font-size: 12px;
font-weight: 600;
color: #047857;
&::before {
content: '';
width: 3px;
height: 14px;
margin-right: 8px;
background: #047857;
border-radius: 2px;
flex-shrink: 0;
}
}
.outbound-release-review-card__cell-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.outbound-release-review-card__cell-item {
padding: 0;
border: none;
border-radius: 0;
background: transparent;
&:not(:last-child) {
padding-bottom: 10px;
margin-bottom: 10px;
border-bottom: 1px solid #e2e8f0;
}
}
.outbound-release-review-card__cell-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
margin: 0;
}
.outbound-release-review-card__cell-field {
min-width: 0;
dt {
margin: 0 0 2px;
font-size: 11px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
}
dd {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #1e293b;
line-height: 1.45;
word-break: break-word;
}
}
.outbound-release-review-card__cells-fallback {
margin: 0;
font-size: 13px;
color: #334155;
line-height: 1.5;
}
.outbound-release-review-card__ops {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
:deep(.el-button + .el-button) {
margin-left: 0 !important;
}
}
.outbound-release-review-cards-empty {
grid-column: 1 / -1;
padding: 24px 0;
}
@media (max-width: 900px) {
.outbound-release-review-cards-wrap {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.outbound-release-review-card__meta,
.outbound-release-review-card__cell-fields {
grid-template-columns: 1fr;
}
}
</style>
@@ -4,8 +4,8 @@
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="640px"
class="dialog-form-layout"
width="720px"
class="dialog-form-layout outbound-release-review-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
@@ -15,19 +15,49 @@
@update:model-value="emit('update:modelValue', $event)"
@closed="resetForm"
>
<div v-if="isSingleMode && singleDetail" class="review-info-summary">
<div class="review-summary-row">
<span class="review-summary-label">申请单号</span>
<span class="review-summary-value">{{ singleDetail.application?.applicationNo || '—' }}</span>
</div>
<div class="review-summary-row">
<span class="review-summary-label">患者姓名</span>
<span class="review-summary-value">{{ singleDetail.patient?.patientName || '—' }}</span>
</div>
<div class="review-summary-row">
<span class="review-summary-label">细胞类型</span>
<span class="review-summary-value">{{ singleDetail.infusionPlan?.cellType || '—' }}</span>
<div v-if="isSingleMode && singleDetail" class="review-context-card">
<div class="review-context-card__hero">
<div class="review-context-card__hero-main">
<span class="review-context-card__eyebrow">申请单号</span>
<span class="review-context-card__code">{{
singleDetail.application?.applicationNo || '—'
}}</span>
</div>
<div class="review-context-card__tags">
<el-tag
v-if="reviewBizTypeLabel"
type="primary"
size="small"
>
{{ reviewBizTypeLabel }}
</el-tag>
<el-tag
v-if="singleDetail.infusionSchedule?.scheduleStatus"
:type="reviewScheduleStatusTag as any"
size="small"
>
{{ reviewScheduleStatusLabel }}
</el-tag>
<el-tag
v-if="singleDetail.infusionSchedule?.qcStatus"
:type="reviewQcStatusTag as any"
size="small"
>
{{ reviewQcStatusLabel }}
</el-tag>
</div>
</div>
<dl class="review-context-grid">
<div
v-for="item in reviewContextItems"
:key="item.label"
class="review-context-kv"
:class="{ 'review-context-kv--span': item.span }"
>
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
</div>
<p v-else-if="scheduleIds.length > 1" class="audit-target-hint">
将对已选
@@ -188,6 +218,14 @@
batchReviewOutboundRelease,
type OutboundReleaseReviewDetail
} from '@/api/outbound-release-review'
import { getPilotBizConfig, resolvePilotBizType } from '@/config/pilot-biz-type'
import {
bizTypeLabel,
qcStatusLabel,
qcStatusTagType,
scheduleStatusLabel,
scheduleStatusTagType
} from '@/utils/status-tag-registry'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
type AttachmentItem = {
@@ -211,12 +249,83 @@
}>()
const formRef = ref<FormInstance>()
const attachmentUploadRef = ref()
const submitting = ref(false)
const singleDetail = ref<OutboundReleaseReviewDetail | null>(null)
const attachmentFiles = ref<AttachmentItem[]>([])
const previewVisible = ref(false)
const previewUrl = ref('')
const previewType = ref<'image' | 'video'>('image')
const isSingleMode = computed(() => props.scheduleIds.length === 1)
const reviewBizType = computed(() =>
resolvePilotBizType(singleDetail.value?.application?.bizType)
)
const reviewBizConfig = computed(() => getPilotBizConfig(reviewBizType.value))
const reviewBizTypeLabel = computed(() => {
const fromApi = singleDetail.value?.application?.bizTypeLabel
if (fromApi) return fromApi
const code = singleDetail.value?.application?.bizType
return code ? bizTypeLabel(code) : ''
})
const reviewScheduleStatusLabel = computed(() =>
scheduleStatusLabel(singleDetail.value?.infusionSchedule?.scheduleStatus || '')
)
const reviewScheduleStatusTag = computed(() =>
scheduleStatusTagType(singleDetail.value?.infusionSchedule?.scheduleStatus || '')
)
const reviewQcStatusLabel = computed(() =>
qcStatusLabel(singleDetail.value?.infusionSchedule?.qcStatus || '')
)
const reviewQcStatusTag = computed(() =>
qcStatusTagType(singleDetail.value?.infusionSchedule?.qcStatus || '')
)
function uniqueJoined(values: Array<string | undefined | null>) {
const items = [...new Set(values.map((v) => String(v || '').trim()).filter(Boolean))]
return items.length ? items.join('、') : '—'
}
function resolveReviewCellType(detail: OutboundReleaseReviewDetail | null) {
if (!detail) return '—'
const planType = detail.infusionPlan?.cellType?.trim()
if (planType) return planType
return uniqueJoined((detail.products || []).map((p) => p.cellTypeName))
}
function resolveReviewSpecText(detail: OutboundReleaseReviewDetail | null) {
if (!detail) return '—'
const planSpec = detail.infusionPlan?.specText?.trim()
if (planSpec) return planSpec
return uniqueJoined((detail.products || []).map((p) => p.specText))
}
const reviewContextItems = computed(() => {
const d = singleDetail.value
if (!d) return [] as Array<{ label: string; value: string; span?: boolean }>
const pickupLabel = reviewBizConfig.value.pickupTimeLabel
const isBasicExperiment = reviewBizType.value === 'basic_experiment'
const items: Array<{ label: string; value: string; span?: boolean }> = [
{ label: '患者姓名', value: d.patient?.patientName || '—' },
{ label: '细胞类型', value: resolveReviewCellType(d) },
{ label: '规格', value: resolveReviewSpecText(d) },
{ label: pickupLabel, value: d.infusionSchedule?.infusionTime || '—' }
]
if (!isBasicExperiment) {
items.push(
{ label: '输注地点', value: d.infusionSchedule?.infusionLocation || '—' },
{ label: '渠道名称', value: d.application?.channelName || '—' }
)
} else {
items.push(
{ label: '课题名称', value: d.application?.projectTitle || '—', span: true },
{ label: '客户名称', value: d.application?.customerName || '—' }
)
}
return items
})
const form = reactive({
reviewConclusion: 'tg',
reviewRemark: ''
@@ -395,31 +504,94 @@
}
}
.review-info-summary {
margin-bottom: 16px;
padding: 12px 16px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 20px;
.review-context-card {
margin-bottom: 20px;
border: 1px solid #d1fae5;
border-radius: 10px;
overflow: hidden;
background: linear-gradient(180deg, #ecfdf5 0%, #fff 42%);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.review-summary-row {
.review-context-card__hero {
display: flex;
gap: 8px;
font-size: 13px;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 12px;
border-bottom: 1px solid #e2e8f0;
background: rgba(255, 255, 255, 0.72);
}
.review-summary-label {
color: var(--mint-text-muted, #334155);
.review-context-card__hero-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.review-context-card__eyebrow {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--mint-text-muted, #64748b);
}
.review-context-card__code {
font-size: 18px;
font-weight: 700;
line-height: 1.3;
color: #0f172a;
word-break: break-all;
}
.review-context-card__tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
flex-shrink: 0;
}
.review-summary-value {
color: #1e293b;
font-weight: 500;
.review-context-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 20px;
margin: 0;
padding: 14px 16px 16px;
}
.review-context-kv {
margin: 0;
min-width: 0;
&--span {
grid-column: 1 / -1;
}
dt {
margin: 0 0 4px;
font-size: 12px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
}
dd {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1e293b;
line-height: 1.5;
word-break: break-word;
}
}
@media (max-width: 640px) {
.review-context-grid {
grid-template-columns: 1fr;
}
}
.form-col-span-2 {
@@ -14,12 +14,27 @@
append-to-body
modal-class="outbound-release-view-dialog-mask"
>
<template #diagnosis-attachments>
<OutboundQcDiagnosisAttachments :files="diagnosisAttachmentFiles" />
</template>
<template #product-table>
<OutboundQcProductTable :products="viewProducts" />
</template>
<template #production-info>
<SampleProductionInfoCards :production-info="detail?.productionInfo" />
</template>
<template #qc-info>
<SampleQcInfoCards :qc-info="detail?.qcInfo" hide-sample-context />
<SampleQcInfoGroupedPanel
:gate-qc-info="detail?.qcInfo"
:process-qc-info-list="detail?.processQcInfoList"
gate-section-label="计划质检(出库门禁)"
process-section-label="生产过程质检"
:show-section-label="false"
hide-sample-context
/>
</template>
<template #review-attachments>
@@ -223,8 +238,10 @@
import { CircleCheck, Document, Download, View } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import SampleQcInfoCards from '@/components/business/sample-qc-info-cards/index.vue'
import SampleQcInfoGroupedPanel from '@/components/business/sample-qc-info-grouped-panel/index.vue'
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
import OutboundQcDiagnosisAttachments from '@/views/quality-inspection/qc-work-order/view-dialogs/OutboundQcDiagnosisAttachments.vue'
import OutboundQcProductTable from '@/views/quality-inspection/qc-work-order/view-dialogs/OutboundQcProductTable.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import {
fetchOutboundReleaseReviewDetail,
@@ -298,6 +315,26 @@
return resolvePilotDetail(detail.value).application?.applicationNo || ''
})
const viewProducts = computed(() => {
if (!detail.value) return []
return resolvePilotDetail(detail.value).products || []
})
const diagnosisAttachmentFiles = computed(() => {
if (!detail.value) return []
const urls = resolvePilotDetail(detail.value).patient?.diagnosisAttachments || ''
if (!urls.trim()) return []
return urls
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((fileUrl, i) => ({
id: `diag_${i}`,
fileUrl,
fileName: fileUrl.split('/').pop() || ''
}))
})
const reviewAttachmentFiles = computed(() => {
const urls = detail.value?.reviewInfo?.reviewAttachments || ''
if (!urls.trim()) return []
@@ -43,7 +43,7 @@
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>支持按申请单号、患者姓名搜索;{{ pickupTimeLabel }}默认今天前 15 天至后 15 天。</span
>支持按申请单号、患者姓名搜索。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
@@ -74,148 +74,22 @@
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table outbound-release-review-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="50" align="center" header-align="center" />
<el-table-column
prop="applicationNo"
label="申请单号"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
prop="patientName"
label="患者姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="bizType"
label="类型"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag v-if="row.bizType" :type="bizTypeTagType(row.bizType) as any" size="small">
{{ row.bizTypeLabel || bizTypeLabel(row.bizType) }}
</el-tag>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column
prop="infusionTime"
:label="pickupTimeLabel"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
prop="scheduleStatus"
label="计划状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
v-if="row.scheduleStatus"
:type="scheduleStatusTagType(row.scheduleStatus)"
size="small"
>
{{ scheduleStatusLabel(row.scheduleStatus) }}
</el-tag>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column
prop="qcStatus"
label="质检状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag v-if="row.qcStatus" :type="qcStatusTagType(row.qcStatus)" size="small">
{{ qcStatusLabel(row.qcStatus) }}
</el-tag>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column
prop="reviewConclusion"
label="审核结论"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
v-if="row.reviewConclusion"
:type="storageReleaseConclusionTagType(row.reviewConclusion)"
size="small"
>
{{ storageReleaseConclusionLabel(row.reviewConclusion) }}
</el-tag>
<el-tag v-else type="info" size="small">待审核</el-tag>
</template>
</el-table-column>
<el-table-column
prop="reviewByName"
label="审核人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="reviewTime"
label="审核时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="260"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="openMaterialDialog(row)">
<el-icon><Box /></el-icon>出库物料
</el-button>
<el-button class="btn-op-item" @click="openReviewDialog(row)">
<el-icon><CircleCheck /></el-icon>审核
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<OutboundReleaseReviewCardList
:rows="tableData"
:loading="loading"
:page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:selected-ids="selectedIds"
:pickup-time-label="pickupTimeLabel"
@toggle-select="handleToggleSelect"
@card-dblclick="handleView"
@view="handleView"
@material="openMaterialDialog"
@review="openReviewDialog"
@page-change="(val) => ((pagination.page = val), loadData())"
@page-size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
/>
<OutboundReleaseViewDialog
v-model:visible="viewVisible"
@@ -242,24 +116,15 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { View, Search, RefreshLeft, CircleCheck, Box } from '@element-plus/icons-vue'
import { Search, RefreshLeft, CircleCheck } from '@element-plus/icons-vue'
import OutboundReleaseViewDialog from './OutboundReleaseViewDialog.vue'
import OutboundReleaseReviewDialog from './OutboundReleaseReviewDialog.vue'
import OutboundReleaseMaterialDialog from './OutboundReleaseMaterialDialog.vue'
import OutboundReleaseReviewCardList from './OutboundReleaseReviewCardList.vue'
import {
fetchOutboundReleaseReviewList,
type OutboundReleaseReview
} from '@/api/outbound-release-review'
import {
storageReleaseConclusionLabel,
storageReleaseConclusionTagType,
scheduleStatusLabel,
scheduleStatusTagType,
qcStatusLabel,
qcStatusTagType,
bizTypeLabel,
bizTypeTagType
} from '@/utils/status-tag-registry'
import { getPilotBizConfig, resolvePilotBizType } from '@/config/pilot-biz-type'
defineOptions({ name: 'PilotServiceOutboundReleaseReview' })
@@ -278,35 +143,33 @@
{ value: 'btg', label: '不通过' }
]
function formatLocalDate(d: Date) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/** 输注时间默认:今天前 15 天 ~ 今天后 15 天 */
function getDefaultInfusionTimeRange(): [string, string] {
const today = new Date()
const start = new Date(today)
start.setDate(start.getDate() - 15)
const end = new Date(today)
end.setDate(end.getDate() + 15)
return [formatLocalDate(start), formatLocalDate(end)]
}
const searchForm = reactive({
reviewConclusion: '',
keyword: '',
infusionTimeRange: getDefaultInfusionTimeRange() as [string, string]
infusionTimeRange: null as [string, string] | null
})
const tableData = ref<OutboundReleaseReview[]>([])
const selectedRows = ref<OutboundReleaseReview[]>([])
const selectedIds = computed(() =>
selectedRows.value.map((r) => String(r.scheduleId || r.id || ''))
)
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const handleSelectionChange = (rows: OutboundReleaseReview[]) => {
selectedRows.value = rows
function rowId(row: OutboundReleaseReview) {
return String(row.scheduleId || row.id || '')
}
function handleToggleSelect(row: OutboundReleaseReview, checked: boolean) {
const id = rowId(row)
if (!id) return
if (checked) {
if (!selectedRows.value.some((r) => rowId(r) === id)) {
selectedRows.value = [...selectedRows.value, row]
}
return
}
selectedRows.value = selectedRows.value.filter((r) => rowId(r) !== id)
}
const canBatchReview = computed(() => selectedRows.value.length > 0)
@@ -338,7 +201,7 @@
function handleReset() {
searchForm.reviewConclusion = ''
searchForm.keyword = ''
searchForm.infusionTimeRange = getDefaultInfusionTimeRange()
searchForm.infusionTimeRange = null
pagination.page = 1
loadData()
}
@@ -401,13 +264,3 @@
loadData()
})
</script>
<style scoped lang="scss">
.outbound-release-review-table {
:deep(td.el-table__cell:has(.btn-op-item)),
:deep(td.el-table-fixed-column--right:has(.btn-op-item)),
:deep(th.el-table-fixed-column--right) {
min-width: 260px !important;
}
}
</style>
@@ -3455,42 +3455,6 @@
min-height: 200px;
}
/* ---- Sticky Tab 导航 ---- */
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin: 0 28px 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #065f46;
}
}
/* ---- KPI 摘要条 ---- */
.kpi-strip {
display: grid;
@@ -2875,42 +2875,6 @@
}
}
/* ---- Sticky Tab 导航 ---- */
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin: 0 28px 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #065f46;
}
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -1510,57 +1510,16 @@
}
}
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin-bottom: 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active):not(.is-disabled) {
color: #065f46;
}
&.is-disabled {
cursor: default;
opacity: 0.65;
pointer-events: none;
}
}
.kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
margin-bottom: 24px;
padding: 6px;
background: linear-gradient(135deg, #e8f4ee 0%, #f8fafc 52%, #e8f4ee 100%);
border: 1px solid #a8d4c4;
padding: 18px 20px;
background: linear-gradient(180deg, #ecfdf5 0%, #fff 100%);
border: none;
border-radius: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85);
box-shadow: none;
&--cols5 {
grid-template-columns: repeat(5, 1fr);
@@ -1571,12 +1530,13 @@
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
padding: 8px 16px;
background: transparent;
border-radius: 8px;
transition: background 0.2s;
&:not(:last-child) {
border-right: 1px solid rgba(168, 212, 196, 0.75);
&:hover {
background: rgba(255, 255, 255, 0.35);
}
}
@@ -1588,8 +1548,8 @@
height: 40px;
flex-shrink: 0;
border-radius: 10px;
background: #fff;
border: 1px solid #a8d4c4;
background: rgba(255, 255, 255, 0.72);
border: none;
color: #065f46;
font-size: 18px;
}
@@ -1618,6 +1578,7 @@
font-size: 16px;
font-weight: 700;
color: #047857;
letter-spacing: 0.02em;
}
}
@@ -1660,16 +1621,12 @@
&--banner {
margin: -22px -24px 18px;
padding: 10px 24px;
border-bottom: none;
border-bottom: 1px solid rgba(16, 185, 129, 0.12);
border-radius: 12px 12px 0 0;
background: var(--primary, #10b981);
&::before {
display: none;
}
background: var(--primary-bg, #ecfdf5);
h3 {
color: #fff;
color: var(--primary-dark, #059669);
}
}
}
@@ -415,20 +415,11 @@
: '支持申请单号、患者姓名、渠道名称模糊查询,输入即检索。'
)
// ── 搜索(输注时间默认最近 30 天) ──
const getDefaultTimeRange = () => {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 30 * 24 * 3600 * 1000)
const fmt = (d: Date) => d.toISOString().slice(0, 10)
return [fmt(start), fmt(end)]
}
const searchForm = reactive({
keyword: '',
infusionStatus: '',
qcStatus: '',
infusionTimeRange: getDefaultTimeRange() as string[]
infusionTimeRange: null as string[] | null
})
// ── 表格 ─────────────────────────────
@@ -530,7 +521,7 @@
keyword: '',
infusionStatus: '',
qcStatus: '',
infusionTimeRange: getDefaultTimeRange()
infusionTimeRange: null
})
pagination.page = 1
loadData()
@@ -109,141 +109,3 @@
}
})
</script>
<style scoped lang="scss">
.pill-tab-nav {
padding: 16px 20px 0;
display: flex;
flex-direction: column;
align-items: center;
}
.pill-tab-nav__rail {
display: flex;
align-items: center;
width: 70%;
max-width: 100%;
margin-inline: auto;
background: var(--surface, #fff);
border: 1px solid var(--border, #e2e8f0);
border-radius: 10px;
padding: 6px;
position: relative;
overflow: hidden;
}
.pill-tab-nav__progress {
position: absolute;
top: 6px;
bottom: 6px;
left: 6px;
background: var(--primary, #10b981);
border: 1px solid var(--primary-dark, #059669);
border-radius: 8px;
box-shadow: 0 1px 3px rgb(16 185 129 / 28%);
transition:
transform 0.25s ease,
width 0.25s ease;
z-index: 0;
}
.pill-tab-nav__step {
flex: 1;
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
min-height: 44px;
padding: 8px 10px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 8px;
transition: color 0.2s ease;
}
.pill-tab-nav__index {
width: 22px;
height: 22px;
border-radius: 50%;
background: #e2e8f0;
color: var(--mint-text-muted, #334155);
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s ease;
}
.pill-tab-nav__text {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
text-align: left;
}
.pill-tab-nav__title {
font-size: 13px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
white-space: nowrap;
}
.pill-tab-nav__desc {
font-size: 11px;
color: var(--mint-text-muted, #334155);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.pill-tab-nav__step.is-active .pill-tab-nav__index {
background: #fff;
color: var(--primary-dark, #059669);
}
.pill-tab-nav__step.is-active .pill-tab-nav__title,
.pill-tab-nav__step.is-active .pill-tab-nav__desc {
color: #fff;
}
.pill-tab-nav__step.is-done .pill-tab-nav__index {
background: var(--primary-bg, #ecfdf5);
color: var(--primary-dark, #059669);
border: 1px solid var(--mint-border, #c5e0d4);
}
.pill-tab-nav__step.is-done .pill-tab-nav__title {
color: var(--text, #1e293b);
}
@media (width <= 960px) {
.pill-tab-nav__desc {
display: none;
}
.pill-tab-nav__text {
align-items: center;
}
}
@media (width <= 720px) {
.pill-tab-nav {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
align-items: stretch;
}
.pill-tab-nav__rail {
width: 100%;
min-width: 680px;
}
}
</style>
@@ -1800,41 +1800,6 @@
}
}
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin: 0 28px 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #065f46;
}
}
.form-tab-pane {
padding: 0 28px 4px;
@@ -710,41 +710,6 @@
}
}
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin: 0 28px 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #065f46;
}
}
.form-tab-pane {
padding: 0 28px 4px;
@@ -21,20 +21,6 @@ import { getCardMetaConfig } from '@/utils/production-work-order-card-meta'
export type ProductionWorkOrderTabPageMode = 'manage' | 'mine'
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultLast30DaysRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
export function useProductionWorkOrderTabPage(
tabKey: Ref<ProductionWorkOrderTabKey>,
pageMode: Ref<ProductionWorkOrderTabPageMode> | ProductionWorkOrderTabPageMode
@@ -46,13 +32,7 @@ export function useProductionWorkOrderTabPage(
const filterTimeLabel = computed(() => filterTimeLabelForTab(tabConfig.value))
const filterTimeHint = computed(() => {
if (pageModeValue.value === 'mine') {
const tab = tabConfig.value
const timeHint = tabUsesInfusionTimeFilter(tab)
? tab.key === 'outbound_basic_experiment'
? '默认展示近 30 天内交付的工单。'
: '默认展示近 30 天内输注的工单。'
: '默认展示近 30 天内创建的工单。'
return `仅展示指派给当前登录人的生产任务(含已转出记录,转出任务仅可查看)。${timeHint}`
return '仅展示指派给当前登录人的生产任务(含已转出记录,转出任务仅可查看)。'
}
return filterTimeHintForTab(tabConfig.value)
})
@@ -62,7 +42,7 @@ export function useProductionWorkOrderTabPage(
workOrderStatus: '',
taskStatus: ''
})
const createTimeRange = ref<[string, string] | null>(getDefaultLast30DaysRange())
const createTimeRange = ref<[string, string] | null>(null)
const tableData = ref<ProductionWorkOrderItem[]>([])
const presetTasksByWorkOrderId = ref<Record<string, Record<string, any>[]>>({})
const loading = ref(false)
@@ -150,7 +130,7 @@ export function useProductionWorkOrderTabPage(
searchForm.keyword = ''
searchForm.workOrderStatus = ''
searchForm.taskStatus = ''
createTimeRange.value = getDefaultLast30DaysRange()
createTimeRange.value = null
pagination.page = 1
loadData()
}
@@ -2,9 +2,9 @@
<template>
<div class="list-body list-wrap qc-work-order-cards-body">
<div v-loading="loading" class="qc-work-order-cards-wrap">
<template v-if="useInboundGroups">
<template v-if="useGroupedList">
<QcWorkOrderSampleGroup
v-for="(group, idx) in inboundGroups"
v-for="(group, idx) in groupedRows"
:key="group.groupKey"
:group="group"
:group-index="cardIndex(idx)"
@@ -63,9 +63,13 @@
import { computed } from 'vue'
import type { QcWorkOrder } from '@/api/qc-work-order'
import type { QcTask } from '@/api/qc-task'
import { groupInboundQcWorkOrders } from '@/utils/qc-work-order-groups'
import { groupInboundQcWorkOrders, groupOutboundQcWorkOrders } from '@/utils/qc-work-order-groups'
import type { QcWorkOrderCardMetaConfig } from '@/utils/qc-work-order-card-meta'
import { cardMetaUsesInboundGroups } from '@/utils/qc-work-order-card-meta'
import {
cardMetaUsesInboundGroups,
cardMetaUsesOutboundGroups,
cardMetaUsesGroupedList
} from '@/utils/qc-work-order-card-meta'
import QcWorkOrderSampleGroup from './QcWorkOrderSampleGroup.vue'
import QcWorkOrderSingleCard from './QcWorkOrderSingleCard.vue'
@@ -99,11 +103,17 @@
'page-size-change': [size: number]
}>()
const useInboundGroups = computed(() => cardMetaUsesInboundGroups(props.cardMetaConfig.tabKey))
const useGroupedList = computed(() => cardMetaUsesGroupedList(props.cardMetaConfig.tabKey))
const inboundGroups = computed(() =>
useInboundGroups.value ? groupInboundQcWorkOrders(props.rows) : []
)
const groupedRows = computed(() => {
if (cardMetaUsesInboundGroups(props.cardMetaConfig.tabKey)) {
return groupInboundQcWorkOrders(props.rows)
}
if (cardMetaUsesOutboundGroups(props.cardMetaConfig.tabKey)) {
return groupOutboundQcWorkOrders(props.rows)
}
return []
})
function cardIndex(i: number) {
return (props.page - 1) * props.pageSize + i + 1
@@ -3,7 +3,7 @@
<article class="qc-sample-row">
<div class="qc-sample-row__ctx">
<span class="qc-sample-row__idx">{{ groupIndex }}</span>
<span class="qc-sample-row__code">{{ contextRow.sampleCode || '—' }}</span>
<span class="qc-sample-row__code">{{ group.groupTitle || group.sampleCode || '—' }}</span>
<span class="qc-sample-row__sub">{{ contextSubtitle }}</span>
<span v-if="group.workOrders.length > 1" class="qc-sample-row__badge">
{{ group.workOrders.length }} 条质检
@@ -13,7 +13,7 @@
<div v-if="group.workOrders.length > 1" class="qc-wo-tabs">
<div class="qc-wo-tabs__head">
<el-icon class="qc-wo-tabs__hint-icon"><Switch /></el-icon>
<span class="qc-wo-tabs__hint">该样本有多条质检工单,点击下方切换查看</span>
<span class="qc-wo-tabs__hint">{{ groupTabsHint }}</span>
</div>
<div class="qc-wo-tabs__track" role="tablist" aria-label="同样本质检工单">
<button
@@ -28,11 +28,7 @@
>
<span
class="qc-wo-tab__source"
:class="
(wo.triggerSource || 'reception') === 'production'
? 'qc-wo-tab__source--production'
: 'qc-wo-tab__source--reception'
"
:class="triggerSourceTabClass(wo.triggerSource)"
>
<span class="qc-wo-tab__dot"></span>
{{ triggerSourceTabLabel(wo) }}
@@ -51,7 +47,7 @@
<QcWorkOrderSingleCard
v-if="activeRow"
layout="inbound-grouped"
layout="grouped"
:row="activeRow"
:card-meta-config="cardMetaConfig"
:task-refresh-token="taskRefreshToken"
@@ -76,8 +72,10 @@
import type { TagType } from '@/utils/status-tag-registry'
import {
type QcWorkOrderSampleGroupModel,
triggerSourceTabLabel
triggerSourceTabLabel,
isGateTriggerSource
} from '@/utils/qc-work-order-groups'
import { getQcWorkOrderTab } from '@/utils/qc-work-order-tabs'
import type { QcWorkOrderCardMetaConfig } from '@/utils/qc-work-order-card-meta'
import { buildGroupContextSubtitle } from '@/utils/qc-work-order-card-meta'
import QcWorkOrderSingleCard from './QcWorkOrderSingleCard.vue'
@@ -132,12 +130,27 @@
})
const contextRow = computed(() => {
const reception =
props.group.workOrders.find((w) => (w.triggerSource || 'reception') === 'reception') ||
const gate =
props.group.workOrders.find((w) => isGateTriggerSource(w.triggerSource)) ||
props.group.workOrders[0]
return reception
return gate
})
const groupTabsHint = computed(() => {
const tab = getQcWorkOrderTab(props.cardMetaConfig.tabKey)
if (tab?.listLayout === 'outbound-grouped') {
return '该计划有多条质检工单,点击下方切换查看'
}
return '该样本有多条质检工单,点击下方切换查看'
})
function triggerSourceTabClass(source?: string) {
const ts = source || 'reception'
if (ts === 'production') return 'qc-wo-tab__source--production'
if (ts === 'schedule') return 'qc-wo-tab__source--schedule'
return 'qc-wo-tab__source--reception'
}
const contextSubtitle = computed(() => {
const row = contextRow.value
if (!row) return '—'
@@ -247,9 +260,9 @@
flex-wrap: wrap;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border: 1px solid #d1fae5;
border-radius: 8px;
background: #f1f5f9;
background: linear-gradient(180deg, var(--primary-bg, #ecfdf5) 0%, #fff 100%);
overflow-x: auto;
}
@@ -260,8 +273,8 @@
min-height: 34px;
padding: 6px 12px;
font-size: 12px;
color: var(--mint-text-muted, #334155);
border: 1px solid transparent;
color: var(--mint-text-muted, #64748b);
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
@@ -269,13 +282,10 @@
transition:
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover:not(.is-active) {
color: #334155;
background: rgba(255, 255, 255, 0.72);
border-color: #dbeafe;
color: #065f46;
}
&:focus-visible {
@@ -284,11 +294,10 @@
}
&.is-active {
color: #065f46;
font-weight: 600;
background: #fff;
border-color: #a7f3d0;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
color: #fff;
font-weight: 500;
background: var(--primary, #10b981);
box-shadow: 0 1px 3px rgba(16, 185, 129, 0.28);
}
}
@@ -307,6 +316,11 @@
background: #eff6ff;
}
&--schedule {
color: #1d4ed8;
background: #eff6ff;
}
&--production {
color: #047857;
background: #ecfdf5;
@@ -134,7 +134,7 @@
defineOptions({ name: 'QcWorkOrderSingleCard' })
type CardLayout = 'standalone' | 'inbound-grouped'
type CardLayout = 'standalone' | 'inbound-grouped' | 'grouped'
const props = withDefaults(
defineProps<{
@@ -175,7 +175,10 @@
)
function triggerSourceTagType(source?: string): TagType {
return (source || 'reception') === 'production' ? 'warning' : 'primary'
const ts = source || 'reception'
if (ts === 'production') return 'warning'
if (ts === 'schedule') return 'primary'
return 'success'
}
function workOrderStatusTagType(status: string): TagType {
@@ -22,20 +22,6 @@ import { getCardMetaConfig } from '@/utils/qc-work-order-card-meta'
export type QcWorkOrderTabPageMode = 'manage' | 'mine'
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultLast30DaysRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
export function useQcWorkOrderTabPage(
tabKey: Ref<QcWorkOrderTabKey>,
pageMode: Ref<QcWorkOrderTabPageMode> | QcWorkOrderTabPageMode
@@ -47,13 +33,7 @@ export function useQcWorkOrderTabPage(
const filterTimeLabel = computed(() => filterTimeLabelForTab(tabConfig.value))
const filterTimeHint = computed(() => {
if (pageModeValue.value === 'mine') {
const tab = tabConfig.value
const timeHint = tabUsesInfusionTimeFilter(tab)
? tab.key === 'outbound_basic_experiment'
? '默认展示近 30 天内交付的工单。'
: '默认展示近 30 天内输注的工单。'
: '默认展示近 30 天内创建的工单。'
return `仅展示指派给当前登录人的质检任务。${timeHint}`
return '仅展示指派给当前登录人的质检任务。'
}
return filterTimeHintForTab(tabConfig.value)
})
@@ -65,7 +45,7 @@ export function useQcWorkOrderTabPage(
taskStatus: '',
taskType: ''
})
const createTimeRange = ref<[string, string] | null>(getDefaultLast30DaysRange())
const createTimeRange = ref<[string, string] | null>(null)
const filterExpanded = ref(false)
const tableData = ref<QcWorkOrder[]>([])
const presetTasksByWorkOrderId = ref<Record<string, QcTask[]>>({})
@@ -91,7 +71,9 @@ export function useQcWorkOrderTabPage(
keyword: searchForm.keyword || undefined,
workOrderStatus: searchForm.workOrderStatus || undefined,
triggerSource:
tab.group === 'inbound' && searchForm.triggerSource ? searchForm.triggerSource : undefined,
tab.listLayout !== 'standalone' && searchForm.triggerSource
? searchForm.triggerSource
: undefined,
tabKey: tabKey.value,
...timeParams
}
@@ -134,7 +116,7 @@ export function useQcWorkOrderTabPage(
keyword: searchForm.keyword || undefined,
workOrderStatus: searchForm.workOrderStatus || undefined,
triggerSource:
tab.group === 'inbound' && searchForm.triggerSource
tab.listLayout !== 'standalone' && searchForm.triggerSource
? searchForm.triggerSource
: undefined,
...toWorkOrderListQuery(tab),
@@ -158,7 +140,7 @@ export function useQcWorkOrderTabPage(
searchForm.triggerSource = ''
searchForm.taskStatus = ''
searchForm.taskType = ''
createTimeRange.value = getDefaultLast30DaysRange()
createTimeRange.value = null
filterExpanded.value = false
pagination.page = 1
loadData()
@@ -21,14 +21,19 @@
<el-option label="已作废" value="yzf" />
</el-select>
</el-form-item>
<el-form-item v-if="tabConfig.group === 'inbound'" label="触发来源">
<el-form-item v-if="tabConfig.listLayout !== 'standalone'" label="触发来源">
<el-select
v-model="searchForm.triggerSource"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="样本接收" value="reception" />
<template v-if="tabConfig.group === 'inbound'">
<el-option label="样本接收" value="reception" />
</template>
<template v-else-if="tabConfig.listLayout === 'outbound-grouped'">
<el-option label="计划质检" value="schedule" />
</template>
<el-option label="生产过程" value="production" />
</el-select>
</el-form-item>
@@ -110,14 +115,19 @@
<div v-show="filterExpanded" id="my-qc-task-filter-extra" class="filter-extra">
<div class="filter-grid my-qc-task-filter-extra-grid">
<el-form-item v-if="tabConfig.group === 'inbound'" label="触发来源">
<el-form-item v-if="tabConfig.listLayout !== 'standalone'" label="触发来源">
<el-select
v-model="searchForm.triggerSource"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="样本接收" value="reception" />
<template v-if="tabConfig.group === 'inbound'">
<el-option label="样本接收" value="reception" />
</template>
<template v-else-if="tabConfig.listLayout === 'outbound-grouped'">
<el-option label="计划质检" value="schedule" />
</template>
<el-option label="生产过程" value="production" />
</el-select>
</el-form-item>
@@ -63,8 +63,8 @@
<span class="filter-hint">
{{
activeTab === 'pending'
? '勾选多个样本后可批量接收;预计到达时间默认为当天前 15 天至后 15 天。'
: '双击卡片可查看接收详情;接收时间默认近 30 天。'
? '勾选多个样本后可批量接收。'
: '双击卡片可查看接收详情。'
}}
</span>
<div class="filter-actions-end">
@@ -748,31 +748,8 @@
const activeTab = ref(props.receivedOnly ? 'received' : 'pending')
const searchForm = reactive({ keyword: '', sourceType: '' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultLast30DaysRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
/** 预计到达时间:当天前 15 天至后 15 天 */
function getDefaultEtaDateRange(): [string, string] {
const start = new Date()
const end = new Date()
start.setDate(start.getDate() - 15)
end.setDate(end.getDate() + 15)
return [formatLocalDate(start), formatLocalDate(end)]
}
const etaDateRange = ref<[string, string] | null>(getDefaultEtaDateRange())
const receptionTimeRange = ref<[string, string] | null>(getDefaultLast30DaysRange())
const etaDateRange = ref<[string, string] | null>(null)
const receptionTimeRange = ref<[string, string] | null>(null)
const tableData = ref<Record<string, unknown>[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
@@ -1405,9 +1382,9 @@
searchForm.keyword = ''
searchForm.sourceType = ''
if (activeTab.value === 'pending') {
etaDateRange.value = getDefaultEtaDateRange()
etaDateRange.value = null
} else {
receptionTimeRange.value = getDefaultLast30DaysRange()
receptionTimeRange.value = null
}
pagination.page = 1
loadData()
@@ -1581,41 +1558,6 @@
}
}
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin: 0 28px 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #065f46;
}
}
.form-tab-pane {
padding: 0 28px 4px;
@@ -53,7 +53,7 @@
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>列表为概要信息,详情见弹窗;默认展示近 30 天内创建的协议单。</span
>列表为概要信息,详情见弹窗。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
@@ -398,26 +398,12 @@
}))
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultCreateTimeRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
/* ====== 列表 ====== */
const searchForm = reactive<{ keyword: string; donationStatus: string }>({
keyword: '',
donationStatus: ''
})
const createTimeRange = ref<[string, string] | null>(getDefaultCreateTimeRange())
const createTimeRange = ref<[string, string] | null>(null)
const sampleCollectTimeRange = ref<[string, string] | null>(null)
const tableData = ref<ResearchDonationRow[]>([])
const selectedRows = ref<ResearchDonationRow[]>([])
@@ -465,7 +451,7 @@
function handleReset() {
searchForm.keyword = ''
searchForm.donationStatus = ''
createTimeRange.value = getDefaultCreateTimeRange()
createTimeRange.value = null
sampleCollectTimeRange.value = null
pagination.page = 1
loadData()
@@ -1897,42 +1897,6 @@
}
}
/* ---- Sticky Tab 导航(与查看弹窗 detail-tabs 一致) ---- */
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin: 0 28px 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #065f46;
}
}
/* ═══════════════════════════════════════════
SECTION CARDS
═══════════════════════════════════════════ */
@@ -1046,70 +1046,30 @@
}
}
/* ---- Sticky Tab 导航 ---- */
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin-bottom: 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #065f46;
}
}
/* ---- KPI 摘要条(与采集协议单查看弹窗一致:一行四列 + 薄荷绿通栏) ---- */
/* ---- KPI 摘要条(与渠道/申请单查看弹窗一致:浅绿渐变通栏) ---- */
.kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 0;
margin-bottom: 24px;
padding: 6px;
background: linear-gradient(135deg, #e8f4ee 0%, #f8fafc 52%, #e8f4ee 100%);
border: 1px solid #a8d4c4;
padding: 18px 20px;
background: linear-gradient(180deg, #ecfdf5 0%, #fff 100%);
border: none;
border-radius: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85);
box-shadow: none;
}
.kpi-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
padding: 8px 16px;
background: transparent;
border-radius: 8px;
transition: background 0.2s;
&:not(:last-child) {
border-right: 1px solid rgba(168, 212, 196, 0.75);
}
&:hover {
background: rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.35);
}
}
@@ -1121,8 +1081,8 @@
height: 40px;
flex-shrink: 0;
border-radius: 10px;
background: #fff;
border: 1px solid #a8d4c4;
background: rgba(255, 255, 255, 0.72);
border: none;
color: #065f46;
font-size: 18px;
}
@@ -1208,16 +1168,12 @@
&--banner {
margin: -22px -24px 18px;
padding: 10px 24px;
border-bottom: none;
border-bottom: 1px solid rgba(16, 185, 129, 0.12);
border-radius: 12px 12px 0 0;
background: var(--primary, #10b981);
&::before {
display: none;
}
background: var(--primary-bg, #ecfdf5);
h3 {
color: #fff;
color: var(--primary-dark, #059669);
}
}
}
@@ -53,7 +53,7 @@
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>列表为概要信息,详情见弹窗;默认展示近 30 天内创建的协议单。</span
>列表为概要信息,详情见弹窗。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
@@ -310,26 +310,12 @@
defineOptions({ name: 'ResearchDonationList' })
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function getDefaultCreateTimeRange(): [string, string] {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 30)
return [formatLocalDate(start), formatLocalDate(end)]
}
/* ====== 列表 ====== */
const searchForm = reactive<{ keyword: string; donationStatus: string }>({
keyword: '',
donationStatus: ''
})
const createTimeRange = ref<[string, string] | null>(getDefaultCreateTimeRange())
const createTimeRange = ref<[string, string] | null>(null)
const sampleCollectTimeRange = ref<[string, string] | null>(null)
const tableData = ref<ResearchDonationRow[]>([])
const selectedRows = ref<ResearchDonationRow[]>([])
@@ -368,7 +354,7 @@
function handleReset() {
searchForm.keyword = ''
searchForm.donationStatus = ''
createTimeRange.value = getDefaultCreateTimeRange()
createTimeRange.value = null
sampleCollectTimeRange.value = null
pagination.page = 1
loadData()
@@ -3262,16 +3262,6 @@
border-left: 4px solid #047857;
}
.detail-tabs {
display: flex;
gap: 4px;
background: #f5f7fa;
padding: 4px;
border-radius: 8px;
margin: 8px 22px 16px;
width: fit-content;
}
.order-tab-pane {
margin-top: 6px;
padding: 0 22px 8px;
@@ -3297,26 +3287,6 @@
text-decoration: underline;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #065f46;
}
}
.lock-tip {
background: #fff1f0;
border: 1px solid #ffccc7;