项目中心-支付模块
This commit is contained in:
@@ -7,6 +7,7 @@ import { AppButton, StatusTag } from '@/components'
|
||||
import { useFinanceStore } from '@/stores/modules/finance'
|
||||
import type { BillStatus } from '@/types/business/finance'
|
||||
import { formatMoney } from '@/utils/format'
|
||||
import { toChannelLabel } from '@/utils/payMeta'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -15,21 +16,21 @@ const { currentBill } = storeToRefs(financeStore)
|
||||
|
||||
const billId = computed(() => String(route.params.id))
|
||||
|
||||
const STATUS_VARIANTS: Record<BillStatus, 'warning' | 'success' | 'info'> = {
|
||||
const STATUS_VARIANTS: Record<BillStatus, 'warning' | 'success' | 'info' | 'danger'> = {
|
||||
待支付: 'warning',
|
||||
已支付: 'success',
|
||||
未到期: 'info',
|
||||
已取消: 'danger',
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
// 支付信息(渠道 / 时间 / 流水号)已由后端回写在账单本身上,无需再查支付结果
|
||||
await financeStore.fetchDetail(billId.value)
|
||||
if (currentBill.value?.status === '已支付') {
|
||||
await financeStore.fetchResult(billId.value)
|
||||
}
|
||||
}
|
||||
|
||||
function onPay(): void {
|
||||
void router.push(`/finance/bills/${billId.value}/pay`)
|
||||
// 进收银台(确认支付):选支付方式 → 微信出码 / 支付宝跳转
|
||||
void router.push({ path: '/finance/checkout', query: { ids: billId.value } })
|
||||
}
|
||||
|
||||
watch(billId, () => {
|
||||
@@ -98,24 +99,29 @@ onMounted(() => {
|
||||
<div class="bill-detail__fields">
|
||||
<div class="bill-detail__field">
|
||||
<span class="bill-detail__label text-secondary">付款金额</span>
|
||||
<span class="bill-detail__value bill-detail__value--amount">¥{{ formatMoney(financeStore.result?.amount ?? currentBill.amount) }}</span>
|
||||
<span class="bill-detail__value bill-detail__value--amount">¥{{ formatMoney(currentBill.amount) }}</span>
|
||||
</div>
|
||||
<div class="bill-detail__field">
|
||||
<span class="bill-detail__label text-secondary">支付方式</span>
|
||||
<span class="bill-detail__value">微信支付</span>
|
||||
<span class="bill-detail__value">{{ toChannelLabel(currentBill.payChannel) }}</span>
|
||||
</div>
|
||||
<div class="bill-detail__field">
|
||||
<span class="bill-detail__label text-secondary">支付时间</span>
|
||||
<span class="bill-detail__value">{{ financeStore.result?.payTime ?? '—' }}</span>
|
||||
<span class="bill-detail__value">{{ currentBill.payTime ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="bill-detail__field">
|
||||
<span class="bill-detail__label text-secondary">交易流水号</span>
|
||||
<span class="bill-detail__value">{{ financeStore.result?.tradeNo ?? currentBill.tradeNo ?? '—' }}</span>
|
||||
<span class="bill-detail__value">{{ currentBill.tradeNo ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 已取消 -->
|
||||
<div v-else-if="currentBill.status === '已取消'" class="bill-detail__tip text-secondary">
|
||||
该账单已取消,如有疑问请联系项目负责人。
|
||||
</div>
|
||||
|
||||
<!-- 未到期:提示 -->
|
||||
<div v-else class="bill-detail__tip text-secondary">
|
||||
账单尚未到期,到期后可前往账单明细完成支付。
|
||||
|
||||
@@ -50,13 +50,13 @@ function onView(id: string): void {
|
||||
}
|
||||
|
||||
function onPay(id: string): void {
|
||||
void router.push(`/finance/bills/${id}/pay`)
|
||||
void router.push({ path: '/finance/checkout', query: { ids: id } })
|
||||
}
|
||||
|
||||
function onBatchPay(ids: string[]): void {
|
||||
const first = ids[0]
|
||||
if (!first) return
|
||||
void router.push(`/finance/bills/${first}/pay`)
|
||||
if (ids.length === 0) return
|
||||
// 单笔 / 多笔都进收银台(收银台按 `?ids=` 聚合出「订单明细」与合计)
|
||||
void router.push({ path: '/finance/checkout', query: { ids: ids.join(',') } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
406
src/views/finance/checkout.vue
Normal file
406
src/views/finance/checkout.vue
Normal file
@@ -0,0 +1,406 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
// 页面容器:数据只经 store,不直接 import api
|
||||
import { useFinanceStore } from '@/stores/modules/finance'
|
||||
import type { PayMethod } from '@/types/business/finance'
|
||||
import { createMerchantOrderNo, PAY_METHODS } from '@/utils/payMeta'
|
||||
import { formatMoney } from '@/utils/format'
|
||||
import CheckoutBillCard from './cpns/CheckoutBillCard.vue'
|
||||
import PayMethodList from './cpns/PayMethodList.vue'
|
||||
import WechatQrDialog from './cpns/WechatQrDialog.vue'
|
||||
|
||||
/**
|
||||
* 收银台(确认支付)页 —— 路由 `/finance/checkout?requirementId=<需求id>[&ids=<期次id>]`。
|
||||
*
|
||||
* <p><b>版式(Web 两栏)</b>:面包屑 → 左栏「订单明细卡 + 选择支付方式卡」(占满剩余宽)
|
||||
* + 右栏 360px **粘性**「支付金额 / 订单数量 / 支付方式 / 确认支付」;窄屏(≤992px)回落单栏。</p>
|
||||
*
|
||||
* <p>⚠️ 与移动端设计稿的差异(本轮改动原因):早期按「支付」移动端设计稿(250 设计px ≈ 375 视口)
|
||||
* 做成一根 **480px 居中窄列 + 底部固定橙色操作条**,在 PC 上左右各留一大片空白(实测 1229 视口下
|
||||
* 卡片仅占 480px)。本轮改成 Web 两栏,并同步把卡片从移动端尺度抬到桌面尺度:卡标题 14→16px
|
||||
* (与同目录 `PayMethodDialog` / `FinanceChart` / `WechatQrDialog` 的 16px/600 对齐)、
|
||||
* 条目内边距 12/14→16/18px、金额字号上调;**底部固定操作条取消**,CTA 移入右侧汇总栏。</p>
|
||||
*
|
||||
* <p><b>订单明细数据源</b>:**复用项目详情「合同财务」那个现成接口**
|
||||
* `GET /web/requirementPool/get/finance/{需求id}` —— 入参是需求 id,后端把「需求 + 项目」
|
||||
* 拼成联合结构后把付款计划下发在 `billPlans` 里。本栈支付口径是 `vm_bill_plan.id`,
|
||||
* 故订单明细直接取这份 `billPlans`(见 `api/finance.ts#getCheckoutBills`)。</p>
|
||||
*
|
||||
* <p><b>支付</b>(后端 `controller/pc/pay/WebScanPayController`,类级 `@SaIgnore`):</p>
|
||||
* <ul>
|
||||
* <li>微信 → `POST /pay/web/wechat/native` `{orderNo, amount, billIds}` ⇒ `codeUrl` → 本页弹窗渲染二维码;</li>
|
||||
* <li>支付宝 → `POST /pay/web/alipay/page` `{outTradeNo, amount, billIds}` ⇒ `formBody`
|
||||
* (实测是收银台跳转地址,新标签页打开)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>`billIds` 为什么必须下发</b>:后端在下单成功时落一条 `vm_finance_payment`
|
||||
* (status = CREATED),并把期次 id 列表写进 `request_payload`;支付成功回调再按这张清单
|
||||
* 为**每期**落一行 `vm_finance_payment_bill` 并把 `vm_bill_plan` 置为 DONE。
|
||||
* 不带 `billIds` 时后端只落支付单,分摊明细与回写都会跳过。</p>
|
||||
*/
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const financeStore = useFinanceStore()
|
||||
const { checkoutBills, checkoutLoading, payOrder } = storeToRefs(financeStore)
|
||||
|
||||
/** 路由带入的**需求 id**(项目详情「立即支付」跳转时带上) */
|
||||
const requirementId = computed(() => {
|
||||
const raw = route.query.requirementId
|
||||
return String(Array.isArray(raw) ? raw[0] : (raw ?? '')).trim()
|
||||
})
|
||||
|
||||
/** 路由**可选**带入的期次 id(`?ids=`):用于精确指定付哪一期 */
|
||||
const planIds = computed(() => {
|
||||
const raw = route.query.ids
|
||||
const text = Array.isArray(raw) ? raw.join(',') : String(raw ?? '')
|
||||
return text.split(',').map((id) => id.trim()).filter(Boolean)
|
||||
})
|
||||
|
||||
/** 下单要带的期次清单(`billIds`):以**实际加载到的期次**为准(`vm_bill_plan.id`) */
|
||||
const billIds = computed(() => checkoutBills.value.map((bill) => bill.id))
|
||||
|
||||
const method = ref<PayMethod>('wechat')
|
||||
const submitting = ref(false)
|
||||
const qrVisible = ref(false)
|
||||
|
||||
const totalAmount = computed(() =>
|
||||
checkoutBills.value.reduce((sum, bill) => sum + Number(bill.amount || 0), 0),
|
||||
)
|
||||
|
||||
/** 右侧汇总栏的「支付方式」展示名:与左侧选择卡同源(`PAY_METHODS`),不另写一份文案 */
|
||||
const methodLabel = computed(
|
||||
() => PAY_METHODS.find((item) => item.value === method.value)?.label ?? '—',
|
||||
)
|
||||
|
||||
const canPay = computed(() => checkoutBills.value.length > 0 && !submitting.value)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
await financeStore.loadCheckout(requirementId.value, planIds.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开支付宝收银台。
|
||||
*
|
||||
* <p>后端 `pageExecute(request, "GET")` 实测返回的是**跳转地址**(`https://…/gateway.do?…`),
|
||||
* 直接开新标签页即可;万一日后改回 POST 模式返回自动提交的 HTML 表单,这里也兜住。</p>
|
||||
*/
|
||||
function openAlipay(body: string): void {
|
||||
if (/^\s*https?:\/\//i.test(body)) {
|
||||
window.open(body, '_blank', 'noopener')
|
||||
return
|
||||
}
|
||||
|
||||
const holder = document.createElement('div')
|
||||
holder.style.display = 'none'
|
||||
holder.innerHTML = body
|
||||
document.body.appendChild(holder)
|
||||
|
||||
const form = holder.querySelector('form')
|
||||
if (form) {
|
||||
form.submit()
|
||||
window.setTimeout(() => holder.remove(), 0)
|
||||
return
|
||||
}
|
||||
|
||||
holder.remove()
|
||||
ElMessage.error('支付宝下单返回内容无法识别')
|
||||
}
|
||||
|
||||
async function onConfirm(): Promise<void> {
|
||||
if (!canPay.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
// 商户订单号由前端生成(后端两个下单接口都要求调用方传 outTradeNo)
|
||||
const orderNo = createMerchantOrderNo()
|
||||
|
||||
if (method.value === 'wechat') {
|
||||
await financeStore.payByWechatNative(orderNo, totalAmount.value, billIds.value)
|
||||
const codeUrl = payOrder.value?.codeUrl
|
||||
if (!codeUrl) {
|
||||
ElMessage.error('微信下单失败:未返回二维码内容')
|
||||
return
|
||||
}
|
||||
qrVisible.value = true
|
||||
return
|
||||
}
|
||||
|
||||
await financeStore.payByAlipayPage(orderNo, totalAmount.value, billIds.value)
|
||||
const body = payOrder.value?.formBody
|
||||
if (!body) {
|
||||
ElMessage.error('支付宝下单失败:未返回跳转地址')
|
||||
return
|
||||
}
|
||||
openAlipay(body)
|
||||
}
|
||||
catch {
|
||||
// 失败原因已由 utils/request.ts 拦截器统一弹出,这里只需兜住 Promise 拒绝
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 二维码过期 / 用户主动刷新:换一个新商户单号重新下单 */
|
||||
async function onRefreshQr(): Promise<void> {
|
||||
try {
|
||||
await financeStore.payByWechatNative(createMerchantOrderNo(), totalAmount.value, billIds.value)
|
||||
}
|
||||
catch {
|
||||
// 同上
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「我已完成支付」→ 结果页。
|
||||
*
|
||||
* <p>⚠️ 账单置为 PAID 由**后端异步回调**(微信/支付宝 → MQ → `WechatPayController#handleCallback`)
|
||||
* 完成,用户点按钮这一刻回调可能还没到,结果页会短暂显示「暂无支付结果」,
|
||||
* 刷新即可。若要做成稳定体感,需要后端提供「按订单号查支付状态」的端点供结果页轮询。</p>
|
||||
*/
|
||||
function onPaid(): void {
|
||||
qrVisible.value = false
|
||||
const first = checkoutBills.value[0]
|
||||
if (!first) return
|
||||
// 结果页同样按「需求 id + 期次 id」查(复用财务接口),故把需求 id 一并带过去
|
||||
void router.push({
|
||||
path: `/finance/bills/${first.id}/pay/result`,
|
||||
query: { requirementId: requirementId.value },
|
||||
})
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
void router.push('/finance/bills')
|
||||
}
|
||||
|
||||
watch([requirementId, planIds], () => {
|
||||
void load()
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
financeStore.clearPayOrder()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container checkout">
|
||||
<nav class="checkout__breadcrumb text-secondary">
|
||||
<RouterLink to="/finance" class="link">
|
||||
财务中心
|
||||
</RouterLink>
|
||||
<span> > </span>
|
||||
<RouterLink to="/finance/bills" class="link">
|
||||
账单明细
|
||||
</RouterLink>
|
||||
<span> > </span>
|
||||
<span>确认支付</span>
|
||||
</nav>
|
||||
|
||||
<div v-if="checkoutBills.length > 0" class="checkout__layout">
|
||||
<div class="checkout__main">
|
||||
<CheckoutBillCard :bills="checkoutBills" />
|
||||
<PayMethodList v-model="method" />
|
||||
</div>
|
||||
|
||||
<aside class="checkout__aside">
|
||||
<section class="checkout__summary">
|
||||
<h2 class="checkout__summary-title">
|
||||
支付金额
|
||||
</h2>
|
||||
<p class="checkout__summary-amount">
|
||||
¥{{ formatMoney(totalAmount) }}
|
||||
</p>
|
||||
|
||||
<div class="checkout__summary-meta">
|
||||
<div class="checkout__summary-row">
|
||||
<span class="checkout__summary-label">订单数量</span>
|
||||
<span class="checkout__summary-value">{{ checkoutBills.length }} 笔</span>
|
||||
</div>
|
||||
<div class="checkout__summary-row">
|
||||
<span class="checkout__summary-label">支付方式</span>
|
||||
<span class="checkout__summary-value">{{ methodLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="checkout__cta"
|
||||
:disabled="!canPay"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ submitting ? '处理中…' : '确认支付' }}
|
||||
</button>
|
||||
|
||||
<p class="checkout__summary-tip">
|
||||
支付完成后订单状态将自动更新
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section v-else-if="!checkoutLoading" class="checkout__empty">
|
||||
<p class="checkout__empty-text">
|
||||
未找到待支付账单,请返回账单明细重新选择
|
||||
</p>
|
||||
<button class="checkout__empty-btn" @click="goBack">
|
||||
返回账单明细
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<WechatQrDialog
|
||||
v-model:visible="qrVisible"
|
||||
:content="payOrder?.codeUrl ?? ''"
|
||||
:amount="totalAmount"
|
||||
@refresh="onRefreshQr"
|
||||
@paid="onPaid"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.checkout {
|
||||
// ⚠️ 第三个参数必须给 stretch:`.flex()` 的 align 默认 center,会给面包屑这类
|
||||
// 内容宽子元素「压窄 + 水平居中」——实测面包屑跑到页面正中间去了。
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-base;
|
||||
padding-top: @spacing-lg;
|
||||
// 底部固定操作条已取消(Web 版 CTA 在右侧汇总栏),这里只留常规页脚呼吸位
|
||||
padding-bottom: @spacing-xxl;
|
||||
|
||||
&__breadcrumb {
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
|
||||
// Web 两栏:左「订单明细 + 选择支付方式」自适应,右 360px 汇总栏
|
||||
// ⚠️ 这里**不能**写 `align-items: start` —— 那会让右栏收缩成自身内容高度,
|
||||
// 侧栏里的 `position: sticky` 就没有可滑动空间,粘性会整个失效(实测踩过)。
|
||||
// 保持默认 stretch,右栏与左栏等高,sticky 才有生效范围。
|
||||
&__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: @spacing-base;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__main {
|
||||
// `.flex()` 的 align 默认 center,会让两张卡按内容宽度收缩 ⇒ 必须显式拉满
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-base;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__aside {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// 汇总栏:跟随滚动(顶栏 64px 是 sticky,故留出顶栏高度 + 一档间距)
|
||||
&__summary {
|
||||
position: sticky;
|
||||
top: calc(@header-height + @spacing-base);
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-base;
|
||||
padding: @spacing-lg;
|
||||
border-radius: @radius-lg;
|
||||
background: @checkout-card;
|
||||
}
|
||||
|
||||
&__summary-title {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__summary-amount {
|
||||
font-size: @font-size-xxl;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: @checkout-amount;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__summary-meta {
|
||||
.flex(column, flex-start, stretch);
|
||||
gap: @spacing-sm;
|
||||
padding-top: @spacing-base;
|
||||
border-top: 1px solid @checkout-line;
|
||||
}
|
||||
|
||||
&__summary-row {
|
||||
.flex(row, space-between, center);
|
||||
gap: @spacing-base;
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
|
||||
&__summary-label {
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__summary-value {
|
||||
color: @heading-color;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__cta {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border-radius: @radius-round;
|
||||
background: @checkout-cta;
|
||||
color: @white;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: @checkout-cta-hover;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__summary-tip {
|
||||
font-size: @font-size-sm;
|
||||
color: @checkout-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
.flex(column, center);
|
||||
gap: @spacing-base;
|
||||
padding: @spacing-xxl @spacing-base;
|
||||
border-radius: @radius-lg;
|
||||
background: @checkout-card;
|
||||
}
|
||||
|
||||
&__empty-text {
|
||||
font-size: @font-size-base;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__empty-btn {
|
||||
height: 36px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @checkout-cta;
|
||||
border-radius: @radius-round;
|
||||
background: @checkout-card;
|
||||
color: @checkout-cta;
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
|
||||
// 窄屏兜底:全站未启用响应式,这里只做「侧栏落到主栏下方」的单栏回落,
|
||||
// 避免主栏被 360px 侧栏挤成一条缝。
|
||||
@media (max-width: 992px) {
|
||||
&__layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
&__summary {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -46,10 +46,11 @@ function toggleAll(): void {
|
||||
|
||||
const allSelected = () => props.bills.length > 0 && props.bills.every((b) => selection.value.has(b.id))
|
||||
|
||||
const STATUS_VARIANTS: Record<BillStatus, 'warning' | 'success' | 'info'> = {
|
||||
const STATUS_VARIANTS: Record<BillStatus, 'warning' | 'success' | 'info' | 'danger'> = {
|
||||
待支付: 'warning',
|
||||
已支付: 'success',
|
||||
未到期: 'info',
|
||||
已取消: 'danger',
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
137
src/views/finance/cpns/CheckoutBillCard.vue
Normal file
137
src/views/finance/cpns/CheckoutBillCard.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Bill } from '@/types/business/finance'
|
||||
import { formatMoney } from '@/utils/format'
|
||||
|
||||
/**
|
||||
* 收银台「订单明细」卡。
|
||||
*
|
||||
* <p>结构:卡标题「订单明细」→ 每笔账单一条**描边内嵌行**(期次名 / 账单号 / 关联项目 + 右侧金额)
|
||||
* → 底部「已选 N笔订单,合计」+ 橙色合计金额。</p>
|
||||
*
|
||||
* <p>多笔账单时内嵌行依次堆叠(原型只画了 1 笔,这里按同一规格扩展)。</p>
|
||||
*
|
||||
* <p>⚠️ 字号/内边距按 **Web(桌面)** 尺度取值,不再是移动端设计稿那套:
|
||||
* 卡标题 16px/600 与同目录 `PayMethodDialog`、`FinanceChart`、`WechatQrDialog` 对齐;
|
||||
* 内嵌行内边距 16×18、字段行 12px、金额 16px、合计 20px/700。</p>
|
||||
*/
|
||||
interface Props {
|
||||
/** 待支付账单(一笔或多笔合并支付) */
|
||||
bills: Bill[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
/** ⚠️ 金额可能以字符串下发(BigDecimal 序列化)⇒ 必须 Number() 归一,否则 reduce 会拼字符串 */
|
||||
const totalAmount = computed(() =>
|
||||
props.bills.reduce((sum, bill) => sum + Number(bill.amount || 0), 0),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="checkout-bill">
|
||||
<h2 class="checkout-bill__title">
|
||||
订单明细
|
||||
</h2>
|
||||
|
||||
<ul class="checkout-bill__list">
|
||||
<li v-for="bill in bills" :key="bill.id" class="checkout-bill__item">
|
||||
<div class="checkout-bill__main">
|
||||
<p class="checkout-bill__plan">
|
||||
{{ bill.planName }}
|
||||
</p>
|
||||
<p class="checkout-bill__meta">
|
||||
账单号:{{ bill.billNo || '—' }}
|
||||
</p>
|
||||
<p class="checkout-bill__meta">
|
||||
关联项目:{{ bill.projectName || '—' }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="checkout-bill__amount">
|
||||
¥{{ formatMoney(bill.amount) }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="checkout-bill__total">
|
||||
<span class="checkout-bill__total-label">已选 {{ bills.length }}笔订单,合计</span>
|
||||
<span class="checkout-bill__total-amount">¥{{ formatMoney(totalAmount) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.checkout-bill {
|
||||
padding: @spacing-lg;
|
||||
border-radius: @radius-lg;
|
||||
background: @checkout-card;
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__list {
|
||||
.flex(column, flex-start);
|
||||
// `.flex()` 的 align 默认 center,会让内嵌行按内容宽度收缩 ⇒ 这里必须显式拉满
|
||||
align-items: stretch;
|
||||
gap: @spacing-sm;
|
||||
margin-top: @spacing-base;
|
||||
}
|
||||
|
||||
// 描边内嵌行(移动端设计稿实取描边 #e1e1e1、圆角约 7 设计px ≈ 10px)
|
||||
&__item {
|
||||
.flex(row, space-between, center);
|
||||
gap: @spacing-base;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid @checkout-line;
|
||||
border-radius: 10px;
|
||||
background: @checkout-card;
|
||||
}
|
||||
|
||||
&__main {
|
||||
.flex(column, flex-start);
|
||||
// 同上:期次名 / 账单号 / 关联项目 必须左对齐,不能居中
|
||||
align-items: flex-start;
|
||||
gap: @spacing-xs;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__plan {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: @font-size-sm;
|
||||
color: @checkout-muted;
|
||||
.ellipsis();
|
||||
}
|
||||
|
||||
&__amount {
|
||||
flex-shrink: 0;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__total {
|
||||
.flex(row, space-between, center);
|
||||
gap: @spacing-base;
|
||||
margin-top: @spacing-base;
|
||||
}
|
||||
|
||||
&__total-label {
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
&__total-amount {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: 700;
|
||||
color: @checkout-amount;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
225
src/views/finance/cpns/PayMethodList.vue
Normal file
225
src/views/finance/cpns/PayMethodList.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import type { PayMethod } from '@/types/business/finance'
|
||||
import { PAY_METHODS } from '@/utils/payMeta'
|
||||
|
||||
/**
|
||||
* 收银台「选择支付方式」卡。
|
||||
*
|
||||
* <p>结构:卡头「选择支付方式」+ 下方两行支付方式**满宽铺满卡片**(选中行整行底色点亮,
|
||||
* 微信 = 浅绿),右侧圆形单选钮。</p>
|
||||
*
|
||||
* <p>⚠️ 卡头**不带序号徽标**:早期按移动端设计稿做过一个黄色方形「1」,
|
||||
* 与原型 `docs/prototype/财务中心-演示.html` 的「选择支付方式」弹窗(只有标题 + × 关闭)
|
||||
* 不一致,已按用户要求删除。</p>
|
||||
*
|
||||
* <p>⚠️ 字号/尺寸按 **Web(桌面)** 尺度取值:卡标题 16px/600(与同目录 `PayMethodDialog` 对齐)、
|
||||
* 行高 72px、图标 40px、单选钮 20px —— 不再沿用移动端设计稿那套 57px 行高 / 32px 图标。</p>
|
||||
*/
|
||||
interface Props {
|
||||
/** 当前选中的支付方式 */
|
||||
modelValue: PayMethod
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [method: PayMethod] }>()
|
||||
|
||||
function select(value: PayMethod): void {
|
||||
if (value === props.modelValue) return
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pay-method">
|
||||
<div class="pay-method__head">
|
||||
<h2 class="pay-method__title">
|
||||
选择支付方式
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<ul class="pay-method__list">
|
||||
<li
|
||||
v-for="item in PAY_METHODS"
|
||||
:key="item.value"
|
||||
class="pay-method__row"
|
||||
:class="[`pay-method__row--${item.value}`, { 'is-active': item.value === modelValue }]"
|
||||
role="radio"
|
||||
:aria-checked="item.value === modelValue"
|
||||
:aria-label="item.label"
|
||||
tabindex="0"
|
||||
@click="select(item.value)"
|
||||
@keydown.enter="select(item.value)"
|
||||
@keydown.space.prevent="select(item.value)"
|
||||
>
|
||||
<span class="pay-method__icon" :class="`pay-method__icon--${item.value}`" aria-hidden="true">
|
||||
<!-- 微信:绿底白双气泡 -->
|
||||
<svg
|
||||
v-if="item.value === 'wechat'"
|
||||
class="pay-method__logo"
|
||||
viewBox="0 0 32 32"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect class="pay-method__bubble" x="2.5" y="4.5" width="17" height="14" rx="7" />
|
||||
<rect
|
||||
class="pay-method__bubble-front"
|
||||
x="12.5"
|
||||
y="12.5"
|
||||
width="17"
|
||||
height="15"
|
||||
rx="7.5"
|
||||
/>
|
||||
<circle class="pay-method__eye" cx="8.5" cy="11.5" r="1.5" />
|
||||
<circle class="pay-method__eye" cx="14.5" cy="11.5" r="1.5" />
|
||||
<circle class="pay-method__eye" cx="19" cy="20" r="1.3" />
|
||||
<circle class="pay-method__eye" cx="24" cy="20" r="1.3" />
|
||||
</svg>
|
||||
<!-- 支付宝:蓝底白「支」 -->
|
||||
<template v-else>支</template>
|
||||
</span>
|
||||
|
||||
<span class="pay-method__text">
|
||||
<span class="pay-method__name">{{ item.label }}</span>
|
||||
<span class="pay-method__hint">{{ item.hint }}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="pay-method__radio"
|
||||
:class="[`pay-method__radio--${item.value}`, { 'is-checked': item.value === modelValue }]"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.pay-method {
|
||||
border-radius: @radius-lg;
|
||||
background: @checkout-card;
|
||||
overflow: hidden;
|
||||
|
||||
// 卡头:与「订单明细」卡标题同规格(左右内边距与卡内其它内容对齐)
|
||||
&__head {
|
||||
padding: @spacing-lg @spacing-lg @spacing-base;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
// 支付方式行:满宽铺满卡片(移动端设计稿实取 x 5..242 = 整卡宽)
|
||||
&__row {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-base;
|
||||
height: 72px;
|
||||
padding: 0 @spacing-lg;
|
||||
background: @checkout-card;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&--wechat.is-active {
|
||||
background: @checkout-method-active-bg;
|
||||
}
|
||||
|
||||
&--alipay.is-active {
|
||||
background: fade(@checkout-alipay, 12%);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
.flex(row, center, center);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: @radius-round;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--wechat {
|
||||
background: @checkout-wechat;
|
||||
}
|
||||
|
||||
&--alipay {
|
||||
background: @checkout-alipay;
|
||||
color: @white;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
&__bubble {
|
||||
fill: @white;
|
||||
}
|
||||
|
||||
// 前景气泡描边取图标底色 → 与后景气泡之间留出白-绿分隔缝
|
||||
&__bubble-front {
|
||||
fill: @white;
|
||||
stroke: @checkout-wechat;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
&__eye {
|
||||
fill: @checkout-wechat;
|
||||
}
|
||||
|
||||
&__text {
|
||||
.flex(column, flex-start);
|
||||
// `.flex()` 的 align 默认 center,会让「名称 / 副标题」各自居中 ⇒ 必须显式左对齐
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: @font-size-sm;
|
||||
color: @checkout-muted;
|
||||
}
|
||||
|
||||
&__radio {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1.5px solid @checkout-radio-idle;
|
||||
border-radius: @radius-round;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
// 选中:实心圆 + 白色对勾
|
||||
&.is-checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
border-right: 2px solid @white;
|
||||
border-bottom: 2px solid @white;
|
||||
transform: translate(-50%, -60%) rotate(45deg);
|
||||
}
|
||||
|
||||
&--wechat.is-checked {
|
||||
border-color: @checkout-wechat;
|
||||
background: @checkout-wechat;
|
||||
}
|
||||
|
||||
&--alipay.is-checked {
|
||||
border-color: @checkout-alipay;
|
||||
background: @checkout-alipay;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
274
src/views/finance/cpns/WechatQrDialog.vue
Normal file
274
src/views/finance/cpns/WechatQrDialog.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import qrcode from 'qrcode-generator'
|
||||
import { useCountDown } from '@/composables/useCountDown'
|
||||
import { formatMoney } from '@/utils/format'
|
||||
|
||||
/**
|
||||
* 微信 Native 支付二维码弹窗。
|
||||
*
|
||||
* <p>后端 `/pay/web/wechat/native` 返回 `codeUrl`(形如 `weixin://wxpay/bizpayurl?pr=xxx`),
|
||||
* 本组件用 `qrcode-generator` 把它渲染成二维码(`createSvgTag` → `v-html`),
|
||||
* 用户用微信「扫一扫」完成支付。</p>
|
||||
*
|
||||
* <p>⚠️ 后端**没有「查询支付结果」接口**,所以没有轮询——付完由用户点「我已完成支付」
|
||||
* 进入支付结果页;二维码倒计时只是展示(Native 侧 `code_url` 真实有效期由微信控制)。</p>
|
||||
*/
|
||||
interface Props {
|
||||
visible: boolean
|
||||
/** 二维码内容(微信 codeUrl) */
|
||||
content: string
|
||||
/** 订单金额(元) */
|
||||
amount: number
|
||||
/** 二维码倒计时(秒),默认 5 分钟 */
|
||||
expiresIn?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { expiresIn: 300 })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [visible: boolean]
|
||||
/** 重新下单换一张二维码 */
|
||||
refresh: []
|
||||
/** 用户声明已付款 */
|
||||
paid: []
|
||||
}>()
|
||||
|
||||
const { seconds, start, reset: resetCountDown } = useCountDown(props.expiresIn)
|
||||
const started = ref(false)
|
||||
|
||||
const expired = computed(() => started.value && seconds.value <= 0)
|
||||
|
||||
const countdownText = computed(() => {
|
||||
const m = Math.floor(seconds.value / 60)
|
||||
const s = seconds.value % 60
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
/** 内容仅来自后端下发的 codeUrl,本地生成 SVG,非用户输入 */
|
||||
const qrSvg = computed(() => {
|
||||
if (!props.content) return ''
|
||||
try {
|
||||
const qr = qrcode(0, 'M')
|
||||
qr.addData(props.content)
|
||||
qr.make()
|
||||
return qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true })
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.content] as const,
|
||||
([visible, content]) => {
|
||||
if (visible && content) {
|
||||
start(props.expiresIn)
|
||||
started.value = true
|
||||
return
|
||||
}
|
||||
if (!visible) {
|
||||
resetCountDown()
|
||||
started.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function close(): void {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
start(props.expiresIn)
|
||||
started.value = true
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
function onPaid(): void {
|
||||
emit('paid')
|
||||
}
|
||||
|
||||
function onBoxClick(): void {
|
||||
if (expired.value) refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="wx-qr" @click.self="close">
|
||||
<div class="wx-qr__panel">
|
||||
<h3 class="wx-qr__title">
|
||||
微信扫码支付
|
||||
</h3>
|
||||
<p class="wx-qr__amount">
|
||||
¥{{ formatMoney(amount) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="wx-qr__box"
|
||||
:class="{ 'is-expired': expired }"
|
||||
@click="onBoxClick"
|
||||
>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- 内容由本地 QR 库生成,非用户输入 -->
|
||||
<div v-if="!expired && qrSvg" class="wx-qr__code" v-html="qrSvg" />
|
||||
<div v-else class="wx-qr__mask">
|
||||
<span class="wx-qr__mask-icon">⟳</span>
|
||||
<span class="wx-qr__mask-text">
|
||||
{{ expired ? '二维码已过期' : '二维码生成中' }}<br>点击刷新
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="wx-qr__status">
|
||||
<template v-if="!expired">
|
||||
请使用手机微信 <strong>[扫一扫]</strong> 扫描上方二维码,
|
||||
二维码 <strong class="wx-qr__count">{{ countdownText }}</strong> 后失效
|
||||
</template>
|
||||
<template v-else>
|
||||
二维码已过期,请点击二维码或下方按钮刷新
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<div class="wx-qr__actions">
|
||||
<button class="wx-qr__btn" @click="refresh">
|
||||
刷新二维码
|
||||
</button>
|
||||
<button class="wx-qr__btn wx-qr__btn--cta" @click="onPaid">
|
||||
我已完成支付
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.wx-qr {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
.flex(row, center);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
|
||||
&__panel {
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: @spacing-lg;
|
||||
border-radius: @radius-lg;
|
||||
background: @checkout-card;
|
||||
box-shadow: @shadow-lg;
|
||||
.flex(column, flex-start, center);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
margin-top: @spacing-xs;
|
||||
font-size: @font-size-xl;
|
||||
font-weight: 600;
|
||||
color: @checkout-amount;
|
||||
}
|
||||
|
||||
&__box {
|
||||
.flex(row, center, center);
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
margin-top: @spacing-base;
|
||||
border: 1px solid @checkout-line;
|
||||
border-radius: @radius-base;
|
||||
background: @checkout-card;
|
||||
|
||||
&.is-expired {
|
||||
background: @bg-color;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__code {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: @spacing-sm;
|
||||
|
||||
// v-html 注入的 SVG 拿不到 scoped 属性,必须 :deep
|
||||
:deep(svg) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__mask {
|
||||
.flex(column, center);
|
||||
gap: @spacing-xs;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__mask-icon {
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__mask-text {
|
||||
font-size: @font-size-sm;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__status {
|
||||
margin-top: @spacing-base;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
|
||||
strong {
|
||||
color: @heading-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__count {
|
||||
color: @checkout-amount;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
.flex(row, center);
|
||||
gap: @spacing-sm;
|
||||
width: 100%;
|
||||
margin-top: @spacing-lg;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: @radius-round;
|
||||
background: @checkout-card;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @checkout-cta;
|
||||
color: @checkout-cta;
|
||||
}
|
||||
|
||||
&--cta {
|
||||
border-color: @checkout-cta;
|
||||
background: @checkout-cta;
|
||||
color: @white;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: @checkout-cta-hover;
|
||||
color: @white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,3 +6,6 @@ export { default as BillSummaryBar } from './cpns/BillSummaryBar.vue'
|
||||
export { default as PayMethodDialog } from './cpns/PayMethodDialog.vue'
|
||||
export { default as InvoiceTitleCard } from './cpns/InvoiceTitleCard.vue'
|
||||
export { default as InvoiceTitleDialog } from './cpns/InvoiceTitleDialog.vue'
|
||||
export { default as CheckoutBillCard } from './cpns/CheckoutBillCard.vue'
|
||||
export { default as PayMethodList } from './cpns/PayMethodList.vue'
|
||||
export { default as WechatQrDialog } from './cpns/WechatQrDialog.vue'
|
||||
|
||||
@@ -14,13 +14,19 @@ const { result } = storeToRefs(financeStore)
|
||||
|
||||
const billId = computed(() => String(route.params.id))
|
||||
|
||||
/** 需求 id(收银台带过来):有它就走「需求 + 期次」的财务口径查支付结果 */
|
||||
const requirementId = computed(() => {
|
||||
const raw = route.query.requirementId
|
||||
return String(Array.isArray(raw) ? raw[0] : (raw ?? '')).trim()
|
||||
})
|
||||
|
||||
const methodLabel = computed(() => {
|
||||
if (!result.value) return '—'
|
||||
return result.value.method === 'wechat' ? '微信支付' : '支付宝支付'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void financeStore.fetchResult(billId.value)
|
||||
void financeStore.fetchResult(billId.value, requirementId.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user