feat: 细胞存储缴费催缴、统一样本信息与协议支付能力增强

汇总近期业务改动:催缴菜单与后端、样本信息快捷登记与进度、运费关联、客户档案扩展、协议优惠/期次与押金划转,并同步 SQL 迁移与管理端页面收敛。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-10 21:20:37 +08:00
1 parent 2d3472f5c4
commit db31990ebe
235 files changed
+14898 -10196

No files matched your search

@@ -1,8 +1,76 @@
<!-- 缴退费确认流程(纵向倒序时间线 + 独立操作区) -->
<!-- 缴退费确认流程(纵向正序时间线 + 独立操作区) -->
<template>
<div class="clinical-payment-confirm-timeline">
<div v-if="showBasicSummary && isProtocolFlow" class="payment-basic-summary">
<div v-if="showBasicSummary && isPresignFlow" class="payment-basic-summary">
<dl class="payment-basic-summary__grid">
<div class="payment-basic-summary__item">
<dt>缴费类型</dt>
<dd>
<el-tag type="warning" size="small">预签约意向金</el-tag>
</dd>
</div>
<div class="payment-basic-summary__item">
<dt>协议编号</dt>
<dd>{{ dash(detail?.protocolNo) }}</dd>
</div>
<div class="payment-basic-summary__item">
<dt>客户姓名</dt>
<dd>{{ dash(detail?.motherName) }}</dd>
</div>
<div class="payment-basic-summary__item">
<dt>订单状态</dt>
<dd>
<el-tag :type="presignOrderStatusTagType(String(detail?.orderStatus || ''))" size="small">
{{ presignOrderStatusLabel(String(detail?.orderStatus || '')) }}
</el-tag>
</dd>
</div>
<div class="payment-basic-summary__item">
<dt>业务日期</dt>
<dd>{{ formatPaymentDueDate(detail?.orderDate) }}</dd>
</div>
<div class="payment-basic-summary__item">
<dt>预产期</dt>
<dd>{{ formatPaymentDueDate(detail?.expectedDeliveryDate) }}</dd>
</div>
<div class="payment-basic-summary__item">
<dt>样本类型</dt>
<dd>{{ dash(detail?.sampleTypeNames) }}</dd>
</div>
<div class="payment-basic-summary__item">
<dt>合同金额(元)</dt>
<dd>{{ formatProtocolAmount(detail?.contractAmount) }}</dd>
</div>
<div class="payment-basic-summary__item">
<dt>合同缴费方式</dt>
<dd>{{ protocolPayModeLabel(String(detail?.contractPayMode || '')) }}</dd>
</div>
<div class="payment-basic-summary__item">
<dt>本次意向金(元)</dt>
<dd>{{ formatProtocolAmount(detail?.transferAmount) }}</dd>
</div>
<div class="payment-basic-summary__item">
<dt>采集专员</dt>
<dd>{{ dash(detail?.collectorUserName) }}</dd>
</div>
<div v-if="showPayStatus" class="payment-basic-summary__item">
<dt>缴费状态</dt>
<dd>
<el-tag :type="protocolPayStatusTagType(String(detail?.payStatus || ''))" size="small">
{{ protocolPayStatusLabel(String(detail?.payStatus || '')) }}
</el-tag>
</dd>
</div>
</dl>
</div>
<div v-else-if="showBasicSummary && isProtocolFlow" class="payment-basic-summary">
<dl class="payment-basic-summary__grid">
<div class="payment-basic-summary__item">
<dt>缴费类型</dt>
<dd>
<el-tag type="primary" size="small">正式协议缴费</el-tag>
</dd>
</div>
<div class="payment-basic-summary__item">
<dt>协议状态</dt>
<dd>
@@ -148,7 +216,9 @@
<dt>{{ field.label }}</dt>
<dd v-if="field.slot === 'attachments'">
<ProtocolAttachmentReadonly
size="compact"
:files="attachmentFiles(field.attachmentUrls)"
@preview="handleAttachmentPreview"
@download="(f) => emit('download', f)"
/>
</dd>
@@ -171,16 +241,38 @@
<div v-if="$slots['action-panel']" class="action-panel">
<slot name="action-panel" />
</div>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
modal-class="protocol-dialog-mask"
>
<div v-if="previewType === 'image'" class="preview-container">
<img :src="previewUrl" alt="预览" />
</div>
<div v-else-if="previewType === 'video'" class="preview-container">
<video :src="previewUrl" controls />
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { computed, useSlots } from 'vue'
import { computed, ref, useSlots } from 'vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { getPilotBizConfig } from '@/config/pilot-biz-type'
import { maskIdCard } from '@/utils/mask-sensitive'
import { payStatusLabel, payStatusTagType } from '@/utils/status-tag-registry'
import {
payStatusLabel,
payStatusTagType,
presignOrderStatusLabel,
presignOrderStatusTagType
} from '@/utils/status-tag-registry'
import {
buildPaymentFlowSteps,
type PaymentFlowStep,
@@ -192,7 +284,7 @@
payModeLabel,
payStatusLabel as protocolPayStatusLabel,
payStatusTagType as protocolPayStatusTagType,
protocolStatusLabel, protocolStatusDisplayLabel,
protocolStatusDisplayLabel,
protocolStatusTagType
} from '@/utils/protocol-payment-labels'
import { formatMoneyDisplay } from '@/utils/money-format'
@@ -225,7 +317,11 @@
}
function attachmentFiles(urlField?: string) {
const fromDetail = props.detail?.attachments
const urls = String(urlField || '')
if (Array.isArray(fromDetail) && fromDetail.length && !urls.trim()) {
return fromDetail as Array<Record<string, unknown>>
}
if (!urls.trim()) return []
return urls
.split(',')
@@ -238,6 +334,50 @@
.filter(Boolean) as Array<Record<string, unknown>>
}
const previewVisible = ref(false)
const previewUrl = ref('')
const previewType = ref<'image' | 'video'>('image')
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function resolveFileUrl(suffix: string) {
if (!suffix) return ''
if (/^https?:\/\//i.test(suffix) || suffix.startsWith('/')) return suffix
return minioBaseUrl ? `${minioBaseUrl}/${suffix.replace(/^\//, '')}` : suffix
}
function fileKind(file: Record<string, unknown>): 'image' | 'video' | 'other' {
const url = String(file.fileUrl || '').toLowerCase()
const name = String(file.fileName || '').toLowerCase()
const type = String(file.fileType || '')
if (
/\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
/\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
type.startsWith('video/')
) {
return 'video'
}
if (
/\.(pdf|doc|docx|xls|xlsx|zip)(\?|$)/.test(url) ||
/\.(pdf|doc|docx|xls|xlsx|zip)$/.test(name)
) {
return 'other'
}
return 'image'
}
function handleAttachmentPreview(file: Record<string, unknown>) {
const url = resolveFileUrl(String(file.fileUrl || ''))
if (!url) return
const kind = fileKind(file)
if (kind === 'other') {
window.open(url, '_blank')
return
}
previewUrl.value = url
previewType.value = kind
previewVisible.value = true
}
function visibleFields(step: PaymentFlowStep) {
return step.fields.filter(
(field) => field.slot !== 'attachments' || attachmentFiles(field.attachmentUrls).length
@@ -250,7 +390,11 @@
return visibleFields(step).length > 0
}
const isProtocolFlow = computed(() => props.flowVariant === 'protocol')
const isPresignFlow = computed(() => String(props.detail?.source || '') === 'presign')
const isProtocolFlow = computed(
() => props.flowVariant === 'protocol' && !isPresignFlow.value
)
const isBasicExperiment = computed(
() => String(props.detail?.bizType || '') === 'basic_experiment'
@@ -271,14 +415,14 @@
}
const steps = computed(() =>
isProtocolFlow.value
props.flowVariant === 'protocol'
? buildProtocolPaymentFlowSteps(props.detail, props.activeStepKey)
: buildPaymentFlowSteps(props.detail, props.activeStepKey)
)
/** 倒序:最新环节在上,最早环节在下;隐藏尚未到达的环节 */
/** 正序:先缴费确认,后财务确认;隐藏尚未到达的环节 */
const displaySteps = computed(() =>
[...steps.value].filter((step) => step.state !== 'pending').reverse()
steps.value.filter((step) => step.state !== 'pending')
)
</script>
@@ -489,7 +633,7 @@
}
.v-timeline-field--full {
flex: 1 1 220px;
flex: 1 1 100%;
}
@media (max-width: 560px) {
@@ -523,4 +667,14 @@
line-height: 1.4;
}
}
.preview-container {
img,
video {
display: block;
max-width: 100%;
max-height: 70vh;
margin: 0 auto;
}
}
</style>
@@ -363,8 +363,9 @@
</el-table>
</div>
<template #footer>
<el-button @click="cellSelectVisible = false">取消</el-button>
<el-button @click="cellSelectVisible = false"><el-icon><Close /></el-icon>取消</el-button>
<el-button type="primary" :disabled="!cellSelectedRows.length" @click="confirmCellSelect">
<el-icon><Check /></el-icon>
确定(已选 {{ cellSelectedRows.length }} 项)
</el-button>
</template>
@@ -406,7 +407,9 @@
Document,
Download,
View,
VideoPlay
VideoPlay,
Check,
Close
} from '@element-plus/icons-vue'
import { fetchUploadAttachment } from '@/api/business'
import { useIndexedAttachmentDropZone } from '@/composables/useAttachmentDropZone'
@@ -0,0 +1,283 @@
<template>
<div class="protocol-handle-panel">
<el-form label-position="top" class="biz-dialog-form" @submit.prevent>
<div class="form-grid-2">
<el-form-item label="办理时间" required>
<el-date-picker
v-model="form.handleTime"
type="datetime"
placeholder="请选择办理时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
clearable
/>
</el-form-item>
<el-form-item label="办理人员" :required="!!form.handleTime">
<el-select
v-model="form.handlerUserId"
filterable
remote
clearable
placeholder="输入姓名检索"
:remote-method="searchUsers"
:loading="userLoading"
style="width: 100%"
>
<el-option
v-for="u in userOptions"
:key="u.id"
:label="u.nickName || u.userName"
:value="u.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="attachmentLabel" class="form-col-span-2">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
@change="onFileChange"
>
<div class="upload-card">
<el-icon :size="28" color="#047857"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
<div v-for="(file, index) in files" :key="file.id || index" class="attachment-item">
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<el-icon class="action-btn action-btn--danger" @click="files.splice(index, 1)">
<Delete />
</el-icon>
</div>
</div>
</div>
<span class="upload-hint">支持图片、PDF、Office 等,单文件不超过 20MB</span>
</div>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="500" show-word-limit />
</el-form-item>
</div>
<div class="handle-actions">
<el-button class="btn-main" type="primary" :loading="saving" @click="handleSave">
<el-icon><Check /></el-icon>
保存
</el-button>
</div>
</el-form>
</div>
</template>
<script setup lang="ts">
import { Check, Delete, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { fetchUserList } from '@/api/admin'
import { fetchUploadAttachment } from '@/api/business'
import {
fetchUpdateFullmoonHandle,
fetchUpdateStorageCertHandle
} from '@/api/sample-info-handle'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'ProtocolHandlePanel' })
const props = defineProps<{
mode: 'storage-cert' | 'insurance'
protocolId: string
initial?: Record<string, unknown> | null
}>()
const emit = defineEmits<{ (e: 'saved'): void }>()
const userStore = useUserStore()
const saving = ref(false)
const userLoading = ref(false)
const userOptions = ref<Array<{ id: string; nickName?: string; userName?: string }>>([])
const files = ref<Array<{ id: string; fileName: string; fileUrl: string }>>([])
const form = reactive({
handleTime: '' as string,
handlerUserId: '' as string,
remark: ''
})
const attachmentLabel = computed(() =>
props.mode === 'storage-cert' ? '储存证附件' : '保险附件'
)
function urlsToFiles(raw: string) {
return String(raw || '')
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((url, i) => ({
id: `f_${i}`,
fileName: decodeURIComponent(url.split('/').pop() || `附件${i + 1}`),
fileUrl: url
}))
}
function applyInitial() {
const init = props.initial || {}
form.handleTime = String(init.handleTime || '')
form.handlerUserId = String(init.handlerUserId || '')
form.remark = String(
props.mode === 'storage-cert' ? init.remark || '' : init.handleRemark || ''
)
const urlField = props.mode === 'storage-cert' ? 'certImageUrls' : 'insuranceAttachments'
files.value = urlsToFiles(String(init[urlField] || ''))
const uid = form.handlerUserId || String(userStore.info?.userId || '')
if (!form.handlerUserId && !form.handleTime) {
form.handlerUserId = uid
}
if (form.handlerUserId) {
const name = String(init.handlerUserName || userStore.info?.userName || '')
if (name) {
userOptions.value = [
{ id: form.handlerUserId, nickName: name, userName: name }
]
}
}
}
watch(
() => [props.protocolId, props.initial],
() => applyInitial(),
{ immediate: true, deep: true }
)
async function searchUsers(kw: string) {
userLoading.value = true
try {
const res = await fetchUserList({ page: 1, pageSize: 50, keyword: kw || '', status: '1' })
userOptions.value = ((res as any)?.list || []).map((u: any) => ({
id: String(u.id),
nickName: u.nickName,
userName: u.userName
}))
} finally {
userLoading.value = false
}
}
async function onFileChange(uploadFile: any) {
const file = uploadFile.raw || uploadFile
if (!file) return
const fd = new FormData()
fd.append('folder', props.mode === 'storage-cert' ? 'storage-cert' : 'fullmoon-todo')
fd.append('file', file)
const res = (await fetchUploadAttachment(fd)) as { id?: string; fileName?: string; fileUrl?: string }
files.value.push({
id: String(res.id || Date.now()),
fileName: file.name || res.fileName || '附件',
fileUrl: res.fileUrl || ''
})
}
async function handleSave() {
if (form.handleTime && !form.handlerUserId) {
ElMessage.warning('填写办理时间时必须指定办理人员')
return
}
if (!props.protocolId) return
saving.value = true
try {
const urls = files.value.map((f) => f.fileUrl).filter(Boolean).join(',')
if (props.mode === 'storage-cert') {
await fetchUpdateStorageCertHandle(props.protocolId, {
handleTime: form.handleTime || null,
handlerUserId: form.handleTime ? form.handlerUserId : null,
certImageUrls: urls,
remark: form.remark
})
} else {
await fetchUpdateFullmoonHandle(props.protocolId, {
handleTime: form.handleTime || null,
handlerUserId: form.handleTime ? form.handlerUserId : null,
insuranceAttachments: urls,
handleRemark: form.remark
})
}
ElMessage.success('保存成功')
emit('saved')
} finally {
saving.value = false
}
}
</script>
<style scoped lang="scss">
.handle-actions {
margin-top: 8px;
}
.action-btn--danger {
cursor: pointer;
color: var(--el-color-danger);
}
.attachment-upload {
width: 100%;
}
.upload-card {
width: 140px;
height: 96px;
border: 1px dashed #a7f3d0;
border-radius: 8px;
background: #f0fdf4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
}
.upload-card-text {
font-size: 12px;
color: #047857;
font-weight: 500;
}
.upload-hint {
display: block;
font-size: 11px;
color: var(--mint-text-muted, #64748b);
margin-top: 8px;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.attachment-item {
width: 140px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.attachment-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 8px;
}
.attachment-name {
flex: 1;
min-width: 0;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -59,6 +59,9 @@
align-items: center;
flex-wrap: wrap;
gap: 6px;
/* 避免换行后撑满单元格导致在 align=center 列内左对齐 */
width: fit-content;
max-width: 100%;
vertical-align: middle;
}
@@ -0,0 +1,196 @@
<template>
<div class="list-body list-wrap sample-freight-pick">
<el-table
ref="tableRef"
v-loading="loading"
:data="flatRows"
stripe
class="list-table"
row-key="rowKey"
highlight-current-row
:max-height="maxHeight"
:span-method="spanMethod"
@row-click="onRowClick"
>
<el-table-column prop="radio" label="" min-width="48" align="center" header-align="center">
<template #default="{ row }">
<el-radio
v-if="row.isFirst"
:model-value="modelValue"
:value="row.freightId"
@click.stop
@change="emitSelect(row.freight)"
/>
</template>
</el-table-column>
<el-table-column
prop="batchNo"
label="批次号"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="transportMode"
label="运输方式"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag type="primary" size="small" effect="plain">
{{ row.transportModeLabel || row.transportMode || '—' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="freightAmount"
label="运费(元)"
min-width="110"
align="right"
header-align="center"
>
<template #default="{ row }">¥{{ formatMoneyDisplay(row.freightAmount) }}</template>
</el-table-column>
<el-table-column
prop="linkedSampleCount"
label="关联数"
min-width="80"
align="center"
header-align="center"
/>
<el-table-column label="来源" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
v-if="row.sampleSource"
:type="row.sampleSource === 'cell_storage' ? 'success' : 'warning'"
size="small"
>
{{ row.sampleSource === 'cell_storage' ? '细胞存储' : '科研捐赠' }}
</el-tag>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column
prop="sampleCode"
label="样本编码"
min-width="120"
show-overflow-tooltip
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.sampleCode || '—' }}</template>
</el-table-column>
<el-table-column
prop="sampleTypeName"
label="样本类型"
min-width="110"
show-overflow-tooltip
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.sampleTypeName || '—' }}</template>
</el-table-column>
<el-table-column
prop="createByName"
label="创建人"
min-width="100"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.createByName || row.createBy || '—' }}</template>
</el-table-column>
<el-table-column
prop="createTime"
label="创建时间"
min-width="170"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.createTime || '—' }}</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import type { SampleFreight } from '@/api/sample-freight'
import { formatMoneyDisplay } from '@/utils/money-format'
import {
flattenSampleFreights,
sampleFreightSpanMethod,
type SampleFreightFlatRow
} from '@/utils/sample-freight-flatten'
defineOptions({ name: 'SampleFreightPickTable' })
const props = withDefaults(
defineProps<{
list: SampleFreight[]
loading?: boolean
modelValue?: string
maxHeight?: number | string
}>(),
{
loading: false,
modelValue: '',
maxHeight: 280
}
)
const emit = defineEmits<{
'update:modelValue': [string]
select: [SampleFreight | null]
}>()
const tableRef = ref()
const flatRows = computed(() => flattenSampleFreights(props.list || []))
function spanMethod(args: { column: any; row: SampleFreightFlatRow }) {
return sampleFreightSpanMethod(args)
}
function emitSelect(freight: SampleFreight | null) {
const id = freight?.id || ''
emit('update:modelValue', id)
emit('select', freight)
}
function onRowClick(row: SampleFreightFlatRow) {
emitSelect(row.freight)
}
watch(
() => [props.modelValue, flatRows.value] as const,
([id, rows]) => {
nextTick(() => {
const table = tableRef.value
if (!table) return
if (!id) {
table.setCurrentRow?.(null)
return
}
const hit = rows.find((r) => r.freightId === id && r.isFirst)
if (hit) table.setCurrentRow?.(hit)
})
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.sample-freight-pick {
:deep(.el-radio) {
height: auto;
margin-right: 0;
}
.muted {
color: var(--mint-text-muted, #64748b);
}
}
</style>
@@ -3,7 +3,7 @@
<el-dialog
v-model="dialogVisible"
title="样本缴费详情"
width="1080px"
width="min(1200px, 90vw)"
class="dialog-form-layout"
align-center
append-to-body
@@ -14,7 +14,7 @@
>
<div v-loading="loading" class="sample-pay-detail">
<template v-if="detail">
<div class="detail-summary form-grid-2">
<div class="detail-summary form-grid-4">
<div class="detail-field">
<label>协议号</label>
<span>{{ detail.protocolNo || '—' }}</span>
@@ -34,7 +34,11 @@
</div>
<div class="detail-field">
<label>缴费方式</label>
<el-tag :type="payModeTagType(String(detail.payMode || ''))" size="small">
<el-tag
class="detail-tag"
:type="payModeTagType(String(detail.payMode || ''))"
size="small"
>
{{ payModeLabel(String(detail.payMode || '')) || '—' }}
</el-tag>
</div>
@@ -61,6 +65,7 @@
<div class="detail-field">
<label>缴费进度</label>
<el-tag
class="detail-tag"
:type="samplePaymentProgressTagType(String(detail.progressStatus || ''))"
size="small"
>
@@ -227,57 +232,59 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="300" align="center" fixed="right">
<el-table-column label="操作" min-width="150" align="center" fixed="right">
<template #default="{ row, $index }">
<el-button
v-if="!isTransferFieldsLocked(row)"
link
type="primary"
size="small"
:loading="savingKey === transferRowKey(sIdx, $index, row)"
@click="saveTransferRow(sIdx, $index)"
>
<el-icon><Select /></el-icon>
保存
</el-button>
<el-button
v-if="row.id && row.payStatus === 'dfj'"
link
type="primary"
size="small"
:loading="submittingId === row.id"
@click="submitTransfer(row)"
>
<el-icon><Check /></el-icon>
提交财务确认
</el-button>
<el-button
v-if="row.id && row.payStatus === 'jfqrz'"
link
type="warning"
size="small"
:loading="submittingId === row.id"
@click="withdrawTransfer(row)"
>
<el-icon><RefreshLeft /></el-icon>
撤回
</el-button>
<el-button
v-if="!isTransferFieldsLocked(row)"
link
type="danger"
size="small"
:loading="deletingId === String(row.id || '')"
@click="removeTransfer(sIdx, $index)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
<span
v-if="isTransferFieldsLocked(row) && row.payStatus !== 'jfqrz'"
class="muted-dash"
>—</span
>
<div class="transfer-action-stack">
<el-button
v-if="!isTransferFieldsLocked(row)"
link
type="primary"
size="small"
:loading="savingKey === transferRowKey(sIdx, $index, row)"
@click="saveTransferRow(sIdx, $index)"
>
<el-icon><Select /></el-icon>
保存
</el-button>
<el-button
v-if="row.id && row.payStatus === 'dfj'"
link
type="primary"
size="small"
:loading="submittingId === row.id"
@click="submitTransfer(row)"
>
<el-icon><Check /></el-icon>
提交财务确认
</el-button>
<el-button
v-if="row.id && row.payStatus === 'jfqrz'"
link
type="warning"
size="small"
:loading="submittingId === row.id"
@click="withdrawTransfer(row)"
>
<el-icon><RefreshLeft /></el-icon>
撤回
</el-button>
<el-button
v-if="!isTransferFieldsLocked(row)"
link
type="danger"
size="small"
:loading="deletingId === String(row.id || '')"
@click="removeTransfer(sIdx, $index)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
<span
v-if="isTransferFieldsLocked(row) && row.payStatus !== 'jfqrz'"
class="muted-dash"
>—</span
>
</div>
</template>
</el-table-column>
</el-table>
@@ -693,6 +700,11 @@
font-weight: 400;
}
.detail-tag {
align-self: flex-start;
width: fit-content;
}
.amount-text {
font-weight: 600;
color: var(--primary-dark, #059669);
@@ -734,6 +746,17 @@
}
}
.transfer-action-stack {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
.muted-dash {
color: var(--mint-text-muted, #94a3b8);
}
@@ -199,11 +199,13 @@
}
.sample-card__section-label {
font-size: 11px;
margin: 0 0 10px;
padding-left: 10px;
font-size: 13px;
font-weight: 600;
color: var(--mint-text-muted, #334155);
margin-bottom: 10px;
letter-spacing: 0.3px;
line-height: 1.4;
color: #334155;
border-left: 3px solid #10b981;
}
.sample-production-empty,
@@ -103,11 +103,13 @@
}
.sample-card__section-label {
margin-bottom: 10px;
font-size: 11px;
margin: 0 0 10px;
padding-left: 10px;
font-size: 13px;
font-weight: 600;
color: var(--mint-text-muted, #334155);
letter-spacing: 0.3px;
line-height: 1.4;
color: #334155;
border-left: 3px solid #10b981;
}
.sample-qc-grouped-panel {
@@ -124,9 +126,9 @@
}
.sample-qc-grouped-panel__subtitle {
margin: 0 0 8px;
padding-left: 10px;
margin: 0;
font-size: 12px;
font-size: 13px;
font-weight: 600;
line-height: 1.4;
color: #334155;
@@ -2,7 +2,7 @@
<template>
<div class="sample-reception-dual-readonly">
<div v-if="showQc" class="sample-reception-dual-readonly__block">
<h4 v-if="qcTitle" class="detail-section-title">{{ qcTitle }}</h4>
<h4 v-if="qcTitle" class="sample-card__section-label">{{ qcTitle }}</h4>
<div v-if="!qcReception" class="sample-reception-dual-readonly__empty">{{ qcEmptyHint }}</div>
<template v-else>
<div class="readonly-grid form-grid-4">
@@ -62,7 +62,7 @@
</div>
<div v-if="showProduction" class="sample-reception-dual-readonly__block">
<h4 v-if="productionTitle" class="detail-section-title">{{ productionTitle }}</h4>
<h4 v-if="productionTitle" class="sample-card__section-label">{{ productionTitle }}</h4>
<div v-if="!productionReception" class="sample-reception-dual-readonly__empty">
{{ productionEmptyHint }}
</div>
@@ -185,6 +185,16 @@
</script>
<style scoped lang="scss">
.sample-card__section-label {
margin: 0 0 10px;
padding-left: 10px;
font-size: 13px;
font-weight: 600;
line-height: 1.4;
color: #334155;
border-left: 3px solid #10b981;
}
.sample-reception-dual-readonly__block + .sample-reception-dual-readonly__block {
margin-top: 16px;
padding-top: 16px;
@@ -187,11 +187,13 @@
}
.sample-card__section-label {
font-size: 11px;
margin: 0 0 10px;
padding-left: 10px;
font-size: 13px;
font-weight: 600;
color: var(--mint-text-muted, #334155);
margin-bottom: 10px;
letter-spacing: 0.3px;
line-height: 1.4;
color: #334155;
border-left: 3px solid #10b981;
}
.sample-storage-release-empty {
@@ -41,10 +41,10 @@
align="center"
header-align="center"
/>
<el-table-column label="样本类型" min-width="120" show-overflow-tooltip>
<el-table-column label="样本类型" min-width="120">
<template #default="{ row }">{{ sampleTypeName(row.sampleTypeId) }}</template>
</el-table-column>
<el-table-column label="样本编码" min-width="180" show-overflow-tooltip>
<el-table-column label="样本编码" min-width="180">
<template #default="{ row }">
<SampleCodeWithSpecialDisplay
:sample-code="row.sampleCode"
@@ -89,13 +89,13 @@
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="特殊说明" min-width="140" show-overflow-tooltip>
<el-table-column label="特殊说明" min-width="140">
<template #default="{ row }">
<span v-if="row.isSpecial">{{ row.specialRemark || '—' }}</span>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="运输方式" min-width="120" show-overflow-tooltip>
<el-table-column label="运输方式" min-width="120">
<template #default="{ row }">{{ freightModeLabel(row) }}</template>
</el-table-column>
<el-table-column
@@ -103,13 +103,12 @@
min-width="120"
align="right"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ freightAmountText(row) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="260"
min-width="180"
fixed="right"
align="center"
header-align="center"
@@ -132,7 +131,7 @@
<el-dialog
v-model="sampleDialogVisible"
:title="sampleDialogMode === 'add' ? '添加样本' : '编辑样本'"
width="840px"
width="980px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
@@ -148,7 +147,7 @@
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<div class="form-grid-3">
<el-form-item label="样本类型">
<el-select
v-model="sampleDraft.sampleTypeId"
@@ -268,7 +267,7 @@
<el-dialog
v-model="freightDialogVisible"
title="配置运费"
width="720px"
width="1020px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
@@ -308,41 +307,39 @@
</el-form-item>
</div>
</el-form>
<div v-else class="list-body list-wrap">
<el-table
:data="existingFreightList"
stripe
class="list-table inner-table"
highlight-current-row
@current-change="onExistingFreightSelect"
>
<el-table-column label="运输方式" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ row.transportModeLabel || row.transportMode || '—' }}
</template>
</el-table-column>
<el-table-column label="运费(元)" min-width="100" align="right" header-align="center">
<template #default="{ row }">{{ fmtMoney(row.freightAmount) }}</template>
</el-table-column>
<el-table-column
label="关联样本数"
min-width="96"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.linkedSampleCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="创建时间" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.createTime || '—' }}</template>
</el-table-column>
</el-table>
<div v-else class="link-existing-block">
<div class="sample-pick-toolbar" style="margin-bottom: 10px">
<el-input
v-model="existingFreightKeyword"
placeholder="运输方式/协议单号/样本编码"
clearable
style="width: 260px"
@keyup.enter="reloadExistingFreights"
/>
<el-button class="btn-ghost" @click="reloadExistingFreights">
<el-icon><Search /></el-icon>
搜索
</el-button>
</div>
<SampleFreightPickTable
v-model="selectedFreightId"
:list="existingFreightList"
:loading="existingFreightLoading"
:max-height="320"
@select="onExistingFreightSelect"
/>
</div>
<template #footer>
<el-button class="btn-ghost" @click="freightDialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" :loading="freightSubmitting" @click="confirmFreight">
<el-button
class="btn-main"
type="primary"
:loading="freightSubmitting"
@click="confirmFreight"
>
<el-icon><Check /></el-icon>
确定应用
</el-button>
@@ -352,9 +349,10 @@
</template>
<script setup lang="ts">
import { Plus, Edit, Delete, Close, Check, Van } from '@element-plus/icons-vue'
import { Plus, Edit, Delete, Close, Check, Van, Search } from '@element-plus/icons-vue'
import AmountInput from '@/components/common/amount-input/index.vue'
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import SampleFreightPickTable from '@/components/business/sample-freight-pick-table/index.vue'
import { sampleStatusLabel, sampleStatusTagType } from '@/utils/status-tag-registry'
import { formatMoneyDisplay } from '@/utils/money-format'
import { fetchDictDataOptions } from '@/api/admin'
@@ -437,7 +435,10 @@
})
const transportModeOptions = ref<Array<{ label: string; value: string }>>([])
const existingFreightList = ref<SampleFreight[]>([])
const existingFreightLoading = ref(false)
const existingFreightKeyword = ref('')
const selectedExistingFreight = ref<SampleFreight | null>(null)
const selectedFreightId = ref('')
const sampleDialogVisible = ref(false)
const sampleDialogMode = ref<'add' | 'edit'>('add')
@@ -692,6 +693,24 @@
selectedSamples.value = rows
}
async function reloadExistingFreights() {
existingFreightLoading.value = true
try {
const res = await fetchSampleFreightList({
page: 1,
pageSize: 50,
includeItems: true,
keyword: existingFreightKeyword.value || undefined
})
const data = (res as any)?.data || res
existingFreightList.value = data?.list || []
} catch {
existingFreightList.value = []
} finally {
existingFreightLoading.value = false
}
}
async function openFreightDialog() {
if (!selectedSamples.value.length) {
ElMessage.warning('请先勾选需要配置运费的样本')
@@ -701,19 +720,16 @@
freightForm.transportMode = ''
freightForm.freightAmount = null
selectedExistingFreight.value = null
selectedFreightId.value = ''
existingFreightKeyword.value = ''
await loadTransportModeOptions()
try {
const res = await fetchSampleFreightList({ page: 1, pageSize: 50 })
const data = (res as any)?.data || res
existingFreightList.value = data?.list || []
} catch {
existingFreightList.value = []
}
await reloadExistingFreights()
freightDialogVisible.value = true
}
function onExistingFreightSelect(row: SampleFreight | null) {
selectedExistingFreight.value = row
selectedFreightId.value = row?.id || ''
}
async function confirmFreight() {
@@ -951,6 +967,14 @@
.sample-transport-table-wrap {
margin-top: 0;
:deep(.list-table .el-table__cell .cell) {
white-space: normal;
word-break: break-word;
overflow: visible;
text-overflow: unset;
line-height: 1.4;
}
}
}
@@ -965,6 +989,13 @@
margin-top: 0;
}
.sample-pick-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.sample-transport-form-row {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
@@ -244,8 +244,8 @@
sections: ViewSection[]
/** 弹窗宽度,默认 720px */
width?: string
/** 字段栅格列数,默认 2 */
gridColumns?: 2 | 3 | 4
/** 字段栅格列数,默认 2;上限 5(与 OpenSpec form-grid 一致) */
gridColumns?: 2 | 3 | 4 | 5
/** 挂到 body,用于嵌套在其它弹窗内时叠层展示 */
appendToBody?: boolean
/** 遮罩类名(可配合全局样式提高 z-index) */
@@ -402,6 +402,10 @@
&--cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
&--cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
.view-kv {
@@ -482,15 +486,23 @@
line-height: 1.5;
}
@media (max-width: 1280px) {
.view-kv-grid--cols-5 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.view-kv-grid--cols-4 {
.view-kv-grid--cols-4,
.view-kv-grid--cols-5 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.view-kv-grid--cols-3,
.view-kv-grid--cols-4 {
.view-kv-grid--cols-4,
.view-kv-grid--cols-5 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -512,7 +524,8 @@
@media (max-width: 640px) {
.view-kv-grid,
.view-kv-grid--cols-3,
.view-kv-grid--cols-4 {
.view-kv-grid--cols-4,
.view-kv-grid--cols-5 {
grid-template-columns: 1fr;
}
}
@@ -25,7 +25,7 @@
<div v-show="panelOpen" class="oa-icon-picker__panel">
<div class="oa-icon-picker__panel-head">
<strong>图标</strong>
<el-button class="btn-ghost" style="height: 24px" @click="panelOpen = false">收起</el-button>
<el-button class="btn-ghost" style="height: 24px" @click="panelOpen = false"><el-icon><ArrowUp /></el-icon>收起</el-button>
</div>
<div class="oa-icon-picker__grid">
<button
@@ -83,7 +83,8 @@
Upload,
User,
Van,
Wallet
Wallet,
ArrowUp
} from '@element-plus/icons-vue'
import { fetchUploadAttachment } from '@/api/business'
import OaAppIcon from '@/components/oa-approval/OaAppIcon.vue'
@@ -21,6 +21,7 @@
<Upload />
</el-icon>
<el-button v-else type="primary">
<el-icon><Upload /></el-icon>
{{ uploadButtonText }}
</el-button>
</slot>
@@ -49,6 +49,7 @@
type="danger"
@click="removeId(item.id)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
@@ -232,7 +233,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Check, Close, Plus, Search } from '@element-plus/icons-vue'
import { Check, Close, Plus, Search, Delete } from '@element-plus/icons-vue'
import {
fetchOaRelatedCandidates,
fetchOaRelatedSummaries,
@@ -22,6 +22,7 @@
/>
<p v-if="confirmationDateManualFields.length" class="oa-start__tip oa-start__confirm-relink">
<el-button link type="primary" @click="restoreConfirmationDateAuto">
<el-icon><RefreshLeft /></el-icon>
恢复转正日期自动计算
</el-button>
</p>
@@ -76,7 +77,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Check, Close, Document, RefreshRight } from '@element-plus/icons-vue'
import { Check, Close, Document, RefreshRight, RefreshLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { fetchUserList } from '@/api/admin'
import { fetchCnHolidayRange } from '@/api/cnHoliday'