feat(pay):添加统一支付配置菜单

This commit is contained in:
qian committed 2026-09-09 23:47:18 +08:00
1 parent 5c6839127f
commit a266d3cd83
8 files changed
+424 -6

No files matched your search

+9
View File
@@ -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: "审计日志",
+6
View File
@@ -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": "可修改支付、汇率、存储、邮件等全站配置",
+6 -5
View File
@@ -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"},
+6
View File
@@ -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',
+1
View File
@@ -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>
+5 -1
View File
@@ -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 {