feat(documents): refresh detail pages and attachment cards

This commit is contained in:
Cheng Zhou
2026-06-08 11:03:34 +08:00
parent a3f6a04f35
commit 834b4f1d48
11 changed files with 2372 additions and 929 deletions
+475 -188
View File
@@ -9,111 +9,137 @@
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<el-card class="detail-card unified-shell" shadow="never">
<template #header>
<div class="card-header actions-header">
<span class="header-title">{{ TEXT.modules.feeContracts.contractInfoTitle }}</span>
<div class="actions">
<el-button v-if="canWrite" type="primary" :disabled="isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
</div>
<!-- ==================== 合同信息 ==================== -->
<div class="section-card">
<div class="section-card-header">
<div class="section-card-header-left">
<span class="accent-dot accent-dot--blue" />
<span class="section-card-title">{{ TEXT.modules.feeContracts.contractInfoTitle }}</span>
</div>
</template>
<div class="info-grid">
<div class="info-item info-item--highlight">
<span class="info-label">{{ TEXT.modules.feeContracts.contractAmount }}</span>
<span class="info-value amount-value">{{ formatAmountWan(detail.contract_amount) }}<span class="amount-unit"></span></span>
</div>
<div class="info-item">
<span class="info-label">{{ TEXT.common.fields.site }}</span>
<span class="info-value">{{ centerName || TEXT.common.fallback }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ TEXT.common.fields.contractNo }}</span>
<span class="info-value">{{ detail.contract_no || TEXT.common.fallback }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ TEXT.common.fields.signedDate }}</span>
<span class="info-value">{{ displayDate(detail.signed_date) }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ TEXT.modules.feeContracts.contractCases }}</span>
<span class="info-value">{{ detail.contract_cases ?? TEXT.common.fallback }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ TEXT.modules.feeContracts.actualCases }}</span>
<span class="info-value">{{ detail.actual_cases ?? TEXT.common.fallback }}</span>
</div>
<div class="info-item info-item--full">
<span class="info-label">{{ TEXT.common.fields.remark }}</span>
<span class="info-value">{{ detail.remark || TEXT.common.fallback }}</span>
<div class="section-card-header-right">
<div v-if="detail.contract_no" class="contract-badge">{{ detail.contract_no }}</div>
<el-button v-if="canWrite" type="primary" :disabled="isReadOnly" @click="goEdit" class="header-edit-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
</div>
</div>
</el-card>
<el-card class="section-card unified-shell" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
<div class="section-divider" />
<div class="section-card-body">
<div class="stat-grid">
<div class="stat-tile stat-tile--highlight">
<span class="stat-tile-label">{{ TEXT.modules.feeContracts.contractAmount }}</span>
<span class="stat-tile-value stat-tile-value--lg">{{ formatAmountWan(detail.contract_amount) }}<span class="stat-tile-unit"></span></span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">{{ TEXT.common.fields.site }}</span>
<span class="stat-tile-value">{{ centerName || TEXT.common.fallback }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">{{ TEXT.common.fields.signedDate }}</span>
<span class="stat-tile-value">{{ displayDate(detail.signed_date) }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">{{ TEXT.modules.feeContracts.contractCases }}</span>
<span class="stat-tile-value">{{ detail.contract_cases ?? TEXT.common.fallback }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">{{ TEXT.modules.feeContracts.actualCases }}</span>
<span class="stat-tile-value">{{ detail.actual_cases ?? TEXT.common.fallback }}</span>
</div>
<div class="stat-tile stat-tile--span2">
<span class="stat-tile-label">{{ TEXT.common.fields.remark }}</span>
<span class="stat-tile-value stat-tile-value--muted">{{ detail.remark || TEXT.common.fallback }}</span>
</div>
</div>
</template>
<el-table :data="detail.payments" style="width: 100%" :header-cell-style="{ background: '#f8f9fb' }" table-layout="fixed">
<el-table-column :label="TEXT.modules.feeContracts.paymentSeqColumn" min-width="180">
<template #default="scope">
<span class="seq-tag">{{ TEXT.modules.feeContracts.paymentSeq }}{{ scope.row.seq }}{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.amount" align="left" header-align="left" min-width="180">
<template #default="scope">
<span class="amount-text">{{ formatAmountWan(scope.row.amount) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.paidFlag" min-width="180">
<template #default="scope">
<div v-if="scope.row.is_paid" class="status-cell">
<el-tag type="success" size="small">{{ TEXT.modules.feeContracts.isPaid }}</el-tag>
<span class="status-date">{{ displayDate(scope.row.paid_date) }}</span>
</div>
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.verifiedFlag" min-width="180">
<template #default="scope">
<div v-if="scope.row.is_verified" class="status-cell">
<el-tag type="info" size="small">{{ TEXT.modules.feeContracts.isVerified }}</el-tag>
<span class="status-date">{{ displayDate(scope.row.verified_date) }}</span>
</div>
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.remark" min-width="180" show-overflow-tooltip>
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
</el-table-column>
<template #empty>
<div class="table-empty">{{ TEXT.modules.feeContracts.paymentEmpty }}</div>
</template>
</el-table>
</el-card>
</div>
</div>
<el-card class="section-card unified-shell" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.modules.feeContracts.attachmentTitle }}</span>
<!-- ==================== 分期付款 Timeline ==================== -->
<div class="section-card">
<div class="section-card-header">
<div class="section-card-header-left">
<span class="accent-dot accent-dot--amber" />
<span class="section-card-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
</div>
</template>
<AttachmentList
v-if="contractId"
:study-id="studyId"
entity-type="contract_fee"
:entity-id="contractId"
:entity-groups="attachmentGroups"
:readonly="isReadOnly"
:hide-uploader="true"
:refresh-key="attachmentRefreshKey"
/>
</el-card>
<el-tag v-if="detail.payments?.length" size="small" class="payment-count-tag" round>{{ detail.payments.length }} {{ TEXT.modules.feeContracts.paymentSeqUnit }}</el-tag>
</div>
<div class="section-divider" />
<div class="section-card-body section-card-body--no-padding">
<div v-if="!detail.payments?.length" class="empty-state">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<span class="empty-text">{{ TEXT.modules.feeContracts.paymentEmpty }}</span>
</div>
<div v-else class="timeline">
<div v-for="(payment, index) in detail.payments" :key="index" class="tl-row">
<div class="tl-track">
<div :class="['tl-node', payment.is_paid ? 'tl-node--done' : 'tl-node--todo']">
<svg v-if="payment.is_paid" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
<span v-else class="tl-node-num">{{ (index as number) + 1 }}</span>
</div>
<div v-if="showPaymentLine(index)" class="tl-stem" />
</div>
<div :class="['tl-panel', payment.is_paid ? 'tl-panel--paid' : '']">
<div class="tl-panel-top">
<div class="tl-panel-seq">
{{ TEXT.modules.feeContracts.paymentSeq }}{{ (index as number) + 1 }}{{ TEXT.modules.feeContracts.paymentSeqUnit }}
</div>
<div class="tl-panel-amount">
{{ formatAmountWan(payment.amount) }}<span class="amount-unit-sm"></span>
</div>
</div>
<div class="tl-panel-body">
<div class="tl-meta-row">
<div class="tl-meta">
<span class="tl-meta-label">{{ TEXT.modules.feeContracts.paidFlag }}</span>
<span :class="['tl-pill', payment.is_paid ? 'tl-pill--paid' : 'tl-pill--none']">
{{ payment.is_paid ? TEXT.modules.feeContracts.isPaid : TEXT.common.fallback }}
</span>
<span v-if="payment.is_paid" class="tl-meta-date">{{ displayDate(payment.paid_date) }}</span>
</div>
<div class="tl-meta">
<span class="tl-meta-label">{{ TEXT.modules.feeContracts.verifiedFlag }}</span>
<span :class="['tl-pill', payment.is_verified ? 'tl-pill--verified' : 'tl-pill--none']">
{{ payment.is_verified ? TEXT.modules.feeContracts.isVerified : TEXT.common.fallback }}
</span>
<span v-if="payment.is_verified" class="tl-meta-date">{{ displayDate(payment.verified_date) }}</span>
</div>
</div>
<div v-if="payment.remark" class="tl-note">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
{{ payment.remark }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ==================== 附件区 ==================== -->
<div class="section-card">
<div class="section-card-header">
<div class="section-card-header-left">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#22c55e;flex-shrink:0"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="section-card-title">{{ TEXT.modules.feeContracts.attachmentTitle }}</span>
</div>
</div>
<div class="section-divider" />
<div class="section-card-body">
<AttachmentList
v-if="contractId"
:study-id="studyId"
entity-type="contract_fee"
:entity-id="contractId"
:entity-groups="attachmentGroups"
:readonly="isReadOnly"
:hide-uploader="true"
:refresh-key="attachmentRefreshKey"
/>
</div>
</div>
</template>
<ContractFeeEditorDrawer
@@ -227,6 +253,10 @@ const load = async () => {
}
};
const showPaymentLine = (i: any): boolean => {
return Array.isArray(detail.payments) && Number(i) < detail.payments.length - 1;
};
const formatAmountWan = (value: any) => {
const numberValue = Number(value);
if (Number.isNaN(numberValue)) return TEXT.common.fallback;
@@ -257,157 +287,414 @@ onMounted(async () => {
</script>
<style scoped>
/* ==================== Base ==================== */
.page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 4px 0;
}
.actions {
display: flex;
gap: 12px;
}
.header-action-btn {
height: 34px;
border-radius: 8px;
padding: 0 16px;
}
.detail-card, .section-card {
border-radius: 14px;
border: 1px solid var(--unified-shell-divider, #eaf0f8);
overflow: hidden;
}
.section-card :deep(.el-card__body) {
padding: 0;
background: transparent;
}
.card-header {
padding: 4px 0;
/* ==================== Section Cards ==================== */
.section-card {
background: #ffffff;
border-radius: 6px;
overflow: hidden;
box-shadow: none;
}
.actions-header {
.section-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 24px 0;
}
.header-title {
font-size: 15px;
font-weight: 700;
color: var(--unified-title-color, #0f2345);
border-left: 3px solid var(--el-color-primary);
padding-left: 10px;
.section-card-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.info-grid {
.section-card-header-right {
display: flex;
align-items: center;
gap: 10px;
}
.section-card-title {
font-size: 14px;
font-weight: 600;
color: #0a0a0a;
letter-spacing: -0.01em;
}
.section-divider {
display: none;
}
.section-card-body {
padding: 20px 24px 24px;
}
.section-card-body--no-padding {
padding: 0;
}
/* ==================== Accent Dots ==================== */
.accent-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.accent-dot--blue { background: #3b82f6; }
.accent-dot--amber { background: #f59e0b; }
.accent-dot--green { background: #22c55e; }
/* ==================== Contract Badge ==================== */
.contract-badge {
padding: 2px 10px;
font-size: 11px;
font-weight: 500;
color: #525252;
background: #f5f5f5;
border-radius: 4px;
letter-spacing: 0.01em;
}
.header-edit-btn {
height: 32px;
border-radius: 8px;
padding: 0 14px;
font-size: 13px;
font-weight: 500;
}
/* ==================== Stat Tiles ==================== */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
padding: 20px 24px;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.info-item {
.stat-tile {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border-radius: 8px;
transition: background 0.15s ease;
gap: 4px;
padding: 12px 14px;
background: #fafbfc;
border: 1px solid #f0f0f0;
border-radius: 6px;
transition: all 0.15s ease;
}
.info-item:hover {
background: #f8fafc;
.stat-tile:hover {
background: #f5f6f8;
border-color: #e5e5e5;
}
.info-item--highlight {
background: linear-gradient(135deg, #f0f6ff 0%, #eef3fb 100%);
border-radius: 10px;
.stat-tile--highlight {
background: linear-gradient(135deg, #f8faff 0%, #f0f4ff 100%);
border-color: #e0e8f8;
}
.info-item--highlight:hover {
background: linear-gradient(135deg, #e8f1ff 0%, #e6edfa 100%);
.stat-tile--highlight:hover {
background: linear-gradient(135deg, #f0f6ff 0%, #e8f0ff 100%);
}
.info-item--full {
.stat-tile--span2 {
grid-column: 1 / -1;
}
.info-label {
font-size: 12px;
.stat-tile-label {
font-size: 11px;
font-weight: 500;
color: var(--unified-muted-color, #6f84a8);
letter-spacing: 0.02em;
color: #8a8a8a;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.info-value {
.stat-tile-value {
font-size: 14px;
font-weight: 500;
color: var(--ctms-text-main);
font-weight: 600;
color: #0a0a0a;
word-break: break-all;
}
.amount-value {
font-size: 22px;
font-weight: 700;
color: var(--ctms-primary);
font-variant-numeric: tabular-nums;
}
.amount-unit {
font-size: 13px;
.stat-tile-value--lg {
font-size: 22px;
font-weight: 700;
color: #2563eb;
}
.stat-tile-value--muted {
font-weight: 500;
color: var(--unified-muted-color, #6f84a8);
color: #525252;
}
.stat-tile-unit {
font-size: 12px;
font-weight: 500;
color: #a3a3a3;
margin-left: 2px;
}
.seq-tag {
color: var(--ctms-text-secondary);
font-size: 13px;
/* ==================== Payment Count Tag ==================== */
.payment-count-tag {
font-weight: 500;
border: 1px solid #e5e5e5;
color: #525252;
background: #fafafa;
}
.status-cell {
/* ==================== Timeline ==================== */
.timeline {
padding: 20px 24px 4px;
}
.tl-row {
display: flex;
gap: 16px;
}
.tl-track {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-items: center;
width: 28px;
flex-shrink: 0;
}
.status-date {
font-size: 12px;
color: var(--ctms-text-secondary);
font-family: var(--el-font-family);
}
.text-muted {
color: var(--ctms-text-placeholder, #94a3b8);
font-size: 13px;
}
.table-empty {
min-height: 120px;
.tl-node {
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
font-size: 13px;
font-weight: 500;
width: 28px;
height: 28px;
border-radius: 50%;
z-index: 1;
flex-shrink: 0;
transition: all 0.2s ease;
}
.tl-node--done {
background: #3b82f6;
color: #ffffff;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25);
}
.tl-node--todo {
background: #ffffff;
color: #a3a3a3;
border: 1.5px solid #d4d4d4;
}
.tl-node-num {
font-size: 11px;
font-weight: 600;
line-height: 1;
}
.tl-stem {
width: 1.5px;
flex: 1;
min-height: 16px;
background: #e5e5e5;
}
.tl-panel {
flex: 1;
padding: 14px 18px;
margin-bottom: 16px;
background: #ffffff;
border: 1px solid #f0f0f0;
border-radius: 6px;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.tl-panel:hover {
border-color: #d4d4d4;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
}
.tl-panel--paid {
border-left: 2.5px solid #22c55e;
}
.tl-panel-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #f5f5f5;
}
.tl-panel-seq {
font-size: 13px;
font-weight: 600;
color: #0a0a0a;
}
.tl-panel-amount {
font-size: 18px;
font-weight: 700;
color: #2563eb;
font-variant-numeric: tabular-nums;
}
.tl-panel-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.tl-meta-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.tl-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.tl-meta-label {
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tl-pill {
display: inline-flex;
align-items: center;
padding: 1px 9px;
font-size: 11px;
font-weight: 600;
border-radius: 20px;
line-height: 1.6;
}
.tl-pill--paid {
background: #dcfce7;
color: #16a34a;
}
.tl-pill--verified {
background: #dbeafe;
color: #2563eb;
}
.tl-pill--none {
background: #f5f5f5;
color: #a3a3a3;
}
.tl-meta-date {
font-size: 11px;
color: #8a8a8a;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.tl-note {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 12px;
color: #737373;
background: #fafafa;
padding: 8px 12px;
border-radius: 6px;
line-height: 1.5;
}
.tl-note svg {
flex-shrink: 0;
margin-top: 2px;
color: #a3a3a3;
}
.amount-unit-sm {
font-size: 11px;
font-weight: 500;
color: #a3a3a3;
margin-left: 1px;
}
/* ==================== Empty State ==================== */
.empty-state {
min-height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #f5f5f5;
color: #c4c4c4;
}
.empty-text {
font-size: 13px;
font-weight: 500;
color: #a3a3a3;
}
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.info-grid {
.stat-grid {
grid-template-columns: repeat(2, 1fr);
padding: 16px;
}
}
@media (max-width: 640px) {
.info-grid {
.page {
padding: 0;
}
.stat-grid {
grid-template-columns: 1fr;
padding: 12px;
}
.stat-tile--span2 {
grid-column: auto;
}
.section-card-header {
padding: 14px 16px 0;
}
.section-divider {
margin: 12px 16px 0;
}
.section-card-body {
padding: 16px;
}
.timeline {
padding: 16px 16px 0;
}
.tl-panel {
padding: 12px 14px;
margin-bottom: 12px;
}
.tl-meta-row {
flex-direction: column;
gap: 8px;
}
}
</style>