feat(pay):添加统一支付配置菜单
This commit is contained in:
1 parent
5c6839127f
commit
a266d3cd83
8 files changed
+424
-6
No files matched your search
@@ -65,6 +65,15 @@ var predefinedPermissionGroups = []v1.PermissionGroup{
|
||||
"order:view", "order:refund",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "payment",
|
||||
Label: "支付渠道",
|
||||
Permissions: []string{
|
||||
// 渠道配置含商户密钥明文,独立于 system:* 权限点:必须显式授予才能
|
||||
// 查看/修改支付渠道,避免"能进系统设置就能拿到收款密钥"
|
||||
"payment:view", "payment:edit",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "audit",
|
||||
Label: "审计日志",
|
||||
|
||||
@@ -74,6 +74,11 @@ var tierPermissions = map[string]map[string][]string{
|
||||
TierRead: {"order:view"},
|
||||
TierFull: {"order:view", "order:refund"},
|
||||
},
|
||||
// 支付渠道只有查看/编辑两个动作,无删除档:密钥类配置宁可少放开
|
||||
"payment": {
|
||||
TierRead: {"payment:view"},
|
||||
TierOperate: {"payment:view", "payment:edit"},
|
||||
},
|
||||
"promo": {
|
||||
TierRead: {"promo:view"},
|
||||
TierOperate: {"promo:view", "promo:create", "promo:edit"},
|
||||
@@ -130,6 +135,7 @@ var tierPermissions = map[string]map[string][]string{
|
||||
var dangerousPermissions = map[string]string{
|
||||
"billing:refund": "可对任意租户钱包退款/调账",
|
||||
"order:refund": "可对订单发起退款到支付渠道",
|
||||
"payment:edit": "可修改支付渠道配置与商户密钥,直接影响线上收款",
|
||||
"audit:read_sensitive": "可查看敏感数据访问日志",
|
||||
"audit:clear": "可硬删除全部拦截日志(不可恢复)",
|
||||
"system:edit": "可修改支付、汇率、存储、邮件等全站配置",
|
||||
|
||||
@@ -215,11 +215,12 @@ var adminPermissionRules = []permissionRule{
|
||||
{method: "PUT", prefix: "/api/admin/settings/", perm: "system:edit"},
|
||||
// 配置连通性测试(对象存储 / 邮件):会真实调用外部服务并可能发信,按写权限管控
|
||||
{method: "POST", prefix: "/api/admin/settings/", perm: "system:edit"},
|
||||
// Payment settings & channels (system scope)
|
||||
{method: "GET", path: "/api/admin/payment-channels", perm: "system:view"},
|
||||
{method: "PUT", prefix: "/api/admin/payment-channels/", perm: "system:edit"},
|
||||
{method: "GET", path: "/api/admin/payment-settings", perm: "system:view"},
|
||||
{method: "PUT", path: "/api/admin/payment-settings", perm: "system:edit"},
|
||||
// Payment settings & channels:渠道配置含商户密钥明文,使用独立的 payment:*
|
||||
// 权限点,与 system:*(系统设置)解耦,必须显式授予才能访问
|
||||
{method: "GET", path: "/api/admin/payment-channels", perm: "payment:view"},
|
||||
{method: "PUT", prefix: "/api/admin/payment-channels/", perm: "payment:edit"},
|
||||
{method: "GET", path: "/api/admin/payment-settings", perm: "payment:view"},
|
||||
{method: "PUT", path: "/api/admin/payment-settings", perm: "payment:edit"},
|
||||
{method: "GET", path: "/api/admin/data-governance/settings", perm: "system:view"},
|
||||
{method: "PUT", path: "/api/admin/data-governance/settings", perm: "system:edit"},
|
||||
{method: "POST", prefix: "/api/admin/data-governance/", perm: "system:edit"},
|
||||
|
||||
@@ -103,6 +103,12 @@ const adminRoutes: RouteRecordRaw[] = [
|
||||
path: 'payment-settings',
|
||||
redirect: { name: 'AdminSettings' },
|
||||
},
|
||||
{
|
||||
path: 'payment-channels',
|
||||
name: 'AdminPaymentChannels',
|
||||
component: () => import('@/views/finance/PaymentChannelsPage.vue'),
|
||||
meta: { title: '支付渠道', perm: 'payment:view' },
|
||||
},
|
||||
{
|
||||
path: 'redemptions',
|
||||
name: 'AdminRedemptions',
|
||||
|
||||
@@ -403,6 +403,7 @@ const ALL_MENU_GROUPS = [
|
||||
{ name: 'AdminPermissions', label: '角色权限', icon: IconSafe, superOnly: true },
|
||||
{ name: 'AdminPlugins', label: '插件管理', icon: IconCodeBlock, perm: 'system:plugin' },
|
||||
{ name: 'AdminSettings', label: '系统设置', icon: IconSettings, perm: 'system:view' },
|
||||
{ name: 'AdminPaymentChannels', label: '支付渠道', icon: IconIdcard, perm: 'payment:view' },
|
||||
{ name: 'AdminFiles', label: '文件管理', icon: IconStorage, perm: 'file:view' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, type Component } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import request from '@/utils/request'
|
||||
import EpayChannelCard from './payment-channels/EpayChannelCard.vue'
|
||||
|
||||
// 渠道类型 → 配置卡片组件映射:后端新增渠道类型时在此登记对应组件即可,
|
||||
// Tab 按后端返回的渠道列表生成;未登记的渠道类型选中后显示占位提示
|
||||
const channelCards: Record<string, Component> = {
|
||||
epay: EpayChannelCard,
|
||||
}
|
||||
|
||||
// 渠道中文名(侧栏 Tab 与占位提示展示用)
|
||||
const channelNames: Record<string, string> = {
|
||||
epay: '易支付',
|
||||
}
|
||||
|
||||
interface ChannelItem {
|
||||
channel: string
|
||||
is_enabled: boolean
|
||||
config: any
|
||||
}
|
||||
|
||||
const channels = ref<ChannelItem[]>([])
|
||||
const activeChannel = ref('')
|
||||
const loading = ref(false)
|
||||
const callbackBaseURL = ref('')
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 渠道列表与支付设置并行拉取:回调地址是启用渠道的前置条件,为空时提示先去配置
|
||||
const [chRes, settingsRes] = await Promise.all([
|
||||
request.get('/admin/payment-channels'),
|
||||
request.get('/admin/payment-settings'),
|
||||
])
|
||||
channels.value = chRes.data?.data?.list || []
|
||||
callbackBaseURL.value = settingsRes.data?.data?.callback_base_url || ''
|
||||
// 默认选中第一个渠道
|
||||
if (channels.value.length > 0) activeChannel.value = channels.value[0].channel
|
||||
} catch {
|
||||
// 错误提示由拦截器统一弹出
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const activeChannelItem = computed(() => channels.value.find(c => c.channel === activeChannel.value))
|
||||
|
||||
function gotoPaymentSettings() {
|
||||
router.push({ name: 'AdminSettings', query: { tab: 'payment' } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="支付渠道" description="管理租户充值可用的支付方式,按渠道类型独立配置和启停" />
|
||||
|
||||
<!-- 启用任一渠道前必须先配置回调地址(后端保存时强校验),为空时提前引导 -->
|
||||
<AAlert v-if="!loading && !callbackBaseURL" type="warning" class="callback-alert">
|
||||
支付回调基础 URL 未配置,启用渠道前请先前往「系统设置 → 支付」完成配置,否则支付成功后无法回调入账。
|
||||
<AButton type="text" size="mini" @click="gotoPaymentSettings">前往配置</AButton>
|
||||
</AAlert>
|
||||
|
||||
<div class="channels-layout">
|
||||
<!-- 左侧渠道 Tab:后端已注册渠道逐个列出,末尾固定一个「敬请期待」占位 -->
|
||||
<div class="channels-sidebar">
|
||||
<AMenu :selected-keys="[activeChannel]">
|
||||
<AMenuItem v-for="ch in channels" :key="ch.channel">
|
||||
{{ channelNames[ch.channel] || ch.channel }}
|
||||
</AMenuItem>
|
||||
<!-- 更多渠道接入开发中:禁用态占位,点击无响应 -->
|
||||
<AMenuItem key="coming-soon" disabled>敬请期待</AMenuItem>
|
||||
</AMenu>
|
||||
</div>
|
||||
|
||||
<!-- 右侧当前渠道配置卡片 -->
|
||||
<div class="channels-content">
|
||||
<ACard :bordered="false">
|
||||
<ASpin :loading="loading" style="display: block">
|
||||
<template v-if="activeChannelItem">
|
||||
<component
|
||||
:is="channelCards[activeChannelItem.channel]"
|
||||
v-if="channelCards[activeChannelItem.channel]"
|
||||
:key="activeChannelItem.channel"
|
||||
:initial-config="activeChannelItem.config"
|
||||
/>
|
||||
<div v-else class="unsupported-card">
|
||||
{{ channelNames[activeChannelItem.channel] || activeChannelItem.channel }}渠道已注册,但当前版本暂未提供配置界面
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="!loading" class="unsupported-card">暂无可用支付渠道</div>
|
||||
</ASpin>
|
||||
</ACard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.callback-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.channels-layout {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.channels-sidebar {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.channels-sidebar :deep(.arco-menu) {
|
||||
border-radius: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.channels-sidebar :deep(.arco-menu-item) {
|
||||
border-radius: 4px;
|
||||
margin: 2px 4px;
|
||||
}
|
||||
|
||||
.channels-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.unsupported-card {
|
||||
padding: 20px;
|
||||
border: 1px dashed var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 移动端:侧栏 Tab 转为顶部横向滚动条,内容区下沉为单列,避免 200px 固定侧栏挤压内容 */
|
||||
@media (max-width: 768px) {
|
||||
.channels-layout {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.channels-sidebar {
|
||||
width: 100%;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
.channels-sidebar :deep(.arco-menu-inner) {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding: 4px;
|
||||
/* 隐藏横向滚动条,保留可滑动 */
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.channels-sidebar :deep(.arco-menu-inner)::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.channels-sidebar :deep(.arco-menu-item) {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 易支付渠道配置卡片:单例配置(每种渠道类型全平台一份),经
|
||||
// PUT /admin/payment-channels/epay 整体覆盖保存
|
||||
const props = defineProps<{
|
||||
initialConfig: {
|
||||
is_enabled: boolean
|
||||
pay_address: string
|
||||
merchant_id: string
|
||||
merchant_key: string
|
||||
pay_methods: { name: string; type: string; color: string }[]
|
||||
}
|
||||
}>()
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
// 本地编辑副本:保存成功前不影响后端已生效配置
|
||||
const form = reactive({
|
||||
is_enabled: props.initialConfig.is_enabled ?? false,
|
||||
pay_address: props.initialConfig.pay_address ?? '',
|
||||
merchant_id: props.initialConfig.merchant_id ?? '',
|
||||
merchant_key: props.initialConfig.merchant_key ?? '',
|
||||
})
|
||||
|
||||
// 支付方式以 JSON 编辑:各上游网关的 type 取值不一(alipay/wxpay 或站点自定义值),
|
||||
// 固定下拉无法穷举,直接编辑 JSON 数组最灵活
|
||||
const payMethodsJSON = ref(formatPayMethods(props.initialConfig.pay_methods))
|
||||
|
||||
function formatPayMethods(methods: unknown): string {
|
||||
return JSON.stringify(Array.isArray(methods) ? methods : [], null, 2)
|
||||
}
|
||||
|
||||
// 解析并校验支付方式 JSON,非法时弹出对应错误提示并返回 null
|
||||
function parsePayMethods(): { name: string; type: string; color: string }[] | null {
|
||||
let parsed: any
|
||||
const text = payMethodsJSON.value.trim()
|
||||
try {
|
||||
parsed = text === '' ? [] : JSON.parse(text)
|
||||
} catch {
|
||||
Message.error('支付方式 JSON 格式无效,请检查是否为合法 JSON')
|
||||
return null
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
Message.error('支付方式必须是 JSON 数组,如 [{"name":"支付宝","type":"alipay","color":"#1677FF"}]')
|
||||
return null
|
||||
}
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const m = parsed[i]
|
||||
if (!m || typeof m !== 'object' || !String(m.name ?? '').trim() || !String(m.type ?? '').trim()) {
|
||||
Message.error(`支付方式第 ${i + 1} 项缺少 name 或 type 字段`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
return parsed.map((m: any) => ({
|
||||
name: String(m.name).trim(),
|
||||
type: String(m.type).trim(),
|
||||
color: typeof m.color === 'string' ? m.color : '',
|
||||
}))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (form.is_enabled) {
|
||||
if (!form.pay_address.trim() || !form.merchant_id.trim() || !form.merchant_key.trim()) {
|
||||
Message.error('启用易支付需完整填写网关地址、商户 ID 和商户密钥')
|
||||
return
|
||||
}
|
||||
}
|
||||
const methods = parsePayMethods()
|
||||
if (!methods) return
|
||||
saving.value = true
|
||||
try {
|
||||
await request.put('/admin/payment-channels/epay', {
|
||||
config: JSON.stringify({ ...form, pay_methods: methods }),
|
||||
})
|
||||
Message.success('易支付配置已保存')
|
||||
// 回填规范化后的 JSON,让界面与实际提交内容保持一致
|
||||
payMethodsJSON.value = formatPayMethods(methods)
|
||||
} catch {
|
||||
// 错误提示由拦截器统一弹出(如启用时回调地址未配置的后端校验)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="channel-card">
|
||||
<div class="channel-head">
|
||||
<div>
|
||||
<div class="channel-name">易支付</div>
|
||||
<div class="channel-desc">聚合支付网关(支付宝/微信等),MD5 签名协议,直接对接网关无需第三方 SDK</div>
|
||||
</div>
|
||||
<div class="channel-switch">
|
||||
<span class="switch-text">{{ form.is_enabled ? '已启用' : '未启用' }}</span>
|
||||
<ASwitch v-model="form.is_enabled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="channel-body">
|
||||
<div class="form-grid">
|
||||
<AFormItem label="网关地址" help="易支付网关根地址,系统在其后拼接 /submit.php 发起支付" class="field-full">
|
||||
<AInput v-model="form.pay_address" placeholder="https://pay.example.com" allow-clear />
|
||||
</AFormItem>
|
||||
<AFormItem label="商户 ID" help="易支付商户 ID(pid)">
|
||||
<AInput v-model="form.merchant_id" placeholder="例如 1001" allow-clear />
|
||||
</AFormItem>
|
||||
<AFormItem label="商户密钥" help="易支付商户密钥(KEY),用于 MD5 签名与回调验签">
|
||||
<AInputPassword v-model="form.merchant_key" placeholder="商户密钥" allow-clear />
|
||||
</AFormItem>
|
||||
<AFormItem
|
||||
label="支付方式"
|
||||
help="JSON 数组:name=租户充值页显示名称;type=协议支付类型,按上游网关实际支持填写(alipay/wxpay 或自定义值);color=徽章颜色(可选)。数组顺序即展示顺序"
|
||||
class="field-full"
|
||||
>
|
||||
<ATextarea
|
||||
v-model="payMethodsJSON"
|
||||
:auto-size="{ minRows: 4, maxRows: 12 }"
|
||||
class="pay-methods-json"
|
||||
placeholder='[
|
||||
{ "name": "支付宝", "type": "alipay", "color": "#1677FF" },
|
||||
{ "name": "微信支付", "type": "wxpay", "color": "#00C250" }
|
||||
]'
|
||||
/>
|
||||
</AFormItem>
|
||||
</div>
|
||||
|
||||
<div class="channel-footer">
|
||||
<AButton type="primary" :loading="saving" @click="save">保存配置</AButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.channel-card {
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
|
||||
.channel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--color-fill-2);
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.channel-desc {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.channel-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.switch-text {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.channel-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px 28px;
|
||||
}
|
||||
|
||||
.field-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* JSON 编辑区用等宽字体,多行结构更易读 */
|
||||
.pay-methods-json :deep(textarea) {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.channel-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--color-fill-2);
|
||||
}
|
||||
|
||||
/* 移动端:表单字段单列堆叠,头部说明与开关换行,避免内容被挤压 */
|
||||
@media (max-width: 768px) {
|
||||
.channel-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, type Component } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { useSettings } from './settings/useSettings'
|
||||
@@ -41,6 +42,7 @@ const categoryMap: Record<string, Component> = {
|
||||
|
||||
const categories = ref<SettingCategory[]>([])
|
||||
const activeTab = ref('')
|
||||
const route = useRoute()
|
||||
|
||||
const { formValues, loading, saving, refresh, save } = useSettings(() => activeTab.value)
|
||||
|
||||
@@ -89,7 +91,9 @@ async function fetchCategories() {
|
||||
const res: any = await request.get('/admin/settings/categories')
|
||||
categories.value = (res.data?.data?.list || []).sort((a: SettingCategory, b: SettingCategory) => a.order - b.order)
|
||||
if (categories.value.length > 0 && !activeTab.value) {
|
||||
activeTab.value = categories.value[0].key
|
||||
// 支持 ?tab=xxx 直达指定分类(如支付渠道页引导跳转支付分类)
|
||||
const fromQuery = String(route.query.tab || '')
|
||||
activeTab.value = categories.value.some(c => c.key === fromQuery) ? fromQuery : categories.value[0].key
|
||||
await refresh()
|
||||
}
|
||||
} catch {
|
||||
|
||||
Reference in new issue
Block a user