This commit is contained in:
zlwu committed 2026-06-15 11:43:16 +08:00
1 parent 427bcb1551
commit 31b04575e2
247 files changed
+39284 -20367

No files matched your search

@@ -15,9 +15,14 @@
<el-form-item label="物料筛选">
<el-select
v-model="searchForm.materialId"
placeholder="全部物料"
placeholder="输入物料名称检索"
clearable
filterable
remote
reserve-keyword
:loading="materialOptionsLoading"
:remote-method="remoteSearchMaterials"
@visible-change="onMaterialSelectVisible"
>
<el-option
v-for="m in materialOptions"
@@ -281,7 +286,7 @@
import { Search, RefreshLeft, View, Document, CircleCheck } from '@element-plus/icons-vue'
import { fetchMaterialUsageList, fetchMarkMaterialUsageFullyUsed } from '@/api/material-usage'
import type { MaterialUsage } from '@/api/material-usage'
import { fetchMaterialOptions } from '@/api/material'
import { fetchMaterialList } from '@/api/material'
import type { MaterialOption } from '@/api/material'
import { formatMoneyDisplay } from '@/utils/money-format'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
@@ -305,6 +310,7 @@
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const materialOptions = ref<MaterialOption[]>([])
const materialOptionsLoading = ref(false)
function isManualOutRow(row: MaterialUsage) {
if (row.sourceType === 'manual_out') return true
@@ -410,15 +416,39 @@
}
}
async function loadMaterialOptions() {
function mapMaterialListToOptions(list: Array<Record<string, any>>): MaterialOption[] {
return (list || []).map((m) => ({
id: String(m.id || ''),
label: `${m.materialName || ''}${m.materialCode ? ` (${m.materialCode})` : ''}`.trim(),
materialCode: String(m.materialCode || ''),
materialName: String(m.materialName || ''),
category: String(m.category || ''),
specModel: String(m.specModel || ''),
unit: String(m.unit || ''),
stockQty: Number(m.stockQty ?? 0)
}))
}
async function remoteSearchMaterials(query: string) {
materialOptionsLoading.value = true
try {
const res = await fetchMaterialOptions()
materialOptions.value = res || []
const res = await fetchMaterialList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
materialOptions.value = mapMaterialListToOptions(res.list || [])
} catch {
materialOptions.value = []
} finally {
materialOptionsLoading.value = false
}
}
function onMaterialSelectVisible(visible: boolean) {
if (visible) void remoteSearchMaterials('')
}
async function loadData() {
loading.value = true
try {
@@ -542,13 +572,13 @@
})
onMounted(async () => {
await loadMaterialOptions()
if (route.query.materialId) {
searchForm.materialId = route.query.materialId as string
}
if (route.query.stockInNo) {
searchForm.keyword = route.query.stockInNo as string
}
await remoteSearchMaterials('')
loadData()
})
</script>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -246,9 +246,15 @@
<el-form-item label="保险公司" prop="insuranceCompanyId">
<el-select
v-model="formData.insuranceCompanyId"
placeholder="请选择保险公司"
placeholder="输入公司名称检索"
filterable
remote
reserve-keyword
clearable
:loading="insuranceCompanyLoading"
:remote-method="remoteSearchInsuranceCompanies"
style="width: 100%"
@visible-change="onInsuranceCompanySelectVisible"
@change="handleInsuranceCompanyChange"
>
<el-option
@@ -544,16 +550,41 @@
// 保险公司下拉
// ---------------------------------------------------------------------------
const insuranceCompanyOptions = ref<any[]>([])
const insuranceCompanyLoading = ref(false)
async function loadInsuranceCompanies() {
async function remoteSearchInsuranceCompanies(query: string) {
insuranceCompanyLoading.value = true
try {
const res = await fetchInsuranceCompanyList({ page: 1, pageSize: 100 })
const res = await fetchInsuranceCompanyList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined,
status: '1'
})
insuranceCompanyOptions.value = (res as any)?.list || []
ensureInsuranceCompanyInOptions(formData.insuranceCompanyId)
} catch {
insuranceCompanyOptions.value = []
ensureInsuranceCompanyInOptions(formData.insuranceCompanyId)
} finally {
insuranceCompanyLoading.value = false
}
}
function ensureInsuranceCompanyInOptions(companyId: string) {
const id = String(companyId || '').trim()
if (!id || insuranceCompanyOptions.value.some((c) => c.id === id)) return
insuranceCompanyOptions.value = [{ id, companyName: id }, ...insuranceCompanyOptions.value]
}
function onInsuranceCompanySelectVisible(visible: boolean) {
if (visible) void remoteSearchInsuranceCompanies('')
}
async function loadInsuranceCompanies() {
await remoteSearchInsuranceCompanies('')
}
// ---------------------------------------------------------------------------
// 险种价格下拉(按保险公司联动)
// ---------------------------------------------------------------------------
File diff suppressed because it is too large. Load diff
@@ -115,220 +115,181 @@
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="protocolStatus"
label="状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="protocolStatusTagType(String(row.protocolStatus || ''))"
size="small"
>{{ protocolStatusLabel(String(row.protocolStatus || '')) }}</el-tag
>
</template>
</el-table-column>
<el-table-column
prop="expectedDeliveryDate"
label="预产期"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="sampleCollectSummary"
label="是否已采集"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="registerDate"
label="登记日期"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="motherName"
label="母亲姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="motherPhone"
label="母亲电话"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column
prop="motherIdCard"
label="母亲身份证号"
min-width="190"
align="center"
header-align="center"
/>
<el-table-column
prop="fatherName"
label="父亲姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="newbornSex"
label="新生儿性别"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{
newbornSexLabel(String(row.newbornSex || ''))
}}</template>
</el-table-column>
<el-table-column
prop="deliveryMode"
label="分娩方式"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{
deliveryModeLabel(String(row.deliveryMode || ''))
}}</template>
</el-table-column>
<el-table-column
prop="newbornWeightGram"
label="新生儿体重(克)"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="sampleTypeNames"
label="样本类型"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="sampleCodes"
label="样本编码"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="labStorageCodes"
label="实验室入库编码"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.labStorageCodes || '—' }}</template>
</el-table-column>
<el-table-column
prop="regionName"
label="采集地区"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="hospitalName"
label="采集医院"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="collectorUserName"
label="采集专员"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="salesUserName"
label="销售人员"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="sampleCollectTime"
label="样本采集时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="openAuditDialog(row)">
<el-icon><CircleCheck /></el-icon>审核
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div v-loading="loading" class="protocol-card-section">
<div v-if="tableData.length" class="protocol-card-list">
<article
v-for="(row, index) in tableData"
:key="String(row.id)"
class="protocol-card"
:class="{ 'is-selected': isRowSelected(String(row.id)) }"
@dblclick="handleView(row)"
>
<div class="protocol-card__select" @click.stop>
<el-checkbox
:model-value="isRowSelected(String(row.id))"
@change="(val) => toggleRowSelection(row, !!val)"
/>
</div>
</template>
</el-table-column>
</el-table>
<div class="protocol-card__aside">
<div class="protocol-card__name-row">
<span class="protocol-card__index">{{
(pagination.page - 1) * pagination.pageSize + index + 1
}}</span>
<span class="protocol-card__name">{{ row.motherName || '—' }}</span>
</div>
<div class="protocol-card__contact">
<div class="protocol-card__sub">{{ row.motherPhone || '—' }}</div>
<div class="protocol-card__sub protocol-card__sub--id">
{{ row.motherIdCard ? maskIdCard(String(row.motherIdCard)) : '—' }}
</div>
</div>
<el-tag
:type="protocolStatusTagType(String(row.protocolStatus || ''))"
size="small"
class="protocol-card__status"
>{{ protocolStatusLabel(String(row.protocolStatus || '')) }}</el-tag
>
<div v-if="row.createTime" class="protocol-card__create-time">
创建 {{ row.createTime }}
</div>
</div>
<div class="protocol-card__sections">
<section class="protocol-card__block protocol-card__block--basic">
<div class="protocol-card__block-title">基础信息</div>
<div class="protocol-card__kv-grid">
<div class="protocol-card__kv">
<label>协议号</label><span>{{ row.protocolNo || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>登记日期</label><span>{{ row.registerDate || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>采集地区</label><span>{{ row.regionName || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>采集医院</label
><span :title="String(row.hospitalName || '')">{{
row.hospitalName || '—'
}}</span>
</div>
<div class="protocol-card__kv">
<label>采集专员</label><span>{{ row.collectorUserName || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>样本采集</label><span>{{ row.sampleCollectSummary || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>采集时间</label><span>{{ row.sampleCollectTime || '—' }}</span>
</div>
</div>
</section>
<section class="protocol-card__block protocol-card__block--newborn">
<div class="protocol-card__block-title">新生儿</div>
<div class="protocol-card__kv-grid">
<div class="protocol-card__kv">
<label>姓名</label><span>{{ row.newbornName || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>性别</label
><span>{{ newbornSexLabel(String(row.newbornSex || '')) || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>预产期</label><span>{{ row.expectedDeliveryDate || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>分娩时间</label><span>{{ row.newbornDeliveryTime || '—' }}</span>
</div>
</div>
</section>
<section class="protocol-card__block protocol-card__block--samples">
<div class="protocol-card__block-title">
样本信息
<span v-if="getSampleItems(row).length" class="protocol-card__block-count">
{{ getSampleItems(row).length }} 项
</span>
</div>
<div v-if="getSampleItems(row).length" class="protocol-card__sample-list">
<div
v-for="(item, sIdx) in getSampleItems(row)"
:key="sIdx"
class="protocol-card__sample-item"
>
<span class="protocol-card__sample-seq">{{ sIdx + 1 }}</span>
<div class="protocol-card__sample-main">
<div class="protocol-card__sample-line">
<span class="protocol-card__sample-label">类型</span>
<span class="protocol-card__sample-value">{{
item.sampleTypeName || '—'
}}</span>
<el-tag
v-if="item.sampleStatus"
:type="sampleStatusTagType(item.sampleStatus)"
size="small"
class="protocol-card__sample-status"
>
{{ sampleStatusLabel(item.sampleStatus) }}
</el-tag>
</div>
<div class="protocol-card__sample-line">
<span class="protocol-card__sample-label">编码</span>
<span class="protocol-card__sample-value protocol-card__sample-value--code">
{{ item.sampleCode || '—' }}
</span>
</div>
<div class="protocol-card__sample-line">
<span class="protocol-card__sample-label">运输</span>
<span class="protocol-card__sample-value">{{
formatTransportSummary(item.transport)
}}</span>
</div>
</div>
</div>
</div>
<div v-else class="protocol-card__empty">暂无样本</div>
</section>
<section v-if="getPaymentSummary(row)" class="protocol-card__block protocol-card__block--payment">
<div class="protocol-card__block-title">缴费信息</div>
<QcWorkOrderProtocolPaymentPanel
:summary="getPaymentSummary(row)"
compact
embedded
hide-overall-pay-status
hide-installment-detail-amounts
/>
</section>
<section v-else class="protocol-card__block protocol-card__block--payment">
<div class="protocol-card__block-title">缴费信息</div>
<div class="protocol-card__empty">暂无缴费信息</div>
</section>
</div>
<div class="protocol-card__foot">
<div class="protocol-card__actions">
<el-button class="btn-op-item" @click.stop="openAuditDialog(row)">
<el-icon><CircleCheck /></el-icon>审核
</el-button>
<el-button class="btn-op-item" @click.stop="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click.stop="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button class="btn-op-item" @click.stop="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</div>
</article>
</div>
<el-empty
v-else-if="!loading"
class="protocol-card-empty"
description="暂无协议单"
:image-size="80"
/>
</div>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
@@ -386,8 +347,7 @@
Delete,
Search,
RefreshLeft,
CircleCheck,
More
CircleCheck
} from '@element-plus/icons-vue'
import ProtocolFormDialog from '../protocol/ProtocolFormDialog.vue'
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
@@ -398,6 +358,8 @@
type StatusChangeAction,
type StatusChangeTarget
} from '../protocol/ProtocolStatusChangeDialog.vue'
import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
import type { QcProtocolPaymentSummary } from '@/api/qc-work-order'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCollectionProtocolList,
@@ -412,11 +374,100 @@
protocolStatusLabel,
protocolStatusTagType,
newbornSexLabel,
deliveryModeLabel
sampleStatusLabel,
sampleStatusTagType,
transportTypeLabel
} from '@/utils/status-tag-registry'
import { maskIdCard } from '@/utils/mask-sensitive'
defineOptions({ name: 'CellStorageProtocolAuditTab' })
type ProtocolSampleTransport = {
transportType?: string
destSiteName?: string
expressCompanyName?: string
waybillNo?: string
carrierName?: string
carrierPhone?: string
etaTime?: string
}
type ProtocolSampleItem = {
sampleTypeName: string
sampleCode: string
sampleStatus: string
labStorageCode: string
transport?: ProtocolSampleTransport | null
}
function getPaymentSummary(row: CollectionProtocolRow): QcProtocolPaymentSummary | null {
const summary = row.paymentSummary
if (!summary || typeof summary !== 'object') return null
return summary as QcProtocolPaymentSummary
}
function formatTransportSummary(transport?: ProtocolSampleTransport | null): string {
if (!transport?.transportType) return '—'
const parts: string[] = [transportTypeLabel(String(transport.transportType))]
const dest = String(transport.destSiteName || '').trim()
if (dest) parts.push(dest)
if (transport.transportType === 'kdys') {
const expressName = String(transport.expressCompanyName || '').trim()
if (expressName) parts.push(expressName)
const waybill = String(transport.waybillNo || '').trim()
if (waybill) parts.push(waybill)
} else if (transport.transportType === 'ryxd') {
const carrier = String(transport.carrierName || '').trim()
if (carrier) parts.push(carrier)
const phone = String(transport.carrierPhone || '').trim()
if (phone) parts.push(phone)
}
const eta = String(transport.etaTime || '').trim()
if (eta) parts.push(`预计 ${eta}`)
return parts.join(' · ')
}
function getSampleItems(row: CollectionProtocolRow): ProtocolSampleItem[] {
const rawItems = row.sampleItems
if (Array.isArray(rawItems) && rawItems.length) {
return rawItems.map((item) => {
const rec = item as Record<string, unknown>
const transportRaw = rec.transport
const transport =
transportRaw && typeof transportRaw === 'object'
? (transportRaw as ProtocolSampleTransport)
: null
return {
sampleTypeName: String(rec.sampleTypeName || '').trim(),
sampleCode: String(rec.sampleCode || '').trim(),
sampleStatus: String(rec.sampleStatus || '').trim(),
labStorageCode: String(rec.labStorageCode || '').trim(),
transport
}
})
}
const types = String(row.sampleTypeNames || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const codes = String(row.sampleCodes || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const labCodes = String(row.labStorageCodes || '')
.split(',')
.map((s) => s.trim())
const len = Math.max(types.length, codes.length)
if (!len) return []
return Array.from({ length: len }, (_, i) => ({
sampleTypeName: types[i] || '',
sampleCode: codes[i] || '',
sampleStatus: '',
labStorageCode: labCodes[i] || '',
transport: null
}))
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
@@ -443,8 +494,15 @@
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const handleSelectionChange = (rows: CollectionProtocolRow[]) => {
selectedRows.value = rows
const isRowSelected = (id: string) => selectedRows.value.some((r) => String(r.id) === id)
const toggleRowSelection = (row: CollectionProtocolRow, checked: boolean) => {
const id = String(row.id)
if (checked) {
if (!isRowSelected(id)) selectedRows.value = [...selectedRows.value, row]
return
}
selectedRows.value = selectedRows.value.filter((r) => String(r.id) !== id)
}
async function loadData() {
@@ -462,6 +520,8 @@
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
const validIds = new Set(tableData.value.map((r) => String(r.id)))
selectedRows.value = selectedRows.value.filter((r) => validIds.has(String(r.id)))
} finally {
loading.value = false
}
@@ -565,14 +625,6 @@
loadData()
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'view': handleView(row); break
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
}
}
/* ====== 删除 ====== */
function handleDelete(row: CollectionProtocolRow) {
ElMessageBox.confirm(formatProtocolDocDeleteConfirm('cell_storage', row), '删除确认', {
@@ -622,3 +674,359 @@
loadData()
})
</script>
<style scoped lang="scss">
.protocol-card-section {
min-height: 120px;
}
.protocol-card-list {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
}
.protocol-card {
position: relative;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 12px;
width: 100%;
box-sizing: border-box;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 10px;
background: #fff;
padding: 12px 14px 12px 40px;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #c5e0d4;
box-shadow: 0 2px 8px rgb(15 23 42 / 5%);
}
&.is-selected {
border-color: var(--primary, #10b981);
box-shadow: 0 0 0 1px rgb(16 185 129 / 25%);
}
}
.protocol-card__select {
position: absolute;
top: 12px;
left: 12px;
z-index: 1;
}
.protocol-card__aside {
flex: 0 0 148px;
min-width: 132px;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 12px;
border-right: 1px solid #eef2f6;
}
.protocol-card__status {
align-self: flex-start;
}
.protocol-card__create-time {
margin-top: auto;
font-size: 11px;
color: var(--mint-text-muted, #64748b);
line-height: 1.35;
word-break: break-all;
}
.protocol-card__name-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.protocol-card__index {
flex-shrink: 0;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 6px;
background: var(--primary-bg, #ecfdf5);
color: var(--primary-dark, #059669);
font-size: 12px;
font-weight: 600;
line-height: 22px;
text-align: center;
}
.protocol-card__name {
font-size: 15px;
font-weight: 600;
color: var(--text, #1e293b);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.protocol-card__contact {
display: flex;
flex-direction: column;
gap: 2px;
}
.protocol-card__sub {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--id {
white-space: normal;
word-break: break-all;
}
}
.protocol-card__sections {
flex: 1;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 10px;
min-width: 0;
overflow-x: auto;
}
.protocol-card__block {
flex: 1;
min-width: 0;
padding: 8px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f6;
&--basic {
flex: 1.1;
min-width: 180px;
}
&--newborn {
flex: 0.85;
min-width: 140px;
}
&--samples {
flex: 1.35;
min-width: 220px;
}
&--payment {
flex: 1.2;
min-width: 200px;
padding-bottom: 4px;
:deep(.qc-protocol-payment--compact) {
padding: 0;
background: transparent;
}
:deep(.qc-protocol-payment__compact-details) {
border-top-color: #e2e8f0;
}
:deep(.qc-protocol-payment__detail-chip) {
background: #fff;
border-color: #e2e8f0;
}
}
}
.protocol-card__block-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 11px;
font-weight: 600;
color: var(--primary-dark, #059669);
line-height: 1.3;
}
.protocol-card__block-count {
font-size: 11px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
}
.protocol-card__kv-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.protocol-card__kv {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
font-size: 12px;
line-height: 1.35;
label {
flex-shrink: 0;
color: var(--mint-text-muted, #64748b);
}
span {
min-width: 0;
color: var(--text, #1e293b);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.protocol-card__empty {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
}
.protocol-card__sample-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.protocol-card__sample-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
background: #fff;
border: 1px solid var(--mint-border, #e2e8f0);
}
.protocol-card__sample-seq {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 4px;
background: var(--primary-bg, #ecfdf5);
color: var(--primary-dark, #059669);
font-size: 10px;
font-weight: 600;
line-height: 18px;
text-align: center;
}
.protocol-card__sample-main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.protocol-card__sample-line {
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
font-size: 12px;
line-height: 1.35;
}
.protocol-card__sample-label {
flex-shrink: 0;
width: 28px;
color: var(--mint-text-muted, #64748b);
}
.protocol-card__sample-value {
min-width: 0;
flex: 1;
color: var(--text, #1e293b);
word-break: break-all;
&--code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
}
}
.protocol-card__sample-status {
flex-shrink: 0;
margin-left: auto;
}
.protocol-card__foot {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
padding-left: 12px;
border-left: 1px solid #eef2f6;
}
.protocol-card__actions {
display: flex;
flex-direction: column;
flex-shrink: 0;
align-items: stretch;
gap: 6px;
min-width: 72px;
:deep(.el-button) {
width: 100%;
margin: 0;
justify-content: center;
}
}
.protocol-card-empty {
padding: 32px 16px;
}
@media (max-width: 1200px) {
.protocol-card {
flex-wrap: wrap;
}
.protocol-card__sections {
flex-basis: 100%;
order: 3;
}
.protocol-card__foot {
flex-direction: column;
align-items: stretch;
border-left: none;
border-top: 1px solid #eef2f6;
padding-left: 0;
padding-top: 10px;
margin-left: 0;
order: 4;
width: 100%;
}
}
@media (max-width: 768px) {
.protocol-card__sections {
flex-direction: column;
}
.protocol-card__block {
min-width: 0 !important;
}
}
</style>
@@ -2,30 +2,30 @@
<template>
<div class="page-container">
<section class="panel">
<div
class="panel-entity-switch panel-entity-switch--multi"
role="tablist"
aria-label="视图类型"
>
<button
v-for="item in entityTabs"
:key="item.key"
type="button"
class="entity-switch-card"
:class="{ 'is-active': activeTab === item.key }"
role="tab"
:aria-selected="activeTab === item.key"
@click="activeTab = item.key"
>
<span class="entity-switch-card__icon"
><el-icon><component :is="item.icon" /></el-icon
></span>
<span class="entity-switch-card__main">
<span class="entity-switch-card__title">{{ item.title }}</span>
<span class="entity-switch-card__desc">{{ item.desc }}</span>
</span>
</button>
</div>
<nav class="pill-tab-nav" role="tablist" aria-label="视图类型">
<div class="pill-tab-nav__rail">
<div class="pill-tab-nav__progress" :style="pillProgressStyle" />
<button
v-for="(item, index) in entityTabs"
:key="item.key"
type="button"
class="pill-tab-nav__step"
:class="{
'is-active': activeTab === item.key,
'is-done': index < activeTabIndex
}"
role="tab"
:aria-selected="activeTab === item.key"
@click="activeTab = item.key"
>
<span class="pill-tab-nav__index">{{ index + 1 }}</span>
<span class="pill-tab-nav__text">
<span class="pill-tab-nav__title">{{ item.title }}</span>
<span class="pill-tab-nav__desc">{{ item.desc }}</span>
</span>
</button>
</div>
</nav>
<ProtocolAuditTab v-if="activeTab === 'protocol'" />
<PaymentInfoTab v-else-if="activeTab === 'payment-info'" />
@@ -36,8 +36,6 @@
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { Document, Wallet, Medal, Calendar } from '@element-plus/icons-vue'
import ProtocolAuditTab from './ProtocolAuditTab.vue'
import PaymentInfoTab from './PaymentInfoTab.vue'
import StorageCertTab from './StorageCertTab.vue'
@@ -55,31 +53,26 @@
key: EntityTabKey
title: string
desc: string
icon: Component
}> = [
{
key: 'protocol',
title: '协议单管理',
desc: '协议审核与状态维护',
icon: Document
desc: '协议审核与状态维护'
},
{
key: 'payment-info',
title: '缴费信息',
desc: '缴费计划与到账查询',
icon: Wallet
desc: '缴费计划与到账查询'
},
{
key: 'storage-cert',
title: '储存证管理',
desc: '储存证开具与管理',
icon: Medal
desc: '储存证开具与管理'
},
{
key: 'fullmoon-todo',
title: '满月待办管理',
desc: '满月保险待办办理',
icon: Calendar
desc: '满月保险待办办理'
}
]
@@ -87,6 +80,18 @@
const router = useRouter()
const activeTab = ref<EntityTabKey>('protocol')
const activeTabIndex = computed(() =>
Math.max(
0,
entityTabs.findIndex((item) => item.key === activeTab.value)
)
)
const pillProgressStyle = computed(() => ({
width: `calc(${100 / entityTabs.length}% - 3px)`,
transform: `translateX(${activeTabIndex.value * 100}%)`
}))
onMounted(() => {
const tab = String(route.query.tab || '')
if (LEGACY_SAMPLE_TABS.includes(tab as (typeof LEGACY_SAMPLE_TABS)[number])) {
@@ -103,110 +108,139 @@
</script>
<style scoped lang="scss">
.panel-entity-switch {
display: flex;
justify-content: center;
gap: 12px;
padding: 16px 16px 12px;
}
.panel-entity-switch--multi {
flex-wrap: wrap;
gap: 10px;
.entity-switch-card {
flex: 0 1 168px;
min-width: 152px;
max-width: 180px;
padding: 10px 12px;
gap: 8px;
}
.entity-switch-card__icon {
width: 32px;
height: 32px;
font-size: 17px;
}
.entity-switch-card__title {
font-size: 13px;
}
.entity-switch-card__desc {
font-size: 11px;
line-height: 1.35;
}
}
.entity-switch-card {
display: flex;
align-items: center;
gap: 12px;
min-width: 220px;
max-width: 280px;
flex: 1;
padding: 14px 18px;
border: 1px solid var(--mint-border, #c5e0d4);
border-radius: 10px;
background: var(--surface, #fff);
cursor: pointer;
text-align: left;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
&:hover:not(.is-active) {
border-color: #a8d4c4;
background: #f8fafc;
}
&.is-active {
border-color: var(--primary, #047857);
background: var(--primary-bg, #e8f4ee);
box-shadow: 0 0 0 1px rgb(4 120 87 / 12%);
}
}
.entity-switch-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 8px;
background: #f1f5f9;
color: #64748b;
font-size: 20px;
.entity-switch-card.is-active & {
background: #fff;
color: var(--primary-dark, #065f46);
}
}
.entity-switch-card__main {
.pill-tab-nav {
padding: 16px 20px 0;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
align-items: center;
}
.entity-switch-card__title {
font-size: 14px;
font-weight: 600;
color: var(--text, #1e293b);
line-height: 1.4;
.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;
}
.entity-switch-card.is-active & {
color: var(--primary-dark, #065f46);
.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: #475569;
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: #94a3b8;
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;
}
}
.entity-switch-card__desc {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
@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>
@@ -0,0 +1,291 @@
// 采集协议单 · 缴费信息五列看板
.payment-kanban-board {
display: grid;
grid-template-columns: repeat(5, minmax(260px, 1fr));
gap: 12px;
padding: 16px;
overflow-x: auto;
align-items: stretch;
}
.payment-kanban-col {
min-width: 260px;
background: var(--table-head, #f8fafc);
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: 10px;
display: flex;
flex-direction: column;
min-height: calc(100vh - 320px);
max-height: calc(100vh - 200px);
&--dfj {
border-top: 3px solid #fbbf24;
}
&--yjf {
border-top: 3px solid var(--primary, #10b981);
}
&--ytf {
border-top: 3px solid #ef4444;
}
}
.payment-kanban-col-hd {
padding: 10px 12px;
border-bottom: 1px solid var(--mint-border, #d9e8e2);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
background: var(--surface, #fff);
border-radius: 10px 10px 0 0;
.payment-kanban-col--dfj & {
background: linear-gradient(180deg, #fffbeb 0%, #fff 100%);
}
.payment-kanban-col--yjf & {
background: linear-gradient(180deg, #ecfdf5 0%, #fff 100%);
}
.payment-kanban-col--ytf & {
background: linear-gradient(180deg, #fef2f2 0%, #fff 100%);
}
}
.payment-kanban-col-title {
font-size: 13px;
font-weight: 600;
color: var(--mint-text, #1e293b);
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.payment-kanban-count {
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface, #fff);
color: var(--mint-text-muted, #64748b);
border: 1px solid var(--mint-border, #d9e8e2);
}
.payment-kanban-list {
padding: 10px;
overflow-y: auto;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
align-content: start;
}
.payment-kanban-col-footer {
padding: 8px 10px 10px;
border-top: 1px solid var(--mint-border, #d9e8e2);
flex-shrink: 0;
background: var(--surface, #fff);
border-radius: 0 0 10px 10px;
:deep(.el-pagination) {
flex-wrap: wrap;
justify-content: center;
row-gap: 4px;
}
:deep(.el-pagination .el-select) {
width: 88px;
}
}
.payment-card {
position: relative;
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: 10px;
background: var(--surface, #fff);
display: flex;
flex-direction: column;
min-width: 0;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover {
border-color: rgb(16 185 129 / 45%);
box-shadow: 0 4px 12px rgb(16 185 129 / 8%);
}
&--due-soon {
background: #fee2e2;
&:hover {
background: #fecaca;
}
}
}
.payment-card__head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 8px;
border-bottom: 1px solid var(--mint-border, #e2e8f0);
}
.payment-card__head-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.payment-card__identity {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.payment-card__name {
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #1e293b);
line-height: 1.4;
word-break: break-word;
}
.payment-card__sub {
font-size: 11px;
line-height: 1.35;
color: var(--mint-text-muted, #64748b);
word-break: break-all;
&--id {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 10px;
}
}
.payment-card__status {
flex-shrink: 0;
align-self: center;
}
.payment-card__body {
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.payment-card__kv {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
line-height: 1.4;
min-width: 0;
label {
flex-shrink: 0;
width: 72px;
color: var(--mint-text-muted, #64748b);
font-size: 11px;
}
span {
flex: 1;
min-width: 0;
color: var(--text, #1e293b);
word-break: break-word;
}
&--amount span {
font-weight: 600;
color: var(--primary-dark, #059669);
}
}
.payment-card__expand {
padding: 0 12px 8px;
}
.payment-card__expand-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--mint-text-muted, #64748b);
cursor: pointer;
padding: 4px 0;
border: none;
background: transparent;
&:hover {
color: var(--primary-dark, #059669);
}
}
.payment-card__expand-body {
margin-top: 6px;
padding-top: 8px;
border-top: 1px dashed var(--mint-border, #e2e8f0);
display: flex;
flex-direction: column;
gap: 6px;
}
.payment-card__foot {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px 10px;
border-top: 1px solid var(--mint-border, #e2e8f0);
:deep(.btn-op-item.el-button) {
flex: 1;
min-width: 0;
margin: 0;
height: 28px;
font-size: 11px;
padding: 0 6px;
}
}
.payment-kanban-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 24px 12px;
text-align: center;
font-size: 12px;
color: var(--mint-text-muted, #94a3b8);
border: 1px dashed var(--mint-border, #d9e8e2);
border-radius: 10px;
background: rgb(255 255 255 / 60%);
}
@media (width <= 1024px) {
.payment-kanban-board {
grid-template-columns: 1fr;
overflow-x: visible;
}
.payment-kanban-col {
min-width: 0;
max-height: none;
min-height: 280px;
}
}
@@ -6,6 +6,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -3,9 +3,11 @@
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="1380px"
width="1600px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -82,12 +84,11 @@
/>
</el-select>
</el-form-item>
</div>
<div class="form-grid form-grid--col2 basic-remark-attach-row">
<el-form-item label="备注">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="可选" />
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
<div class="form-grid form-grid--attachment">
<el-form-item label="协议附件">
<div class="attachment-upload">
<div class="attachment-list">
@@ -169,7 +170,7 @@
<div class="section-title">
<div class="section-title-left"><h3>采集信息</h3></div>
</div>
<div class="form-grid">
<div class="form-grid form-grid--col6">
<el-form-item label="采集地区">
<el-select
v-model="formData.regionId"
@@ -271,12 +272,22 @@
<SampleTransportFormSection
v-model:samples="formData.samples"
:sample-type-options="sampleTypeOptions"
:sample-type-options-loading="sampleTypeLoading"
:sample-code-options="sampleCodeOptions"
:sample-code-options-loading="sampleCodeLoading"
:unit-options="unitOptions"
:unit-options-loading="unitLoading"
:site-options="siteOptions"
:site-options-loading="siteLoading"
:express-company-options="expressCompanyOptions"
:express-company-options-loading="expressCompanyLoading"
:show-price="true"
@price-change="onSamplePriceChange"
@search-sample-code="remoteSearchSampleCodes"
@search-sample-type="remoteSearchSampleTypes"
@search-unit="remoteSearchUnits"
@search-site="remoteSearchSites"
@search-express-company="remoteSearchExpressCompanies"
/>
</div>
</div>
@@ -486,7 +497,7 @@
<!-- 缴费主表 -->
<div class="payment-main-card">
<div class="section-subtitle">缴费主表</div>
<div class="form-grid form-grid--col5">
<div class="form-grid form-grid--col6">
<el-form-item label="缴费模式">
<el-radio-group v-model="formData.payMode" @change="onPayModeChange">
<el-radio label="dj">趸交</el-radio>
@@ -547,68 +558,52 @@
@change="handleFqParamsChange"
/>
</el-form-item>
<div class="installment-advance-ratios-row form-col-span-2">
<el-form-item label="应缴费提前(天)" class="installment-advance-field">
<el-input
v-model="formData.installmentAdvanceDays"
type="number"
:min="0"
:max="365"
step="1"
style="width: 120px"
@change="handleFqParamsChange"
/>
</el-form-item>
<el-form-item label="缴费比例(%)" class="installment-ratios-field">
<div class="installment-ratios-panel">
<div class="installment-ratios-bar" aria-hidden="true">
<div
v-for="(ratio, rIdx) in formData.installmentRatios"
:key="'bar-' + rIdx"
class="installment-ratios-bar-seg"
:class="{ 'is-auto': rIdx === formData.installmentRatios.length - 1 }"
:style="{ flex: Math.max(Number(ratio) || 0, 0.001) }"
:title="`第${rIdx + 1}期 ${ratio}%`"
/>
</div>
<div class="installment-ratios-cells">
<div
v-for="(_ratio, rIdx) in formData.installmentRatios"
:key="rIdx"
class="installment-ratio-cell"
:class="{ 'is-auto': rIdx === formData.installmentRatios.length - 1 }"
>
<span class="installment-ratio-cell-no">{{ rIdx + 1 }}期</span>
<el-input
v-model="formData.installmentRatios[rIdx]"
type="number"
:min="0"
:max="100"
step="any"
:disabled="rIdx === formData.installmentRatios.length - 1"
@change="onInstallmentRatioChange(rIdx)"
>
<template #suffix>%</template>
</el-input>
</div>
</div>
<div class="installment-ratios-foot">
<span class="form-label-tip">末期为自动补齐</span>
<span
class="installment-ratios-sum"
:class="{ 'is-error': !!installmentRatiosError }"
>
合计 {{ installmentRatiosSum }}%
</span>
</div>
<div v-if="installmentRatiosError" class="form-tip form-tip--error">
{{ installmentRatiosError }}
</div>
</div>
</el-form-item>
</div>
<el-form-item label="应缴费提前(天)">
<el-input
v-model="formData.installmentAdvanceDays"
type="number"
:min="0"
:max="365"
step="1"
style="width: 100%"
@change="handleFqParamsChange"
/>
</el-form-item>
</template>
</div>
<div v-if="formData.payMode === 'fq'" class="installment-amounts-row">
<el-form-item label="分期金额(元)" class="installment-amounts-field">
<div class="installment-amounts-cells">
<div
v-for="(_amount, rIdx) in formData.installmentRatios"
:key="rIdx"
class="installment-amount-cell"
>
<span class="installment-amount-cell-no">{{ rIdx + 1 }}期</span>
<AmountInput
v-model="formData.installmentRatios[rIdx]"
:min="0"
placeholder="金额"
@change="onInstallmentAmountChange(rIdx)"
/>
</div>
</div>
</el-form-item>
<div class="installment-amounts-summary-row">
<span
class="installment-amounts-sum"
:class="{ 'is-error': !!installmentAmountsError }"
>
合计 {{ fmtMoney(installmentAmountsSum) }}
<template v-if="formData.contractAmount != null">
/ 合同 {{ fmtMoney(formData.contractAmount) }}
</template>
</span>
<div v-if="installmentAmountsError" class="form-tip form-tip--error">
{{ installmentAmountsError }}
</div>
</div>
</div>
</div>
<!-- 缴费明细 -->
@@ -844,7 +839,14 @@
</el-dialog>
<!-- ========== 文件预览弹窗 ========== -->
<el-dialog v-model="previewVisible" title="文件预览" width="800px" align-center destroy-on-close>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
<img
:src="previewUrl"
@@ -900,16 +902,14 @@
import { fetchEthnicList, type EthnicOption } from '@/api/ethnic'
import { useUserStore } from '@/store/modules/user'
import {
calcInstallmentDueAmountsByRatios,
calcUnpaidAmountsByRatios,
DEFAULT_INSTALLMENT_ADVANCE_DAYS,
DEFAULT_INSTALLMENT_RATIOS_6,
defaultInstallmentRatios,
finalizeInstallmentRatios,
isInstallmentRatiosValid,
resizeInstallmentRatios,
syncFqPaymentDetailRowCount
} from '@/utils/protocol-payment-installment'
DEFAULT_INSTALLMENT_ADVANCE_DAYS,
isInstallmentAmountsValid,
normalizeInstallmentAmounts,
resizeInstallmentAmounts,
scaleInstallmentAmountsToContract,
sumInstallmentAmounts,
syncFqPaymentDetailRowCount
} from '@/utils/protocol-payment-installment'
import {
applyPayModeSwitch,
canAddPaymentDetail,
@@ -955,7 +955,7 @@
installmentIntervalMonths: null,
installmentStartDate: '',
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
installmentRatios: [...DEFAULT_INSTALLMENT_RATIOS_6]
installmentRatios: [] as number[]
})
const customerId = ref<string | null>(null)
@@ -997,6 +997,10 @@
const unitOptions = ref<Array<{ id: string; unitName: string; unitCode: string }>>([])
const sampleCodeOptions = ref<Api.BizSampleCode.SampleCodeItem[]>([])
const sampleCodeLoading = ref(false)
const sampleTypeLoading = ref(false)
const unitLoading = ref(false)
const siteLoading = ref(false)
const expressCompanyLoading = ref(false)
const expressCompanyOptions = ref<Api.ExpressCompany.ExpressCompanyItem[]>([])
const siteOptions = ref<Api.Site.SiteItem[]>([])
let sampleRowSeq = 0
@@ -1194,7 +1198,7 @@
lumpSumDeadline: '',
installmentStartDate: '',
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS as number | null,
installmentRatios: [...DEFAULT_INSTALLMENT_RATIOS_6] as number[],
installmentRatios: [] as number[] as number[],
// Payment Details
paymentDetails: [] as Array<Record<string, any>>
})
@@ -1248,7 +1252,7 @@
lumpSumDeadline: '',
installmentStartDate: '',
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
installmentRatios: [...DEFAULT_INSTALLMENT_RATIOS_6],
installmentRatios: [] as number[],
paymentDetails: []
})
customerId.value = null
@@ -1262,6 +1266,11 @@
salesSnapshot.value = null
regionOptions.value = []
hospitalOptions.value = []
sampleTypeOptions.value = []
unitOptions.value = []
sampleCodeOptions.value = []
siteOptions.value = []
expressCompanyOptions.value = []
activeFormTab.value = 'basic'
lastConfirmedPayMode.value = 'dj'
lastConfirmedFqParams.value = {
@@ -1269,7 +1278,7 @@
installmentIntervalMonths: null,
installmentStartDate: '',
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
installmentRatios: [...DEFAULT_INSTALLMENT_RATIOS_6]
installmentRatios: [] as number[]
}
}
@@ -1377,13 +1386,11 @@
formData.installmentAdvanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS
}
const count = toIntOrUndefined(formData.installmentCount) || 6
if (reset || !formData.installmentRatios?.length) {
formData.installmentRatios = defaultInstallmentRatios(count)
} else {
formData.installmentRatios = finalizeInstallmentRatios(
resizeInstallmentRatios(formData.installmentRatios, count)
)
}
formData.installmentRatios = normalizeInstallmentAmounts(
formData.installmentRatios || [],
formData.contractAmount,
count
)
}
function toIntOrUndefined(val: unknown): number | undefined {
@@ -1428,7 +1435,7 @@
installmentIntervalMonths: toFloatOrUndefined(formData.installmentIntervalMonths) ?? null,
installmentStartDate: formData.installmentStartDate || '',
installmentAdvanceDays: toIntOrUndefined(formData.installmentAdvanceDays) ?? null,
installmentRatios: finalizeInstallmentRatios([...(formData.installmentRatios || [])])
installmentRatios: [...(formData.installmentRatios || [])].map((v) => Number(v || 0))
}
}
@@ -1584,7 +1591,6 @@
resetForm()
formData.motherEthnic = DEFAULT_MOTHER_ETHNIC
formData.registerDate = new Date().toISOString().slice(0, 10).replace(/-/g, '')
loadSampleOptions()
void loadEthnicOptions()
void remoteSearchCustomers('')
@@ -1686,12 +1692,18 @@
formData.installmentStartDate = String(pay.installmentStartDate || '')
formData.installmentAdvanceDays =
pay.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS
const loadedRatios = Array.isArray(pay.installmentRatios) ? pay.installmentRatios : []
if (loadedRatios.length) {
formData.installmentRatios = loadedRatios.map((v: unknown) => Number(v))
const loadedAmounts = Array.isArray(pay.installmentRatios) ? pay.installmentRatios : []
if (loadedAmounts.length) {
formData.installmentRatios = loadedAmounts.map((v: unknown) => Number(v))
}
if (formData.payMode === 'fq') {
applyFqPaymentDefaults()
const count = toIntOrUndefined(formData.installmentCount) || 6
formData.installmentRatios = normalizeInstallmentAmounts(
formData.installmentRatios,
formData.contractAmount,
count
)
syncLastConfirmedFqParams()
}
}
@@ -1741,8 +1753,7 @@
dialogFormLoading.value = true
try {
const [, , bundle] = await Promise.all([
loadSampleOptions(),
const [, bundle] = await Promise.all([
loadEthnicOptions(),
fetchCollectionProtocolDetail(String(row.id)).catch(() => null)
])
@@ -1779,7 +1790,13 @@
}
await Promise.all(hydrateTasks)
if (formData.samples.length) {
ensureSampleCodesInOptions(formData.samples)
await Promise.all([
remoteSearchSampleTypes(''),
remoteSearchSites(''),
remoteSearchUnits(''),
remoteSearchSampleCodes('')
])
hydrateSampleLookupOptions()
}
} catch {
/* 保留列表行已填基础字段 */
@@ -1792,13 +1809,13 @@
if (!formRef.value) return
await formRef.value.validate()
if (formData.payMode === 'fq') {
if (installmentRatiosError.value) {
await ElMessageBox.alert(installmentRatiosError.value, '缴费比例无效', { type: 'warning' })
if (installmentAmountsError.value) {
await ElMessageBox.alert(installmentAmountsError.value, '分期金额无效', { type: 'warning' })
return
}
const ratios = finalizeInstallmentRatios([...(formData.installmentRatios || [])])
if (!isInstallmentRatiosValid(ratios)) {
await ElMessageBox.alert('缴费比例之和须等于 100%', '缴费比例无效', { type: 'warning' })
const amounts = (formData.installmentRatios || []).map((v) => Number(v || 0))
if (!isInstallmentAmountsValid(amounts, formData.contractAmount)) {
await ElMessageBox.alert('分期金额合计须等于合同金额', '分期金额无效', { type: 'warning' })
return
}
}
@@ -1883,7 +1900,7 @@
installmentAdvanceDays: toIntOrUndefined(formData.installmentAdvanceDays),
installmentRatios:
formData.payMode === 'fq'
? finalizeInstallmentRatios([...(formData.installmentRatios || [])])
? (formData.installmentRatios || []).map((v) => Number(v || 0))
: undefined
},
paymentDetails: formData.paymentDetails.map(
@@ -2116,6 +2133,89 @@
sampleCodeOptions.value = merged
}
function ensureSampleTypesInOptions(samples: Array<Record<string, any>>) {
const merged = [...sampleTypeOptions.value]
for (const s of samples) {
const id = String(s.sampleTypeId || '')
if (!id) continue
const name = String(s.sampleTypeName || '').trim()
const existing = merged.find((x) => x.id === id)
if (existing) {
if (name && (!existing.typeName || existing.typeName === existing.id)) {
existing.typeName = name
}
continue
}
merged.push({
id,
typeCode: '',
typeName: name || id,
storageYears: s.storageYears ?? null,
storagePrice: s.originalPrice ?? null
})
}
sampleTypeOptions.value = merged
}
function ensureUnitsInOptions(samples: Array<Record<string, any>>) {
const merged = [...unitOptions.value]
for (const s of samples) {
const id = String(s.unitId || '')
if (!id) continue
const name = String(s.unitName || '').trim()
const existing = merged.find((x) => x.id === id)
if (existing) {
if (name && (!existing.unitName || existing.unitName === existing.id)) {
existing.unitName = name
}
continue
}
merged.push({ id, unitName: name || id, unitCode: String(s.unitCode || '') })
}
unitOptions.value = merged
}
function ensureSitesInOptions(samples: Array<Record<string, any>>) {
const merged = [...siteOptions.value]
for (const s of samples) {
const id = String(s.transport?.destSiteId || '')
if (!id) continue
const name = String(s.transport?.destSiteName || '').trim()
const existing = merged.find((x) => x.id === id)
if (existing) {
if (name && (!existing.siteName || existing.siteName === existing.id)) {
existing.siteName = name
}
continue
}
merged.push({
id,
siteCode: '',
siteName: name || id,
siteType: '',
status: '',
address: '',
contactName: '',
contactPhone: '',
businessScope: '',
siteImage: '',
tenantNo: '',
orderNum: 0,
remark: '',
createTime: '',
updateTime: ''
})
}
siteOptions.value = merged
}
function hydrateSampleLookupOptions() {
ensureSampleTypesInOptions(formData.samples)
ensureSampleCodesInOptions(formData.samples)
ensureUnitsInOptions(formData.samples)
ensureSitesInOptions(formData.samples)
}
/* ====== 客户检索(母亲信息) ====== */
function formatCustomerLabel(c: { customerName: string; phoneNo: string; idCardNo: string }) {
const parts = [c.customerName || '']
@@ -2248,23 +2348,91 @@
formData.salesUserId = u.id
}
/* ====== 样本类型/单位选项 ====== */
async function loadSampleOptions() {
async function remoteSearchSampleCodes(query: string) {
sampleCodeLoading.value = true
try {
const [stRes, uRes, scRes, ecRes, siteRes] = await Promise.all([
fetchCellSampleTypeList({ page: 1, pageSize: 100 }),
fetchItemUnitList({ page: 1, pageSize: 100 }),
fetchSampleCodeList({ page: 1, pageSize: 100 }),
fetchExpressCompanyList({ page: 1, pageSize: 100 }),
fetchSiteList({ page: 1, pageSize: 100 })
])
sampleTypeOptions.value = (stRes.list || []) as any
unitOptions.value = (uRes.list || []) as any
sampleCodeOptions.value = (scRes.list || []) as any
expressCompanyOptions.value = ecRes.list || []
siteOptions.value = siteRes.list || []
const res = await fetchSampleCodeList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
sampleCodeOptions.value = (res.list || []) as Api.BizSampleCode.SampleCodeItem[]
ensureSampleCodesInOptions(formData.samples)
} catch {
/* ignore */
sampleCodeOptions.value = []
ensureSampleCodesInOptions(formData.samples)
} finally {
sampleCodeLoading.value = false
}
}
async function remoteSearchSampleTypes(query: string) {
sampleTypeLoading.value = true
try {
const res = await fetchCellSampleTypeList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
sampleTypeOptions.value = (res.list || []) as any
ensureSampleTypesInOptions(formData.samples)
} catch {
sampleTypeOptions.value = []
ensureSampleTypesInOptions(formData.samples)
} finally {
sampleTypeLoading.value = false
}
}
async function remoteSearchUnits(query: string) {
unitLoading.value = true
try {
const res = await fetchItemUnitList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
unitOptions.value = (res.list || []) as any
ensureUnitsInOptions(formData.samples)
} catch {
unitOptions.value = []
ensureUnitsInOptions(formData.samples)
} finally {
unitLoading.value = false
}
}
async function remoteSearchSites(query: string) {
siteLoading.value = true
try {
const res = await fetchSiteList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
siteOptions.value = res.list || []
ensureSitesInOptions(formData.samples)
} catch {
siteOptions.value = []
ensureSitesInOptions(formData.samples)
} finally {
siteLoading.value = false
}
}
async function remoteSearchExpressCompanies(query: string) {
expressCompanyLoading.value = true
try {
const res = await fetchExpressCompanyList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
expressCompanyOptions.value = res.list || []
} catch {
expressCompanyOptions.value = []
} finally {
expressCompanyLoading.value = false
}
}
@@ -2341,33 +2509,32 @@
function onInstallmentCountChange() {
const count = toIntOrUndefined(formData.installmentCount) || 0
if (count > 0) {
formData.installmentRatios = finalizeInstallmentRatios(
resizeInstallmentRatios(formData.installmentRatios || [], count)
formData.installmentRatios = resizeInstallmentAmounts(
formData.installmentRatios || [],
count,
formData.contractAmount
)
}
handleFqParamsChange()
}
function onInstallmentRatioChange(changedIdx: number) {
if (changedIdx === formData.installmentRatios.length - 1) return
formData.installmentRatios = finalizeInstallmentRatios([...formData.installmentRatios])
function onInstallmentAmountChange(_changedIdx: number) {
syncPaymentDueAmounts()
handleFqParamsChange()
}
const installmentRatiosError = computed(() => {
const ratios = formData.installmentRatios || []
if (!ratios.length) return ''
const head = ratios.slice(0, -1)
const sum = head.reduce((s, v) => s + Number(v || 0), 0)
if (sum > 100 + 0.001) return '前 N−1 期比例之和不能超过 100%'
const installmentAmountsError = computed(() => {
const amounts = formData.installmentRatios || []
if (!amounts.length || formData.contractAmount == null) return ''
if (amounts.some((v) => Number(v) < 0)) return '分期金额不能为负数'
const sum = sumInstallmentAmounts(amounts)
if (Math.abs(sum - Number(formData.contractAmount)) > 0.01) {
return `分期金额合计须等于合同金额(${fmtMoney(formData.contractAmount)})`
}
return ''
})
const installmentRatiosSum = computed(() => {
const total = (formData.installmentRatios || []).reduce((s, v) => s + Number(v || 0), 0)
return Math.round(total * 100) / 100
})
const installmentAmountsSum = computed(() => sumInstallmentAmounts(formData.installmentRatios || []))
/** 按缴费模式同步各明细应缴费金额(跳过已锁定明细) */
function syncPaymentDueAmounts() {
@@ -2388,27 +2555,16 @@
if (formData.payMode === 'fq') {
const count = formData.installmentCount || formData.paymentDetails.length
if (!count) return
const ratios = finalizeInstallmentRatios(formData.installmentRatios || [])
const amounts = normalizeInstallmentAmounts(
formData.installmentRatios || [],
contract,
Number(count)
)
const paidRows = formData.paymentDetails.filter((d) => String(d.payStatus) === 'yjf')
let amounts: number[]
if (paidRows.length > 0 && formData.paymentDetails.some((d) => isPayDetailLocked(d))) {
const paidSum = sumPaidAmount(formData.paymentDetails)
const remaining = Math.round((Number(contract) - paidSum) * 100) / 100
const unpaidIndices = Array.from({ length: Number(count) }, (_, i) => i).filter(
(i) => !paidRows.some((d) => Number(d.installmentNo) === i + 1)
)
const unpaidAmounts = calcUnpaidAmountsByRatios(remaining, ratios, unpaidIndices)
amounts = Array(Number(count)).fill(0)
unpaidIndices.forEach((idx, j) => {
amounts[idx] = unpaidAmounts[j] ?? 0
})
paidRows.forEach((d) => {
const no = Number(d.installmentNo)
if (no >= 1 && no <= Number(count)) amounts[no - 1] = Number(d.dueAmount) || 0
})
} else {
amounts = calcInstallmentDueAmountsByRatios(Number(contract), ratios)
}
paidRows.forEach((d) => {
const no = Number(d.installmentNo)
if (no >= 1 && no <= Number(count)) amounts[no - 1] = Number(d.dueAmount) || 0
})
formData.paymentDetails.forEach((d, i) => {
if (isPayDetailLocked(d)) return
if (amounts[i] !== undefined) d.dueAmount = amounts[i]
@@ -2422,7 +2578,14 @@
if (s.sampleStatus === 'yzf') return sum
return sum + (Number(s.price) || 0)
}, 0)
formData.contractAmount = Math.round(total * 100) / 100
const nextContract = Math.round(total * 100) / 100
if (formData.payMode === 'fq' && (formData.installmentRatios || []).length) {
formData.installmentRatios = scaleInstallmentAmountsToContract(
formData.installmentRatios,
nextContract
)
}
formData.contractAmount = nextContract
syncPaymentDueAmounts()
}
@@ -2756,15 +2919,20 @@
grid-template-columns: repeat(5, minmax(0, 1fr));
}
&--col6 {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
&--attachment {
margin-top: 16px;
grid-template-columns: 1fr;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.basic-remark-attach-row {
margin-top: 16px;
}
.form-col-span-2 {
grid-column: 1 / -1;
}
@@ -3143,13 +3311,10 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&--col5 {
&--col5,
&--col6 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&--col2.basic-remark-attach-row {
grid-template-columns: 1fr;
}
}
.client-sub-cards {
@@ -3157,106 +3322,48 @@
}
}
.installment-advance-ratios-row {
display: flex;
align-items: flex-start;
gap: 24px;
flex-wrap: wrap;
.installment-amounts-row {
margin-top: 16px;
}
.installment-advance-field {
flex: 0 0 auto;
.installment-amounts-field.el-form-item {
margin-bottom: 0;
}
.installment-ratios-field {
flex: 1 1 360px;
min-width: 0;
.installment-amounts-summary-row {
margin-top: 4px;
}
.installment-ratios-panel {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border: 1px solid #c5e0d4;
border-radius: 8px;
background: #f8fafc;
}
.installment-ratios-bar {
display: flex;
height: 8px;
overflow: hidden;
border-radius: 4px;
background: #e2e8f0;
}
.installment-ratios-bar-seg {
background: #047857;
min-width: 2px;
transition: flex 0.2s ease;
&.is-auto {
background: #6bb99a;
}
& + & {
border-left: 1px solid rgba(255, 255, 255, 0.6);
}
}
.installment-ratios-cells {
.installment-amounts-cells {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 12px 16px;
}
.installment-ratio-cell {
.installment-amount-cell {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1 1 72px;
min-width: 72px;
max-width: 96px;
:deep(.el-input__wrapper) {
padding-left: 8px;
padding-right: 4px;
}
:deep(.el-input__suffix) {
color: var(--mint-text-muted, #64748b);
font-size: 12px;
}
&.is-auto :deep(.el-input__wrapper) {
background: #e8f4ee;
}
gap: 6px;
min-width: 120px;
flex: 1 1 120px;
max-width: 180px;
}
.installment-ratio-cell-no {
font-size: 11px;
color: var(--mint-text-muted, #64748b);
line-height: 1.2;
}
.installment-ratios-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.installment-ratios-sum {
.installment-amount-cell-no {
font-size: 12px;
font-weight: 600;
color: #64748b;
}
.installment-amounts-sum {
font-size: 13px;
font-weight: 600;
color: #065f46;
font-weight: 500;
&.is-error {
color: #dc2626;
}
}
.form-tip--error {
margin-top: 6px;
color: #dc2626;
@@ -6,6 +6,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -6,6 +6,8 @@
width="1600px"
class="dialog-form-layout protocol-view-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -31,43 +33,6 @@
<template v-if="bundle">
<div>
<!-- KPI 摘要条 -->
<div class="kpi-strip">
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><CircleCheck /></el-icon>
</div>
<div class="kpi-card__main">
<div class="kpi-label">协议状态</div>
<div class="kpi-value">
<el-tag
:type="protocolStatusTagType(String(protocol?.protocolStatus || ''))"
size="small"
>
{{ protocolStatusLabel(String(protocol?.protocolStatus || '')) }}
</el-tag>
</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><User /></el-icon>
</div>
<div class="kpi-card__main">
<div class="kpi-label">母亲姓名</div>
<div class="kpi-value">{{ dash(partyCustomer?.motherName) }}</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><Calendar /></el-icon>
</div>
<div class="kpi-card__main">
<div class="kpi-label">登记日期</div>
<div class="kpi-value">{{ formatYmd(protocol?.registerDate) }}</div>
</div>
</div>
</div>
<!-- 基本信息 -->
<div id="view-section-basic" class="section-card section-card--white">
<div class="section-title"><h3>基本信息</h3></div>
@@ -769,9 +734,9 @@
}}</div></div
>
<div class="payment-metric"
><div class="pm-label">缴费比例</div
><div class="pm-label">分期金额(元)</div
><div class="pm-value">{{
formatInstallmentRatios(payment?.installmentRatios)
formatInstallmentAmounts(payment?.installmentRatios)
}}</div></div
>
</template>
@@ -1152,7 +1117,14 @@
</template>
</el-dialog>
<el-dialog v-model="previewVisible" title="文件预览" width="800px" align-center destroy-on-close>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
<img
:src="previewUrl"
@@ -1172,7 +1144,7 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { Calendar, CircleCheck, Close, User } from '@element-plus/icons-vue'
import { Close } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from './ProtocolAttachmentReadonly.vue'
import SampleQcInfoCards from '@/components/business/sample-qc-info-cards/index.vue'
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
@@ -1467,9 +1439,9 @@
return dash(val)
}
function formatInstallmentRatios(ratios?: number[] | null) {
if (!ratios?.length) return '—'
return ratios.map((r) => `${r}%`).join(' / ')
function formatInstallmentAmounts(values?: number[] | null) {
if (!values?.length) return '—'
return values.map((v) => fmtMoney(v)).join(' / ')
}
function formatYm(val: unknown) {
@@ -2,30 +2,30 @@
<template>
<div class="page-container">
<section class="panel">
<div
class="panel-entity-switch panel-entity-switch--multi"
role="tablist"
aria-label="视图类型"
>
<button
v-for="item in entityTabs"
:key="item.key"
type="button"
class="entity-switch-card"
:class="{ 'is-active': activeTab === item.key }"
role="tab"
:aria-selected="activeTab === item.key"
@click="activeTab = item.key"
>
<span class="entity-switch-card__icon"
><el-icon><component :is="item.icon" /></el-icon
></span>
<span class="entity-switch-card__main">
<span class="entity-switch-card__title">{{ item.title }}</span>
<span class="entity-switch-card__desc">{{ item.desc }}</span>
</span>
</button>
</div>
<nav class="pill-tab-nav" role="tablist" aria-label="视图类型">
<div class="pill-tab-nav__rail">
<div class="pill-tab-nav__progress" :style="pillProgressStyle" />
<button
v-for="(item, index) in entityTabs"
:key="item.key"
type="button"
class="pill-tab-nav__step"
:class="{
'is-active': activeTab === item.key,
'is-done': index < activeTabIndex
}"
role="tab"
:aria-selected="activeTab === item.key"
@click="activeTab = item.key"
>
<span class="pill-tab-nav__index">{{ index + 1 }}</span>
<span class="pill-tab-nav__text">
<span class="pill-tab-nav__title">{{ item.title }}</span>
<span class="pill-tab-nav__desc">{{ item.desc }}</span>
</span>
</button>
</div>
</nav>
<SampleCollectionInfoTab v-if="activeTab === 'sample-collection'" />
<SampleReceptionInfoTab v-else-if="activeTab === 'sample-reception'" />
@@ -36,8 +36,6 @@
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { Box, DocumentChecked, FolderOpened, FirstAidKit } from '@element-plus/icons-vue'
import SampleCollectionInfoTab from '../protocol-audit/SampleCollectionInfoTab.vue'
import SampleReceptionInfoTab from '../protocol-audit/SampleReceptionInfoTab.vue'
import SampleQcInfoTab from '../protocol-audit/SampleQcInfoTab.vue'
@@ -58,37 +56,44 @@
key: EntityTabKey
title: string
desc: string
icon: Component
}> = [
{
key: 'sample-collection',
title: '样本采集信息',
desc: '协议样本采集进度',
icon: FirstAidKit
desc: '协议样本采集进度'
},
{
key: 'sample-reception',
title: '样本接收信息',
desc: '接收结论与短信通知',
icon: Box
desc: '接收结论与短信通知'
},
{
key: 'sample-qc',
title: '样本质检信息',
desc: '质检结论与短信通知',
icon: DocumentChecked
desc: '质检结论与短信通知'
},
{
key: 'storage-entry',
title: '样本入库信息',
desc: '样本入库记录查询',
icon: FolderOpened
desc: '样本入库记录查询'
}
]
const route = useRoute()
const activeTab = ref<EntityTabKey>('sample-collection')
const activeTabIndex = computed(() =>
Math.max(
0,
entityTabs.findIndex((item) => item.key === activeTab.value)
)
)
const pillProgressStyle = computed(() => ({
width: `calc(${100 / entityTabs.length}% - 3px)`,
transform: `translateX(${activeTabIndex.value * 100}%)`
}))
onMounted(() => {
const tab = String(route.query.tab || '')
if (VALID_TABS.includes(tab as EntityTabKey)) {
@@ -98,106 +103,139 @@
</script>
<style scoped lang="scss">
.panel-entity-switch {
display: flex;
justify-content: center;
gap: 12px;
padding: 16px 16px 12px;
}
.panel-entity-switch--multi {
flex-wrap: wrap;
gap: 10px;
.entity-switch-card {
flex: 0 1 168px;
min-width: 152px;
max-width: 180px;
padding: 10px 12px;
gap: 8px;
}
.entity-switch-card__icon {
width: 32px;
height: 32px;
font-size: 17px;
}
.entity-switch-card__title {
font-size: 12px;
}
.entity-switch-card__desc {
font-size: 11px;
}
}
.entity-switch-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border: 1px solid var(--mint-border, #c5e0d4);
border-radius: 10px;
background: var(--surface, #fff);
cursor: pointer;
text-align: left;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
&:hover:not(.is-active) {
border-color: #a8d4c4;
background: #f8fafc;
}
&.is-active {
border-color: var(--primary, #047857);
background: var(--primary-bg, #e8f4ee);
box-shadow: 0 0 0 1px rgb(4 120 87 / 12%);
}
}
.entity-switch-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 8px;
background: #f1f5f9;
color: #64748b;
font-size: 20px;
.entity-switch-card.is-active & {
background: #fff;
color: var(--primary-dark, #065f46);
}
}
.entity-switch-card__main {
.pill-tab-nav {
padding: 16px 20px 0;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
align-items: center;
}
.entity-switch-card__title {
font-size: 14px;
font-weight: 600;
color: var(--text, #1e293b);
line-height: 1.4;
.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;
}
.entity-switch-card.is-active & {
color: var(--primary-dark, #065f46);
.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: #475569;
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: #94a3b8;
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;
}
}
.entity-switch-card__desc {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
@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>