增加支付功能
This commit is contained in:
@@ -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 & 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user