feat(payment): 完成阶段7订单+支付管理开发(订单支付/汇总订单/原始订单)

main
halo 2026-07-10 00:04:22 +08:00
parent a9243039b5
commit 0565ac8dc1
11 changed files with 915 additions and 1 deletions

17
src/api/order.js Normal file
View File

@ -0,0 +1,17 @@
import request from './request'
// ---------------- 订单管理(/api/original-order/*、/api/summary-order/*) ----------------
// 说明:
// - `original-order/page` 仅支持 {page,pageSize,originalOrderNo} 过滤,不支持 docx 描述的
// 渠道/下单日期范围/订单状态/结算状态/汇总订单编号/店铺ID,已登记于《缺失的后端接口.md》。
// - `summary-order/page` 仅支持 {page,pageSize,orderNo} 过滤,不支持商品类型/清算状态/是否已
// 推送/订单日期范围/计划清算日/实际清算日范围/贷款人,已登记于《缺失的后端接口.md》。
// - `original-order/export` 入参 {summaryOrderId,originalOrderNo} 均可选,响应 data 结构
// 未在 swagger 中定义,前端按项目既有约定(参考钱包对账单/回单下载)假定为 {fileData: base64},
// 已登记于《缺失的后端接口.md》。
// - `summary-order/detail` 响应即 SummaryOrderVo 本身,不含关联原始订单明细列表,已登记于
// 《缺失的后端接口.md》。
export const originalOrderPageApi = (data) => request.post('/api/original-order/page', data)
export const originalOrderExportApi = (data) => request.post('/api/original-order/export', data)
export const summaryOrderPageApi = (data) => request.post('/api/summary-order/page', data)
export const summaryOrderDetailApi = (data) => request.post('/api/summary-order/detail', data)

14
src/api/payment.js Normal file
View File

@ -0,0 +1,14 @@
import request from './request'
// ---------------- 支付管理(/api/payment/*、/api/payment-management/*) ----------------
// 说明:
// - `/api/payment/query-credit-quota`:查询授信额度,入参仅 {accountNo}(钱包账号字符串),
// 响应 data 字段在 swagger 中完全未定义具体结构(无 $ref、无 properties),属于未文档化接口,
// 前端按"任意存在字段"做防御性展示,已登记于《缺失的后端接口.md》。
// - `/api/payment/order-pay`:订单支付,入参 CreatePaymentBto 不含短信验证码字段,短验无法
// 真正传递给后端校验,前端仅做本地格式校验(非空+6位数字),已登记于《缺失的后端接口.md》。
// - 查询支付记录/详情走 payment-management 组,与创建支付的 payment 组为同一 tag 下的不同前缀。
export const queryCreditQuotaApi = (data) => request.post('/api/payment/query-credit-quota', data)
export const orderPayApi = (data) => request.post('/api/payment/order-pay', data)
export const queryPaymentRecordApi = (data) => request.post('/api/payment-management/query-payment-record', data)
export const queryPaymentDetailApi = (data) => request.post('/api/payment-management/query-payment-detail', data)

View File

@ -0,0 +1,34 @@
// src/constants/orderEnums.js
// 订单管理相关枚举字典,取值与中文标签均从 swagger 组件 schema 的 description 字段程序化
// 提取,未做任何人工编造。
// OrderTypeEnum 出现在 OriginalOrderVo.orderType 字段上,语义为"该原始订单记录本身的类型标
// 记"(与"汇总订单"模块概念同名但用途不同,详见《缺失的后端接口.md》)。
export const ORDER_TYPE_OPTIONS = [
{ value: 'SUMMARY', label: '汇总订单' },
{ value: 'ORIGINAL', label: '原始订单' }
]
export const ORDER_TYPE_MAP = {
SUMMARY: { text: '汇总订单', color: 'blue' },
ORIGINAL: { text: '原始订单', color: 'default' }
}
export const SUMMARY_ORDER_STATUS_OPTIONS = [
{ value: 'REGISTERED', label: '已登记' },
{ value: 'SETTLED', label: '已完结' }
]
export const SUMMARY_ORDER_STATUS_MAP = {
REGISTERED: { text: '已登记', color: 'blue' },
SETTLED: { text: '已完结', color: 'green' }
}
export const SETTLEMENT_STATUS_OPTIONS = [
{ value: 'UNSCHEDULED', label: '未清算' },
{ value: 'SETTLED_REPAID', label: '已清算并还款' },
{ value: 'FAILED', label: '清算失败' }
]
export const SETTLEMENT_STATUS_MAP = {
UNSCHEDULED: { text: '未清算', color: 'default' },
SETTLED_REPAID: { text: '已清算并还款', color: 'green' },
FAILED: { text: '清算失败', color: 'red' }
}

View File

@ -0,0 +1,40 @@
// src/constants/paymentEnums.js
// 支付管理相关枚举字典,取值与中文标签均从 swagger 组件 schema 的 description 字段程序化
// 提取,未做任何人工编造。
export const PAYMENT_METHOD_OPTIONS = [
{ value: 'BALANCE', label: '余额支付' },
{ value: 'FINANCE', label: '融资支付' },
{ value: 'COMBINED', label: '组合支付' }
]
export const PAYMENT_METHOD_MAP = {
BALANCE: { text: '余额支付', color: 'blue' },
FINANCE: { text: '融资支付', color: 'purple' },
COMBINED: { text: '组合支付', color: 'cyan' }
}
export const PAYMENT_STATUS_OPTIONS = [
{ value: 'PENDING_VERIFICATION', label: '待验证' },
{ value: 'VERIFYING', label: '验证中' },
{ value: 'PROCESSING', label: '支付中' },
{ value: 'SUCCESS', label: '支付成功' },
{ value: 'FAILED', label: '支付失败' },
{ value: 'CANCELLED', label: '已取消' }
]
export const PAYMENT_STATUS_MAP = {
PENDING_VERIFICATION: { text: '待验证', color: 'default' },
VERIFYING: { text: '验证中', color: 'processing' },
PROCESSING: { text: '支付中', color: 'processing' },
SUCCESS: { text: '支付成功', color: 'green' },
FAILED: { text: '支付失败', color: 'red' },
CANCELLED: { text: '已取消', color: 'default' }
}
export const FUND_SOURCE_OPTIONS = [
{ value: 'BALANCE', label: '余额' },
{ value: 'FINANCE', label: '融资' }
]
export const FUND_SOURCE_MAP = {
BALANCE: { text: '余额', color: 'blue' },
FINANCE: { text: '融资', color: 'purple' }
}

View File

@ -13,7 +13,10 @@ export const routePathComponentMap = {
'/wallet/enterprise-open': 'wallet/enterprise-open/EnterpriseOpenList',
'/credit/loan-application': 'credit/loan-application/LoanApplicationList',
'/merchant/management': 'merchant/management/MerchantList',
'/merchant/invoice': 'merchant/invoice/InvoiceList'
'/merchant/invoice': 'merchant/invoice/InvoiceList',
'/payment/order-payment': 'payment/order-payment/PaymentRecordList',
'/order/summary': 'order/summary/SummaryOrderList',
'/order/original': 'order/original/OriginalOrderList'
}
// 新增/编辑/查看等表单页不是后端菜单节点(不出现在侧边栏),但仍需要注册为

View File

@ -0,0 +1,96 @@
<!-- src/views/order/original/OriginalOrderList.vue -->
<!-- 4.2 原始订单:查询按 /api/original-order/page 实际支持的入参( originalOrderNo)实现,
不支持 docx 描述的渠道/下单日期范围/订单状态/结算状态/汇总订单编号/店铺ID过滤条件,原型图
"需优先选择电商渠道才能加载数据"的前置步骤后端不需要,未实现该门槛;原型多出的"新增/修改"
按钮 docx 未提及(原始订单数据来源为电商平台爬虫/文件导入,不在本前端范围新建导入功能),
实现,已登记于缺失的后端接口.md"订单导出"调用 /api/original-order/export,响应结构
未在 swagger 中定义,前端按项目既有约定(对账单/回单下载模式)假定返回 {fileData: base64},
导出文件按 xlsx 处理,若实际结构不同将导致导出失败,已登记于缺失的后端接口.md -->
<template>
<div class="original-order-list-page">
<ProTable
ref="tableRef"
:columns="columns"
:fetch-data="loadList"
:row-selection="false"
:initial-search="{ originalOrderNo: '' }"
>
<template #search="{ form }">
<a-form-item label="订单编号">
<a-input v-model:value="form.originalOrderNo" placeholder="请输入原始订单编号" allow-clear style="width: 200px" />
</a-form-item>
</template>
<template #actions>
<a-button :loading="exporting" @click="handleExport"></a-button>
</template>
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'index'">{{ index + 1 }}</template>
<template v-else-if="column.dataIndex === 'orderType'">
{{ ORDER_TYPE_OPTIONS.find((opt) => opt.value === record.orderType)?.label || record.orderType || '-' }}
</template>
</template>
</ProTable>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import ProTable from '@/components/ProTable.vue'
import { originalOrderPageApi, originalOrderExportApi } from '@/api/order'
import { ORDER_TYPE_OPTIONS } from '@/constants/orderEnums'
import { downloadBase64File } from '@/utils/download'
const tableRef = ref(null)
const exporting = ref(false)
const columns = [
{ title: '序号', dataIndex: 'index', width: 60 },
{ title: '订单编号', dataIndex: 'originalOrderNo' },
{ title: '所属汇总订单ID', dataIndex: 'summaryOrderId' },
{ title: '商户ID', dataIndex: 'merchantId' },
{ title: '订单类型', dataIndex: 'orderType' },
{ title: '订单日期', dataIndex: 'orderDate' },
{ title: '商品名称', dataIndex: 'productName' },
{ title: '商品数量', dataIndex: 'productQuantity' },
{ title: '单价', dataIndex: 'unitPrice' },
{ title: '订单金额', dataIndex: 'totalAmount' },
{ title: '买家名称', dataIndex: 'buyerName' },
{ title: '卖家名称', dataIndex: 'sellerName' },
{ title: '订单状态', dataIndex: 'orderStatus' },
{ title: '备注', dataIndex: 'remark' }
]
async function loadList(params) {
const res = await originalOrderPageApi(params)
if (res.data.code === 200) {
return { list: res.data.data.records || [], total: res.data.data.total || 0 }
}
return { list: [], total: 0 }
}
async function handleExport() {
const originalOrderNo = tableRef.value?.searchForm?.originalOrderNo
exporting.value = true
try {
const res = await originalOrderExportApi({ originalOrderNo: originalOrderNo || undefined })
if (res.data.code === 200) {
const fileData = res.data.data?.fileData
if (fileData) {
downloadBase64File(
fileData,
`原始订单_${dayjs().format('YYYYMMDDHHmmss')}.xlsx`,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
} else {
message.warning('导出接口未返回可识别的文件内容,响应结构与前端约定不一致,请联系后端确认')
}
}
} finally {
exporting.value = false
}
}
</script>

View File

@ -0,0 +1,119 @@
<!-- src/views/order/summary/SummaryOrderList.vue -->
<!-- 4.1 汇总订单:查询按 /api/summary-order/page 实际支持的入参( orderNo)实现,不支持 docx
描述的商品类型/清算状态/是否已推送/订单日期范围/计划清算日/实际清算日范围/贷款人过滤条件,
已登记于缺失的后端接口.md列表列按 SummaryOrderVo 实际返回字段展示,docx 描述的"预计
结算额/可融资金额/已使用额度/剩余额度/超额退款/累计已补差额/推送状态/计划清算日"等字段在
真实模型中部分缺失部分字段名不同( financeQuota可融资金额accumulatedLoanAmount
使用额度),前端已按最接近的语义映射展示,"剩余额度"由前端用 financeQuota-accumulatedLoanAmount
本地计算得出(非后端返回字段)"查看"操作弹出详情弹窗展示同一 Vo 的完整字段;后端无"关联
原始订单明细"查询能力(original-order/page 不支持按 summaryOrderId 过滤),详情弹窗不展示
关联原始订单列表,已登记于缺失的后端接口.md -->
<template>
<div class="summary-order-list-page">
<ProTable ref="tableRef" :columns="columns" :fetch-data="loadList" :row-selection="false" :initial-search="{ orderNo: '' }">
<template #search="{ form }">
<a-form-item label="订单编号">
<a-input v-model:value="form.orderNo" placeholder="请输入汇总订单编号" allow-clear style="width: 200px" />
</a-form-item>
</template>
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'index'">{{ index + 1 }}</template>
<template v-else-if="column.dataIndex === 'orderStatus'">
<StatusTag :value="record.orderStatus" :map="SUMMARY_ORDER_STATUS_MAP" />
</template>
<template v-else-if="column.dataIndex === 'settlementStatus'">
<StatusTag :value="record.settlementStatus" :map="SETTLEMENT_STATUS_MAP" />
</template>
<template v-else-if="column.dataIndex === 'remainingQuota'">
{{ computeRemainingQuota(record) }}
</template>
<template v-else-if="column.dataIndex === 'action'">
<a @click="detailRef.show(record)"></a>
</template>
</template>
</ProTable>
<a-modal v-model:open="detailOpen" title="汇总订单详情" width="800px" :footer="null">
<a-descriptions v-if="detailRecord" bordered :column="2" size="small">
<a-descriptions-item label="订单编号">{{ detailRecord.orderNo }}</a-descriptions-item>
<a-descriptions-item label="商户编号">{{ detailRecord.merchantNo }}</a-descriptions-item>
<a-descriptions-item label="订单日期">{{ detailRecord.orderDate }}</a-descriptions-item>
<a-descriptions-item label="订单总额">{{ detailRecord.totalAmount }}</a-descriptions-item>
<a-descriptions-item label="已发货未收款总额">{{ detailRecord.shippedUncollectedAmount }}</a-descriptions-item>
<a-descriptions-item label="预计退货总额">{{ detailRecord.estimatedReturnAmount }}</a-descriptions-item>
<a-descriptions-item label="预计应收款总额">{{ detailRecord.estimatedReceivableAmount }}</a-descriptions-item>
<a-descriptions-item label="实际退货总额">{{ detailRecord.actualReturnAmount }}</a-descriptions-item>
<a-descriptions-item label="融资额度(可融资金额)">{{ detailRecord.financeQuota }}</a-descriptions-item>
<a-descriptions-item label="累计融资金额(已使用额度)">{{ detailRecord.accumulatedLoanAmount }}</a-descriptions-item>
<a-descriptions-item label="剩余额度(本地计算)">{{ computeRemainingQuota(detailRecord) }}</a-descriptions-item>
<a-descriptions-item label="累计补足差额">{{ detailRecord.accumulatedSupplementAmount }}</a-descriptions-item>
<a-descriptions-item label="订单状态">
<StatusTag :value="detailRecord.orderStatus" :map="SUMMARY_ORDER_STATUS_MAP" />
</a-descriptions-item>
<a-descriptions-item label="清算状态">
<StatusTag :value="detailRecord.settlementStatus" :map="SETTLEMENT_STATUS_MAP" />
</a-descriptions-item>
<a-descriptions-item label="摘要" :span="2">{{ detailRecord.remark || '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="2">{{ detailRecord.createdAt }}</a-descriptions-item>
</a-descriptions>
<a-alert
style="margin-top: 12px"
type="warning"
show-icon
message="后端暂不支持按汇总订单查询关联的原始订单明细,此处未展示关联原始订单列表,详见《缺失的后端接口.md》"
/>
</a-modal>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ProTable from '@/components/ProTable.vue'
import StatusTag from '@/components/StatusTag.vue'
import { summaryOrderPageApi, summaryOrderDetailApi } from '@/api/order'
import { SUMMARY_ORDER_STATUS_MAP, SETTLEMENT_STATUS_MAP } from '@/constants/orderEnums'
const tableRef = ref(null)
const detailOpen = ref(false)
const detailRecord = ref(null)
const columns = [
{ title: '序号', dataIndex: 'index', width: 60 },
{ title: '订单编号', dataIndex: 'orderNo' },
{ title: '商户编号', dataIndex: 'merchantNo' },
{ title: '订单日期', dataIndex: 'orderDate' },
{ title: '订单总额', dataIndex: 'totalAmount' },
{ title: '预计应收款总额', dataIndex: 'estimatedReceivableAmount' },
{ title: '融资额度', dataIndex: 'financeQuota' },
{ title: '累计融资金额', dataIndex: 'accumulatedLoanAmount' },
{ title: '剩余额度', dataIndex: 'remainingQuota' },
{ title: '累计补足差额', dataIndex: 'accumulatedSupplementAmount' },
{ title: '订单状态', dataIndex: 'orderStatus' },
{ title: '清算状态', dataIndex: 'settlementStatus' },
{ title: '操作', dataIndex: 'action', width: 80, fixed: 'right' }
]
async function loadList(params) {
const res = await summaryOrderPageApi(params)
if (res.data.code === 200) {
return { list: res.data.data.records || [], total: res.data.data.total || 0 }
}
return { list: [], total: 0 }
}
function computeRemainingQuota(record) {
const quota = Number(record.financeQuota)
const used = Number(record.accumulatedLoanAmount)
if (Number.isNaN(quota) || Number.isNaN(used)) return '-'
return (quota - used).toFixed(2)
}
const detailRef = {
show: async (record) => {
const res = await summaryOrderDetailApi({ id: record.id })
detailRecord.value = res.data.code === 200 ? res.data.data : record
detailOpen.value = true
}
}
</script>

View File

@ -0,0 +1,67 @@
<!-- src/views/payment/order-payment/PaymentDetailModal.vue -->
<!-- 支付详情弹窗,对应 /api/payment-management/query-payment-detail(入参仅 {id}),
响应 PaymentRecordDetailVo 在列表字段基础上多一个 detailList(各资金渠道明细) -->
<template>
<a-modal v-model:open="open" title="支付详情" width="640px" :footer="null">
<a-descriptions v-if="detail" bordered :column="2" size="small">
<a-descriptions-item label="支付方式">
<StatusTag :value="detail.paymentMethod" :map="PAYMENT_METHOD_MAP" />
</a-descriptions-item>
<a-descriptions-item label="支付状态">
<StatusTag :value="detail.status" :map="PAYMENT_STATUS_MAP" />
</a-descriptions-item>
<a-descriptions-item label="支付总金额">{{ detail.totalAmount }}</a-descriptions-item>
<a-descriptions-item label="凡荣e链流水号">{{ detail.frchainSerialNo || '-' }}</a-descriptions-item>
<a-descriptions-item label="余额支付金额">{{ detail.balanceAmount || '-' }}</a-descriptions-item>
<a-descriptions-item label="融资支付金额">{{ detail.financeAmount || '-' }}</a-descriptions-item>
<a-descriptions-item label="支付完成时间">{{ detail.paidAt || '-' }}</a-descriptions-item>
<a-descriptions-item label="失败原因">{{ detail.failedReason || '-' }}</a-descriptions-item>
<a-descriptions-item label="收款方户名">{{ detail.payeeInfo?.payeeName || '-' }}</a-descriptions-item>
<a-descriptions-item label="收款方账号">{{ detail.payeeInfo?.payeeAccountNo || '-' }}</a-descriptions-item>
<a-descriptions-item label="收款方开户行" :span="2">{{ detail.payeeInfo?.payeeBankName || '-' }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detail.remark || '-' }}</a-descriptions-item>
</a-descriptions>
<a-divider>支付明细</a-divider>
<a-table
:columns="detailColumns"
:data-source="detail?.detailList || []"
:pagination="false"
row-key="id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'fundChannel'">
<StatusTag :value="record.fundChannel" :map="FUND_SOURCE_MAP" />
</template>
</template>
</a-table>
</a-modal>
</template>
<script setup>
import { ref } from 'vue'
import StatusTag from '@/components/StatusTag.vue'
import { queryPaymentDetailApi } from '@/api/payment'
import { PAYMENT_METHOD_MAP, PAYMENT_STATUS_MAP, FUND_SOURCE_MAP } from '@/constants/paymentEnums'
const open = ref(false)
const detail = ref(null)
const detailColumns = [
{ title: '资金渠道', dataIndex: 'fundChannel' },
{ title: '渠道金额', dataIndex: 'channelAmount' },
{ title: '备注', dataIndex: 'remark' }
]
async function show(record) {
detail.value = null
open.value = true
const res = await queryPaymentDetailApi({ id: record.id })
if (res.data.code === 200) {
detail.value = res.data.data
}
}
defineExpose({ show })
</script>

View File

@ -0,0 +1,336 @@
<!-- src/views/payment/order-payment/PaymentFormModal.vue -->
<!-- 3.1 订单支付 发起支付弹窗,对应 /api/payment/order-pay(CreatePaymentBto)
重要设计假设( swagger 未明确说明,已登记于缺失的后端接口.md):
- CreatePaymentBto 仅有一个 walletAccountId 字段,无法同时指定"A1余额账户"+"A3融资账户"两个
不同账户ID结合 wallet-account/detail 以一个账户id返回其所属客户整套 A1/A2/A6/A7 余额的
既有模式,推断 walletAccountId 应始终填"客户主账户(A1)" id,融资额度查询
(query-credit-quota)则用该账户的 accountNo 反查,不需要单独选择 A3 账户
- query-credit-quota 响应 data 结构完全未文档化,前端只做防御性字段探测展示,无法保证
"已签约且剩余额度>0"的判断完全准确
- CreatePaymentBto 无短信验证码字段,短验流程仅为前端 UI 展示,不会被后端真正校验
- loanApplyId 取自 credit-apply/loan-application/list 返回记录的数字主键 id(
loanApplicationId 业务单号),仅列出 applicationStatus=SUBMITTED(已申请/流程终态)的记录
供选择,因该枚举无"审批通过/放款"等更精确状态 -->
<template>
<a-modal v-model:open="open" title="发起支付" width="760px" :confirm-loading="submitting" @ok="handleSubmit" @cancel="handleCancel">
<a-form layout="vertical" :model="form">
<a-form-item label="付款钱包账户" required>
<a-input-group compact style="display: flex">
<a-input :value="payerDisplay" placeholder="请选择付款钱包账户" readonly />
<a-button @click="pickerRef.show()"></a-button>
</a-input-group>
</a-form-item>
<a-descriptions v-if="balanceInfo" bordered size="small" :column="2" style="margin-bottom: 16px">
<a-descriptions-item label="A1主账户可用余额">{{ balanceInfo.mainBalance ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="A2电商账户可用余额">{{ balanceInfo.a2Balance ?? '-' }}</a-descriptions-item>
</a-descriptions>
<a-form-item label="支付方式" required>
<a-radio-group v-model:value="form.paymentMethod">
<a-radio v-for="opt in PAYMENT_METHOD_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-radio>
</a-radio-group>
</a-form-item>
<template v-if="form.paymentMethod === 'BALANCE' || form.paymentMethod === 'COMBINED'">
<a-form-item label="余额支付金额(元)" required>
<a-input-number v-model:value="form.balanceAmount" :min="0" :precision="2" style="width: 240px" />
</a-form-item>
</template>
<template v-if="form.paymentMethod === 'FINANCE' || form.paymentMethod === 'COMBINED'">
<a-form-item label="融资支付金额(元)" required>
<a-input-number v-model:value="form.financeAmount" :min="0" :precision="2" style="width: 240px" />
</a-form-item>
<a-alert
v-if="quotaInfo"
type="info"
show-icon
style="margin-bottom: 16px"
message="授信额度查询结果(接口响应结构未文档化,以下为原始字段展示)"
>
<template #description>
<pre style="margin: 0; white-space: pre-wrap">{{ JSON.stringify(quotaInfo, null, 2) }}</pre>
</template>
</a-alert>
<a-alert v-else-if="form.walletAccountId" type="warning" show-icon style="margin-bottom: 16px" message="尚未查询到授信额度信息,融资支付功能将被禁用" />
<a-form-item label="关联贷款申请" required>
<a-select
v-model:value="form.loanApplyId"
placeholder="请输入客户名称搜索已申请的贷款记录"
show-search
:filter-option="false"
:options="loanOptions"
:loading="loadingLoan"
@search="handleSearchLoan"
/>
</a-form-item>
</template>
<a-form-item label="支付总金额(元,自动计算)">
<a-input :value="totalAmountDisplay" readonly />
</a-form-item>
<a-divider>收款人信息</a-divider>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="收款方户名" required>
<a-input v-model:value="form.payeeName" placeholder="请输入收款方户名" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="收款方账号" required>
<a-input v-model:value="form.payeeAccountNo" placeholder="请输入收款方账号" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="收款方开户行">
<a-input v-model:value="form.payeeBankName" placeholder="请输入收款方开户行" />
</a-form-item>
</a-col>
</a-row>
<a-divider>短信验证</a-divider>
<a-alert
type="warning"
show-icon
style="margin-bottom: 12px"
message="后端支付接口未提供短信验证码校验字段,以下验证流程仅为前端交互展示,不会被后端真正核验,详见《缺失的后端接口.md》"
/>
<a-row :gutter="16">
<a-col :span="10">
<a-form-item label="验证手机号" required>
<a-input v-model:value="form.smsPhone" placeholder="请输入11位手机号" maxlength="11" />
</a-form-item>
</a-col>
<a-col :span="10">
<a-form-item label="短信验证码" required>
<a-input v-model:value="form.smsCode" placeholder="请输入验证码" maxlength="6" />
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item label=" ">
<a-button :disabled="smsCountdown > 0" :loading="sendingSms" @click="handleSendSms">
{{ smsCountdown > 0 ? `${smsCountdown}s后重发` : '发送验证码' }}
</a-button>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="备注">
<a-textarea v-model:value="form.remark" :rows="2" placeholder="请输入备注" />
</a-form-item>
</a-form>
<WalletAccountPicker ref="pickerRef" @select="handlePickPayer" />
</a-modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import WalletAccountPicker from '@/components/WalletAccountPicker.vue'
import { fetchWalletAccountDetailApi } from '@/api/wallet'
import { fetchLoanApplicationListApi } from '@/api/credit'
import { queryCreditQuotaApi, orderPayApi } from '@/api/payment'
import { sendSmsCodeApi } from '@/api/auth'
import { PAYMENT_METHOD_OPTIONS } from '@/constants/paymentEnums'
const emit = defineEmits(['success'])
const open = ref(false)
const submitting = ref(false)
const pickerRef = ref(null)
const balanceInfo = ref(null)
const quotaInfo = ref(null)
const loadingLoan = ref(false)
const loanOptions = ref([])
const sendingSms = ref(false)
const smsCountdown = ref(0)
let countdownTimer = null
const payerDisplay = ref('')
const payerAccountNo = ref('')
const defaultForm = () => ({
walletAccountId: undefined,
paymentMethod: 'BALANCE',
balanceAmount: undefined,
financeAmount: undefined,
loanApplyId: undefined,
payeeName: '',
payeeAccountNo: '',
payeeBankName: '',
smsPhone: '',
smsCode: '',
remark: ''
})
const form = reactive(defaultForm())
const totalAmountDisplay = computed(() => {
const balance = form.paymentMethod !== 'FINANCE' ? Number(form.balanceAmount) || 0 : 0
const finance = form.paymentMethod !== 'BALANCE' ? Number(form.financeAmount) || 0 : 0
return (balance + finance).toFixed(2)
})
async function handlePickPayer(record) {
form.walletAccountId = record.id
payerDisplay.value = `${record.accountName}(${record.accountNo})`
payerAccountNo.value = record.accountNo
balanceInfo.value = null
quotaInfo.value = null
const res = await fetchWalletAccountDetailApi({ id: record.id })
if (res.data.code === 200) {
balanceInfo.value = res.data.data
}
}
watch(
() => form.paymentMethod,
async (method) => {
if ((method === 'FINANCE' || method === 'COMBINED') && payerAccountNo.value && !quotaInfo.value) {
const res = await queryCreditQuotaApi({ accountNo: payerAccountNo.value })
if (res.data.code === 200) {
quotaInfo.value = res.data.data
}
}
}
)
async function handleSearchLoan(keyword) {
if (!keyword) {
loanOptions.value = []
return
}
loadingLoan.value = true
try {
const res = await fetchLoanApplicationListApi({ page: 1, pageSize: 50, customerName: keyword, applicationStatus: 'SUBMITTED' })
if (res.data.code === 200) {
loanOptions.value = (res.data.data.records || []).map((item) => ({
label: `${item.loanApplicationId} - ${item.customerName} - ¥${item.loanAmount}`,
value: item.id
}))
}
} finally {
loadingLoan.value = false
}
}
async function handleSendSms() {
if (!/^1\d{10}$/.test(form.smsPhone)) {
message.warning('请输入正确的11位手机号')
return
}
sendingSms.value = true
try {
const res = await sendSmsCodeApi({ phone: form.smsPhone, businessType: 'PAYMENT' })
if (res.data.code === 200) {
message.success('验证码已发送')
smsCountdown.value = 60
countdownTimer = setInterval(() => {
smsCountdown.value -= 1
if (smsCountdown.value <= 0) clearInterval(countdownTimer)
}, 1000)
}
} finally {
sendingSms.value = false
}
}
function validateForm() {
if (!form.walletAccountId) {
message.warning('请选择付款钱包账户')
return false
}
if ((form.paymentMethod === 'BALANCE' || form.paymentMethod === 'COMBINED') && !(Number(form.balanceAmount) > 0)) {
message.warning('请输入余额支付金额')
return false
}
if ((form.paymentMethod === 'FINANCE' || form.paymentMethod === 'COMBINED') && !(Number(form.financeAmount) > 0)) {
message.warning('请输入融资支付金额')
return false
}
if ((form.paymentMethod === 'FINANCE' || form.paymentMethod === 'COMBINED') && !form.loanApplyId) {
message.warning('请选择关联贷款申请')
return false
}
if (form.paymentMethod === 'BALANCE' || form.paymentMethod === 'COMBINED') {
const avail = Number(balanceInfo.value?.mainBalance)
if (!Number.isNaN(avail) && Number(form.balanceAmount) > avail) {
message.error('余额支付金额超出可用余额')
return false
}
}
if (!form.payeeName || !form.payeeAccountNo) {
message.warning('请完整填写收款方户名与账号')
return false
}
if (!/^1\d{10}$/.test(form.smsPhone)) {
message.warning('请输入正确的验证手机号')
return false
}
if (!/^\d{6}$/.test(form.smsCode)) {
message.warning('请输入6位数字验证码')
return false
}
return true
}
async function handleSubmit() {
if (!validateForm()) return
submitting.value = true
try {
const detailList = []
if (form.paymentMethod === 'BALANCE' || form.paymentMethod === 'COMBINED') {
detailList.push({ fundChannel: 'BALANCE', channelAmount: String(form.balanceAmount) })
}
if (form.paymentMethod === 'FINANCE' || form.paymentMethod === 'COMBINED') {
detailList.push({ fundChannel: 'FINANCE', channelAmount: String(form.financeAmount) })
}
const payload = {
walletAccountId: form.walletAccountId,
loanApplyId: form.paymentMethod === 'BALANCE' ? undefined : form.loanApplyId,
paymentMethod: form.paymentMethod,
payeeInfo: {
payeeName: form.payeeName,
payeeAccountNo: form.payeeAccountNo,
payeeBankName: form.payeeBankName
},
totalAmount: totalAmountDisplay.value,
balanceAmount: form.paymentMethod === 'FINANCE' ? undefined : String(form.balanceAmount ?? ''),
financeAmount: form.paymentMethod === 'BALANCE' ? undefined : String(form.financeAmount ?? ''),
remark: form.remark,
paymentDetailBtoList: detailList
}
const res = await orderPayApi(payload)
if (res.data.code === 200) {
message.success('支付请求已提交')
open.value = false
emit('success')
}
} finally {
submitting.value = false
}
}
function handleCancel() {
open.value = false
}
function show() {
Object.assign(form, defaultForm())
payerDisplay.value = ''
payerAccountNo.value = ''
balanceInfo.value = null
quotaInfo.value = null
loanOptions.value = []
if (countdownTimer) clearInterval(countdownTimer)
smsCountdown.value = 0
open.value = true
}
defineExpose({ show })
</script>

View File

@ -0,0 +1,130 @@
<!-- src/views/payment/order-payment/PaymentRecordList.vue -->
<!-- 3.1 订单支付:列表查询字段严格按 /api/payment-management/query-payment-record
(FindPaymentRecordListQto)实际支持的入参实现(钱包账户ID/支付方式/支付状态/支付完成时间范围)
原型图要求的"机构/商户/订单编号/付款人/收款人"等查询条件及列,后端 PaymentRecordListVo
均未提供对应字段,已在缺失的后端接口.md登记为缺口,未在此臆造 -->
<template>
<div class="payment-record-list-page">
<ProTable
ref="proTableRef"
:columns="columns"
:fetch-data="loadList"
:row-selection="false"
:initial-search="{ walletAccountIdIs: undefined, paymentMethodList: [], statusList: [], startDate: undefined, endDate: undefined }"
>
<template #search="{ form }">
<a-form-item label="付款钱包账户">
<a-input-group compact style="display: flex">
<a-input :value="payerDisplayMap[form.walletAccountIdIs] || ''" placeholder="请选择付款钱包账户" readonly style="width: 200px" />
<a-button @click="openPickerFor(form)"></a-button>
<a-button v-if="form.walletAccountIdIs" @click="form.walletAccountIdIs = undefined"></a-button>
</a-input-group>
</a-form-item>
<a-form-item label="支付方式">
<a-select v-model:value="form.paymentMethodList" mode="multiple" placeholder="请选择支付方式" allow-clear style="width: 200px">
<a-select-option v-for="opt in PAYMENT_METHOD_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="支付状态">
<a-select v-model:value="form.statusList" mode="multiple" placeholder="请选择支付状态" allow-clear style="width: 220px">
<a-select-option v-for="opt in PAYMENT_STATUS_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="支付完成时间">
<a-range-picker
:value="form.startDate && form.endDate ? [form.startDate, form.endDate] : []"
value-format="YYYY-MM-DD"
@change="(vals) => { form.startDate = vals?.[0]; form.endDate = vals?.[1] }"
/>
</a-form-item>
</template>
<template #actions>
<a-button type="primary" @click="formModalRef.show()"></a-button>
</template>
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'index'">{{ index + 1 }}</template>
<template v-else-if="column.dataIndex === 'paymentMethod'">
<StatusTag :value="record.paymentMethod" :map="PAYMENT_METHOD_MAP" />
</template>
<template v-else-if="column.dataIndex === 'status'">
<StatusTag :value="record.status" :map="PAYMENT_STATUS_MAP" />
</template>
<template v-else-if="column.dataIndex === 'payeeName'">{{ record.payeeInfo?.payeeName || '-' }}</template>
<template v-else-if="column.dataIndex === 'payeeAccountNo'">{{ record.payeeInfo?.payeeAccountNo || '-' }}</template>
<template v-else-if="column.dataIndex === 'action'">
<a @click="detailModalRef.show(record)"></a>
</template>
</template>
</ProTable>
<PaymentFormModal ref="formModalRef" @success="reloadList" />
<PaymentDetailModal ref="detailModalRef" />
<WalletAccountPicker ref="pickerRef" @select="handlePickPayer" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import ProTable from '@/components/ProTable.vue'
import StatusTag from '@/components/StatusTag.vue'
import WalletAccountPicker from '@/components/WalletAccountPicker.vue'
import PaymentFormModal from './PaymentFormModal.vue'
import PaymentDetailModal from './PaymentDetailModal.vue'
import { queryPaymentRecordApi } from '@/api/payment'
import { PAYMENT_METHOD_OPTIONS, PAYMENT_METHOD_MAP, PAYMENT_STATUS_OPTIONS, PAYMENT_STATUS_MAP } from '@/constants/paymentEnums'
const proTableRef = ref(null)
const formModalRef = ref(null)
const detailModalRef = ref(null)
const pickerRef = ref(null)
const payerDisplayMap = ref({})
let activeForm = null
const columns = [
{ title: '序号', dataIndex: 'index', width: 60 },
{ title: '支付方式', dataIndex: 'paymentMethod' },
{ title: '支付总金额', dataIndex: 'totalAmount' },
{ title: '余额支付金额', dataIndex: 'balanceAmount' },
{ title: '融资支付金额', dataIndex: 'financeAmount' },
{ title: '收款方户名', dataIndex: 'payeeName' },
{ title: '收款方账号', dataIndex: 'payeeAccountNo' },
{ title: '支付状态', dataIndex: 'status' },
{ title: '凡荣e链流水号', dataIndex: 'frchainSerialNo' },
{ title: '支付完成时间', dataIndex: 'paidAt' },
{ title: '失败原因', dataIndex: 'failedReason' },
{ title: '操作', dataIndex: 'action', width: 80 }
]
function openPickerFor(form) {
activeForm = form
pickerRef.value.show()
}
function handlePickPayer(record) {
if (activeForm) {
activeForm.walletAccountIdIs = record.id
payerDisplayMap.value[record.id] = `${record.accountName}(${record.accountNo})`
}
}
async function loadList(params) {
const payload = { ...params }
if (payload.startDate) payload.paidAtStart = `${payload.startDate} 00:00:00`
if (payload.endDate) payload.paidAtEnd = `${payload.endDate} 23:59:59`
delete payload.startDate
delete payload.endDate
if (!payload.paymentMethodList?.length) delete payload.paymentMethodList
if (!payload.statusList?.length) delete payload.statusList
const res = await queryPaymentRecordApi(payload)
if (res.data.code === 200) {
return { list: res.data.data.records || [], total: res.data.data.total || 0 }
}
return { list: [], total: 0 }
}
function reloadList() {
proTableRef.value?.reload?.()
}
</script>

View File

@ -187,6 +187,26 @@
| `POST invoice-management/repayment/list` | 查询可匹配的本地回款流水,`FindRepaymentListQto`(merchantIdIs/accountNoLike/oppAccountNoLike/timeStart-End) |
| `POST invoice-management/offline-recharge-unmatched-detail` | 查询线下来账明细,**透传中台30908接口**,需 `channelNo`+交易日期范围,用于"自动匹配"高级场景,本次未在 UI 中实现对应入口 |
### 1.15 支付管理 —— 订单支付(`/api/payment/*`、`/api/payment-management/*`)
| 接口 | 说明 |
| --- | --- |
| `POST payment/query-credit-quota` | 查询授信额度,入参仅 `{accountNo}`(钱包账号字符串),响应 `data` **在 swagger 中完全未定义结构**(无 `$ref`/无 `properties`),真正的未文档化接口,前端只能做防御性字段展示 |
| `POST payment/order-pay` | 创建支付,`CreatePaymentBto`:`walletAccountId/loanApplyId(可空)/paymentMethod/payeeInfo/totalAmount/balanceAmount/financeAmount/status/remark/paymentDetailBtoList`,**无短信验证码字段** |
| `POST payment-management/query-payment-record` | 分页查询支付记录,`FindPaymentRecordListQto`,仅支持 `walletAccountIdIs/paymentMethodList/statusList/paidAtStart/paidAtEnd`,响应 `PaymentRecordListVo[]` |
| `POST payment-management/query-payment-detail` | `{id}`,响应 `PaymentRecordDetailVo`(列表字段 + `detailList: PaymentDetailVo[]`) |
| — | `PaymentMethodEnum`:`BALANCE`余额支付/`FINANCE`融资支付/`COMBINED`组合支付;`PaymentStatusEnum`:`PENDING_VERIFICATION/VERIFYING/PROCESSING/SUCCESS/FAILED/CANCELLED`;`FundSourceEnum`:`BALANCE/FINANCE` |
### 1.16 订单管理 —— 汇总订单/原始订单(`/api/summary-order/*`、`/api/original-order/*`)
| 接口 | 说明 |
| --- | --- |
| `POST summary-order/page` | 请求仅支持 `{page,pageSize,orderNo}`,响应 `SummaryOrderVo`(无关联原始订单明细列表) |
| `POST summary-order/detail` | `{id}`,响应即 `SummaryOrderVo` 本身,**不含关联原始订单明细** |
| `POST original-order/page` | 请求仅支持 `{page,pageSize,originalOrderNo}`,**不支持按 `summaryOrderId` 过滤** |
| `POST original-order/export` | 入参 `{summaryOrderId,originalOrderNo}` 均可选,响应 `data` **未定义结构**,前端沿用 `downloadStatement` 已建立的 `{fileData: base64}` 假设处理 |
| — | `SettlementStatusEnum`:`UNSCHEDULED/SETTLED_REPAID/FAILED`;`SummaryOrderVo` 含 `financeQuota/accumulatedLoanAmount` 等字段但**无剩余额度字段**,前端本地计算 `剩余额度 = financeQuota - accumulatedLoanAmount` |
---
## Part 2:待后端确认 / 建议补充事项
@ -313,3 +333,41 @@
43. **`/api/invoice/page`(透传中台31909查询)与 `/api/invoice-management/invoice/list`(本地查询)为
功能重叠的两组平行接口**:前者需 `channelNo` 且字段命名与本地 `InvoiceVo` 不同,本项目统一采用
后者用于列表展示,前者未使用,建议后端确认二者分工或说明是否为废弃接口。
44. **`payment/query-credit-quota` 响应 `data` 字段完全未定义结构**:swagger 中该字段无 `$ref`、无
`properties`,前端在支付表单中改为原样展示返回的 JSON 供操作人员肉眼判断,无法做强类型的"剩余
额度"校验与展示,若融资金额超出实际额度,只能依赖后端 `order-pay` 接口调用失败后的错误提示兜底,
建议后端补充该接口的响应字段定义。
45. **`order-pay``CreatePaymentBto` 无短信验证码字段**:docx 描述的"发起支付前需短信验证"流程在
后端契约层面无法真正落地,前端仍按 docx 实现了"发送验证码(复用 `send-sms-code`)+ 输入验证码"的
交互与本地格式校验(非空+6位数字),但验证码本身不会被传给 `order-pay` 也不会被后端比对校验,
该环节目前形同虚设,需后端评估是否要在 `CreatePaymentBto` 中补充 `smsCode` 字段并在服务端真正
校验。
46. **`payment-management/query-payment-record`(及其 `PaymentRecordListVo`/`PaymentRecordDetailVo`)
均无机构/商户/订单编号/付款人姓名等字段**:原型图查询区与列表列包含"机构/商户/订单编号/付款人/
付款账号/服务费"等信息,后端当前的支付记录模型仅关联到 `walletAccountId`,不关联商户或订单,
前端已按接口实际返回字段精简查询表单与列表列,未臆造上述字段,建议后端评估是否需要在支付记录
落库时补充商户/订单关联字段。
47. **`CreatePaymentBto` 只有一个 `walletAccountId` 字段,无法同时指定"余额账户"与"融资账户"两个不同
账户 ID**:结合 `wallet-account/detail` 以一个账户 id 返回其所属客户整套 `mainBalance/a2Balance/
a6Balance/a7Balance` 余额的既有设计模式,前端推断该字段应始终填客户主账户(A1)的 id,融资额度
查询(`query-credit-quota`)则用该账户的 `accountNo` 反查,组合支付时的"融资部分"通过
`loanApplyId` 关联的贷款申请单据来承载客户与金额信息,不再单独指定 A3 账户 ID。此为前端基于现有
接口设计模式的**推断性假设**,未在 swagger 中被明确证实,建议后端确认该理解是否准确。
48. **`loanApplyId` 取自 `credit-apply/loan-application/list` 返回记录的数字主键 `id`**,且该模块使用的
`ApplicationStatusEnum`(`DRAFT/CONFIRMED/SUBMITTED/APPLY_FAILED`)**无"已批准/生效/放款"等更精确的
终态**,前端支付表单在选择"关联贷款申请"时只能按 `SUBMITTED`(已申请)状态筛选可选记录,无法准确
判断该笔贷款是否已实际放款、是否仍有可用余额支持本次融资支付,建议后端补充更精确的贷款状态枚举
或提供专门的"可用于支付的贷款额度"查询接口。
49. **`summary-order/page`/`original-order/page` 查询入参严重少于 docx/原型描述**:`summary-order/page`
仅支持 `orderNo`,`original-order/page` 仅支持 `originalOrderNo`,原型图要求的渠道/日期范围/订单
状态/结算状态/店铺 ID 等查询条件均无对应参数,前端已按接口实际支持字段精简查询表单,建议后端
评估是否需要补充查询参数。
50. **`original-order/export` 响应 `data` 字段未定义结构**:与 `download-statement`/`download-receipt`
等已文档化 `fileData` 字段的导出接口不同,该接口响应结构在 swagger 中完全空白,前端沿用既有
"假设为 `{fileData: base64}`"的处理模式实现下载,若实际结构不同将导致导出功能报错或下载内容
异常,建议后端补充响应字段定义。
51. **`summary-order/detail` 不返回关联原始订单明细,且 `original-order/page` 不支持按
`summaryOrderId` 过滤查询**:两个缺口叠加导致"查看汇总订单关联的原始订单明细列表"功能在当前
接口能力下无法实现,前端在汇总订单详情弹窗中用 `a-alert` 明确提示该缺口,未展示虚假数据,建议
后端为 `original-order/page` 补充 `summaryOrderId` 过滤参数,或在 `summary-order/detail` 响应中
直接内嵌关联的原始订单列表。