项目中心-支付模块

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

@@ -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">
账单尚未到期,到期后可前往账单明细完成支付。

View File

@@ -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(() => {

View 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> &gt; </span>
<RouterLink to="/finance/bills" class="link">
账单明细
</RouterLink>
<span> &gt; </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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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'

View File

@@ -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>