增加支付功能

This commit is contained in:
2026-09-15 12:46:29 +08:00
parent 943c3708b0
commit a82b300b1a
46 changed files with 11675 additions and 21 deletions
+331
View File
@@ -0,0 +1,331 @@
import request from "@/utils/request";
/*************************************************
*************** 平台端支付模块接口 ***************
* 业务:平台向租户收取平台使用费,资金直接进平台对公账户;
* 不涉及退款流程(仅保留后台手动原路退回兜底);
* 推广佣金为平台自有资金支出,与收款解耦;不跨境。
*************************************************/
/**
* 支付模块接口前缀。
* 需求约定里接口写作 /api/payment/*、/api/commission/*,
* 但本项目平台端接口统一走 /platform 前缀(vite.config.js 的 dev 代理也只转发 /platform、/backend)。
* 若后端最终把支付路由挂在 /api 下,只需要改下面这两个常量。
*/
const PAY = "/platform/payment";
const COMMISSION = "/platform/commission";
/* ================= 支付单 ================= */
/**
* 创建支付单
* TODO: 后端实现 POST /api/payment/create
* 约定入参:{ out_trade_no, tenant_id, order_type, amount(分), channel, subject, return_url, client_ip }
* 约定返回:{ pay_no, channel, pay_params } pay_params 交给前端拉起渠道支付
* @param {Object} data 下单参数
* @returns {Promise}
*/
export function createPayment(data) {
return request({
url: `${PAY}/create`,
method: "post",
data,
});
}
/**
* 查询支付单(本地库)
* TODO: 后端实现 GET /api/payment/query/:payNo
* @param {string} payNo 支付单号
* @param {Object} [params] { sync: true } 时同步渠道最新状态后再返回
* @returns {Promise}
*/
export function queryPayment(payNo, params = {}) {
return request({
url: `${PAY}/query/${payNo}`,
method: "get",
params,
});
}
/**
* 手动原路退回(兜底,不做业务层退款流程)
* TODO: 后端实现 POST /api/payment/refund
* @param {Object} data { pay_no, amount(分), reason }
* @returns {Promise}
*/
export function refundPayment(data) {
return request({
url: `${PAY}/refund`,
method: "post",
data,
});
}
/**
* 支付单列表
* TODO: 后端实现 GET /api/payment/orders
* @param {Object} params { page, pageSize, tenant_id, channel, status, start_time, end_time }
* @returns {Promise}
*/
export function getOrders(params) {
return request({
url: `${PAY}/orders`,
method: "get",
params,
});
}
/**
* 支付单详情(含状态流转记录)
* TODO: 后端实现 GET /api/payment/orders/:payNo
* @param {string} payNo 支付单号
* @returns {Promise}
*/
export function getOrderDetail(payNo) {
return request({
url: `${PAY}/orders/${payNo}`,
method: "get",
});
}
/* ================= 渠道配置 ================= */
/**
* 渠道配置列表(首页概览用)
* TODO: 后端实现 GET /api/payment/channels
* @returns {Promise}
*/
export function getChannelList() {
return request({
url: `${PAY}/channels`,
method: "get",
});
}
/**
* 获取单个渠道配置
* TODO: 后端实现 GET /api/payment/channels/:channel
* 注意:敏感字段(密钥/私钥/Secret)后端只返回掩码,前端提交时掩码值应原样回传表示“不修改”
* @param {string} channel 渠道标识 wechat / alipay / unionpay / cloudpay / paypal
* @returns {Promise}
*/
export function getChannelConfig(channel) {
return request({
url: `${PAY}/channels/${channel}`,
method: "get",
});
}
/**
* 保存渠道配置
* TODO: 后端实现 POST /api/payment/channels/:channel
* @param {string} channel 渠道标识
* @param {Object} data 渠道参数
* @returns {Promise}
*/
export function saveChannelConfig(channel, data) {
return request({
url: `${PAY}/channels/${channel}`,
method: "post",
data,
});
}
/**
* 测试渠道连通性(校验参数 + 渠道鉴权)
* TODO: 后端实现 POST /api/payment/channels/:channel/test
* @param {string} channel 渠道标识
* @param {Object} [data] 未保存的参数可一并提交
* @returns {Promise}
*/
export function testChannelConnection(channel, data) {
return request({
url: `${PAY}/channels/${channel}/test`,
method: "post",
data,
});
}
/**
* 启用 / 停用渠道
* TODO: 后端实现 POST /api/payment/channels/:channel/status
* @param {string} channel 渠道标识
* @param {boolean} enabled 是否启用
* @returns {Promise}
*/
export function toggleChannel(channel, enabled) {
return request({
url: `${PAY}/channels/${channel}/status`,
method: "post",
data: { enabled },
});
}
/**
* 上传渠道证书(微信 apiclient_cert.pem / apiclient_key.pem、银联证书等)
* TODO: 后端实现 POST /api/payment/channels/:channel/certificate
* @param {string} channel 渠道标识
* @param {File} file 证书文件
* @param {string} [certType] 证书类型 cert / key
* @returns {Promise}
*/
export function uploadChannelCert(channel, file, certType = "cert") {
const formData = new FormData();
formData.append("file", file);
formData.append("cert_type", certType);
return request({
url: `${PAY}/channels/${channel}/certificate`,
method: "post",
data: formData,
timeout: 0,
});
}
/* ================= 推广佣金 ================= */
/**
* 佣金台账列表
* TODO: 后端实现 GET /api/commission/list
* @param {Object} params { page, pageSize, promoter, status, start_time, end_time }
* @returns {Promise}
*/
export function getCommissionList(params) {
return request({
url: `${COMMISSION}/list`,
method: "get",
params,
});
}
/**
* 佣金统计(应付总额 / 已付总额 / 待处理笔数)
* TODO: 后端实现 GET /api/commission/stats
* @param {Object} [params] 统计维度(时间范围等)
* @returns {Promise}
*/
export function getCommissionStats(params) {
return request({
url: `${COMMISSION}/stats`,
method: "get",
params,
});
}
/**
* 标记佣金已发放
* TODO: 后端实现 POST /api/commission/:id/pay
* @param {number|string} id 佣金单 id
* @param {Object} [data] { pay_method, remark }
* @returns {Promise}
*/
export function markCommissionPaid(id, data = {}) {
return request({
url: `${COMMISSION}/markPaid/${id}`,
method: "post",
data,
});
}
/**
* 佣金规则列表
* TODO: 后端实现 GET /api/commission/rules
* @param {Object} [params] { page, pageSize, keyword }
* @returns {Promise}
*/
export function getCommissionRules(params) {
return request({
url: `${COMMISSION}/rules`,
method: "get",
params,
});
}
/**
* 新增佣金规则
* TODO: 后端实现 POST /api/commission/rules
* @param {Object} data 规则数据
* @returns {Promise}
*/
export function saveCommissionRule(data) {
return request({
url: `${COMMISSION}/rules`,
method: "post",
data,
});
}
/**
* 更新佣金规则
* TODO: 后端实现 POST /api/commission/rules/:id
* @param {number|string} id 规则 id
* @param {Object} data 规则数据
* @returns {Promise}
*/
export function updateCommissionRule(id, data) {
return request({
url: `${COMMISSION}/rules/${id}`,
method: "post",
data,
});
}
/**
* 删除佣金规则
* TODO: 后端实现 DELETE /api/commission/rules/:id
* @param {number|string} id 规则 id
* @returns {Promise}
*/
export function deleteCommissionRule(id) {
return request({
url: `${COMMISSION}/rules/${id}`,
method: "delete",
});
}
/**
* 启用 / 停用佣金规则
* TODO: 后端实现 POST /api/commission/rules/:id/status
* @param {number|string} id 规则 id
* @param {number} status 1 启用 / 0 停用
* @returns {Promise}
*/
export function toggleCommissionRule(id, status) {
return request({
url: `${COMMISSION}/rules/${id}/status`,
method: "post",
data: { status },
});
}
/* ================= 对账 ================= */
/**
* 对账差异列表
* TODO: 后端实现 GET /api/payment/reconcile
* @param {Object} params { page, pageSize, channel, reconcile_date, diff_type, handle_status }
* @returns {Promise}
*/
export function getReconcileList(params) {
return request({
url: `${PAY}/reconcile`,
method: "get",
params,
});
}
/**
* 标记对账差异已处理
* TODO: 后端实现 POST /api/payment/reconcile/:id/handle
* @param {number|string} id 差异记录 id
* @param {Object} [data] { handle_type, remark }
* @returns {Promise}
*/
export function markReconcileHandled(id, data = {}) {
return request({
url: `${PAY}/reconcile/handle/${id}`,
method: "post",
data,
});
}
@@ -0,0 +1,341 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>支付宝配置</h2>
<p class="sub-title">平台使用费收款渠道,支持电脑网站支付与手机网站支付(RSA2 签名)。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
</div>
<el-divider></el-divider>
<!-- 配置步骤说明 -->
<el-card shadow="never" class="guide-card">
<template #header>
<span class="card-title">配置步骤</span>
</template>
<ol class="step-list">
<li>
<b>去哪拿参数:</b>登录支付宝开放平台
<a href="https://open.alipay.com" target="_blank" rel="noopener">open.alipay.com</a>
→ 「控制台 → 网页/移动应用」取<b>APPID</b>;「开发设置 → 接口加签方式」选择<b>公钥模式</b>,
使用工具生成应用私钥后上传应用公钥,页面会回显<b>支付宝公钥</b>;
再到「产品绑定」开通<b>电脑网站支付</b>与<b>手机网站支付</b>。
</li>
<li>
<b>回调怎么配:</b>支付宝异步通知地址(notify_url)随下单请求携带,<b>无需在开放平台登记</b>;
只需保证本页<b>回调地址</b>公网可访问、处理成功返回字符串 <code>success</code>(全小写)。
若需登录态同步跳回,由下单时的 return_url 决定。
</li>
<li>
<b>怎么验证:</b>保存后点「测试连接」,系统会调用 <code>alipay.trade.query</code> 校验 APPID 与签名配置;
通过后启用渠道,建议先用 1 分钱真实下单验证异步通知能正常更新订单状态。
</li>
</ol>
<el-alert
type="info"
:closable="false"
show-icon
title="应用私钥用于本地签名,必须保密;支付宝公钥用于验签,属公开信息,可明文核对。"
/>
</el-card>
<!-- 参数配置 -->
<el-card shadow="never">
<template #header>
<span class="card-title">参数配置</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="160px" class="config-form">
<el-form-item label="APPID" prop="appid">
<el-input v-model="form.appid" placeholder="开放平台应用 APPID,如 2021003123456789" clearable />
</el-form-item>
<el-form-item label="应用私钥" prop="app_private_key">
<div class="secret-row">
<el-input
v-if="editing.app_private_key"
v-model="form.app_private_key"
type="password"
show-password
:rows="4"
placeholder="RSA2 应用私钥(PKCS8,单行或 -----BEGIN 形式)"
/>
<el-input v-else :model-value="maskSecret(form.app_private_key)" disabled placeholder="未配置" />
<el-button link type="primary" @click="toggleSecret('app_private_key')">
{{ editing.app_private_key ? '取消' : '修改' }}
</el-button>
</div>
<div class="field-tip">回显为掩码表示已保存;点击「修改」后留空保存,表示保持原私钥不变。</div>
</el-form-item>
<el-form-item label="支付宝公钥" prop="alipay_public_key">
<el-input
v-model="form.alipay_public_key"
type="textarea"
:rows="4"
placeholder="支付宝公钥,用于校验渠道回调签名"
/>
<div class="field-tip">该值为开放平台回显的公钥,非密钥,可明文保存与核对。</div>
</el-form-item>
<el-form-item label="签名类型">
<el-radio-group v-model="form.sign_type">
<el-radio value="RSA2">RSA2(推荐)</el-radio>
<el-radio value="RSA">RSA</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="回调地址">
<div class="readonly-row">
<el-input :model-value="CALLBACK_URL" readonly />
<el-button @click="copyText(CALLBACK_URL)">复制</el-button>
</div>
<div class="field-tip">后端路由约定:POST /api/payment/callback/alipay,需公网 HTTPS 且返回 success。</div>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<span class="field-tip inline">停用后收银台不再展示支付宝。</span>
</el-form-item>
</el-form>
<div class="form-foot">
<el-button type="primary" :loading="saving" @click="handleSave">保存配置</el-button>
<el-button :loading="testing" @click="handleTest">测试连接</el-button>
<el-button v-if="form.enabled" type="danger" plain @click="handleToggle(false)">停用渠道</el-button>
<el-button v-else type="success" plain @click="handleToggle(true)">启用渠道</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel } from '@/api/payment'
const CHANNEL = 'alipay'
/** 对外回调地址:后端约定 POST /api/payment/callback/:channel,生产环境请替换为正式对外域名 */
const CALLBACK_URL = `${window.location.origin}/api/payment/callback/${CHANNEL}`
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
const form = reactive({
appid: '',
app_private_key: '',
alipay_public_key: '',
sign_type: 'RSA2',
enabled: true
})
const rules: FormRules = {
appid: [{ required: true, message: '请输入 APPID', trigger: 'blur' }],
app_private_key: [{ required: true, message: '请输入应用私钥', trigger: 'blur' }],
alipay_public_key: [{ required: true, message: '请输入支付宝公钥', trigger: 'blur' }]
}
/* ---------------- 敏感字段掩码编辑 ---------------- */
const editing = reactive<Record<string, boolean>>({ app_private_key: false })
const secretBackup = reactive<Record<string, string>>({ app_private_key: '' })
/** 掩码规则:仅保留末 4 位 */
function maskSecret(value: string) {
const s = String(value || '')
if (!s) return ''
if (s.length <= 4) return '******'
return `******${s.slice(-4)}`
}
function toggleSecret(key: 'app_private_key') {
if (editing[key]) {
form[key] = secretBackup[key]
editing[key] = false
return
}
secretBackup[key] = form[key]
form[key] = ''
editing[key] = true
}
/* ---------------- 加载 / 保存 / 测试 ---------------- */
async function loadConfig() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
Object.assign(form, {
appid: '2021003123456789',
app_private_key: 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC9f2c7b1a4d6e8f0a',
alipay_public_key:
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt9f2c7b1a4d6e8f0a3c5b7d9e1f4a6c8',
sign_type: 'RSA2',
enabled: true
})
}
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求(app_private_key 为掩码时表示不修改)
// await saveChannelConfig(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 400))
ElMessage.success('支付宝配置已保存')
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:APPID 与 RSA2 签名校验通过')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请检查 APPID、应用私钥与支付宝公钥')
} finally {
testing.value = false
}
}
async function handleToggle(enable: boolean) {
try {
await ElMessageBox.confirm(
`确定要${enable ? '启用' : '停用'}支付宝吗?${enable ? '启用后租户收银台将展示支付宝。' : '停用后租户将无法使用支付宝。'}`,
'提示',
{ type: 'warning' }
)
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`支付宝已${enable ? '启用' : '停用'}`)
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择复制')
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
}
.card-title {
font-size: 15px;
font-weight: 600;
}
.guide-card {
margin-bottom: 20px;
.step-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
li {
margin-bottom: 8px;
}
b {
color: #303133;
}
code {
padding: 1px 6px;
background: #f5f7fa;
border-radius: 4px;
color: #f56c6c;
font-family: Consolas, monospace;
}
a {
color: #409eff;
}
}
:deep(.el-alert) {
margin-top: 12px;
}
}
.config-form {
max-width: 780px;
.field-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: #909399;
&.inline {
margin: 0 0 0 12px;
}
}
.secret-row,
.readonly-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
}
.form-foot {
display: flex;
gap: 8px;
padding-left: 160px;
margin-top: 8px;
}
</style>
@@ -0,0 +1,296 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>云闪付配置</h2>
<p class="sub-title">云闪付走银联通道收款,复用银联商户参数,本页只控制收银台展示与开关。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
</div>
<el-divider></el-divider>
<!-- 配置步骤说明 -->
<el-card shadow="never" class="guide-card">
<template #header>
<span class="card-title">配置步骤</span>
</template>
<ol class="step-list">
<li>
<b>去哪拿参数:</b>云闪付<b>不单独申请商户号</b>,直接复用「银联配置」中的商户号(mer_id)与收单机构代码;
收银台展示的云闪付标识由银联/收单机构下发的物料决定。
</li>
<li>
<b>回调怎么配:</b>云闪付交易通知由<b>银联通道统一下发</b>,默认复用银联回调地址;
若收单机构要求独立地址,需在入网时报备本页展示的地址(后台通知返回 ok)。
</li>
<li>
<b>怎么验证:</b>确认「银联配置」已保存并通过测试连接后,开启下方「在收银台展示云闪付标识」并启用渠道,
用银联卡在云闪付 App 内扫码/被扫各验证一笔。
</li>
</ol>
<el-alert
type="info"
:closable="false"
show-icon
:title="`银联参数继承状态:${unionpayReady ? '已配置' : '未配置,请先完成银联配置'}`"
/>
</el-card>
<!-- 参数配置 -->
<el-card shadow="never">
<template #header>
<span class="card-title">参数配置</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="180px" class="config-form">
<el-form-item label="继承商户号">
<el-input :model-value="form.mer_id" readonly placeholder="读取自银联配置" />
<div class="field-tip">只读字段,实际取「银联配置」中的商户号,如需修改请前往银联配置页。</div>
</el-form-item>
<el-form-item label="继承收单机构">
<el-input :model-value="form.acq_ins_code" readonly placeholder="读取自银联配置" />
</el-form-item>
<el-form-item label="在收银台展示云闪付标识">
<el-switch v-model="form.show_logo" active-text="展示" inactive-text="隐藏" />
<span class="field-tip inline">关闭后租户收银台不展示云闪付入口,但已发起的订单仍可正常回调。</span>
</el-form-item>
<el-form-item label="展示位置">
<el-checkbox-group v-model="form.logo_positions">
<el-checkbox value="pc">PC 收银台</el-checkbox>
<el-checkbox value="h5">手机 H5 收银台</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="回调地址">
<div class="readonly-row">
<el-input :model-value="CALLBACK_URL" readonly />
<el-button @click="copyText(CALLBACK_URL)">复制</el-button>
</div>
<div class="field-tip">后端路由约定:POST /api/payment/callback/cloudpay,默认与银联共用通道通知。</div>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<span class="field-tip inline">需先启用「银联」渠道,云闪付才会在收银台生效。</span>
</el-form-item>
</el-form>
<div class="form-foot">
<el-button type="primary" :loading="saving" @click="handleSave">保存配置</el-button>
<el-button :loading="testing" @click="handleTest">测试连接</el-button>
<el-button v-if="form.enabled" type="danger" plain @click="handleToggle(false)">停用渠道</el-button>
<el-button v-else type="success" plain @click="handleToggle(true)">启用渠道</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel } from '@/api/payment'
const CHANNEL = 'cloudpay'
/** 对外回调地址:后端约定 POST /api/payment/callback/:channel,生产环境请替换为正式对外域名 */
const CALLBACK_URL = `${window.location.origin}/api/payment/callback/${CHANNEL}`
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
/** 银联参数是否已配置(继承判断) */
const unionpayReady = ref(false)
const form = reactive({
mer_id: '',
acq_ins_code: '',
show_logo: true,
logo_positions: ['pc', 'h5'],
enabled: false
})
const rules: FormRules = {
logo_positions: [{ required: true, message: '请至少选择一个展示位置', trigger: 'change' }]
}
/* ---------------- 加载 / 保存 / 测试 ---------------- */
async function loadConfig() {
// TODO: 接口联通后替换为真实请求(商户号/收单机构建议由后端从银联配置继承返回)
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
// const unionpay = await getChannelConfig('unionpay')
// unionpayReady.value = !!unionpay?.data?.mer_id
Object.assign(form, {
mer_id: '898110158000000',
acq_ins_code: '0402 - 中国建设银行',
show_logo: true,
logo_positions: ['pc', 'h5'],
enabled: false
})
unionpayReady.value = false
}
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求
// await saveChannelConfig(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 400))
ElMessage.success('云闪付配置已保存')
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求(后端会校验银联参数是否可用)
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:云闪付通道可用(继承银联参数)')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请先完成银联配置')
} finally {
testing.value = false
}
}
async function handleToggle(enable: boolean) {
if (enable && !unionpayReady.value) {
ElMessage.warning('请先完成并启用「银联配置」,云闪付复用银联商户参数')
return
}
try {
await ElMessageBox.confirm(
`确定要${enable ? '启用' : '停用'}云闪付吗?`,
'提示',
{ type: 'warning' }
)
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`云闪付已${enable ? '启用' : '停用'}`)
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择复制')
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
}
.card-title {
font-size: 15px;
font-weight: 600;
}
.guide-card {
margin-bottom: 20px;
.step-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
li {
margin-bottom: 8px;
}
b {
color: #303133;
}
code {
padding: 1px 6px;
background: #f5f7fa;
border-radius: 4px;
color: #f56c6c;
font-family: Consolas, monospace;
}
}
:deep(.el-alert) {
margin-top: 12px;
}
}
.config-form {
max-width: 780px;
.field-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: #909399;
&.inline {
margin: 0 0 0 12px;
}
}
.readonly-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
}
.form-foot {
display: flex;
gap: 8px;
padding-left: 180px;
margin-top: 8px;
}
</style>
@@ -0,0 +1,334 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>PayPal 配置</h2>
<p class="sub-title">面向港澳台及海外租户的收款渠道,当前业务不跨境,默认保持停用。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
</div>
<el-divider></el-divider>
<!-- 配置步骤说明 -->
<el-card shadow="never" class="guide-card">
<template #header>
<span class="card-title">配置步骤</span>
</template>
<ol class="step-list">
<li>
<b>去哪拿参数:</b>登录 PayPal 开发者后台
<a href="https://developer.paypal.com" target="_blank" rel="noopener">developer.paypal.com</a>
→ 「Apps &amp; Credentials」创建 REST App,分别取<b>沙箱</b>与<b>正式</b>环境的 Client ID / Client Secret;
<b>Webhook ID</b> 在 App 详情的 Webhooks 列表中获取。
</li>
<li>
<b>回调怎么配:</b>在 App 详情 → Webhooks 中新增本页<b>回调地址</b>,勾选事件
<code>PAYMENT.CAPTURE.COMPLETED</code>、<code>PAYMENT.CAPTURE.DENIED</code>、<code>PAYMENT.CAPTURE.REFUNDED</code>,
保存后回到列表复制 Webhook ID 填入本页;后端按 PayPal 签名头校验通知来源。
</li>
<li>
<b>怎么验证:</b>保存后点「测试连接」,系统会调用 OAuth2 Token 接口校验 Client ID / Secret;
通过后用沙箱买家账号下单,确认 Webhook 能更新订单状态,再切换正式凭证并启用渠道。
</li>
</ol>
<el-alert
type="warning"
:closable="false"
show-icon
title="Client Secret 只在创建时完整展示一次,请在此处保存;沙箱与正式凭证不可混用。"
/>
</el-card>
<!-- 参数配置 -->
<el-card shadow="never">
<template #header>
<span class="card-title">参数配置</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="160px" class="config-form">
<el-form-item label="Client ID" prop="client_id">
<el-input v-model="form.client_id" placeholder="PayPal REST App 的 Client ID" clearable />
</el-form-item>
<el-form-item label="Client Secret" prop="client_secret">
<div class="secret-row">
<el-input
v-if="editing.client_secret"
v-model="form.client_secret"
type="password"
show-password
placeholder="PayPal REST App 的 Client Secret"
clearable
/>
<el-input v-else :model-value="maskSecret(form.client_secret)" disabled placeholder="未配置" />
<el-button link type="primary" @click="toggleSecret('client_secret')">
{{ editing.client_secret ? '取消' : '修改' }}
</el-button>
</div>
<div class="field-tip">回显为掩码表示已保存;点击「修改」后留空保存,表示保持原 Secret 不变。</div>
</el-form-item>
<el-form-item label="Webhook ID" prop="webhook_id">
<el-input v-model="form.webhook_id" placeholder="PayPal Webhook 列表中的 ID" clearable />
</el-form-item>
<el-form-item label="运行环境">
<el-radio-group v-model="form.env">
<el-radio value="sandbox">沙箱</el-radio>
<el-radio value="live">正式</el-radio>
</el-radio-group>
<div class="field-tip">切到正式环境前,请确认 Client ID / Secret / Webhook ID 均为正式凭证。</div>
</el-form-item>
<el-form-item label="回调地址">
<div class="readonly-row">
<el-input :model-value="CALLBACK_URL" readonly />
<el-button @click="copyText(CALLBACK_URL)">复制</el-button>
</div>
<div class="field-tip">后端路由约定:POST /api/payment/callback/paypal,需公网 HTTPS 且可被 PayPal 访问。</div>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<span class="field-tip inline">仅当有海外租户时才建议启用。</span>
</el-form-item>
</el-form>
<div class="form-foot">
<el-button type="primary" :loading="saving" @click="handleSave">保存配置</el-button>
<el-button :loading="testing" @click="handleTest">测试连接</el-button>
<el-button v-if="form.enabled" type="danger" plain @click="handleToggle(false)">停用渠道</el-button>
<el-button v-else type="success" plain @click="handleToggle(true)">启用渠道</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel } from '@/api/payment'
const CHANNEL = 'paypal'
/** 对外回调地址:后端约定 POST /api/payment/callback/:channel,生产环境请替换为正式对外域名 */
const CALLBACK_URL = `${window.location.origin}/api/payment/callback/${CHANNEL}`
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
const form = reactive({
client_id: '',
client_secret: '',
webhook_id: '',
env: 'sandbox',
enabled: false
})
const rules: FormRules = {
client_id: [{ required: true, message: '请输入 Client ID', trigger: 'blur' }],
client_secret: [{ required: true, message: '请输入 Client Secret', trigger: 'blur' }],
webhook_id: [{ required: true, message: '请输入 Webhook ID', trigger: 'blur' }]
}
/* ---------------- 敏感字段掩码编辑 ---------------- */
const editing = reactive<Record<string, boolean>>({ client_secret: false })
const secretBackup = reactive<Record<string, string>>({ client_secret: '' })
/** 掩码规则:仅保留末 4 位 */
function maskSecret(value: string) {
const s = String(value || '')
if (!s) return ''
if (s.length <= 4) return '******'
return `******${s.slice(-4)}`
}
function toggleSecret(key: 'client_secret') {
if (editing[key]) {
form[key] = secretBackup[key]
editing[key] = false
return
}
secretBackup[key] = form[key]
form[key] = ''
editing[key] = true
}
/* ---------------- 加载 / 保存 / 测试 ---------------- */
async function loadConfig() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
Object.assign(form, {
client_id: '',
client_secret: '',
webhook_id: '',
env: 'sandbox',
enabled: false
})
}
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求(client_secret 为掩码时表示不修改)
// await saveChannelConfig(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 400))
ElMessage.success('PayPal 配置已保存')
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:OAuth2 凭证校验通过')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请检查 Client ID / Secret 与运行环境')
} finally {
testing.value = false
}
}
async function handleToggle(enable: boolean) {
try {
await ElMessageBox.confirm(
`确定要${enable ? '启用' : '停用'}PayPal 吗?${enable ? '请确认已使用正式环境凭证。' : '停用后海外租户将无法使用 PayPal 付款。'}`,
'提示',
{ type: 'warning' }
)
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`PayPal 已${enable ? '启用' : '停用'}`)
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择复制')
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
}
.card-title {
font-size: 15px;
font-weight: 600;
}
.guide-card {
margin-bottom: 20px;
.step-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
li {
margin-bottom: 8px;
}
b {
color: #303133;
}
code {
padding: 1px 6px;
background: #f5f7fa;
border-radius: 4px;
color: #f56c6c;
font-family: Consolas, monospace;
}
a {
color: #409eff;
}
}
:deep(.el-alert) {
margin-top: 12px;
}
}
.config-form {
max-width: 780px;
.field-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: #909399;
&.inline {
margin: 0 0 0 12px;
}
}
.secret-row,
.readonly-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
}
.form-foot {
display: flex;
gap: 8px;
padding-left: 160px;
margin-top: 8px;
}
</style>
@@ -0,0 +1,341 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>银联配置</h2>
<p class="sub-title">银联全渠道收单(PC 网关支付 / 手机控件支付),参数由收单机构下发。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
</div>
<el-divider></el-divider>
<!-- 配置步骤说明 -->
<el-card shadow="never" class="guide-card">
<template #header>
<span class="card-title">配置步骤</span>
</template>
<ol class="step-list">
<li>
<b>去哪拿参数:</b>完成银联入网(
<a href="https://open.unionpay.com" target="_blank" rel="noopener">open.unionpay.com</a>
或对应收单机构渠道)后,由<b>收单机构下发</b>商户号(mer_id)与收单机构代码(acq_ins_code,如 0402 建设银行、0104 中国银行),
并获取<b>商户证书</b>(.pfx / .cer,含证书密码,由客户经理提供)。
</li>
<li>
<b>回调怎么配:</b>银联的<b>前台/后台通知地址需在入网时报备</b>给收单机构,报备通过后把本页回调地址提供给客户经理;
未报备的地址会被银联侧直接拒绝。回调处理成功返回 <code>ok</code>。
</li>
<li>
<b>怎么验证:</b>保存后点「测试连接」,系统会调用银联交易状态查询接口校验证书与商户号配置;
通过后启用渠道,建议用 1 分钱真实下单验证前台跳转与后台通知。
</li>
</ol>
<el-alert
type="warning"
:closable="false"
show-icon
title="银联证书文件与证书密码属高危凭证,仅在平台端保存;证书有有效期,到期前需重新上传,否则会收单失败。"
/>
</el-card>
<!-- 参数配置 -->
<el-card shadow="never">
<template #header>
<span class="card-title">参数配置</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="160px" class="config-form">
<el-form-item label="商户号 mer_id" prop="mer_id">
<el-input v-model="form.mer_id" placeholder="银联下发的商户号,如 898110158000000" clearable />
</el-form-item>
<el-form-item label="收单机构" prop="acq_ins_code">
<el-select v-model="form.acq_ins_code" placeholder="请选择收单机构" style="width: 100%">
<el-option
v-for="item in acqOptions"
:key="item.code"
:label="`${item.code} - ${item.name}`"
:value="item.code"
/>
</el-select>
<div class="field-tip">收单机构代码必须与入网时下发的一致,填错会导致交易被拒。</div>
</el-form-item>
<el-form-item label="商户证书" prop="cert_file">
<div class="upload-col">
<div class="upload-row">
<el-upload
:auto-upload="false"
:show-file-list="false"
accept=".pfx,.p12,.cer"
:on-change="(file) => handleCertChange(file)"
>
<el-button>选择银联商户证书</el-button>
</el-upload>
<span class="file-name">{{ certFile || '未上传' }}</span>
</div>
<div class="field-tip">支持 .pfx / .p12 / .cer,证书仅在保存时上传后端,由后端加密保存。</div>
</div>
</el-form-item>
<el-form-item label="回调地址">
<div class="readonly-row">
<el-input :model-value="CALLBACK_URL" readonly />
<el-button @click="copyText(CALLBACK_URL)">复制</el-button>
</div>
<div class="field-tip">
后端路由约定:POST /api/payment/callback/unionpay,需在银联入网时报备该地址。
</div>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<span class="field-tip inline">停用后收银台不再展示银联。</span>
</el-form-item>
</el-form>
<div class="form-foot">
<el-button type="primary" :loading="saving" @click="handleSave">保存配置</el-button>
<el-button :loading="testing" @click="handleTest">测试连接</el-button>
<el-button v-if="form.enabled" type="danger" plain @click="handleToggle(false)">停用渠道</el-button>
<el-button v-else type="success" plain @click="handleToggle(true)">启用渠道</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel, uploadChannelCert } from '@/api/payment'
const CHANNEL = 'unionpay'
/** 对外回调地址:后端约定 POST /api/payment/callback/:channel,生产环境请替换为正式对外域名 */
const CALLBACK_URL = `${window.location.origin}/api/payment/callback/${CHANNEL}`
/** 常用收单机构代码(可按实际入网情况补充) */
const acqOptions = [
{ code: '0402', name: '中国建设银行' },
{ code: '0104', name: '中国银行' },
{ code: '0102', name: '中国工商银行' },
{ code: '0103', name: '中国农业银行' },
{ code: '0308', name: '招商银行' },
{ code: '0305', name: '中国民生银行' }
]
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
const certFile = ref('')
const form = reactive({
mer_id: '',
acq_ins_code: '',
cert_file: '',
enabled: false
})
const rules: FormRules = {
mer_id: [{ required: true, message: '请输入银联商户号', trigger: 'blur' }],
acq_ins_code: [{ required: true, message: '请选择收单机构', trigger: 'change' }]
}
/* ---------------- 加载 / 保存 / 测试 ---------------- */
async function loadConfig() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
Object.assign(form, {
mer_id: '',
acq_ins_code: '',
cert_file: '',
enabled: false
})
certFile.value = ''
}
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求
// await saveChannelConfig(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 400))
ElMessage.success('银联配置已保存')
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:商户号、收单机构与证书校验通过')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请检查商户号、收单机构与证书')
} finally {
testing.value = false
}
}
async function handleToggle(enable: boolean) {
try {
await ElMessageBox.confirm(
`确定要${enable ? '启用' : '停用'}银联吗?${enable ? '启用前请确认回调地址已向收单机构报备。' : '停用后租户将无法使用银联支付。'}`,
'提示',
{ type: 'warning' }
)
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`银联已${enable ? '启用' : '停用'}`)
}
function handleCertChange(file: any) {
// TODO: 接口联通后替换为 uploadChannelCert(CHANNEL, file.raw, 'cert')
certFile.value = file?.name || ''
form.cert_file = certFile.value
ElMessage.success(`已选择证书:${certFile.value}`)
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择复制')
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
}
.card-title {
font-size: 15px;
font-weight: 600;
}
.guide-card {
margin-bottom: 20px;
.step-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
li {
margin-bottom: 8px;
}
b {
color: #303133;
}
code {
padding: 1px 6px;
background: #f5f7fa;
border-radius: 4px;
color: #f56c6c;
font-family: Consolas, monospace;
}
a {
color: #409eff;
}
}
:deep(.el-alert) {
margin-top: 12px;
}
}
.config-form {
max-width: 780px;
.field-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: #909399;
&.inline {
margin: 0 0 0 12px;
}
}
.readonly-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.upload-col {
width: 100%;
}
.upload-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.file-name {
font-size: 12px;
color: #909399;
}
}
.form-foot {
display: flex;
gap: 8px;
padding-left: 160px;
margin-top: 8px;
}
</style>
@@ -0,0 +1,378 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>微信支付配置</h2>
<p class="sub-title">平台使用费收款主渠道,支持 JSAPI(公众号内)、Native(PC 扫码)支付。</p>
</div>
<StatusTag :status="form.enabled ? 'enabled' : 'disabled'" dict="channel" />
</div>
<el-divider></el-divider>
<!-- 配置步骤说明 -->
<el-card shadow="never" class="guide-card">
<template #header>
<span class="card-title">配置步骤</span>
</template>
<ol class="step-list">
<li>
<b>去哪拿参数:</b>登录微信商户平台
<a href="https://pay.weixin.qq.com" target="_blank" rel="noopener">pay.weixin.qq.com</a>
→ 「账户中心 → 商户信息」取<b>商户号 mch_id</b>;「产品中心 → 开发配置」取并绑定<b>APPID</b>;
「账户中心 → API 安全」申请<b>APIv3 密钥</b>并下载<b>API 证书</b>(apiclient_cert.pem / apiclient_key.pem)。
</li>
<li>
<b>回调怎么配:</b>微信支付无需在商户平台登记回调域名,只需保证本页<b>回调地址</b>公网可访问、且处理成功时返回
<code>SUCCESS</code>。若使用 JSAPI 支付,还需在「产品中心 → 开发配置 → JSAPI 支付授权目录」中登记收银台域名。
</li>
<li>
<b>怎么验证:</b>填完参数先点「保存配置」,再点「测试连接」,系统会调用订单查询接口校验证书与 APIv3 密钥签名;
通过后可点「启用渠道」开始收款。建议先用 1 分钱真实下单走通全链路。
</li>
</ol>
<el-alert
type="warning"
:closable="false"
show-icon
title="APIv3 密钥与商户私钥属于高危凭证,仅在平台端配置,切勿下发到租户端或写入前端代码。"
/>
</el-card>
<!-- 参数配置 -->
<el-card shadow="never">
<template #header>
<span class="card-title">参数配置</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="160px" class="config-form">
<el-form-item label="商户号 mch_id" prop="mch_id">
<el-input v-model="form.mch_id" placeholder="请输入微信支付商户号,如 1620888999" clearable />
</el-form-item>
<el-form-item label="APPID" prop="appid">
<el-input v-model="form.appid" placeholder="公众号 / 小程序 / APP 的 APPID" clearable />
<div class="field-tip">需与当前商户号完成绑定,否则拉起支付会报「商户号与 AppID 不匹配」。</div>
</el-form-item>
<el-form-item label="APIv3 密钥" prop="api_v3_key">
<div class="secret-row">
<el-input
v-if="editing.api_v3_key"
v-model="form.api_v3_key"
type="password"
show-password
placeholder="请输入 32 位 APIv3 密钥"
clearable
/>
<el-input v-else :model-value="maskSecret(form.api_v3_key)" disabled placeholder="未配置" />
<el-button link type="primary" @click="toggleSecret('api_v3_key')">
{{ editing.api_v3_key ? '取消' : '修改' }}
</el-button>
</div>
<div class="field-tip">回显为掩码表示已保存;点击「修改」后留空保存,表示保持原密钥不变。</div>
</el-form-item>
<el-form-item label="商户证书">
<div class="upload-col">
<div class="upload-row">
<el-upload
:auto-upload="false"
:show-file-list="false"
accept=".pem,.p12"
:on-change="(file) => handleCertChange(file, 'cert')"
>
<el-button>选择 apiclient_cert.pem</el-button>
</el-upload>
<span class="file-name">{{ certFiles.cert || '未上传' }}</span>
</div>
<div class="upload-row">
<el-upload
:auto-upload="false"
:show-file-list="false"
accept=".pem,.p12"
:on-change="(file) => handleCertChange(file, 'key')"
>
<el-button>选择 apiclient_key.pem</el-button>
</el-upload>
<span class="file-name">{{ certFiles.key || '未上传' }}</span>
</div>
<div class="field-tip">证书仅在保存时上传到后端,由后端解密保存,前端不做本地持久化。</div>
</div>
</el-form-item>
<el-form-item label="回调地址">
<div class="readonly-row">
<el-input :model-value="CALLBACK_URL" readonly />
<el-button @click="copyText(CALLBACK_URL)">复制</el-button>
</div>
<div class="field-tip">后端路由约定:POST /api/payment/callback/wechat,需公网 HTTPS 且直接返回 SUCCESS。</div>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
<span class="field-tip inline">停用后收银台不再展示微信支付。</span>
</el-form-item>
</el-form>
<div class="form-foot">
<el-button type="primary" :loading="saving" @click="handleSave">保存配置</el-button>
<el-button :loading="testing" @click="handleTest">测试连接</el-button>
<el-button v-if="form.enabled" type="danger" plain @click="handleToggle(false)">停用渠道</el-button>
<el-button v-else type="success" plain @click="handleToggle(true)">启用渠道</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getChannelConfig, saveChannelConfig, testChannelConnection, toggleChannel, uploadChannelCert } from '@/api/payment'
const CHANNEL = 'wechat'
/** 对外回调地址:后端约定 POST /api/payment/callback/:channel,生产环境请替换为正式对外域名 */
const CALLBACK_URL = `${window.location.origin}/api/payment/callback/${CHANNEL}`
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
const form = reactive({
mch_id: '',
appid: '',
api_v3_key: '',
enabled: true
})
const certFiles = reactive<{ cert: string; key: string }>({ cert: '', key: '' })
const rules: FormRules = {
mch_id: [{ required: true, message: '请输入商户号', trigger: 'blur' }],
appid: [{ required: true, message: '请输入 APPID', trigger: 'blur' }],
api_v3_key: [{ required: true, message: '请输入 APIv3 密钥', trigger: 'blur' }]
}
/* ---------------- 敏感字段掩码编辑 ---------------- */
const editing = reactive<Record<string, boolean>>({ api_v3_key: false })
const secretBackup = reactive<Record<string, string>>({ api_v3_key: '' })
/** 掩码规则:仅保留末 4 位 */
function maskSecret(value: string) {
const s = String(value || '')
if (!s) return ''
if (s.length <= 4) return '******'
return `******${s.slice(-4)}`
}
function toggleSecret(key: 'api_v3_key') {
if (editing[key]) {
form[key] = secretBackup[key]
editing[key] = false
return
}
secretBackup[key] = form[key]
form[key] = ''
editing[key] = true
}
/* ---------------- 加载 / 保存 / 测试 ---------------- */
async function loadConfig() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelConfig(CHANNEL)
// if (res.success && res.data) Object.assign(form, res.data, { enabled: !!res.data.enabled })
Object.assign(form, {
mch_id: '1620888999',
appid: 'wx9a2c7b1a4d6e8f0a',
api_v3_key: 'WXv3Key9f2c7b1a4d6e8f0a3c5b7d9e',
enabled: true
})
certFiles.cert = 'apiclient_cert.pem'
certFiles.key = 'apiclient_key.pem'
}
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
// TODO: 接口联通后替换为真实请求(api_v3_key 为掩码时表示不修改)
// await saveChannelConfig(CHANNEL, { ...form, cert_files: certFiles })
await new Promise((resolve) => setTimeout(resolve, 400))
ElMessage.success('微信支付配置已保存')
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await testChannelConnection(CHANNEL, { ...form })
await new Promise((resolve) => setTimeout(resolve, 600))
ElMessage.success('连接成功:商户证书与 APIv3 密钥校验通过')
} catch (error: any) {
ElMessage.error(error?.message || '连接失败,请检查商户号、密钥与证书')
} finally {
testing.value = false
}
}
async function handleToggle(enable: boolean) {
try {
await ElMessageBox.confirm(
`确定要${enable ? '启用' : '停用'}微信支付吗?${enable ? '启用后租户收银台将展示微信支付。' : '停用后租户将无法使用微信支付。'}`,
'提示',
{ type: 'warning' }
)
} catch {
return
}
// TODO: 接口联通后替换为 toggleChannel(CHANNEL, enable)
form.enabled = enable
ElMessage.success(`微信支付已${enable ? '启用' : '停用'}`)
}
function handleCertChange(file: any, certType: 'cert' | 'key') {
// TODO: 接口联通后替换为 uploadChannelCert(CHANNEL, file.raw, certType)
certFiles[certType] = file?.name || ''
ElMessage.success(`已选择证书:${file?.name || ''}`)
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择复制')
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
}
.card-title {
font-size: 15px;
font-weight: 600;
}
.guide-card {
margin-bottom: 20px;
.step-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
li {
margin-bottom: 8px;
}
b {
color: #303133;
}
code {
padding: 1px 6px;
background: #f5f7fa;
border-radius: 4px;
color: #f56c6c;
font-family: Consolas, monospace;
}
a {
color: #409eff;
}
}
:deep(.el-alert) {
margin-top: 12px;
}
}
.config-form {
max-width: 780px;
.field-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: #909399;
&.inline {
margin: 0 0 0 12px;
}
}
.secret-row,
.readonly-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.upload-col {
width: 100%;
}
.upload-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.file-name {
font-size: 12px;
color: #909399;
}
}
.form-foot {
display: flex;
gap: 8px;
padding-left: 160px;
margin-top: 8px;
}
</style>
@@ -0,0 +1,421 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>推广佣金台账</h2>
<p class="sub-title">
推广佣金为平台自有资金支出,与租户收款解耦;佣金按规则在业务订单支付成功后生成,独立发放。
</p>
</div>
<div class="header-actions">
<el-button @click="goRules">
<el-icon><Setting /></el-icon>
佣金规则
</el-button>
<el-button :loading="loading" @click="refresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 统计卡片 -->
<div class="stat-grid">
<el-card shadow="never" class="stat-card">
<div class="stat-label">应付总额</div>
<div class="stat-value warning">¥{{ fenToYuan(stats.payable_amount) }}</div>
<div class="stat-tip">已生成佣金、尚未发放的金额</div>
</el-card>
<el-card shadow="never" class="stat-card">
<div class="stat-label">已付总额</div>
<div class="stat-value success">¥{{ fenToYuan(stats.paid_amount) }}</div>
<div class="stat-tip">已完成发放登记的金额</div>
</el-card>
<el-card shadow="never" class="stat-card">
<div class="stat-label">待处理笔数</div>
<div class="stat-value danger">{{ stats.pending_count }} 笔</div>
<div class="stat-tip">含应付与税务处理中的佣金单</div>
</el-card>
</div>
<!-- 筛选区 -->
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="推广方">
<el-input
v-model="filters.promoter"
placeholder="推广方名称 / 编号"
clearable
style="width: 220px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部状态" clearable style="width: 160px">
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="下单时间">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
unlink-panels
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 台账列表 -->
<el-card shadow="never">
<el-table :data="tableData" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="commission_no" label="佣金单号" min-width="180" show-overflow-tooltip />
<el-table-column prop="out_trade_no" label="关联订单" min-width="180">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="goOrder(row)">{{ row.out_trade_no }}</el-button>
</template>
</el-table-column>
<el-table-column prop="promoter_name" label="推广方" min-width="180" show-overflow-tooltip />
<el-table-column label="佣金金额" width="130" align="right">
<template #default="{ row }">
<span class="amount">¥{{ fenToYuan(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="130" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="commission" />
</template>
</el-table-column>
<el-table-column label="发放方式" width="130" align="center">
<template #default="{ row }">{{ payMethodLabel(row.pay_method) }}</template>
</el-table-column>
<el-table-column prop="create_time" label="生成时间" width="180" align="center" />
<el-table-column prop="paid_at" label="发放时间" width="180" align="center">
<template #default="{ row }">{{ row.paid_at || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.status === 'payable'"
size="small"
type="primary"
link
@click="handleMarkPaid(row)"
>
标记已付
</el-button>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="page.current"
v-model:page-size="page.size"
:total="page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Setting } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getCommissionList, getCommissionStats, markCommissionPaid } from '@/api/payment'
const router = useRouter()
const loading = ref(false)
/** TODO: 路由 path 需与你配置的菜单 path 保持一致 */
const RULES_PATH = '/system/payment/commission/rules'
const ORDER_DETAIL_PATH = '/system/payment/orders/detail'
const filters = reactive<{
promoter: string
status: string
dateRange: [string, string] | []
}>({
promoter: '',
status: '',
dateRange: []
})
const page = reactive({ current: 1, size: 10, total: 0 })
const statusOptions = [
{ label: '应付', value: 'payable' },
{ label: '已付', value: 'settled' },
{ label: '税务处理中', value: 'tax_processing' },
{ label: '已作废', value: 'canceled' }
]
const payMethodMap: Record<string, string> = {
balance: '余额抵扣',
bank: '银行转账',
manual: '人工发放',
offset: '下期抵扣'
}
const payMethodLabel = (method: string) => payMethodMap[method] || method || '-'
/**
* 统计卡片数据:TODO 换成 getCommissionStats()
* 后端聚合自 yz_platform_commission_order:
* 应付总额 = sum(amount) where status='payable'
* 已付总额 = sum(amount) where status='settled'
* 待处理笔数 = count(*) where status in ('payable','tax_processing')
*/
const stats = reactive({
payable_amount: 1288000,
paid_amount: 4560500,
pending_count: 23
})
/* ---------------- Mock 数据(本地筛选 + 分页) ---------------- */
interface CommissionRow {
id: number
commission_no: string
out_trade_no: string
promoter_name: string
amount: number
status: string
pay_method: string
create_time: string
paid_at: string
}
const ALL_COMMISSIONS: CommissionRow[] = Array.from({ length: 24 }).map((_, index) => {
const promoters = ['云泽渠道-张明', '渠道伙伴-李雷', '服务商-杭州微光', '渠道伙伴-王芳', '服务商-深圳智联']
const statuses = ['payable', 'payable', 'settled', 'settled', 'tax_processing']
const methods = ['bank', 'balance', 'manual', 'offset']
const day = String(15 - Math.floor(index / 8)).padStart(2, '0')
const status = statuses[index % statuses.length]
return {
id: index + 1,
commission_no: `CM202609${day}${String(3000 + index)}`,
out_trade_no: `ORD202609${day}${String(2000 + index)}`,
promoter_name: promoters[index % promoters.length],
amount: [9990, 19990, 29990, 49990][index % 4],
status,
pay_method: methods[index % methods.length],
create_time: `2026-09-${day} 11:${String((index * 5) % 60).padStart(2, '0')}:00`,
paid_at: status === 'settled' ? `2026-09-${day} 16:20:00` : ''
}
})
const tableData = ref<CommissionRow[]>([])
async function fetchList() {
loading.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await getCommissionList({
// page: page.current,
// pageSize: page.size,
// promoter: filters.promoter,
// status: filters.status,
// start_time: filters.dateRange?.[0],
// end_time: filters.dateRange?.[1]
// })
// tableData.value = res.data.list || []
// page.total = res.data.total || 0
const [start, end] = filters.dateRange || []
const filtered = ALL_COMMISSIONS.filter((item) => {
if (filters.promoter && !item.promoter_name.includes(filters.promoter)) return false
if (filters.status && item.status !== filters.status) return false
const date = item.create_time.slice(0, 10)
if (start && date < start) return false
if (end && date > end) return false
return true
})
page.total = filtered.length
const from = (page.current - 1) * page.size
tableData.value = filtered.slice(from, from + page.size)
} catch (error: any) {
ElMessage.error(error?.message || '获取佣金台账失败')
} finally {
loading.value = false
}
}
function handleSearch() {
page.current = 1
fetchList()
}
function handleReset() {
filters.promoter = ''
filters.status = ''
filters.dateRange = []
page.current = 1
fetchList()
}
async function refresh() {
await fetchList()
ElMessage.success('刷新成功')
}
async function handleMarkPaid(row: CommissionRow) {
try {
const { value } = await ElMessageBox.prompt(
`确认将佣金单 ${row.commission_no}(¥${fenToYuan(row.amount)})标记为已发放?`,
'标记已付',
{
type: 'warning',
inputPlaceholder: '请输入发放方式说明 / 流水号',
inputValidator: (val: string) => (val && val.trim() ? true : '请填写发放说明'),
confirmButtonText: '确认发放'
}
)
// TODO: 接口联通后替换为 markCommissionPaid(row.id, { pay_method: row.pay_method, remark: value })
row.status = 'settled'
row.paid_at = new Date().toLocaleString('zh-CN')
ElMessage.success('已标记为已付')
await fetchList()
} catch {
// 取消
}
}
function goRules() {
router.push(RULES_PATH)
}
function goOrder(row: CommissionRow) {
router.push({ path: ORDER_DETAIL_PATH, query: { outTradeNo: row.out_trade_no } })
}
/** 金额:分 -> 元 */
function fenToYuan(fen: number | string | null | undefined): string {
const n = Number(fen ?? 0)
if (!Number.isFinite(n)) return '0.00'
return (n / 100).toFixed(2)
}
onMounted(() => {
fetchList()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
.header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
.stat-label {
font-size: 13px;
color: #909399;
}
.stat-value {
margin-top: 8px;
font-size: 24px;
font-weight: 600;
&.warning {
color: #e6a23c;
}
&.success {
color: #67c23a;
}
&.danger {
color: #f56c6c;
}
}
.stat-tip {
margin-top: 6px;
font-size: 12px;
color: #909399;
}
}
.filter-card {
margin-bottom: 20px;
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: -18px;
}
}
.amount {
font-weight: 600;
color: #f56c6c;
}
.text-muted {
color: #909399;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
</style>
@@ -0,0 +1,633 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>佣金规则</h2>
<p class="sub-title">
规则决定业务订单支付成功后生成多少佣金;佣金由平台自有资金支出,与租户收款金额解耦。
</p>
</div>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增规则
</el-button>
<el-button @click="goLedger">
<el-icon><Tickets /></el-icon>
佣金台账
</el-button>
</div>
</div>
<el-divider></el-divider>
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="规则名称">
<el-input
v-model="filters.keyword"
placeholder="规则名称关键词"
clearable
style="width: 240px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-table :data="tableData" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="rule_name" label="规则名称" min-width="200" show-overflow-tooltip />
<el-table-column label="计算方式" width="120" align="center">
<template #default="{ row }">
<el-tag size="small" :type="calcTypeTag(row.calc_type)">{{ calcTypeLabel(row.calc_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="值" min-width="240">
<template #default="{ row }">{{ valueText(row) }}</template>
</el-table-column>
<el-table-column label="适用订单类型" width="160" align="center">
<template #default="{ row }">{{ orderTypeLabel(row.order_type) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-muted">{{ row.remark || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.status === 1"
@change="(val) => handleStatusChange(row, val)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button size="small" type="danger" link @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="page.current"
v-model:page-size="page.size"
:total="page.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</el-card>
<!-- 新增 / 编辑规则 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑佣金规则' : '新增佣金规则'"
width="720px"
:close-on-click-modal="false"
@closed="resetForm"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="140px">
<el-form-item label="规则名称" prop="rule_name">
<el-input v-model="form.rule_name" placeholder="如:渠道推广-标准版年度 5%" clearable />
</el-form-item>
<el-form-item label="计算方式" prop="calc_type">
<el-radio-group v-model="form.calc_type">
<el-radio value="percent">按比例</el-radio>
<el-radio value="fixed">固定金额</el-radio>
<el-radio value="tiered">阶梯</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="form.calc_type === 'percent'"
label="比例值"
prop="value"
>
<el-input v-model="form.value" placeholder="如 5" style="width: 200px">
<template #append>%</template>
</el-input>
<span class="field-tip">按业务订单实付金额的百分比计算佣金。</span>
</el-form-item>
<el-form-item v-else-if="form.calc_type === 'fixed'" label="固定金额" prop="value">
<el-input v-model="form.value" placeholder="如 50.00" style="width: 200px">
<template #append>元</template>
</el-input>
<span class="field-tip">每笔符合条件的业务订单固定发放该金额(保存时按分存储)。</span>
</el-form-item>
<el-form-item v-else label="阶梯配置">
<div class="tier-block">
<el-table :data="form.steps" size="small" border style="width: 100%">
<el-table-column label="起始金额(元,含)" min-width="150">
<template #default="{ row }">
<el-input v-model="row.min" placeholder="0" />
</template>
</el-table-column>
<el-table-column label="结束金额(元,不含)" min-width="150">
<template #default="{ row }">
<el-input v-model="row.max" placeholder="不限可留空" />
</template>
</el-table-column>
<el-table-column label="比例(%)" min-width="120">
<template #default="{ row }">
<el-input v-model="row.rate" placeholder="如 5" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button size="small" type="danger" link @click="removeStep($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-button class="add-step" size="small" @click="addStep">
<el-icon><Plus /></el-icon>
添加阶梯
</el-button>
<div class="field-tip">阶梯按订单金额落在区间内取对应比例,区间需连续且不重叠。</div>
</div>
</el-form-item>
<el-form-item label="适用订单类型" prop="order_type">
<el-select v-model="form.order_type" placeholder="请选择" style="width: 260px">
<el-option label="全部订单" value="all" />
<el-option label="平台使用费" value="platform_usage" />
<el-option label="模块购买" value="module_shop" />
<el-option label="服务费" value="service_fee" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="停用" />
<span class="field-tip inline">停用后新生成的佣金单不再应用该规则,历史佣金单不受影响。</span>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选,如适用渠道、生效时间说明" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { Plus, Edit, Delete, Tickets } from '@element-plus/icons-vue'
// TODO: 接口联调时打开
// import {
// getCommissionRules,
// saveCommissionRule,
// updateCommissionRule,
// deleteCommissionRule,
// toggleCommissionRule
// } from '@/api/payment'
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
/** TODO: 路由 path 需与你配置的菜单 path 保持一致 */
const LEDGER_PATH = '/system/payment/commission'
const filters = reactive({ keyword: '' })
const page = reactive({ current: 1, size: 10, total: 0 })
interface TierStep {
min: string
max: string
rate: string
}
interface RuleRow {
id: number
rule_name: string
calc_type: 'percent' | 'fixed' | 'tiered'
/** percent: 百分比数值;fixed: 金额(分);tiered: 见 steps */
value: number
steps?: { min: number; max: number | null; rate: number }[]
order_type: string
status: number
remark: string
}
const ALL_RULES: RuleRow[] = [
{
id: 1,
rule_name: '渠道推广-平台使用费 5%',
calc_type: 'percent',
value: 5,
order_type: 'platform_usage',
status: 1,
remark: '默认规则,适用于全部签约渠道伙伴'
},
{
id: 2,
rule_name: '服务商-模块购买固定 50 元',
calc_type: 'fixed',
value: 5000,
order_type: 'module_shop',
status: 1,
remark: '每笔模块购买订单固定发放'
},
{
id: 3,
rule_name: '阶梯激励-年度大单',
calc_type: 'tiered',
// 阶梯配置入库为 yz_platform_commission_rule.tier_json,接口读出时反序列化为 steps 数组
value: 0,
steps: [
{ min: 0, max: 1000000, rate: 3 },
{ min: 1000000, max: 5000000, rate: 5 },
{ min: 5000000, max: null, rate: 8 }
],
order_type: 'all',
status: 1,
remark: '按订单金额区间取比例'
},
{
id: 4,
rule_name: '老规则-暂停使用',
calc_type: 'percent',
value: 3,
order_type: 'service_fee',
status: 0,
remark: '2025 年规则,已停用'
}
]
const tableData = ref<RuleRow[]>([])
const calcTypeLabel = (type: string) =>
({ percent: '按比例', fixed: '固定金额', tiered: '阶梯' } as Record<string, string>)[type] || type
const calcTypeTag = (type: string) =>
({ percent: 'primary', fixed: 'success', tiered: 'warning' } as Record<string, any>)[type] || 'info'
const orderTypeMap: Record<string, string> = {
all: '全部订单',
platform_usage: '平台使用费',
module_shop: '模块购买',
service_fee: '服务费'
}
const orderTypeLabel = (type: string) => orderTypeMap[type] || type || '-'
/** 规则值展示 */
function valueText(row: RuleRow) {
if (row.calc_type === 'percent') return `${row.value}%(按订单实付金额)`
if (row.calc_type === 'fixed') return `¥${fenToYuan(row.value)} / 笔`
const steps = row.steps || []
if (!steps.length) return '-'
return steps
.map((item) => {
const min = fenToYuan(item.min)
const max = item.max === null || item.max === undefined ? '不限' : fenToYuan(item.max)
return `${min} ~ ${max} 元:${item.rate}%`
})
.join(';')
}
/* ---------------- 列表 ---------------- */
async function fetchList() {
loading.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await getCommissionRules({ page: page.current, pageSize: page.size, keyword: filters.keyword })
// tableData.value = res.data.list || []
// page.total = res.data.total || 0
const filtered = ALL_RULES.filter((item) =>
filters.keyword ? item.rule_name.includes(filters.keyword) : true
)
page.total = filtered.length
const from = (page.current - 1) * page.size
tableData.value = filtered.slice(from, from + page.size)
} catch (error: any) {
ElMessage.error(error?.message || '获取佣金规则失败')
} finally {
loading.value = false
}
}
function handleSearch() {
page.current = 1
fetchList()
}
function handleReset() {
filters.keyword = ''
page.current = 1
fetchList()
}
/* ---------------- 新增 / 编辑 ---------------- */
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref<FormInstance>()
const form = reactive<{
id: number | null
rule_name: string
calc_type: 'percent' | 'fixed' | 'tiered'
value: string
steps: TierStep[]
order_type: string
status: number
remark: string
}>({
id: null,
rule_name: '',
calc_type: 'percent',
value: '',
steps: [{ min: '', max: '', rate: '' }],
order_type: 'platform_usage',
status: 1,
remark: ''
})
const rules: FormRules = {
rule_name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
calc_type: [{ required: true, message: '请选择计算方式', trigger: 'change' }],
order_type: [{ required: true, message: '请选择适用订单类型', trigger: 'change' }],
value: [
{
validator: (_rule, value, callback) => {
if (form.calc_type === 'tiered') {
callback()
return
}
if (value === '' || value === null || value === undefined) {
callback(new Error('请输入规则值'))
return
}
const num = Number(value)
if (!Number.isFinite(num) || num <= 0) {
callback(new Error('请输入大于 0 的数值'))
return
}
callback()
},
trigger: 'blur'
}
]
}
function handleAdd() {
isEdit.value = false
dialogVisible.value = true
}
function handleEdit(row: RuleRow) {
isEdit.value = true
form.id = row.id
form.rule_name = row.rule_name
form.calc_type = row.calc_type
form.order_type = row.order_type
form.status = row.status
form.remark = row.remark
if (row.calc_type === 'fixed') {
// 固定金额在库中以分存储,编辑时转元展示
form.value = fenToYuan(row.value)
} else if (row.calc_type === 'percent') {
form.value = String(row.value)
} else {
form.value = ''
}
form.steps = (row.steps || []).map((item) => ({
min: fenToYuan(item.min),
max: item.max === null || item.max === undefined ? '' : fenToYuan(item.max),
rate: String(item.rate)
}))
if (!form.steps.length) form.steps = [{ min: '', max: '', rate: '' }]
dialogVisible.value = true
}
function resetForm() {
form.id = null
form.rule_name = ''
form.calc_type = 'percent'
form.value = ''
form.steps = [{ min: '', max: '', rate: '' }]
form.order_type = 'platform_usage'
form.status = 1
form.remark = ''
formRef.value?.clearValidate()
}
function addStep() {
form.steps.push({ min: '', max: '', rate: '' })
}
function removeStep(index: number) {
if (form.steps.length <= 1) {
ElMessage.warning('至少保留一个阶梯')
return
}
form.steps.splice(index, 1)
}
/** 校验阶梯配置:区间必填、比例必填且大于 0 */
function validateSteps(): string | null {
for (let i = 0; i < form.steps.length; i += 1) {
const step = form.steps[i]
if (step.min === '' || step.min === null || step.min === undefined) {
return `第 ${i + 1} 档起始金额不能为空`
}
if (step.rate === '' || !(Number(step.rate) > 0)) {
return `第 ${i + 1} 档比例需大于 0`
}
if (step.max !== '' && Number(step.max) <= Number(step.min)) {
return `第 ${i + 1} 档结束金额需大于起始金额`
}
}
return null
}
async function handleSubmit() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
if (form.calc_type === 'tiered') {
const error = validateSteps()
if (error) {
ElMessage.warning(error)
return
}
}
saving.value = true
try {
const payload: Record<string, any> = {
rule_name: form.rule_name,
calc_type: form.calc_type,
order_type: form.order_type,
status: form.status,
remark: form.remark
}
if (form.calc_type === 'percent') {
payload.value = Number(form.value)
} else if (form.calc_type === 'fixed') {
// 元 -> 分
payload.value = Math.round(Number(form.value) * 100)
} else {
payload.value = 0
payload.steps = form.steps.map((item) => ({
min: Math.round(Number(item.min) * 100),
max: item.max === '' ? null : Math.round(Number(item.max) * 100),
rate: Number(item.rate)
}))
}
// TODO: 接口联通后替换为真实请求
// if (isEdit.value && form.id) await updateCommissionRule(form.id, payload)
// else await saveCommissionRule(payload)
await new Promise((resolve) => setTimeout(resolve, 400))
ElMessage.success(isEdit.value ? '规则已更新' : '规则已新增')
dialogVisible.value = false
await fetchList()
} catch (error: any) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleStatusChange(row: RuleRow, val: boolean | string | number) {
const next = val ? 1 : 0
// TODO: 接口联通后替换为 toggleCommissionRule(row.id, next)
row.status = next
ElMessage.success(`规则已${next ? '启用' : '停用'}`)
}
async function handleDelete(row: RuleRow) {
try {
await ElMessageBox.confirm(`确定删除规则「${row.rule_name}」吗?已生成的佣金单不受影响。`, '警告', {
type: 'warning'
})
} catch {
return
}
try {
// TODO: 接口联通后替换为 deleteCommissionRule(row.id)
ElMessage.success('删除成功')
await fetchList()
} catch (error: any) {
ElMessage.error(error?.message || '删除失败')
}
}
function goLedger() {
router.push(LEDGER_PATH)
}
/** 金额:分 -> 元 */
function fenToYuan(fen: number | string | null | undefined): string {
const n = Number(fen ?? 0)
if (!Number.isFinite(n)) return '0.00'
return (n / 100).toFixed(2)
}
onMounted(() => {
fetchList()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
.header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
}
.filter-card {
margin-bottom: 20px;
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: -18px;
}
}
.text-muted {
color: #909399;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.field-tip {
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: #909399;
&.inline {
margin: 0 0 0 12px;
}
}
.tier-block {
width: 100%;
.add-step {
margin-top: 8px;
}
}
</style>
@@ -0,0 +1,161 @@
<template>
<el-card class="channel-card" shadow="hover" :body-style="{ padding: '18px 20px' }">
<div class="card-head">
<div class="head-left">
<div class="icon-wrap" :class="statusClass">
<el-icon :size="22">
<component :is="iconComp" />
</el-icon>
</div>
<div class="title-wrap">
<div class="channel-name">{{ name }}</div>
<div class="merchant-no">商户号:{{ merchantNo || '未配置' }}</div>
</div>
</div>
<StatusTag :status="status" dict="channel" />
</div>
<div v-if="desc" class="card-desc">{{ desc }}</div>
<div class="card-foot">
<el-button size="small" type="primary" plain @click="emit('config')">配置</el-button>
<el-button v-if="status === 'enabled'" size="small" type="danger" plain @click="emit('disable')">
停用
</el-button>
<el-button v-else size="small" type="success" plain @click="emit('enable')">启用</el-button>
</div>
</el-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
ChatDotRound,
Wallet,
CreditCard,
Lightning,
Money,
PriceTag
} from '@element-plus/icons-vue'
import StatusTag from './StatusTag.vue'
/** 常用渠道图标映射:微信 / 支付宝 / 银联 / 云闪付 / PayPal / 兜底 */
const ICONS: Record<string, any> = {
ChatDotRound,
Wallet,
CreditCard,
Lightning,
Money,
PriceTag
}
const props = withDefaults(
defineProps<{
/** 渠道名称,如「微信支付」 */
name: string
/** 渠道状态:enabled 已启用 / disabled 已停用 / unconfigured 未配置 */
status?: string
/** 商户号,未配置时显示「未配置」 */
merchantNo?: string
/** 图标名:ChatDotRound / Wallet / CreditCard / Lightning / Money */
icon?: string
/** 卡片补充说明 */
desc?: string
}>(),
{
status: 'unconfigured',
merchantNo: '',
icon: 'PriceTag',
desc: ''
}
)
const emit = defineEmits<{
(e: 'config'): void
(e: 'enable'): void
(e: 'disable'): void
}>()
const iconComp = computed(() => ICONS[props.icon] || PriceTag)
const statusClass = computed(() => {
if (props.status === 'enabled') return 'is-enabled'
if (props.status === 'unconfigured') return 'is-unconfigured'
return 'is-disabled'
})
</script>
<style scoped lang="less">
.channel-card {
margin-bottom: 0;
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.head-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.icon-wrap {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
&.is-enabled {
background: linear-gradient(135deg, #52c41a, #95de64);
}
&.is-disabled {
background: linear-gradient(135deg, #909399, #b1b3b8);
}
&.is-unconfigured {
background: linear-gradient(135deg, #e6a23c, #f3d19e);
}
}
.title-wrap {
min-width: 0;
}
.channel-name {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.merchant-no {
margin-top: 4px;
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-desc {
margin-top: 12px;
font-size: 12px;
line-height: 1.6;
color: #909399;
}
.card-foot {
margin-top: 16px;
display: flex;
gap: 8px;
}
}
</style>
@@ -0,0 +1,113 @@
<template>
<el-tag :type="current.type" :size="size" :effect="effect">
{{ text || current.text }}
</el-tag>
</template>
<script setup lang="ts">
import { computed } from 'vue'
type TagType = 'primary' | 'success' | 'info' | 'warning' | 'danger'
type DictName = 'common' | 'channel' | 'order' | 'commission' | 'reconcile' | 'diff'
interface StatusItem {
text: string
type: TagType
}
const props = withDefaults(
defineProps<{
/** 状态值,支持字符串或数字(如 1 / 0、'enabled'、'paid') */
status?: string | number
/** 自定义展示文案,不传则取字典里的文案 */
text?: string
/** 字典分组,不同业务模块状态含义不同 */
dict?: DictName
size?: 'large' | 'default' | 'small'
effect?: 'dark' | 'light' | 'plain'
}>(),
{
status: '',
text: '',
dict: 'common',
size: 'small',
effect: 'light'
}
)
/** 通用状态(渠道 / 规则 / 单据都常见的枚举) */
const COMMON: Record<string, StatusItem> = {
'1': { text: '启用', type: 'success' },
'0': { text: '停用', type: 'info' },
enabled: { text: '已启用', type: 'success' },
disabled: { text: '已停用', type: 'info' },
unconfigured: { text: '未配置', type: 'warning' },
yes: { text: '是', type: 'success' },
no: { text: '否', type: 'info' }
}
/** 渠道状态 */
const CHANNEL: Record<string, StatusItem> = {
enabled: { text: '已启用', type: 'success' },
disabled: { text: '已停用', type: 'info' },
unconfigured: { text: '未配置', type: 'warning' }
}
/** 支付单状态 */
const ORDER: Record<string, StatusItem> = {
created: { text: '已创建', type: 'info' },
pending: { text: '待支付', type: 'warning' },
paying: { text: '支付中', type: 'warning' },
paid: { text: '支付成功', type: 'success' },
success: { text: '支付成功', type: 'success' },
failed: { text: '支付失败', type: 'danger' },
closed: { text: '已关闭', type: 'info' },
timeout: { text: '已超时', type: 'info' },
refunding: { text: '退款中', type: 'warning' },
refunded: { text: '已退款', type: 'info' },
partial_refunded: { text: '部分退款', type: 'warning' }
}
/** 佣金状态 */
const COMMISSION: Record<string, StatusItem> = {
payable: { text: '应付', type: 'warning' },
settled: { text: '已付', type: 'success' },
tax_processing: { text: '税务处理中', type: 'primary' },
canceled: { text: '已作废', type: 'info' }
}
/** 对账处理状态 */
const RECONCILE: Record<string, StatusItem> = {
unhandled: { text: '待处理', type: 'warning' },
handling: { text: '处理中', type: 'primary' },
handled: { text: '已处理', type: 'success' },
ignored: { text: '已忽略', type: 'info' }
}
/** 对账差异类型 */
const DIFF: Record<string, StatusItem> = {
long: { text: '长款', type: 'warning' },
short: { text: '短款', type: 'danger' },
status_mismatch: { text: '状态不一致', type: 'warning' }
}
const DICTS: Record<DictName, Record<string, StatusItem>> = {
common: COMMON,
channel: CHANNEL,
order: ORDER,
commission: COMMISSION,
reconcile: RECONCILE,
diff: DIFF
}
const current = computed<StatusItem>(() => {
const key = props.status === undefined || props.status === null ? '' : String(props.status)
return (
DICTS[props.dict]?.[key] ||
COMMON[key] || {
text: key || '-',
type: 'info'
}
)
})
</script>
@@ -0,0 +1,370 @@
<template>
<div class="doc-wrap">
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">渠道配置通用约定</span>
</template>
<ul class="dot-list">
<li>所有渠道配置只在<b>平台端</b>维护,租户端不接触任何渠道密钥。</li>
<li>敏感字段(密钥 / 私钥 / Secret)保存后端只返回掩码;页面提交掩码表示<b>不修改原值</b>。</li>
<li>回调地址统一形如 <code>https://你的域名/api/payment/callback/{渠道}</code>,必须<b>公网 HTTPS 可访问</b>,不能带鉴权、不能重定向。</li>
<li>回调处理需<b>幂等</b>:同一笔通知可能重复推送,业务上按渠道流水号唯一键去重。</li>
<li>配置完成后先「测试连接」,再用 1 分钱真实下单走通全链路,最后才启用渠道。</li>
</ul>
</el-card>
<el-collapse v-model="activeNames">
<el-collapse-item v-for="doc in channels" :key="doc.key" :name="doc.key">
<template #title>
<div class="panel-title">
<span class="name">{{ doc.name }}</span>
<StatusTag :status="doc.status" dict="channel" />
</div>
</template>
<div class="section">
<h4>1. 去哪拿参数</h4>
<ul class="dot-list">
<li v-for="(line, index) in doc.where" :key="index">{{ line }}</li>
</ul>
<div v-if="doc.links && doc.links.length" class="links">
<span>相关地址:</span>
<a v-for="link in doc.links" :key="link.url" :href="link.url" target="_blank" rel="noopener">
{{ link.text }}
</a>
</div>
</div>
<div class="section">
<h4>2. 填什么</h4>
<el-table :data="doc.fields" size="small" border style="width: 100%">
<el-table-column prop="label" label="字段" width="180" />
<el-table-column label="必填" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.required ? 'danger' : 'info'" effect="plain">
{{ row.required ? '必填' : '选填' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="敏感" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.secret ? 'warning' : 'info'" effect="plain">
{{ row.secret ? '掩码' : '明文' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="tip" label="说明" min-width="320" />
</el-table>
</div>
<div class="section">
<h4>3. 回调怎么配</h4>
<div class="callback-row">
<el-input :model-value="callbackUrl(doc.key)" readonly />
<el-button @click="copyText(callbackUrl(doc.key))">复制</el-button>
</div>
<p class="para">{{ doc.callback }}</p>
</div>
<div class="section">
<h4>4. 怎么验证</h4>
<ol class="step-list">
<li v-for="(step, index) in doc.verify" :key="index">{{ step }}</li>
</ol>
</div>
<el-alert
v-if="doc.alert"
:type="doc.alert.type"
:closable="false"
show-icon
:title="doc.alert.text"
/>
</el-collapse-item>
</el-collapse>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import StatusTag from '../components/StatusTag.vue'
interface FieldDoc {
label: string
required: boolean
secret?: boolean
tip: string
}
interface ChannelDoc {
key: string
name: string
status: string
where: string[]
links?: { text: string; url: string }[]
fields: FieldDoc[]
callback: string
verify: string[]
alert?: { type: 'info' | 'warning' | 'error' | 'success'; text: string }
}
const activeNames = ref<string[]>(['wechat'])
const callbackUrl = (channel: string) => `${window.location.origin}/api/payment/callback/${channel}`
const channels: ChannelDoc[] = [
{
key: 'wechat',
name: '微信支付',
status: 'enabled',
where: [
'登录微信商户平台,进入「账户中心 → 商户信息」,记录商户号 mch_id。',
'进入「产品中心 → 开发配置」,绑定需要收款的 APPID(公众号 / 小程序 / APP)。',
'进入「账户中心 → API 安全」,申请 APIv3 密钥(32 位,只在申请时展示)。',
'同页「API 证书」下载证书文件,取 apiclient_cert.pem 与 apiclient_key.pem。'
],
links: [{ text: '微信商户平台', url: 'https://pay.weixin.qq.com' }],
fields: [
{ label: '商户号 mch_id', required: true, tip: '微信支付商户号,纯数字,注意不要填成 APPID' },
{ label: 'APPID', required: true, tip: '必须与该商户号完成绑定,否则拉起支付会报商户号与 AppID 不匹配' },
{ label: 'APIv3 密钥', required: true, secret: true, tip: '32 位字符串,用于解密回调与接口签名,丢失只能重新申请' },
{ label: '商户证书', required: true, secret: true, tip: 'apiclient_cert.pem + apiclient_key.pem,上传后由后端加密保存' },
{ label: '启用状态', required: false, tip: '停用后收银台不再展示微信支付' }
],
callback:
'微信支付无需在商户平台登记回调域名,只要地址公网可访问、处理成功返回字符串 SUCCESS 即可。若使用 JSAPI 支付,需在「产品中心 → 开发配置 → JSAPI 支付授权目录」登记收银台域名(含 https 且以 / 结尾)。',
verify: [
'保存配置后点击「测试连接」,系统调用订单查询接口校验商户号、证书与 APIv3 密钥。',
'用 1 分钱在租户端真实下单,确认能拉起微信收银台并完成支付。',
'确认支付单状态在 10 秒内变为「支付成功」,业务订单同步为已支付。',
'在商户平台查看该笔订单,核对金额与商户单号是否与本地一致。'
],
alert: {
type: 'warning',
text: 'APIv3 密钥与商户私钥只在平台端保存,切勿放进前端代码或下发给租户。'
}
},
{
key: 'alipay',
name: '支付宝',
status: 'enabled',
where: [
'登录支付宝开放平台,进入「控制台 → 网页/移动应用」,记录应用 APPID。',
'进入「开发设置 → 接口加签方式」,选择公钥模式,用签名工具生成应用私钥,上传应用公钥。',
'保存后页面会回显支付宝公钥,复制备用(用于校验渠道回调签名)。',
'进入「产品绑定」,开通电脑网站支付与手机网站支付。'
],
links: [{ text: '支付宝开放平台', url: 'https://open.alipay.com' }],
fields: [
{ label: 'APPID', required: true, tip: '开放平台应用 ID,注意区分「应用 APPID」与「商户号 PID」' },
{ label: '应用私钥', required: true, secret: true, tip: 'RSA2 应用私钥(PKCS8),仅平台端保存' },
{ label: '支付宝公钥', required: true, tip: '开放平台回显的公钥,属公开信息,用于回调验签' },
{ label: '签名类型', required: true, tip: '默认 RSA2,与开放平台加签方式保持一致' },
{ label: '启用状态', required: false, tip: '停用后收银台不再展示支付宝' }
],
callback:
'支付宝异步通知地址(notify_url)随下单请求携带,无需在开放平台登记;处理成功必须返回字符串 success(全小写),否则支付宝会按 4m/10h/24h 等策略重复通知。同步跳转地址由下单时的 return_url 决定。',
verify: [
'保存后点击「测试连接」,系统调用 alipay.trade.query 校验 APPID 与签名配置。',
'用 1 分钱真实下单,确认能跳转支付宝收银台并完成支付。',
'在开放平台「交易 → 交易明细」核对订单,确认异步通知已被后端接收。',
'确认支付单与业务订单状态均更新为成功。'
],
alert: {
type: 'info',
text: '常见失败原因:应用私钥与上传到开放平台的应用公钥不配对,或使用了支付宝公钥去签名(两者角色不能互换)。'
}
},
{
key: 'unionpay',
name: '银联',
status: 'unconfigured',
where: [
'完成银联入网(或通过第三方收单机构入网)后,由收单机构下发商户号 mer_id。',
'向客户经理获取收单机构代码 acq_ins_code(如 0402 建设银行、0104 中国银行)。',
'获取商户证书文件(.pfx / .p12 / .cer)与证书密码,证书有有效期,到期需重新下发。',
'索取测试环境商户号用于联调(测试与生产商户号不通用)。'
],
links: [{ text: '银联开放平台', url: 'https://open.unionpay.com' }],
fields: [
{ label: '商户号 mer_id', required: true, tip: '收单机构下发,测试与生产不同' },
{ label: '收单机构', required: true, tip: '收单机构代码,必须与入网时一致,填错交易会被拒' },
{ label: '商户证书', required: true, secret: true, tip: '.pfx / .p12 / .cer 证书文件,由后端加密保存' },
{ label: '启用状态', required: false, tip: '停用后租户无法使用银联支付' }
],
callback:
'银联的前台通知与后台通知地址需要在入网时报备给收单机构,报备通过后使用本页地址;未报备的地址会被银联侧拒绝。后台通知处理成功返回 ok。',
verify: [
'保存后点击「测试连接」,系统调用银联交易状态查询接口校验证书与商户号。',
'用测试商户号在测试环境跑通 PC 网关支付。',
'生产启用前,务必确认回调地址已完成报备,并与客户经理确认可收款。',
'用 1 分钱真实支付验证前台跳转与后台通知均正常。'
],
alert: {
type: 'warning',
text: '证书与证书密码属高危凭证;证书过期会导致全部银联交易失败,建议记录到期日并提前 1 个月更换。'
}
},
{
key: 'cloudpay',
name: '云闪付',
status: 'disabled',
where: [
'云闪付走银联通道,不单独申请商户号,直接复用「银联配置」中的商户号与收单机构代码。',
'收银台展示所需云闪付标识物料由银联 / 收单机构提供,或使用平台内置标识。',
'如需独立商户号,需单独与收单机构签约后再在本页维护。'
],
fields: [
{ label: '继承商户号', required: false, tip: '只读,取银联配置的商户号,如需修改请前往银联配置页' },
{ label: '继承收单机构', required: false, tip: '只读,取银联配置的收单机构代码' },
{ label: '在收银台展示云闪付标识', required: false, tip: '关闭后不展示入口,但已发起订单仍可正常回调' },
{ label: '展示位置', required: false, tip: '可分别控制在 PC 收银台与手机 H5 收银台展示' },
{ label: '启用状态', required: false, tip: '需先启用银联渠道,云闪付才会在收银台生效' }
],
callback:
'云闪付交易通知默认由银联通道统一下发,复用银联回调地址;若收单机构要求独立地址,需在入网时报备本页地址,后台通知处理成功返回 ok。',
verify: [
'先在「银联配置」完成保存并通过测试连接。',
'回到本页开启展示标识并启用渠道。',
'用云闪付 App 在收银台完成一笔扫码支付,确认回调正常。',
'核对支付单渠道字段为「云闪付」,对账时注意与银联账单的渠道标识差异。'
],
alert: {
type: 'info',
text: '云闪付与银联共用商户参数与证书,因此银联侧报备、证书更换会同时影响云闪付。'
}
},
{
key: 'paypal',
name: 'PayPal',
status: 'disabled',
where: [
'登录 PayPal 开发者后台,进入「Apps & Credentials」,分别查看沙箱与正式环境的应用列表。',
'创建或打开 REST App,复制 Client ID 与 Client Secret(Secret 只在创建时完整展示)。',
'在 App 详情 → Webhooks 中新增回调地址并勾选事件,保存后复制 Webhook ID。',
'如无海外收款资质,可仅保留沙箱配置用于功能联调。'
],
links: [{ text: 'PayPal 开发者后台', url: 'https://developer.paypal.com' }],
fields: [
{ label: 'Client ID', required: true, tip: 'REST App 的 Client ID,沙箱与正式不通用' },
{ label: 'Client Secret', required: true, secret: true, tip: 'REST App 的 Secret,仅平台端保存' },
{ label: 'Webhook ID', required: true, tip: '在 App 的 Webhooks 列表中复制,用于校验通知来源' },
{ label: '运行环境', required: true, tip: '沙箱 / 正式;切换前确认三处凭证都来自同一环境' },
{ label: '启用状态', required: false, tip: '仅在有海外租户时启用' }
],
callback:
'需在 PayPal App 的 Webhooks 中登记本页地址,并勾选 PAYMENT.CAPTURE.COMPLETED、PAYMENT.CAPTURE.DENIED、PAYMENT.CAPTURE.REFUNDED 等事件;后端按 PayPal 签名头校验通知来源,处理成功返回 HTTP 200。',
verify: [
'保存后点击「测试连接」,系统调用 OAuth2 Token 接口校验 Client ID / Secret。',
'用沙箱买家账号完成一笔支付,确认 Webhook 被接收且订单状态更新。',
'在 PayPal 后台 Webhooks 的 Event log 中确认事件已成功推送(返回 2xx)。',
'确认无误后切换到正式环境凭证并启用渠道。'
],
alert: {
type: 'warning',
text: '当前业务不跨境,PayPal 仅为预留渠道,默认停用;启用前请确认已完成外汇与合规评估。'
}
}
]
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择复制')
}
}
</script>
<style scoped lang="less">
.doc-wrap {
.block-card {
margin-bottom: 20px;
.card-title {
font-size: 15px;
font-weight: 600;
}
}
.panel-title {
display: flex;
align-items: center;
gap: 10px;
.name {
font-size: 14px;
font-weight: 600;
color: #303133;
}
}
.section {
margin-bottom: 18px;
h4 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: #303133;
padding-left: 8px;
border-left: 3px solid #409eff;
}
}
.dot-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
b {
color: #303133;
}
code {
padding: 1px 6px;
background: #f5f7fa;
border-radius: 4px;
color: #f56c6c;
font-family: Consolas, monospace;
}
}
.step-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
}
.para {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.9;
color: #606266;
}
.links {
margin-top: 6px;
font-size: 13px;
color: #909399;
a {
margin-right: 12px;
color: #409eff;
}
}
.callback-row {
display: flex;
align-items: center;
gap: 8px;
max-width: 720px;
}
}
</style>
@@ -0,0 +1,246 @@
<template>
<div class="doc-wrap">
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">常见问题</span>
</template>
<el-collapse v-model="activeNames">
<el-collapse-item v-for="item in faqs" :key="item.key" :name="item.key">
<template #title>
<div class="q-title">
<el-tag size="small" :type="item.tagType" effect="plain">{{ item.tag }}</el-tag>
<span>{{ item.question }}</span>
</div>
</template>
<div class="answer">
<p v-for="(para, index) in item.answer" :key="index" class="para">{{ para }}</p>
<template v-if="item.steps && item.steps.length">
<div class="steps-title">处理步骤</div>
<ol class="step-list">
<li v-for="(step, index) in item.steps" :key="index">{{ step }}</li>
</ol>
</template>
<el-alert
v-if="item.tip"
class="tip"
:type="item.tagType"
:closable="false"
show-icon
:title="item.tip"
/>
</div>
</el-collapse-item>
</el-collapse>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface FaqItem {
key: string
tag: string
tagType: 'primary' | 'success' | 'info' | 'warning' | 'danger'
question: string
answer: string[]
steps?: string[]
tip?: string
}
const activeNames = ref<string[]>(['faq-order'])
const faqs: FaqItem[] = [
{
key: 'faq-order',
tag: '订单',
tagType: 'warning',
question: '支付成功了,但订单状态没更新,怎么处理?',
answer: [
'绝大多数情况是渠道异步回调没有到达后端,或到达了但处理报错。前端页面上显示的成功提示只代表浏览器侧动作完成,不代表服务端已入账。',
'系统已提供两层兜底:一是支付单详情页的「手动查询渠道状态」,会主动向渠道查询并同步;二是后端定时补偿任务会扫描长时间处于 paying 的支付单。'
],
steps: [
'先到「支付订单」中按租户或业务订单号找到该笔支付单,确认状态是否仍为「待支付 / 支付中」。',
'进入支付单详情,点「手动查询渠道状态」,若渠道侧已成功,本地会立即同步为「支付成功」并把业务订单置为已支付。',
'如渠道返回未支付,请让用户重新支付:旧支付单会保持未支付状态,不会重复扣款。',
'若查询成功但业务订单仍未更新,检查回调处理代码中业务订单状态同步逻辑,以及是否存在事务失败日志。',
'最后检查回调地址是否可达:外网能否访问 https://域名/api/payment/callback/{渠道},网关/防火墙是否放行。'
],
tip: '排查顺序建议:支付单状态 → 渠道查询 → 后端回调日志 → 回调地址连通性。'
},
{
key: 'faq-callback-repeat',
tag: '回调',
tagType: 'warning',
question: '渠道回调会重复推送吗?会不会重复记账?',
answer: [
'会。微信、支付宝、银联都会在收到非成功响应或网络异常时按策略重推同一条通知(支付宝最长 24 小时,微信为 15s/15s/30s… 逐步拉长)。',
'因此回调处理必须是幂等的:以「渠道 + 渠道流水号」做唯一键,重复通知直接返回成功响应,不再重复更新订单或重复生成佣金。'
],
steps: [
'确认回调表/支付单表在 (channel, channel_trade_no) 上有唯一索引。',
'回调处理第一步做去重判定:已处理过则直接返回渠道要求成功响应(微信 SUCCESS / 支付宝 success / 银联 ok)。',
'状态更新使用条件更新(如 WHERE status <> paid),避免并发下重复触发业务动作。',
'佣金生成挂在支付成功事件上,同样按业务订单号去重,保证一笔订单只生成一张佣金单。'
],
tip: '自测方法:在回调处理里临时打印日志,用渠道后台的「重发通知」按钮推同一条通知 3 次,确认只产生一次状态变更与一张佣金单。'
},
{
key: 'faq-commission',
tag: '佣金',
tagType: 'primary',
question: '推广佣金怎么发?和租户付款是什么关系?',
answer: [
'佣金是平台自有资金支出,与租户付款资金流完全解耦:租户的钱进平台对公账户,佣金由平台另行向推广方支付,两者不共用一笔资金,也不做分账。',
'佣金的产生由「佣金规则」决定:业务订单支付成功后,匹配启用中的规则(按比例 / 固定金额 / 阶梯)计算金额并生成佣金单,状态为「应付」。'
],
steps: [
'在「佣金规则」中配置规则:按比例填百分比,固定金额填元(系统按分存储),阶梯配置各金额区间的比例,并选择适用订单类型。',
'业务订单支付成功后自动生成佣金单,出现在「佣金台账」中,状态为「应付」。',
'财务实际打款后,在台账中对佣金单点「标记已付」,填写发放方式与流水号(税务处理中的先标记为对应状态)。',
'顶部统计卡片可查看应付总额、已付总额与待处理笔数,作为付款计划依据。'
],
tip: '修改规则只影响之后生成的佣金单,历史佣金单不会被追溯调整;如需调整历史数据,请单独走人工核对。'
},
{
key: 'faq-new-channel',
tag: '扩展',
tagType: 'info',
question: '要新增一个支付渠道(比如新增数字人民币)怎么做?',
answer: [
'渠道是插件式接入:后端实现一个适配器接口,前端在渠道配置目录下新增一个配置页,其余页面(订单列表、对账、首页概览)只需在渠道枚举里补一项即可自动适配。'
],
steps: [
'后端 internal/payment/channel/ 下新增 <channel>.go,实现统一下单、查询、回调验签、退款(如需)四个方法。',
'在支付服务的适配器工厂里注册该渠道,并在渠道枚举与配置表里补一条记录。',
'前端 src/views/system/payment/channels/ 下新增配置页(可复制现有渠道页改造),字段按渠道要求定义,敏感字段使用掩码回显。',
'在 channels 目录对应的渠道枚举(首页概览、订单列表、对账、使用说明)中补充渠道名称与图标。',
'补充回调路由 POST /api/payment/callback/<channel>,并确认渠道后台已登记该地址。',
'联调通过后先用 1 分钱真实下单验证,再启用渠道。'
],
tip: '不要在各业务页面里写渠道 if-else:渠道差异必须收敛在适配器与配置页内,否则后续维护成本会指数上升。'
},
{
key: 'faq-reconcile',
tag: '对账',
tagType: 'danger',
question: '对账出现差异怎么处理?',
answer: [
'对账差异分三类:长款(渠道有、本地无,疑似漏单)、短款(本地有、渠道无,疑似用户未真正付款成功)、状态不一致(金额一致但状态不同,通常是回调丢失或延迟)。',
'所有差异都需要人工核对后在对账差异页标记处理,并填写处理说明,系统不会自动改账。'
],
steps: [
'按差异类型筛选,先处理「长款」:到渠道后台用渠道流水号核对,确认租户确实付款后,按业务订单号补记支付单并置为已支付。',
'再处理「短款」:确认渠道侧确实无此笔成功记录,通常是用户未完成付款或支付超时,将本地支付单置为已关闭即可。',
'处理「状态不一致」:直接用支付单详情页的「手动查询渠道状态」同步,多数情况会立即一致。',
'点击「标记处理」,填写处理说明(如:已补单 / 已联系渠道核实 / 账单重复记录已忽略),便于日后追溯。',
'同一差异反复出现时,检查回调地址连通性与定时补偿任务是否正常执行。'
],
tip: '对账日期为渠道账单的自然日,跨日退款的差异会在下一个对账日体现,属于正常现象,不要手工改账。'
},
{
key: 'faq-domain',
tag: '回调',
tagType: 'info',
question: '域名更换或回调地址调整后要注意什么?',
answer: [
'回调地址是渠道侧登记的固定地址,更换域名后必须同步更新各渠道的登记信息,否则通知会全部丢失,只能依赖手动查询兜底。'
],
steps: [
'微信:异步通知地址随请求携带,无需登记,但要确认 JSAPI 支付授权目录已更新为新域名。',
'支付宝:异步通知地址随请求携带,同样无需登记,确认新域名可公网访问即可。',
'银联 / 云闪付:必须向收单机构重新报备回调地址,报备通过后才会推送通知。',
'PayPal:在 App 的 Webhooks 中更新地址,并重新复制 Webhook ID 核对。',
'切换后立即用 1 分钱下单验证回调可达,并观察回调日志。'
],
tip: '建议保留旧域名的回调路由至少一个版本周期,避免切换期间在途订单通知丢失。'
},
{
key: 'faq-cert',
tag: '凭证',
tagType: 'warning',
question: '银联证书或微信证书过期会有什么表现?',
answer: [
'证书过期后渠道侧会直接拒绝请求,表现为:下单报错、回调验签失败、主动查询接口报证书不合法。支付单会卡在「待支付 / 支付中」。'
],
steps: [
'记录各渠道证书到期日,提前 1 个月申请新证书。',
'在对应渠道配置页重新上传证书文件并保存,然后点「测试连接」确认可用。',
'证书更新后重启一次支付相关服务,确保后端重新加载证书。',
'用 1 分钱真实下单验证下单与回调链路。'
],
tip: 'APIv3 密钥与证书是两套东西:换了证书不用换 APIv3 密钥,别混着一起改。'
},
{
key: 'faq-test-ok',
tag: '排查',
tagType: 'warning',
question: '「测试连接」成功,但真实支付失败,为什么?',
answer: [
'测试连接只校验凭证与接口鉴权,不覆盖下列与实际收款相关的配置,因此测试通过不代表能真实收款。'
],
steps: [
'检查渠道是否已启用(渠道配置页的启用状态与首页概览状态)。',
'检查 APPID / 商户号绑定关系、支付授权目录(微信 JSAPI)是否与收银台域名一致。',
'检查证书是否已上传且未过期,私钥与上传到渠道侧的公钥是否配对。',
'检查回调地址是否公网可达、是否被网关的鉴权拦截导致渠道收到非成功响应。',
'查看后端日志中渠道返回的原始错误码,按渠道文档对照处理。'
],
tip: '联调阶段建议固定使用 1 分钱的最小金额,避免误产生大额测试交易。'
}
]
</script>
<style scoped lang="less">
.doc-wrap {
.block-card {
.card-title {
font-size: 15px;
font-weight: 600;
}
}
}
.q-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.answer {
padding-left: 4px;
.para {
margin: 0 0 10px;
font-size: 13px;
line-height: 1.9;
color: #606266;
}
.steps-title {
margin: 12px 0 6px;
font-size: 13px;
font-weight: 600;
color: #303133;
}
.step-list {
margin: 0 0 10px;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
}
.tip {
margin-top: 8px;
}
}
</style>
@@ -0,0 +1,339 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>支付模块使用说明</h2>
<p class="sub-title">
模块定位、渠道配置、代码引用位置与调用示例,新同学按左侧顺序读一遍即可完成一次接入。
</p>
</div>
<el-divider></el-divider>
<div class="guide-layout">
<!-- 左侧导航 -->
<el-menu :default-active="active" class="guide-menu" @select="handleSelect">
<el-menu-item v-for="item in navItems" :key="item.key" :index="item.key">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu>
<!-- 右侧内容 -->
<div class="guide-content">
<!-- 1. 模块总览(内联) -->
<div v-if="active === 'overview'">
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">模块定位</span>
</template>
<p class="para">
支付模块负责<b>平台向租户收取平台使用费</b>的收款链路(下单、拉起支付、渠道回调、订单状态同步、日终对账),
以及<b>推广佣金的台账与发放登记</b>。模块只服务平台端收款,不参与租户与他方之间的交易资金流转。
</p>
<ul class="dot-list">
<li><b>收款方:</b>平台主体,款项直接进入平台对公账户,<b>不涉及分账、不涉及资金池</b>。</li>
<li><b>退款:</b>业务上不做退款入口,仅在后台保留「手动原路退回」兜底能力,需人工确认后出账。</li>
<li><b>佣金:</b>推广佣金由平台自有资金支出,与租户收款解耦,单独台账、单独发放。</li>
<li><b>跨境:</b>当前业务不跨境,PayPal 仅作为预留渠道,默认停用。</li>
</ul>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">资金流向</span>
</template>
<div class="flow">
<div class="flow-node">租户下单<br /><span>(平台使用费)</span></div>
<div class="flow-arrow">→</div>
<div class="flow-node">支付渠道<br /><span>微信 / 支付宝 / 银联 / 云闪付</span></div>
<div class="flow-arrow">→</div>
<div class="flow-node highlight">平台对公账户<br /><span>直接到账,不经过平台其他账户</span></div>
<div class="flow-arrow">→</div>
<div class="flow-node">系统记账<br /><span>支付单 + 业务订单状态</span></div>
</div>
<el-alert
class="flow-tip"
type="info"
:closable="false"
show-icon
title="分账说明:平台收款即终结,不存在「收款后再分给租户」或「平台代收代付」场景,因此支付模块不实现分账能力。"
/>
<p class="para">
推广佣金是<b>反向资金流</b>:由平台从自有资金中向推广方支付,与租户付款、渠道结算完全解耦,
因此在菜单上佣金台账与支付订单是并列的两个功能,而不是支付单的下级。
</p>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">渠道清单</span>
</template>
<el-table :data="channels" stripe style="width: 100%">
<el-table-column prop="name" label="渠道" width="140" />
<el-table-column prop="scope" label="覆盖场景" min-width="220" />
<el-table-column prop="status" label="当前状态" width="140" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="channel" />
</template>
</el-table-column>
<el-table-column prop="remark" label="说明" min-width="260" show-overflow-tooltip />
</el-table>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">核心概念:业务订单 vs 支付单</span>
</template>
<el-table :data="concepts" border style="width: 100%">
<el-table-column prop="dimension" label="对比维度" width="160" />
<el-table-column prop="biz" label="业务订单(out_trade_no)" min-width="280" />
<el-table-column prop="pay" label="支付单(pay_no)" min-width="280" />
</el-table>
<el-alert
class="flow-tip"
type="warning"
:closable="false"
show-icon
title="幂等要点:同一业务订单可存在多笔支付单(超时关闭后重新下单),只有状态为 paid 的支付单才算收款成功;佣金按业务订单支付成功事件生成。"
/>
</el-card>
</div>
<!-- 2. 渠道配置说明 -->
<ConfigDoc v-else-if="active === 'config'" />
<!-- 3/4/5. 哪里引用了支付 / 后端接口调用 / 前端调用示例 -->
<UsageDoc v-else-if="isUsage" :scroll-to="anchor" />
<!-- 6. 常见问题 -->
<FaqDoc v-else-if="active === 'faq'" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Document, Setting, Link, Connection, Monitor, QuestionFilled } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
import ConfigDoc from './config.vue'
import UsageDoc from './usage.vue'
import FaqDoc from './faq.vue'
const active = ref('overview')
const navItems = [
{ key: 'overview', title: '支付模块总览', icon: Document },
{ key: 'config', title: '渠道配置说明', icon: Setting },
{ key: 'refs', title: '哪里引用了支付', icon: Link },
{ key: 'api', title: '后端接口调用', icon: Connection },
{ key: 'frontend', title: '前端调用示例', icon: Monitor },
{ key: 'faq', title: '常见问题', icon: QuestionFilled }
]
const isUsage = computed(() => ['refs', 'api', 'frontend'].includes(active.value))
/** 三个引用/示例导航共用 usage.vue,通过锚点滚动到对应区块 */
const anchor = computed(() => {
if (active.value === 'refs') return 'usage-ref'
if (active.value === 'api') return 'usage-chain'
return 'usage-frontend'
})
function handleSelect(key: string) {
active.value = key
}
const channels = [
{
name: '微信支付',
scope: '公众号 JSAPI、PC 扫码 Native',
status: 'enabled',
remark: '平台使用费主渠道,回调地址 /api/payment/callback/wechat'
},
{
name: '支付宝',
scope: '电脑网站支付、手机网站支付',
status: 'enabled',
remark: 'RSA2 签名,异步通知无需在开放平台登记'
},
{
name: '银联',
scope: 'PC 网关支付、手机控件支付',
status: 'unconfigured',
remark: '需收单机构下发商户号与证书,回调地址需报备'
},
{
name: '云闪付',
scope: '云闪付 App 扫码 / 被扫',
status: 'disabled',
remark: '复用银联商户参数,仅控制收银台标识展示'
},
{
name: 'PayPal',
scope: '海外租户信用卡 / 余额支付',
status: 'disabled',
remark: '跨境预留渠道,当前业务不跨境,默认关闭'
}
]
const concepts = [
{
dimension: '定义',
biz: '租户在平台产生的一笔应付费用(如年度平台使用费)',
pay: '针对某个业务订单发起的一次收款尝试'
},
{
dimension: '主键',
biz: 'out_trade_no(业务系统生成,全局唯一)',
pay: 'pay_no(支付模块生成,形如 P202609150009)'
},
{
dimension: '状态机',
biz: '待支付 → 已支付 / 已取消 / 已关闭',
pay: 'created → pending → paying → paid / failed / closed(+ refunded)'
},
{
dimension: '金额',
biz: '订单金额(分),与套餐价格一致',
pay: '实付金额(分),正常情况下与业务订单金额相等'
},
{
dimension: '谁维护',
biz: '业务模块(租户/订单)维护,支付成功后由回调驱动变更',
pay: '支付模块维护,渠道回调与主动查询双写'
},
{
dimension: '与佣金关系',
biz: '支付成功后按佣金规则生成佣金单',
pay: '本身不产生佣金,佣金与收款资金解耦'
}
]
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
}
.guide-layout {
display: flex;
align-items: flex-start;
gap: 20px;
}
.guide-menu {
width: 220px;
flex-shrink: 0;
border-right: 1px solid #ebeef5;
border-radius: 6px;
overflow: hidden;
}
.guide-content {
flex: 1;
min-width: 0;
}
.block-card {
margin-bottom: 20px;
.card-title {
font-size: 15px;
font-weight: 600;
}
}
.para {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.9;
color: #606266;
b {
color: #303133;
}
}
.dot-list {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.9;
color: #606266;
b {
color: #303133;
}
}
.flow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
.flow-node {
min-width: 150px;
padding: 12px 16px;
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
color: #303133;
text-align: center;
line-height: 1.6;
span {
font-size: 12px;
font-weight: 400;
color: #909399;
}
&.highlight {
background: #ecf5ff;
border-color: #b3d8ff;
color: #409eff;
}
}
.flow-arrow {
color: #c0c4cc;
font-size: 16px;
}
}
.flow-tip {
margin-top: 12px;
}
@media (max-width: 1200px) {
.guide-layout {
flex-direction: column;
}
.guide-menu {
width: 100%;
border-right: none;
border-bottom: 1px solid #ebeef5;
}
}
</style>
@@ -0,0 +1,495 @@
<template>
<div class="doc-wrap">
<!-- A + B:引用位置 -->
<section :ref="setSectionRef('usage-ref')" class="section-block">
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">A. 后端 Go 引用位置</span>
</template>
<el-table :data="goRefs" size="small" border style="width: 100%">
<el-table-column prop="path" label="文件 / 目录" width="290">
<template #default="{ row }">
<code class="path">{{ row.path }}</code>
</template>
</el-table-column>
<el-table-column prop="desc" label="职责说明" min-width="360" />
</el-table>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">B. 前端 Vite 引用位置</span>
</template>
<el-table :data="feRefs" size="small" border style="width: 100%">
<el-table-column prop="path" label="文件 / 目录" width="290">
<template #default="{ row }">
<code class="path">{{ row.path }}</code>
</template>
</el-table-column>
<el-table-column prop="desc" label="职责说明" min-width="360" />
</el-table>
</el-card>
</section>
<!-- C:调用链 + 接口约定 -->
<section :ref="setSectionRef('usage-chain')" class="section-block">
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">C-1. 收款调用链</span>
</template>
<div class="chain">
<div v-for="(step, index) in chain" :key="index" class="chain-item">
<div class="chain-index">{{ index + 1 }}</div>
<div class="chain-body">
<div class="chain-text">{{ step.text }}</div>
<div class="chain-note">{{ step.note }}</div>
</div>
<div v-if="index < chain.length - 1" class="chain-line"></div>
</div>
</div>
<el-alert
class="tip"
type="info"
:closable="false"
show-icon
title="状态以服务端为准:前端拉起的支付结果、轮询结果都只是展示,最终以渠道回调(或后端主动查询)写入的支付单状态为准。"
/>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">C-2. 后端接口约定</span>
</template>
<el-table :data="apiList" size="small" border style="width: 100%">
<el-table-column prop="method" label="方法" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="methodTag(row.method)" effect="plain">{{ row.method }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" width="270">
<template #default="{ row }">
<code class="path">{{ row.path }}</code>
</template>
</el-table-column>
<el-table-column prop="params" label="关键参数" min-width="220" />
<el-table-column prop="desc" label="说明" min-width="260" />
</el-table>
<el-alert
class="tip"
type="warning"
:closable="false"
show-icon
title="前缀说明:约定里写作 /api/payment/*,本项目平台端接口统一走 /platform 前缀(dev 代理只转发 /platform、/backend),前端封装里用常量 PAY 统一控制,后端路由确定后只改一处。"
/>
</el-card>
</section>
<!-- C-3:前端调用示例 -->
<section :ref="setSectionRef('usage-frontend')" class="section-block">
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">C-3. 前端代码示例:接口封装</span>
</template>
<pre class="code-block">{{ apiCode }}</pre>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">C-4. 前端代码示例:租户端下单页调用(Vue 3 script setup)</span>
</template>
<pre class="code-block">{{ pageCode }}</pre>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">C-5. 平台端主动查询 / 退回示例</span>
</template>
<pre class="code-block">{{ adminCode }}</pre>
</el-card>
</section>
</div>
</template>
<script setup lang="ts">
import { nextTick, watch } from 'vue'
const props = defineProps<{
/** 需要滚动到的区块 id:usage-ref / usage-chain / usage-frontend */
scrollTo?: string
}>()
const sectionEls: Record<string, HTMLElement | null> = {}
function setSectionRef(key: string) {
return (el: any) => {
sectionEls[key] = (el as HTMLElement) || null
}
}
function scrollToSection(key: string) {
nextTick(() => {
sectionEls[key]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
watch(
() => props.scrollTo,
(key) => {
if (key) scrollToSection(key)
},
{ immediate: true }
)
/* ---------------- A. 后端引用位置 ---------------- */
const goRefs = [
{
path: 'internal/payment/service.go',
desc: '支付服务核心:创建支付单、查询支付单、状态机流转;根据 channel 选择对应渠道适配器,统一对外暴露 Pay / Query 方法。'
},
{
path: 'internal/payment/channel/wechat.go',
desc: '微信支付适配器:统一下单参数组装、APIv3 签名、证书加载、回调报文解密与验签,返回支付参数(code_url / JSAPI 参数)。'
},
{
path: 'internal/payment/channel/alipay.go',
desc: '支付宝适配器:RSA2 签名与验签、电脑/手机网站支付参数组装、异步通知解析。'
},
{
path: 'internal/payment/callback.go',
desc: '回调统一入口:按渠道验签 → 以渠道流水号做幂等去重 → 更新支付单状态 → 同步业务订单状态 → 触发佣金规则,处理成功按渠道约定返回(SUCCESS / success / ok)。'
},
{
path: 'internal/payment/reconcile.go',
desc: '日终对账:拉取渠道账单文件、与本地支付流水按流水号比对,产出长款 / 短款 / 状态不一致三类差异并落库。'
},
{
path: 'internal/commission/',
desc: '佣金域:规则匹配(按比例 / 固定金额 / 阶梯)、佣金单生成、发放登记与状态维护;资金来自平台自有,与收款链路解耦。'
}
]
/* ---------------- B. 前端引用位置 ---------------- */
const feRefs = [
{
path: 'src/api/payment.js',
desc: '支付模块接口封装:createPayment / queryPayment / refundPayment / getOrders / getOrderDetail / 渠道配置 / 佣金 / 对账,统一走 @/utils/request。'
},
{
path: 'src/views/system/payment/',
desc: '平台端支付管理页面(本模块):首页概览、渠道配置、支付订单、佣金台账与规则、对账差异、使用说明。'
},
{
path: 'src/views/tenant/order/',
desc: '租户端下单页:选择套餐与支付渠道,调用 createPayment 拿到渠道参数后拉起支付,并用 queryPayment 做结果兜底轮询。'
}
]
/* ---------------- C-1. 调用链 ---------------- */
const chain = [
{
text: '租户端下单页 → src/api/payment.js 的 createPayment()',
note: '前端只提交业务订单号、订单类型、金额(分)与渠道,不接触任何渠道密钥'
},
{
text: '后端 POST /api/payment/create',
note: '支付服务校验业务订单、金额与渠道开关,选择渠道适配器'
},
{
text: '渠道适配器向渠道下单,返回支付参数',
note: '微信返回 code_url / JSAPI 参数,支付宝返回跳转表单,银联返回网关地址'
},
{
text: '前端拉起支付(扫码 / 跳转 / JSAPI)',
note: '用户完成付款,此时前端只展示「支付中」,不落最终状态'
},
{
text: '渠道异步回调 POST /api/payment/callback/:channel',
note: '验签 → 幂等去重 → 支付单置为 paid → 业务订单同步为已支付'
},
{
text: '触发佣金规则,生成佣金单',
note: '佣金为平台自有资金支出,独立台账,与收款资金流解耦'
},
{
text: '日终对账,产出差异',
note: '渠道账单与本地流水比对,差异进入对账差异页人工处理'
}
]
/* ---------------- C-2. 接口约定 ---------------- */
const apiList = [
{
method: 'POST',
path: '/api/payment/create',
params: 'out_trade_no、order_type、amount(分)、channel、subject',
desc: '创建支付单并返回渠道支付参数(pay_no + pay_params)'
},
{
method: 'GET',
path: '/api/payment/query/:payNo',
params: 'payNo、sync(可选)',
desc: '查询支付单;sync=true 时后端主动向渠道查询并同步最新状态'
},
{
method: 'POST',
path: '/api/payment/callback/:channel',
params: '渠道原始报文(渠道服务器调用,前端不调用)',
desc: '渠道异步通知入口:验签、幂等、更新支付单与业务订单'
},
{
method: 'POST',
path: '/api/payment/refund',
params: 'pay_no、amount(分)、reason',
desc: '手动原路退回(兜底能力,需人工确认,不做业务退款流程)'
},
{
method: 'GET',
path: '/api/payment/orders',
params: 'page、pageSize、tenant_id、channel、status、start_time、end_time',
desc: '支付单列表(平台端订单管理、首页最近交易)'
},
{
method: 'GET',
path: '/api/commission/list',
params: 'page、pageSize、promoter、status、start_time、end_time',
desc: '佣金台账列表;统计接口 /api/commission/stats 返回应付 / 已付 / 待处理'
}
]
const methodTag = (method: string) => (method === 'GET' ? 'success' : 'primary')
/* ---------------- C-3/C-4/C-5. 代码示例 ---------------- */
const apiCode = `// src/api/payment.js(节选)
import request from "@/utils/request";
// 接口前缀:约定文档为 /api/payment,本项目平台端统一 /platform 前缀,改这一处即可
const PAY = "/platform/payment";
/**
* 创建支付单:返回渠道支付参数,前端据此拉起支付
* @param {Object} data { out_trade_no, order_type, amount(分), channel, subject }
*/
export function createPayment(data) {
return request({ url: \`\${PAY}/create\`, method: "post", data });
}
/**
* 查询支付单:sync=true 时后端会主动向渠道同步最新状态
* @param {string} payNo 支付单号
*/
export function queryPayment(payNo, params = {}) {
return request({ url: \`\${PAY}/query/\${payNo}\`, method: "get", params });
}
/** 手动原路退回(兜底) */
export function refundPayment(data) {
return request({ url: \`\${PAY}/refund\`, method: "post", data });
}`
const pageCode = `<!-- src/views/tenant/order/index.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { ElMessage } from "element-plus";
import { createPayment, queryPayment } from "@/api/payment";
const channels = [
{ label: "微信支付", value: "wechat" },
{ label: "支付宝", value: "alipay" },
{ label: "银联", value: "unionpay" }
];
const loading = ref(false);
const payNo = ref("");
async function handlePay(channel: string) {
loading.value = true;
try {
// 1. 创建支付单:金额单位统一为「分」
const res = await createPayment({
out_trade_no: "ORD202609150001",
order_type: "platform_usage",
amount: 299900, // 2999.00 元
channel,
subject: "平台使用费 - 2026 年度"
});
if (!res?.success) {
ElMessage.error(res?.message || "下单失败");
return;
}
payNo.value = res.data.pay_no;
// 2. 用渠道返回的参数拉起支付
await launchChannelPay(res.data.channel, res.data.pay_params);
// 3. 前端轮询仅用于展示,最终状态以服务端回调结果为准
await pollStatus();
} catch (error: any) {
ElMessage.error(error?.message || "支付发起失败");
} finally {
loading.value = false;
}
}
/** 渠道差异封装在各自适配逻辑里,页面只调用统一入口 */
async function launchChannelPay(channel: string, payParams: Record<string, any>) {
if (channel === "wechat" && payParams.code_url) {
// 展示二维码,用户扫码支付(Native)
renderQrCode(payParams.code_url);
return;
}
if (channel === "alipay" && payParams.form) {
// 支付宝返回自动提交表单:插入容器后触发表单提交即可跳转
const container = document.createElement("div");
container.innerHTML = payParams.form;
document.body.appendChild(container);
(container.querySelector("form") as HTMLFormElement)?.submit();
}
}
/** 兜底轮询:每 3 秒查一次,最多 20 次 */
async function pollStatus() {
for (let i = 0; i < 20; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 3000));
const res = await queryPayment(payNo.value, { sync: true });
const status = res?.data?.status;
if (status === "paid") {
ElMessage.success("支付成功");
return;
}
if (status === "failed" || status === "closed") {
ElMessage.error("支付未完成,请重新下单");
return;
}
}
ElMessage.warning("支付结果确认中,可稍后在订单列表查看");
}
function renderQrCode(_url: string) {
// 使用项目内二维码组件渲染
}
<\/script>`
const adminCode = `// src/views/system/payment/orders/detail.vue(节选)
import { ElMessage, ElMessageBox } from "element-plus";
import { queryPayment, refundPayment } from "@/api/payment";
/** 手动查询渠道状态:回调丢失时的兜底手段 */
async function handleQueryChannel() {
const res = await queryPayment(payNo.value, { sync: true });
if (res?.data?.status === "paid") {
ElMessage.success("渠道状态已同步为支付成功");
}
await loadDetail();
}
/** 手动原路退回:仅平台管理员使用,需填写原因 */
async function handleManualRefund() {
const { value: reason } = await ElMessageBox.prompt("请输入退回原因", "手动原路退回", {
inputValidator: (val: string) => (val?.trim() ? true : "退回原因不能为空")
});
await refundPayment({ pay_no: detail.pay_no, amount: detail.amount, reason });
ElMessage.success("退回申请已提交,请以渠道退回结果为准");
}`
</script>
<style scoped lang="less">
.doc-wrap {
.section-block {
scroll-margin-top: 20px;
}
.block-card {
margin-bottom: 20px;
.card-title {
font-size: 15px;
font-weight: 600;
}
}
}
.path {
padding: 1px 6px;
background: #f5f7fa;
border-radius: 4px;
font-family: Consolas, monospace;
font-size: 12px;
color: #f56c6c;
}
.chain {
.chain-item {
position: relative;
display: flex;
gap: 12px;
padding-bottom: 18px;
}
.chain-index {
width: 24px;
height: 24px;
flex-shrink: 0;
border-radius: 50%;
background: #409eff;
color: #fff;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.chain-body {
padding-top: 1px;
}
.chain-text {
font-size: 13px;
font-weight: 600;
color: #303133;
line-height: 1.7;
}
.chain-note {
margin-top: 2px;
font-size: 12px;
color: #909399;
line-height: 1.7;
}
.chain-line {
position: absolute;
left: 11px;
top: 24px;
bottom: 0;
width: 1px;
background: #dcdfe6;
}
}
.tip {
margin-top: 12px;
}
.code-block {
margin: 0;
padding: 16px 18px;
background: #282c34;
border-radius: 6px;
color: #abb2bf;
font-family: Consolas, Menlo, monospace;
font-size: 12.5px;
line-height: 1.75;
max-height: 520px;
overflow: auto;
white-space: pre;
tab-size: 2;
}
</style>
+549
View File
@@ -0,0 +1,549 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>支付管理</h2>
<p class="sub-title">
平台向租户收取平台使用费,款项直接进入平台对公账户;不涉及分账与业务退款(仅保留后台手动原路退回兜底),推广佣金为平台自有资金支出。
</p>
</div>
<div class="header-actions">
<el-button :loading="loading" @click="refresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 一、渠道状态概览 -->
<el-card shadow="never" class="block-card">
<template #header>
<div class="card-header">
<span class="card-title">渠道状态概览</span>
<div class="card-header-right">
<span class="card-tip">共 {{ channelList.length }} 个渠道,已启用 {{ enabledCount }} 个</span>
<el-radio-group v-model="viewMode" size="small">
<el-radio-button value="table">表格</el-radio-button>
<el-radio-button value="card">卡片</el-radio-button>
</el-radio-group>
</div>
</div>
</template>
<el-table
v-if="viewMode === 'table'"
:data="channelList"
stripe
v-loading="loading"
style="width: 100%"
>
<el-table-column label="渠道名称" min-width="180">
<template #default="{ row }">
<div class="channel-cell">
<el-icon :size="16" class="channel-icon">
<component :is="channelIcon(row.icon)" />
</el-icon>
<span>{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="channel" />
</template>
</el-table-column>
<el-table-column label="商户号" min-width="200">
<template #default="{ row }">
<span v-if="row.merchant_no">{{ row.merchant_no }}</span>
<span v-else class="text-muted">未配置</span>
</template>
</el-table-column>
<el-table-column label="说明" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-muted">{{ row.remark }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="goChannelConfig(row)">
<el-icon><Setting /></el-icon>
配置
</el-button>
<el-button
v-if="row.status === 'enabled'"
size="small"
type="danger"
link
@click="toggleChannelStatus(row, false)"
>
停用
</el-button>
<el-button
v-else
size="small"
type="success"
link
:disabled="row.status === 'unconfigured'"
@click="toggleChannelStatus(row, true)"
>
启用
</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="channel-card-grid" v-loading="loading">
<ChannelCard
v-for="row in channelList"
:key="row.channel"
:name="row.name"
:status="row.status"
:merchant-no="row.merchant_no"
:icon="row.icon"
:desc="row.remark"
@config="goChannelConfig(row)"
@enable="toggleChannelStatus(row, true)"
@disable="toggleChannelStatus(row, false)"
/>
</div>
</el-card>
<!-- 二、快捷入口 -->
<div class="entry-grid">
<el-card
v-for="entry in entries"
:key="entry.key"
shadow="hover"
class="entry-card"
@click="goEntry(entry.path)"
>
<div class="entry-body">
<div class="entry-icon" :style="{ background: entry.color }">
<el-icon :size="22"><component :is="entry.icon" /></el-icon>
</div>
<div class="entry-text">
<div class="entry-title">{{ entry.title }}</div>
<div class="entry-desc">{{ entry.desc }}</div>
</div>
</div>
</el-card>
</div>
<!-- 三、最近交易 -->
<el-card shadow="never" class="block-card">
<template #header>
<div class="card-header">
<span class="card-title">最近交易(最近 10 条)</span>
<el-button size="small" type="primary" link @click="goEntry(ROUTES.orders)">
查看全部
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
</template>
<el-table :data="recentOrders" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="tenant_name" label="租户" min-width="200" show-overflow-tooltip />
<el-table-column label="金额" width="140" align="right">
<template #default="{ row }">
<span class="amount">¥{{ fenToYuan(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="渠道" width="140" align="center">
<template #default="{ row }">{{ channelName(row.channel) }}</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="order" />
</template>
</el-table-column>
<el-table-column label="时间" width="180" align="center">
<template #default="{ row }">{{ row.create_time || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="goOrderDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Refresh,
Setting,
ArrowRight,
Tickets,
Coin,
DataAnalysis,
Document,
ChatDotRound,
Wallet,
CreditCard,
Lightning,
Money
} from '@element-plus/icons-vue'
import StatusTag from './components/StatusTag.vue'
import ChannelCard from './components/ChannelCard.vue'
// TODO: 接口联调时打开
// import { getChannelList, getOrders, toggleChannel } from '@/api/payment'
const router = useRouter()
const loading = ref(false)
/** 渠道概览视图:table 表格 / card 卡片 */
const viewMode = ref<'table' | 'card'>('table')
/** TODO: 路由 path 需与你配置的菜单 path 保持一致,例如实际为 /payment/orders 时请同步修改 */
const ROUTES = {
orders: '/system/payment/orders',
orderDetail: '/system/payment/orders/detail',
commission: '/system/payment/commission',
reconcile: '/system/payment/reconcile',
guide: '/system/payment/guide',
channels: {
wechat: '/system/payment/channels/wechat',
alipay: '/system/payment/channels/alipay',
unionpay: '/system/payment/channels/unionpay',
cloudpay: '/system/payment/channels/cloudpay',
paypal: '/system/payment/channels/paypal'
} as Record<string, string>
}
/** 渠道图标映射(图标名与 ChannelCard 组件保持一致) */
const CHANNEL_ICONS: Record<string, any> = {
ChatDotRound,
Wallet,
CreditCard,
Lightning,
Money
}
const channelIcon = (icon: string) => CHANNEL_ICONS[icon] || Money
/* ---------------- 渠道概览 ---------------- */
interface ChannelRow {
channel: string
name: string
status: 'enabled' | 'disabled' | 'unconfigured'
merchant_no: string
icon: string
remark: string
}
const channelList = ref<ChannelRow[]>([
{
channel: 'wechat',
name: '微信支付',
status: 'enabled',
merchant_no: '1620888999',
icon: 'ChatDotRound',
remark: 'JSAPI / Native 扫码收款,平台使用费主渠道'
},
{
channel: 'alipay',
name: '支付宝',
status: 'enabled',
merchant_no: '2088123456789012',
icon: 'Wallet',
remark: '电脑网站支付 / 手机网站支付'
},
{
channel: 'unionpay',
name: '银联',
status: 'unconfigured',
merchant_no: '',
icon: 'CreditCard',
remark: '银联全渠道收单,需上传商户证书'
},
{
channel: 'cloudpay',
name: '云闪付',
status: 'disabled',
merchant_no: '898110158000000',
icon: 'Lightning',
remark: '复用银联商户参数,仅控制收银台是否展示云闪付标识'
},
{
channel: 'paypal',
name: 'PayPal',
status: 'disabled',
merchant_no: '',
icon: 'Money',
remark: '面向港澳台及海外租户,当前无跨境需求,默认关闭'
}
])
const enabledCount = computed(() => channelList.value.filter((i) => i.status === 'enabled').length)
const channelName = (channel: string) =>
channelList.value.find((i) => i.channel === channel)?.name || channel
/* ---------------- 快捷入口 ---------------- */
const entries = [
{
key: 'orders',
title: '支付订单',
desc: '支付单、业务订单与状态流转',
icon: Tickets,
color: 'linear-gradient(135deg, #409eff, #79bbff)',
path: ROUTES.orders
},
{
key: 'commission',
title: '推广佣金',
desc: '佣金台账、规则与发放登记',
icon: Coin,
color: 'linear-gradient(135deg, #e6a23c, #f3d19e)',
path: ROUTES.commission
},
{
key: 'reconcile',
title: '对账管理',
desc: '渠道账单与本地的差异核对',
icon: DataAnalysis,
color: 'linear-gradient(135deg, #67c23a, #95de64)',
path: ROUTES.reconcile
},
{
key: 'guide',
title: '使用说明',
desc: '渠道配置、调用链与常见问题',
icon: Document,
color: 'linear-gradient(135deg, #909399, #b1b3b8)',
path: ROUTES.guide
}
]
/* ---------------- 最近交易 ---------------- */
interface RecentOrder {
pay_no: string
tenant_name: string
amount: number
channel: string
status: string
create_time: string
}
const recentOrders = ref<RecentOrder[]>([])
/** 最近 10 条:TODO 换成 getOrders({ page: 1, pageSize: 10 }) */
function loadRecentOrders() {
recentOrders.value = [
{ pay_no: 'P202609150009', tenant_name: '杭州云泽科技有限公司', amount: 299900, channel: 'wechat', status: 'paid', create_time: '2026-09-15 10:24:31' },
{ pay_no: 'P202609150008', tenant_name: '宁波海曙贸易有限公司', amount: 99900, channel: 'alipay', status: 'paid', create_time: '2026-09-15 09:58:02' },
{ pay_no: 'P202609150007', tenant_name: '上海弘远信息技术有限公司', amount: 199900, channel: 'wechat', status: 'pending', create_time: '2026-09-15 09:31:47' },
{ pay_no: 'P202609150006', tenant_name: '深圳市博通电子商务有限公司', amount: 499900, channel: 'alipay', status: 'paid', create_time: '2026-09-15 09:12:15' },
{ pay_no: 'P202609150005', tenant_name: '北京中科智联科技有限公司', amount: 99900, channel: 'wechat', status: 'failed', create_time: '2026-09-15 08:56:39' },
{ pay_no: 'P202609140021', tenant_name: '成都天府软件有限公司', amount: 299900, channel: 'wechat', status: 'paid', create_time: '2026-09-14 18:42:10' },
{ pay_no: 'P202609140020', tenant_name: '武汉光谷数字科技有限公司', amount: 199900, channel: 'alipay', status: 'closed', create_time: '2026-09-14 17:20:55' },
{ pay_no: 'P202609140019', tenant_name: '西安丝路网络科技有限公司', amount: 99900, channel: 'wechat', status: 'paid', create_time: '2026-09-14 16:05:23' },
{ pay_no: 'P202609140018', tenant_name: '青岛海洋装备有限公司', amount: 599900, channel: 'alipay', status: 'paid', create_time: '2026-09-14 14:48:07' },
{ pay_no: 'P202609140017', tenant_name: '长沙湘江云计算有限公司', amount: 299900, channel: 'wechat', status: 'refunded', create_time: '2026-09-14 11:33:41' }
]
}
/* ---------------- 数据加载与操作 ---------------- */
async function fetchChannels() {
// TODO: 接口联通后替换为真实请求
// const res = await getChannelList()
// if (res.success) channelList.value = res.data.list || res.data || []
}
async function fetchData() {
loading.value = true
try {
await Promise.all([fetchChannels(), Promise.resolve(loadRecentOrders())])
} catch (error: any) {
ElMessage.error(error?.message || '加载支付概览失败')
} finally {
loading.value = false
}
}
async function refresh() {
await fetchData()
ElMessage.success('刷新成功')
}
function goChannelConfig(row: ChannelRow) {
const path = ROUTES.channels[row.channel]
if (!path) {
ElMessage.warning('该渠道配置页路由未配置')
return
}
router.push(path)
}
function goEntry(path?: string) {
if (!path) return
router.push(path)
}
function goOrderDetail(row: RecentOrder) {
router.push({ path: ROUTES.orderDetail, query: { payNo: row.pay_no } })
}
async function toggleChannelStatus(row: ChannelRow, enable: boolean) {
// TODO: 接口联通后替换为 toggleChannel(row.channel, enable)
row.status = enable ? 'enabled' : 'disabled'
ElMessage.success(`${row.name}已${enable ? '启用' : '停用'}`)
}
/* ---------------- 工具函数 ---------------- */
/** 金额:分 -> 元 */
function fenToYuan(fen: number | string | null | undefined): string {
const n = Number(fen ?? 0)
if (!Number.isFinite(n)) return '0.00'
return (n / 100).toFixed(2)
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
.header-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
}
.block-card {
margin-bottom: 20px;
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header-right {
display: flex;
align-items: center;
gap: 12px;
}
.card-title {
font-size: 15px;
font-weight: 600;
}
.card-tip {
font-size: 12px;
color: #909399;
}
}
.channel-card-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.channel-cell {
display: flex;
align-items: center;
gap: 8px;
.channel-icon {
color: #409eff;
}
}
.text-muted {
color: #909399;
}
.amount {
font-weight: 600;
color: #f56c6c;
}
.entry-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.entry-card {
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: translateY(-2px);
}
.entry-body {
display: flex;
align-items: center;
gap: 12px;
}
.entry-icon {
width: 44px;
height: 44px;
border-radius: 10px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.entry-text {
min-width: 0;
}
.entry-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.entry-desc {
margin-top: 4px;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
}
@media (max-width: 1200px) {
.entry-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -0,0 +1,416 @@
<template>
<div class="container-box" v-loading="loading">
<el-page-header :content="payNo || '支付订单详情'" @back="goBack">
<template #extra>
<div class="header-actions">
<el-button :loading="querying" @click="handleQueryChannel">
<el-icon><Refresh /></el-icon>
手动查询渠道状态
</el-button>
<el-button type="danger" plain :disabled="!canRefund" @click="handleManualRefund">
手动原路退回
</el-button>
<el-button @click="goBack">返回列表</el-button>
</div>
</template>
</el-page-header>
<el-divider></el-divider>
<!-- 支付单基本信息 -->
<el-card shadow="never" class="block-card">
<template #header>
<div class="card-header">
<span class="card-title">支付单信息</span>
<StatusTag :status="detail.status" dict="order" />
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="支付单号">{{ detail.pay_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="支付状态">
<StatusTag :status="detail.status" dict="order" />
</el-descriptions-item>
<el-descriptions-item label="支付金额">
<span class="amount">¥{{ fenToYuan(detail.amount) }}</span>
<span class="text-muted">({{ detail.amount }} 分)</span>
</el-descriptions-item>
<el-descriptions-item label="币种">{{ detail.currency || 'CNY' }}</el-descriptions-item>
<el-descriptions-item label="支付渠道">{{ channelLabel(detail.channel) }}</el-descriptions-item>
<el-descriptions-item label="渠道流水号">{{ detail.channel_trade_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detail.create_time || '-' }}</el-descriptions-item>
<el-descriptions-item label="支付完成时间">{{ detail.paid_at || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 业务订单信息 -->
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">业务订单信息</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="业务订单号">{{ detail.out_trade_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单类型">{{ orderTypeLabel(detail.order_type) }}</el-descriptions-item>
<el-descriptions-item label="租户">{{ detail.tenant_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="租户编号">{{ detail.tenant_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单金额">
<span class="amount">¥{{ fenToYuan(detail.order_amount) }}</span>
</el-descriptions-item>
<el-descriptions-item label="业务订单状态">
<StatusTag :status="detail.order_status" dict="order" />
</el-descriptions-item>
<el-descriptions-item label="订单摘要" :span="2">
{{ detail.subject || '-' }}
</el-descriptions-item>
</el-descriptions>
<el-alert
class="order-tip"
type="info"
:closable="false"
show-icon
title="业务订单与支付单是两张表:一个业务订单可能对应多笔支付单(如超时重下),以状态为 paid 的支付单为准。"
/>
</el-card>
<!-- 渠道信息 -->
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">渠道信息</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="渠道">{{ channelLabel(detail.channel) }}</el-descriptions-item>
<el-descriptions-item label="商户号">{{ detail.merchant_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="渠道流水号">{{ detail.channel_trade_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="回调时间">{{ detail.notify_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="回调地址" :span="2">
{{ detail.notify_url || '-' }}
</el-descriptions-item>
<el-descriptions-item label="最近查询时间" :span="2">
{{ detail.last_query_at || '-' }}
</el-descriptions-item>
</el-descriptions>
<div class="raw-block">
<div class="raw-title">渠道回调原文(脱敏后摘要)</div>
<pre class="raw-json">{{ detail.callback_raw || '暂无回调数据' }}</pre>
</div>
</el-card>
<!-- 状态流转记录 -->
<el-card shadow="never" class="block-card">
<template #header>
<span class="card-title">状态流转记录</span>
</template>
<el-timeline v-if="detail.records && detail.records.length">
<el-timeline-item
v-for="(item, index) in detail.records"
:key="index"
:timestamp="item.create_time"
placement="top"
>
<div class="record-row">
<StatusTag :status="item.to_status" dict="order" />
<span class="record-remark">{{ item.remark }}</span>
</div>
<div class="record-operator">操作来源:{{ item.operator }}</div>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无流转记录" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getOrderDetail, queryPayment, refundPayment } from '@/api/payment'
const route = useRoute()
const router = useRouter()
/** TODO: 路由 path 需与你配置的菜单 path 保持一致 */
const LIST_PATH = '/system/payment/orders'
const payNo = computed(() => String(route.query.payNo || ''))
/** 从佣金台账等入口跳转时可能只带业务订单号 */
const outTradeNoQuery = computed(() => String(route.query.outTradeNo || ''))
const loading = ref(false)
const querying = ref(false)
/** 状态流转记录:对应 yz_platform_payment_order_log(to_status / operator / remark / create_time) */
interface RecordItem {
to_status: string
create_time: string
operator: string
remark: string
}
const detail = reactive<Record<string, any>>({
pay_no: '',
status: '',
amount: 0,
currency: 'CNY',
channel: '',
channel_trade_no: '',
create_time: '',
paid_at: '',
out_trade_no: '',
order_type: 'platform_usage',
tenant_name: '',
tenant_id: '',
order_amount: 0,
order_status: '',
subject: '',
merchant_no: '',
notify_at: '',
notify_url: '',
last_query_at: '',
callback_raw: '',
records: [] as RecordItem[]
})
/** 仅支付成功且未退款时允许手动原路退回(兜底能力) */
const canRefund = computed(() => detail.status === 'paid')
const channelMap: Record<string, string> = {
wechat: '微信支付',
alipay: '支付宝',
unionpay: '银联',
cloudpay: '云闪付',
paypal: 'PayPal'
}
const channelLabel = (channel: string) => channelMap[channel] || channel || '-'
const orderTypeMap: Record<string, string> = {
platform_usage: '平台使用费',
module_shop: '模块购买',
service_fee: '服务费'
}
const orderTypeLabel = (type: string) => orderTypeMap[type] || type || '-'
/* ---------------- 数据加载 ---------------- */
async function loadDetail() {
loading.value = true
try {
// TODO: 接口联通后替换为真实请求(后端支持按 payNo 或 outTradeNo 查询)
// const res = await getOrderDetail(payNo.value || outTradeNoQuery.value)
// if (res.success) Object.assign(detail, res.data)
Object.assign(detail, {
pay_no: payNo.value || 'P202609150009',
out_trade_no: outTradeNoQuery.value || 'ORD202609150001',
status: 'paid',
amount: 299900,
currency: 'CNY',
channel: 'wechat',
channel_trade_no: '4200001234202609150987654321',
create_time: '2026-09-15 10:23:12',
paid_at: '2026-09-15 10:24:31',
out_trade_no: 'ORD202609150001',
order_type: 'platform_usage',
tenant_name: '杭州云泽科技有限公司',
tenant_id: 'T10001',
order_amount: 299900,
order_status: 'paid',
subject: '平台使用费 - 2026 年度(标准版)',
merchant_no: '1620888999',
notify_at: '2026-09-15 10:24:33',
notify_url: `${window.location.origin}/api/payment/callback/wechat`,
last_query_at: '2026-09-15 10:25:02',
callback_raw: JSON.stringify(
{
id: 'evt_20260915102431001',
event_type: 'TRANSACTION.SUCCESS',
resource: {
out_trade_no: 'ORD202609150001',
transaction_id: '4200001234202609150987654321',
trade_state: 'SUCCESS',
amount: { total: 299900, payer_total: 299900, currency: 'CNY' }
}
},
null,
2
),
records: [
{
to_status: 'created',
create_time: '2026-09-15 10:23:12',
operator: '租户端下单页',
remark: '创建支付单,渠道:微信支付(Native 扫码)'
},
{
to_status: 'paying',
create_time: '2026-09-15 10:23:35',
operator: '渠道回调',
remark: '用户扫码完成付款,渠道返回支付中'
},
{
to_status: 'paid',
create_time: '2026-09-15 10:24:31',
operator: '渠道回调',
remark: '支付成功,订单金额 299900 分,已同步业务订单状态为已支付'
}
] as RecordItem[]
})
} catch (error: any) {
ElMessage.error(error?.message || '获取支付单详情失败')
} finally {
loading.value = false
}
}
/* ---------------- 操作 ---------------- */
/** 手动查询渠道状态:主动调用渠道查询接口并回写本地状态(兜底回调丢失) */
async function handleQueryChannel() {
querying.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await queryPayment(payNo.value, { sync: true })
await new Promise((resolve) => setTimeout(resolve, 600))
detail.last_query_at = new Date().toLocaleString('zh-CN')
ElMessage.success('渠道状态已查询:SUCCESS(本地状态已同步)')
await loadDetail()
} catch (error: any) {
ElMessage.error(error?.message || '查询渠道状态失败')
} finally {
querying.value = false
}
}
/** 手动原路退回(兜底,不做业务层退款流程) */
async function handleManualRefund() {
let reason = ''
try {
const { value } = await ElMessageBox.prompt(
`确定对支付单 ${detail.pay_no} 发起原路退回吗?金额 ¥${fenToYuan(detail.amount)},此操作会真实出账且不可撤销。`,
'手动原路退回',
{
type: 'warning',
inputPlaceholder: '请输入退回原因(必填)',
inputValidator: (val: string) => (val && val.trim() ? true : '退回原因不能为空'),
confirmButtonText: '确认退回'
}
)
reason = value
} catch {
return
}
try {
// TODO: 接口联通后替换为真实请求
// await refundPayment({ pay_no: detail.pay_no, amount: detail.amount, reason })
detail.status = 'refunding'
detail.records.unshift({
to_status: 'refunding',
create_time: new Date().toLocaleString('zh-CN'),
operator: '平台管理员',
remark: `手动原路退回:${reason}`
})
ElMessage.success('退回申请已提交,请以渠道退回结果为准')
} catch (error: any) {
ElMessage.error(error?.message || '退回失败')
}
}
function goBack() {
router.push(LIST_PATH)
}
/** 金额:分 -> 元 */
function fenToYuan(fen: number | string | null | undefined): string {
const n = Number(fen ?? 0)
if (!Number.isFinite(n)) return '0.00'
return (n / 100).toFixed(2)
}
onMounted(() => {
loadDetail()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-actions {
display: flex;
gap: 8px;
}
.block-card {
margin-bottom: 20px;
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 15px;
font-weight: 600;
}
}
.amount {
font-weight: 600;
color: #f56c6c;
}
.text-muted {
margin-left: 6px;
font-size: 12px;
color: #909399;
}
.order-tip {
margin-top: 12px;
}
.raw-block {
margin-top: 16px;
.raw-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #303133;
}
.raw-json {
margin: 0;
padding: 12px 16px;
background: #f5f7fa;
border-radius: 6px;
font-size: 12px;
line-height: 1.7;
color: #606266;
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
}
.record-row {
display: flex;
align-items: center;
gap: 8px;
}
.record-remark {
font-size: 13px;
color: #303133;
}
.record-operator {
margin-top: 4px;
font-size: 12px;
color: #909399;
}
</style>
@@ -0,0 +1,352 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>支付订单</h2>
<div class="header-actions">
<el-button :loading="loading" @click="refresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 筛选区 -->
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="租户">
<el-select
v-model="filters.tenant_id"
placeholder="全部租户"
clearable
filterable
remote
:remote-method="searchTenant"
:loading="tenantLoading"
style="width: 220px"
>
<el-option
v-for="item in tenantOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="渠道">
<el-select v-model="filters.channel" placeholder="全部渠道" clearable style="width: 160px">
<el-option
v-for="item in channelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部状态" clearable style="width: 160px">
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
unlink-panels
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 列表 -->
<el-card shadow="never">
<el-table :data="tableData" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="pay_no" label="支付单号" min-width="180">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="goDetail(row)">
{{ row.pay_no }}
</el-button>
</template>
</el-table-column>
<el-table-column prop="out_trade_no" label="业务订单号" min-width="180" show-overflow-tooltip />
<el-table-column prop="tenant_name" label="租户" min-width="200" show-overflow-tooltip />
<el-table-column label="金额" width="130" align="right">
<template #default="{ row }">
<span class="amount">¥{{ fenToYuan(row.amount) }}</span>
</template>
</el-table-column>
<el-table-column label="渠道" width="120" align="center">
<template #default="{ row }">{{ channelName(row.channel) }}</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<StatusTag :status="row.status" dict="order" />
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="180" align="center" />
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="goDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="page.current"
v-model:page-size="page.size"
:total="page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getOrders } from '@/api/payment'
const router = useRouter()
const loading = ref(false)
const tenantLoading = ref(false)
/** TODO: 路由 path 需与你配置的菜单 path 保持一致 */
const DETAIL_PATH = '/system/payment/orders/detail'
const filters = reactive<{
tenant_id: string | number | ''
channel: string
status: string
dateRange: [string, string] | []
}>({
tenant_id: '',
channel: '',
status: '',
dateRange: []
})
const page = reactive({ current: 1, size: 10, total: 0 })
const channelOptions = [
{ label: '微信支付', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '银联', value: 'unionpay' },
{ label: '云闪付', value: 'cloudpay' },
{ label: 'PayPal', value: 'paypal' }
]
const statusOptions = [
{ label: '待支付', value: 'pending' },
{ label: '支付中', value: 'paying' },
{ label: '支付成功', value: 'paid' },
{ label: '支付失败', value: 'failed' },
{ label: '已关闭', value: 'closed' },
{ label: '已退款', value: 'refunded' }
]
const tenantOptions = ref([
{ id: 'T10001', name: '杭州云泽科技有限公司' },
{ id: 'T10002', name: '宁波海曙贸易有限公司' },
{ id: 'T10003', name: '上海弘远信息技术有限公司' },
{ id: 'T10004', name: '深圳市博通电子商务有限公司' }
])
const channelName = (channel: string) =>
channelOptions.find((i) => i.value === channel)?.label || channel
/** 租户远程搜索:TODO 替换为 getTenantList({ keyword }) */
async function searchTenant(keyword: string) {
tenantLoading.value = true
try {
const all = [
{ id: 'T10001', name: '杭州云泽科技有限公司' },
{ id: 'T10002', name: '宁波海曙贸易有限公司' },
{ id: 'T10003', name: '上海弘远信息技术有限公司' },
{ id: 'T10004', name: '深圳市博通电子商务有限公司' },
{ id: 'T10005', name: '北京中科智联科技有限公司' },
{ id: 'T10006', name: '成都天府软件有限公司' }
]
tenantOptions.value = keyword ? all.filter((i) => i.name.includes(keyword)) : all
} finally {
tenantLoading.value = false
}
}
/* ---------------- Mock 数据(本地筛选 + 分页) ---------------- */
interface OrderRow {
pay_no: string
out_trade_no: string
tenant_id: string
tenant_name: string
amount: number
channel: string
status: string
create_time: string
}
const ALL_ORDERS: OrderRow[] = Array.from({ length: 26 }).map((_, index) => {
const channels = ['wechat', 'alipay', 'unionpay', 'cloudpay']
const statuses = ['paid', 'paid', 'paid', 'pending', 'failed', 'closed', 'refunded']
const tenants = [
{ id: 'T10001', name: '杭州云泽科技有限公司' },
{ id: 'T10002', name: '宁波海曙贸易有限公司' },
{ id: 'T10003', name: '上海弘远信息技术有限公司' },
{ id: 'T10004', name: '深圳市博通电子商务有限公司' },
{ id: 'T10005', name: '北京中科智联科技有限公司' },
{ id: 'T10006', name: '成都天府软件有限公司' }
]
const amounts = [99900, 199900, 299900, 499900, 599900]
const tenant = tenants[index % tenants.length]
const day = String(15 - Math.floor(index / 6)).padStart(2, '0')
const hour = String(20 - (index % 12)).padStart(2, '0')
const minute = String((index * 7) % 60).padStart(2, '0')
return {
pay_no: `P202609${day}${String(1000 + index)}`,
out_trade_no: `ORD202609${day}${String(2000 + index)}`,
tenant_id: tenant.id,
tenant_name: tenant.name,
amount: amounts[index % amounts.length],
channel: channels[index % channels.length],
status: statuses[index % statuses.length],
create_time: `2026-09-${day} ${hour}:${minute}:00`
}
})
const tableData = ref<OrderRow[]>([])
async function fetchList() {
loading.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await getOrders({
// page: page.current,
// pageSize: page.size,
// tenant_id: filters.tenant_id,
// channel: filters.channel,
// status: filters.status,
// start_time: filters.dateRange?.[0],
// end_time: filters.dateRange?.[1]
// })
// tableData.value = res.data.list || []
// page.total = res.data.total || 0
const [start, end] = filters.dateRange || []
const filtered = ALL_ORDERS.filter((item) => {
if (filters.tenant_id && item.tenant_id !== filters.tenant_id) return false
if (filters.channel && item.channel !== filters.channel) return false
if (filters.status && item.status !== filters.status) return false
const date = item.create_time.slice(0, 10)
if (start && date < start) return false
if (end && date > end) return false
return true
})
page.total = filtered.length
const from = (page.current - 1) * page.size
tableData.value = filtered.slice(from, from + page.size)
} catch (error: any) {
ElMessage.error(error?.message || '获取支付订单失败')
} finally {
loading.value = false
}
}
function handleSearch() {
page.current = 1
fetchList()
}
function handleReset() {
filters.tenant_id = ''
filters.channel = ''
filters.status = ''
filters.dateRange = []
page.current = 1
fetchList()
}
async function refresh() {
await fetchList()
ElMessage.success('刷新成功')
}
function goDetail(row: OrderRow) {
router.push({ path: DETAIL_PATH, query: { payNo: row.pay_no } })
}
/** 金额:分 -> 元 */
function fenToYuan(fen: number | string | null | undefined): string {
const n = Number(fen ?? 0)
if (!Number.isFinite(n)) return '0.00'
return (n / 100).toFixed(2)
}
onMounted(() => {
fetchList()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
}
.filter-card {
margin-bottom: 20px;
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: -18px;
}
}
.amount {
font-weight: 600;
color: #f56c6c;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
</style>
@@ -0,0 +1,461 @@
<template>
<div class="container-box">
<div class="header-bar">
<div>
<h2>对账差异</h2>
<p class="sub-title">
按日拉取渠道账单与本地支付流水比对,差异需人工核对后标记处理,避免漏单与重复入账。
</p>
</div>
<div class="header-actions">
<el-button :loading="loading" @click="refresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<el-alert
class="reconcile-tip"
type="warning"
:closable="false"
show-icon
title="差异类型说明:长款=渠道有、本地无(疑似漏单);短款=本地有、渠道无(疑似未成功);状态不一致=金额一致但状态不同。"
/>
<!-- 筛选区 -->
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="渠道">
<el-select v-model="filters.channel" placeholder="全部渠道" clearable style="width: 160px">
<el-option
v-for="item in channelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="对账日期">
<el-date-picker
v-model="filters.bill_date"
type="date"
placeholder="选择对账日期"
value-format="YYYY-MM-DD"
style="width: 180px"
/>
</el-form-item>
<el-form-item label="差异类型">
<el-select v-model="filters.diff_type" placeholder="全部类型" clearable style="width: 160px">
<el-option label="长款" value="long" />
<el-option label="短款" value="short" />
<el-option label="状态不一致" value="status_mismatch" />
</el-select>
</el-form-item>
<el-form-item label="处理状态">
<el-select v-model="filters.handle_status" placeholder="全部状态" clearable style="width: 160px">
<el-option label="待处理" value="unhandled" />
<el-option label="处理中" value="handling" />
<el-option label="已处理" value="handled" />
<el-option label="已忽略" value="ignored" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 差异列表 -->
<el-card shadow="never">
<el-table :data="tableData" stripe v-loading="loading" style="width: 100%">
<el-table-column label="渠道" width="120" align="center">
<template #default="{ row }">{{ channelLabel(row.channel) }}</template>
</el-table-column>
<el-table-column prop="bill_date" label="对账日期" width="130" align="center" />
<el-table-column prop="local_trade_no" label="本地流水号" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.local_trade_no">{{ row.local_trade_no }}</span>
<span v-else class="text-muted">无本地记录</span>
</template>
</el-table-column>
<el-table-column prop="channel_trade_no" label="渠道流水号" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.channel_trade_no">{{ row.channel_trade_no }}</span>
<span v-else class="text-muted">无渠道记录</span>
</template>
</el-table-column>
<el-table-column label="本地金额" width="120" align="right">
<template #default="{ row }">
<span v-if="row.local_amount !== null">¥{{ fenToYuan(row.local_amount) }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="渠道金额" width="120" align="right">
<template #default="{ row }">
<span v-if="row.channel_amount !== null">¥{{ fenToYuan(row.channel_amount) }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="差异类型" width="180">
<template #default="{ row }">
<StatusTag :status="row.diff_type" dict="diff" />
<span class="diff-amount">{{ diffAmountText(row) }}</span>
</template>
</el-table-column>
<el-table-column label="处理状态" width="120" align="center">
<template #default="{ row }">
<StatusTag :status="row.handle_status" dict="reconcile" />
</template>
</el-table-column>
<el-table-column label="处理备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-muted">{{ row.remark || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.handle_status === 'unhandled' || row.handle_status === 'handling'"
size="small"
type="primary"
link
@click="handleMarkHandled(row)"
>
标记处理
</el-button>
<span v-else class="text-muted">已处理</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="page.current"
v-model:page-size="page.size"
:total="page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import StatusTag from '../components/StatusTag.vue'
// TODO: 接口联调时打开
// import { getReconcileList, markReconcileHandled } from '@/api/payment'
const loading = ref(false)
const filters = reactive({
channel: '',
bill_date: '',
diff_type: '',
handle_status: ''
})
const page = reactive({ current: 1, size: 10, total: 0 })
const channelOptions = [
{ label: '微信支付', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '银联', value: 'unionpay' },
{ label: '云闪付', value: 'cloudpay' },
{ label: 'PayPal', value: 'paypal' }
]
const channelMap: Record<string, string> = {
wechat: '微信支付',
alipay: '支付宝',
unionpay: '银联',
cloudpay: '云闪付',
paypal: 'PayPal'
}
const channelLabel = (channel: string) => channelMap[channel] || channel || '-'
/* ---------------- Mock 数据(本地筛选 + 分页) ---------------- */
interface ReconcileRow {
id: number
channel: string
bill_date: string
local_trade_no: string
channel_trade_no: string
local_amount: number | null
channel_amount: number | null
diff_type: 'long' | 'short' | 'status_mismatch'
handle_status: 'unhandled' | 'handling' | 'handled' | 'ignored'
remark: string
}
const ALL_DIFFS: ReconcileRow[] = [
{
id: 1,
channel: 'wechat',
bill_date: '2026-09-14',
local_trade_no: '',
channel_trade_no: '4200001234202609140011223344',
local_amount: null,
channel_amount: 299900,
diff_type: 'long',
handle_status: 'unhandled',
remark: ''
},
{
id: 2,
channel: 'alipay',
bill_date: '2026-09-14',
local_trade_no: 'P202609140012',
channel_trade_no: '',
local_amount: 99900,
channel_amount: null,
diff_type: 'short',
handle_status: 'unhandled',
remark: ''
},
{
id: 3,
channel: 'wechat',
bill_date: '2026-09-13',
local_trade_no: 'P202609130031',
channel_trade_no: '4200001234202609130055667788',
local_amount: 199900,
channel_amount: 199900,
diff_type: 'status_mismatch',
handle_status: 'handled',
remark: '渠道已成功,本地回调丢失,已手动查询同步为已支付'
},
{
id: 4,
channel: 'unionpay',
bill_date: '2026-09-13',
local_trade_no: 'P202609130028',
channel_trade_no: 'UP2026091300286547',
local_amount: 499900,
channel_amount: 499900,
diff_type: 'status_mismatch',
handle_status: 'handling',
remark: '已联系收单机构核实,等待回复'
},
{
id: 5,
channel: 'alipay',
bill_date: '2026-09-12',
local_trade_no: '',
channel_trade_no: '2026091222001445123456789012',
local_amount: null,
channel_amount: 99900,
diff_type: 'long',
handle_status: 'ignored',
remark: '渠道账单重复记录(已确认同一笔订单),无需处理'
},
{
id: 6,
channel: 'wechat',
bill_date: '2026-09-12',
local_trade_no: 'P202609120009',
channel_trade_no: '',
local_amount: 299900,
channel_amount: null,
diff_type: 'short',
handle_status: 'handled',
remark: '用户超时未支付,本地已置为已关闭'
},
{
id: 7,
channel: 'cloudpay',
bill_date: '2026-09-11',
local_trade_no: 'P202609110004',
channel_trade_no: 'UP2026091100043321',
local_amount: 599900,
channel_amount: 599900,
diff_type: 'status_mismatch',
handle_status: 'handled',
remark: '银联通道时报状态延迟,次日后台查询已一致'
},
{
id: 8,
channel: 'wechat',
bill_date: '2026-09-11',
local_trade_no: 'P202609110002',
channel_trade_no: '4200001234202609110099001122',
local_amount: 199900,
channel_amount: 199800,
diff_type: 'short',
handle_status: 'unhandled',
remark: ''
}
]
const tableData = ref<ReconcileRow[]>([])
async function fetchList() {
loading.value = true
try {
// TODO: 接口联通后替换为真实请求
// const res = await getReconcileList({
// page: page.current,
// pageSize: page.size,
// channel: filters.channel,
// bill_date: filters.bill_date,
// diff_type: filters.diff_type,
// handle_status: filters.handle_status
// })
// tableData.value = res.data.list || []
// page.total = res.data.total || 0
const filtered = ALL_DIFFS.filter((item) => {
if (filters.channel && item.channel !== filters.channel) return false
if (filters.bill_date && item.bill_date !== filters.bill_date) return false
if (filters.diff_type && item.diff_type !== filters.diff_type) return false
if (filters.handle_status && item.handle_status !== filters.handle_status) return false
return true
})
page.total = filtered.length
const from = (page.current - 1) * page.size
tableData.value = filtered.slice(from, from + page.size)
} catch (error: any) {
ElMessage.error(error?.message || '获取对账差异失败')
} finally {
loading.value = false
}
}
/* ---------------- 操作 ---------------- */
async function handleMarkHandled(row: ReconcileRow) {
let remark = ''
try {
const { value } = await ElMessageBox.prompt('请填写处理说明(会记录到备注,便于日后追溯)', '标记处理', {
type: 'warning',
inputPlaceholder: '如:已手动补单/已联系渠道核实/已忽略',
inputValidator: (val: string) => (val && val.trim() ? true : '请填写处理说明'),
confirmButtonText: '确认'
})
remark = value
} catch {
return
}
try {
// TODO: 接口联通后替换为 markReconcileHandled(row.id, { handle_type: 'manual', remark })
row.handle_status = 'handled'
row.remark = remark
ElMessage.success('已标记为已处理')
await fetchList()
} catch (error: any) {
ElMessage.error(error?.message || '标记失败')
}
}
function handleSearch() {
page.current = 1
fetchList()
}
function handleReset() {
filters.channel = ''
filters.bill_date = ''
filters.diff_type = ''
filters.handle_status = ''
page.current = 1
fetchList()
}
async function refresh() {
await fetchList()
ElMessage.success('刷新成功')
}
/** 差异金额文案:本地与渠道金额都有的才显示差额 */
function diffAmountText(row: ReconcileRow) {
if (row.local_amount === null || row.channel_amount === null) return ''
const diff = row.channel_amount - row.local_amount
if (diff === 0) return ''
const sign = diff > 0 ? '+' : '-'
return `(差额 ${sign}¥${fenToYuan(Math.abs(diff))})`
}
/** 金额:分 -> 元 */
function fenToYuan(fen: number | string | null | undefined): string {
const n = Number(fen ?? 0)
if (!Number.isFinite(n)) return '0.00'
return (n / 100).toFixed(2)
}
onMounted(() => {
fetchList()
})
</script>
<style scoped lang="less">
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.sub-title {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.6;
color: #909399;
}
.header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
}
.reconcile-tip {
margin-bottom: 20px;
}
.filter-card {
margin-bottom: 20px;
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: -18px;
}
}
.text-muted {
color: #909399;
}
.diff-amount {
margin-left: 6px;
font-size: 12px;
color: #909399;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
</style>