项目中心-支付模块

This commit is contained in:
wangzzpeng
2026-09-30 08:04:16 +08:00
parent 6b88c3805f
commit ce8210fb3f
35 changed files with 2618 additions and 175 deletions

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import type { FileInfo } from '@/types/api'
import type { ProjectBill } from '@/types/business/project'
import { fileLabelOf, fileToneOf } from '@/utils/fileMeta'
import { formatFileSize, formatMoney } from '@/utils/format'
interface Props {
@@ -18,54 +18,82 @@ const emit = defineEmits<{
download: [file: FileInfo]
}>()
const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
'已支付': 'success',
'待支付': 'warning',
'未到期': 'info',
/**
* 是否可跳收银台支付。
*
* <p>用「待支付 + id 是纯数字」判断,而不是过去的「来源是账单」——本栈的支付口径就是
* **计划 id**(收银台 `?ids=` 与 `vm_finance_payment.bill_id` 同一口径),计划卡本就该能付;
* 数字校验只是防止空 id 拼出 `?ids=`。</p>
*/
function canPay(bill: ProjectBill): boolean {
return bill.status === '待支付' && /^\d+$/.test(bill.id)
}
/**
* 三态配色类(设计稿口径,挂到 `.project-finance__plan` 上,由后代选择器分发):
* 已支付 = 绿、待支付 = 橙、未到期 = 灰,**徽标实底 + 金额文字同色**。
*/
const TONE_CLASS: Record<ProjectBill['status'], string> = {
'已支付': 'is-paid',
'待支付': 'is-pending',
'未到期': 'is-idle',
}
/** 文件行副标题(设计稿:「2.4MB · 上传于 2026-8-17 17:00」) */
function fileMeta(file: FileInfo): string {
const size = file.size ? formatFileSize(file.size) : ''
const time = file.uploadTime ? `上传于 ${file.uploadTime}` : ''
return [size, time].filter(Boolean).join(' · ')
}
</script>
<template>
<section class="project-finance">
<!-- 付款计划 -->
<!-- 付款计划(数据源 vm_bill_plan):卡内三态 = 已支付 / 待支付 / 未到期 -->
<h2 class="project-finance__title">
付款计划
</h2>
<div class="project-finance__plans">
<article
v-for="bill in paymentPlans"
:key="bill.id"
:key="bill.id || bill.planName"
class="project-finance__plan"
:class="TONE_CLASS[bill.status]"
>
<div class="project-finance__plan-head">
<span class="project-finance__plan-name">{{ bill.planName }}</span>
<StatusTag :label="bill.status" :variant="BILL_VARIANTS[bill.status]" />
</div>
<div class="project-finance__plan-meta">
<div class="project-finance__field">
<span class="project-finance__label text-secondary">关联项目</span>
<span class="project-finance__value">{{ bill.projectName }}</span>
</div>
<div class="project-finance__field">
<span class="project-finance__label text-secondary">账单号</span>
<span class="project-finance__value">{{ bill.billNo }}</span>
</div>
</div>
<div class="project-finance__amount">
¥{{ formatMoney(bill.amount) }}
</div>
<div class="project-finance__plan-foot">
<span v-if="bill.status === '已支付' && bill.tradeNo" class="project-finance__trade text-secondary">
交易流水号:{{ bill.tradeNo }}
<span class="project-finance__plan-name">
{{ bill.planName }}<template v-if="bill.periodRatio"> 比例{{ bill.periodRatio }}%</template>
</span>
<button
v-if="bill.status === '待支付'"
class="project-finance__pay"
@click="emit('pay', bill.id)"
>
立即支付
</button>
<span class="project-finance__badge">{{ bill.status }}</span>
</div>
<div class="project-finance__plan-body">
<div class="project-finance__fields">
<!-- 交易流水号只在已支付时出现(设计稿:已支付那张卡多一行流水号) -->
<p v-if="bill.status === '已支付' && bill.tradeNo" class="project-finance__field">
交易流水号:{{ bill.tradeNo }}
</p>
<p class="project-finance__field">
账单号:{{ bill.billNo || '—' }}
</p>
<p class="project-finance__field">
关联项目:{{ bill.projectName || '—' }}
</p>
</div>
<div class="project-finance__amount">
¥{{ formatMoney(bill.amount) }}
</div>
</div>
<!-- 支付口径 = 计划 id(与收银台 ?ids= / vm_finance_payment.bill_id 一致) -->
<button
v-if="canPay(bill)"
class="project-finance__pay"
type="button"
@click="emit('pay', bill.id)"
>
立即支付
</button>
</article>
<p v-if="paymentPlans.length === 0" class="project-finance__empty text-secondary">
暂无付款计划
@@ -82,17 +110,38 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
:key="`invoice-${index}`"
class="project-finance__file"
>
<span
class="project-finance__file-badge"
:style="{ color: fileToneOf(file), backgroundColor: `${fileToneOf(file)}1a` }"
>
{{ fileLabelOf(file) }}
</span>
<div class="project-finance__file-info">
<span class="project-finance__file-name" :title="file.name">{{ file.name }}</span>
<span class="project-finance__file-meta text-secondary">
{{ file.size ? formatFileSize(file.size) + ' · ' : '' }}{{ file.uploadTime }}
</span>
<span class="project-finance__file-meta">{{ fileMeta(file) }}</span>
</div>
<div class="project-finance__file-actions">
<button class="project-finance__link" @click="emit('preview', file)">
<button class="project-finance__act" type="button" @click="emit('preview', file)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8s-2.4 4.5-6.5 4.5S1.5 8 1.5 8Z"
stroke="currentColor"
stroke-width="1.2"
/>
<circle cx="8" cy="8" r="1.8" stroke="currentColor" stroke-width="1.2" />
</svg>
预览
</button>
<button class="project-finance__link" @click="emit('download', file)">
<button class="project-finance__act" type="button" @click="emit('download', file)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M8 2v8m0 0 3-3m-3 3L5 7M3 13h10"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
下载
</button>
</div>
@@ -106,18 +155,39 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
<h2 class="project-finance__title">
合同文件
</h2>
<div v-if="contractFile" class="project-finance__contract">
<div v-if="contractFile" class="project-finance__file">
<span
class="project-finance__file-badge"
:style="{ color: fileToneOf(contractFile), backgroundColor: `${fileToneOf(contractFile)}1a` }"
>
{{ fileLabelOf(contractFile) }}
</span>
<div class="project-finance__file-info">
<span class="project-finance__file-name" :title="contractFile.name">{{ contractFile.name }}</span>
<span class="project-finance__file-meta text-secondary">
{{ contractFile.size ? formatFileSize(contractFile.size) + ' · ' : '' }}{{ contractFile.uploadTime }}
</span>
<span class="project-finance__file-meta">{{ fileMeta(contractFile) }}</span>
</div>
<div class="project-finance__file-actions">
<button class="project-finance__link" @click="emit('preview', contractFile)">
<button class="project-finance__act" type="button" @click="emit('preview', contractFile)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8s-2.4 4.5-6.5 4.5S1.5 8 1.5 8Z"
stroke="currentColor"
stroke-width="1.2"
/>
<circle cx="8" cy="8" r="1.8" stroke="currentColor" stroke-width="1.2" />
</svg>
预览
</button>
<button class="project-finance__link" @click="emit('download', contractFile)">
<button class="project-finance__act" type="button" @click="emit('download', contractFile)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M8 2v8m0 0 3-3m-3 3L5 7M3 13h10"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
下载
</button>
</div>
@@ -137,107 +207,166 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
background: @white;
box-shadow: @shadow-base;
// 区块标题:与「项目进度/项目文件/售后信息」同款(16px/600 + 左侧蓝色竖条)
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
.section-title();
}
// 只读回落态(后端未重启、响应无 bills)的说明行
&__note {
font-size: @font-size-sm;
}
// ---------- 付款计划 ----------
// 设计稿:卡片**竖排单列、定宽左对齐**(不是横排多列网格)
&__plans {
.flex(row, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
.flex(column, flex-start, flex-start);
gap: @spacing-sm;
}
&__plan {
.flex(column, flex-start, stretch);
gap: @spacing-sm;
width: 320px;
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
width: @finance-card-width;
padding: 12px;
border-radius: @finance-card-radius;
background: @white;
// 设计稿用阴影立卡片、**不描边**;阴影比页面默认更淡,只做「浮起」暗示
box-shadow: @finance-card-shadow;
// 三态配色(徽标实底白字 + 金额同色)
&.is-paid {
.project-finance__badge {
background: @finance-paid;
}
.project-finance__amount {
color: @finance-paid;
}
}
&.is-pending {
.project-finance__badge {
background: @finance-pending;
}
.project-finance__amount {
color: @finance-pending-amount;
}
}
&.is-idle {
.project-finance__badge {
background: @finance-idle;
}
.project-finance__amount {
color: @finance-idle;
}
}
}
&__plan-head {
.flex(row, space-between, center);
.flex(row, space-between, flex-start);
gap: @spacing-sm;
}
&__plan-name {
font-size: @font-size-base;
font-weight: 600;
font-weight: 700;
letter-spacing: 0.8px;
color: @heading-color;
.ellipsis();
}
&__plan-meta {
// 状态徽标:实底白字的小圆角块(设计 48×22 / 圆角 4 / 10 设计px 字号)
&__badge {
.flex(row, center, center);
flex-shrink: 0;
padding: 2px @spacing-sm;
border-radius: @radius-sm;
font-size: 11px;
line-height: 16px;
color: @white;
}
&__plan-body {
.flex(row, space-between, flex-start);
gap: @spacing-base;
}
&__fields {
.flex(column, flex-start, stretch);
gap: @spacing-xs;
gap: 4px;
min-width: 0;
}
&__field {
.flex(row, flex-start);
gap: @spacing-sm;
font-size: @font-size-sm;
}
&__label {
flex-shrink: 0;
}
&__value {
color: @text-color;
line-height: 17px;
color: @finance-meta-color;
.ellipsis();
}
// 金额:设计稿在卡片**右侧**、字号最大且随状态变色(不是左下角恒定深色)
&__amount {
flex-shrink: 0;
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__plan-foot {
.flex(row, space-between, center);
min-height: 28px;
}
&__trade {
font-size: @font-size-sm;
.ellipsis();
font-weight: 700;
line-height: 28px;
white-space: nowrap;
}
// 「立即支付」:设计稿是卡内**满宽**金色胶囊(不是右下角的小蓝按钮)
&__pay {
height: 28px;
padding: 0 @spacing-base;
height: 22px;
border: none;
border-radius: @radius-sm;
background: @primary-color;
border-radius: @radius-round;
background: @finance-pending;
color: @white;
font-size: @font-size-sm;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: @primary-hover;
background: @finance-cta-hover;
}
}
// ---------- 发票 / 合同文件 ----------
&__files {
.flex(column, flex-start, stretch);
gap: @spacing-sm;
margin: 0;
padding: 0;
list-style: none;
}
&__file,
&__contract {
.flex(row, space-between);
gap: @spacing-base;
// 设计稿:整行通铺浅灰底、无描边无圆角(区别于卡片)
&__file {
.flex(row, space-between, center);
gap: @spacing-sm;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
background: @finance-row-bg;
}
// 文件类型标识:按后缀着色的小方块(与需求提报页附件行同一套色板)
&__file-badge {
.flex(row, center, center);
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: @radius-sm;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.2px;
}
&__file-info {
.flex(column, flex-start, stretch);
flex: 1;
gap: 2px;
min-width: 0;
}
@@ -250,21 +379,25 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
&__file-meta {
font-size: @font-size-sm;
color: @text-color-secondary;
}
&__file-actions {
.flex(row, center);
gap: @spacing-sm;
.flex(row, flex-end, center);
gap: @spacing-base;
flex-shrink: 0;
}
&__link {
&__act {
.flex(row, center, center);
gap: 4px;
font-size: @font-size-sm;
color: @primary-color;
transition: opacity 0.2s;
color: @text-color;
cursor: pointer;
transition: color 0.2s;
&:hover {
opacity: 0.75;
color: @primary-color;
}
}