Parcourir la source

feat: 支付管理/应用信息(alpha)

(cherry picked from commit 323417b0d4afac0aa6aede6aae3c5d865efa47cb)
dhb52 il y a 1 an
Parent
commit
3f479ecffc

+ 7 - 31
src/api/pay/channel/index.ts

@@ -7,64 +7,40 @@ export interface ChannelVO {
   status: number
   remark: string
   feeRate: number
-  merchantId: number
   appId: number
   createTime: Date
 }
 
-export interface ChannelPageReqVO extends PageParam {
-  code?: string
-  status?: number
-  remark?: string
-  feeRate?: number
-  merchantId?: number
-  appId?: number
-  config?: string
-  createTime?: Date[]
-}
-
-export interface ChannelExportReqVO {
-  code?: string
-  status?: number
-  remark?: string
-  feeRate?: number
-  merchantId?: number
-  appId?: number
-  config?: string
-  createTime?: Date[]
-}
-
 // 查询列表支付渠道
-export const getChannelPageApi = (params: ChannelPageReqVO) => {
+export const getChannelPage = (params: PageParam) => {
   return request.get({ url: '/pay/channel/page', params })
 }
 
 // 查询详情支付渠道
-export const getChannelApi = (merchantId: number, appId: string, code: string) => {
+export const getChannel = (appId: string, code: string) => {
   const params = {
-    merchantId: merchantId,
     appId: appId,
     code: code
   }
-  return request.get({ url: '/pay/channel/get-channel', params: params })
+  return request.get({ url: '/pay/channel/get', params: params })
 }
 
 // 新增支付渠道
-export const createChannelApi = (data: ChannelVO) => {
+export const createChannel = (data: ChannelVO) => {
   return request.post({ url: '/pay/channel/create', data })
 }
 
 // 修改支付渠道
-export const updateChannelApi = (data: ChannelVO) => {
+export const updateChannel = (data: ChannelVO) => {
   return request.put({ url: '/pay/channel/update', data })
 }
 
 // 删除支付渠道
-export const deleteChannelApi = (id: number) => {
+export const deleteChannel = (id: number) => {
   return request.delete({ url: '/pay/channel/delete?id=' + id })
 }
 
 // 导出支付渠道
-export const exportChannelApi = (params: ChannelExportReqVO) => {
+export const exportChannel = (params) => {
   return request.download({ url: '/pay/channel/export-excel', params })
 }

+ 6 - 1
src/utils/constants.ts

@@ -118,6 +118,10 @@ export const PayChannelEnum = {
   ALIPAY_BAR: {
     code: 'alipay_bar',
     name: '支付宝条码支付'
+  },
+  MOCK: {
+    code: 'mock',
+    name: '模拟支付'
   }
 }
 
@@ -126,7 +130,8 @@ export const PayChannelEnum = {
  */
 export const PayType = {
   WECHAT: 'WECHAT',
-  ALIPAY: 'ALIPAY'
+  ALIPAY: 'ALIPAY',
+  MOCK: 'MOCK'
 }
 
 /**

+ 130 - 0
src/views/pay/app/components/AppForm.vue

@@ -0,0 +1,130 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="160px"
+    >
+      <el-form-item label="应用名" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入应用名" />
+      </el-form-item>
+      <el-form-item label="开启状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="支付结果的回调地址" prop="payNotifyUrl">
+        <el-input v-model="formData.payNotifyUrl" placeholder="请输入支付结果的回调地址" />
+      </el-form-item>
+      <el-form-item label="退款结果的回调地址" prop="refundNotifyUrl">
+        <el-input v-model="formData.refundNotifyUrl" placeholder="请输入退款结果的回调地址" />
+      </el-form-item>
+      <el-form-item label="备注" prop="remark">
+        <el-input v-model="formData.remark" placeholder="请输入备注" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import * as AppApi from '@/api/pay/app'
+import { CommonStatusEnum } from '@/utils/constants'
+
+defineOptions({ name: 'PayAppForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  packageId: undefined,
+  contactName: undefined,
+  contactMobile: undefined,
+  accountCount: undefined,
+  expireTime: undefined,
+  domain: undefined,
+  status: CommonStatusEnum.ENABLE
+})
+const formRules = reactive({
+  name: [{ required: true, message: '应用名不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }],
+  payNotifyUrl: [{ required: true, message: '支付结果的回调地址不能为空', trigger: 'blur' }],
+  refundNotifyUrl: [{ required: true, message: '退款结果的回调地址不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await AppApi.getApp(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as AppApi.AppVO
+    if (formType.value === 'create') {
+      await AppApi.createApp(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await AppApi.updateApp(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    status: CommonStatusEnum.ENABLE,
+    remark: undefined,
+    payNotifyUrl: undefined,
+    refundNotifyUrl: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 315 - 0
src/views/pay/app/components/alipayChannelForm.vue

@@ -0,0 +1,315 @@
+<template>
+  <div>
+    <el-dialog
+      v-model="dialogVisible"
+      :title="title"
+      @closed="close"
+      append-to-body
+      destroy-on-close
+      width="830px"
+    >
+      <el-form
+        ref="formRef"
+        :model="formData"
+        :rules="rules"
+        label-width="100px"
+        v-loading="formLoading"
+      >
+        <el-form-item label-width="180px" label="渠道费率" prop="feeRate">
+          <el-input v-model="formData.feeRate" placeholder="请输入渠道费率" clearable>
+            <template #append>%</template>
+          </el-input>
+        </el-form-item>
+        <el-form-item label-width="180px" label="开放平台 APPID" prop="config.appId">
+          <el-input v-model="formData.config.appId" placeholder="请输入开放平台 APPID" clearable />
+        </el-form-item>
+        <el-form-item label-width="180px" label="渠道状态" prop="status">
+          <el-radio-group v-model="formData.status">
+            <el-radio
+              v-for="dict in getDictOptions(DICT_TYPE.COMMON_STATUS)"
+              :key="parseInt(dict.value)"
+              :label="parseInt(dict.value)"
+            >
+              {{ dict.label }}
+            </el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label-width="180px" label="网关地址" prop="config.serverUrl">
+          <el-radio-group v-model="formData.config.serverUrl">
+            <el-radio label="https://openapi.alipay.com/gateway.do">线上环境</el-radio>
+            <el-radio label="https://openapi-sandbox.dl.alipaydev.com/gateway.do"
+              >沙箱环境</el-radio
+            >
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label-width="180px" label="算法类型" prop="config.signType">
+          <el-radio-group v-model="formData.config.signType">
+            <el-radio key="RSA2" label="RSA2">RSA2</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label-width="180px" label="公钥类型" prop="config.mode">
+          <el-radio-group v-model="formData.config.mode">
+            <el-radio key="公钥模式" :label="1">公钥模式</el-radio>
+            <el-radio key="证书模式" :label="2">证书模式</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <div v-if="formData.config.mode === 1">
+          <el-form-item label-width="180px" label="应用私钥" prop="config.privateKey">
+            <el-input
+              type="textarea"
+              :autosize="{ minRows: 8, maxRows: 8 }"
+              v-model="formData.config.privateKey"
+              placeholder="请输入应用私钥"
+              clearable
+              :style="{ width: '100%' }"
+            />
+          </el-form-item>
+          <el-form-item label-width="180px" label="支付宝公钥" prop="config.alipayPublicKey">
+            <el-input
+              type="textarea"
+              :autosize="{ minRows: 8, maxRows: 8 }"
+              v-model="formData.config.alipayPublicKey"
+              placeholder="请输入支付宝公钥"
+              clearable
+              :style="{ width: '100%' }"
+            />
+          </el-form-item>
+        </div>
+        <div v-if="formData.config.mode === 2">
+          <el-form-item label-width="180px" label="商户公钥应用证书" prop="config.appCertContent">
+            <el-input
+              v-model="formData.config.appCertContent"
+              type="textarea"
+              placeholder="请上传商户公钥应用证书"
+              readonly
+              :autosize="{ minRows: 8, maxRows: 8 }"
+              :style="{ width: '100%' }"
+            />
+          </el-form-item>
+          <el-form-item label-width="180px" label="">
+            <el-upload
+              action=""
+              ref="privateKeyContentFile"
+              :limit="1"
+              :accept="fileAccept"
+              :http-request="appCertUpload"
+              :before-upload="fileBeforeUpload"
+            >
+              <el-button size="small" type="primary" icon="el-icon-upload">点击上传</el-button>
+            </el-upload>
+          </el-form-item>
+          <el-form-item
+            label-width="180px"
+            label="支付宝公钥证书"
+            prop="config.alipayPublicCertContent"
+          >
+            <el-input
+              v-model="formData.config.alipayPublicCertContent"
+              type="textarea"
+              placeholder="请上传支付宝公钥证书"
+              readonly
+              :autosize="{ minRows: 8, maxRows: 8 }"
+              :style="{ width: '100%' }"
+            />
+          </el-form-item>
+          <el-form-item label-width="180px" label="">
+            <el-upload
+              ref="privateCertContentFile"
+              action=""
+              :limit="1"
+              :accept="fileAccept"
+              :before-upload="fileBeforeUpload"
+              :http-request="alipayPublicCertUpload"
+            >
+              <el-button size="small" type="primary" icon="el-icon-upload">点击上传</el-button>
+            </el-upload>
+          </el-form-item>
+          <el-form-item label-width="180px" label="根证书" prop="config.rootCertContent">
+            <el-input
+              v-model="formData.config.rootCertContent"
+              type="textarea"
+              placeholder="请上传根证书"
+              readonly
+              :autosize="{ minRows: 8, maxRows: 8 }"
+              :style="{ width: '100%' }"
+            />
+          </el-form-item>
+          <el-form-item label-width="180px" label="">
+            <el-upload
+              ref="privateCertContentFile"
+              :limit="1"
+              :accept="fileAccept"
+              action=""
+              :before-upload="fileBeforeUpload"
+              :http-request="rootCertUpload"
+            >
+              <el-button size="small" type="primary" icon="el-icon-upload">点击上传</el-button>
+            </el-upload>
+          </el-form-item>
+        </div>
+        <el-form-item label-width="180px" label="备注" prop="remark">
+          <el-input v-model="formData.remark" :style="{ width: '100%' }" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="close">取消</el-button>
+        <el-button type="primary" @click="submitForm">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+<script lang="ts" setup name="AlipayChannelForm">
+import { createChannel, getChannel, updateChannel } from '@/api/pay/channel'
+import { CommonStatusEnum } from '@/utils/constants'
+import { DICT_TYPE, getDictOptions } from '@/utils/dict'
+
+const message = useMessage() // 消息弹窗
+
+const emit = defineEmits(['success'])
+
+const dialogVisible = ref(false)
+const formLoading = ref(false)
+const title = ref('')
+const formData = ref<any>({
+  appId: '',
+  code: '',
+  status: undefined,
+  feeRate: undefined,
+  remark: '',
+  config: {
+    appId: '',
+    serverUrl: null,
+    signType: '',
+    mode: null,
+    privateKey: '',
+    alipayPublicKey: '',
+    appCertContent: '',
+    alipayPublicCertContent: '',
+    rootCertContent: ''
+  }
+})
+
+const rules = {
+  feeRate: [{ required: true, message: '请输入渠道费率', trigger: 'blur' }],
+  status: [{ required: true, message: '渠道状态不能为空', trigger: 'blur' }],
+  'config.appId': [{ required: true, message: '请输入开放平台上创建的应用的 ID', trigger: 'blur' }],
+  'config.serverUrl': [{ required: true, message: '请传入网关地址', trigger: 'blur' }],
+  'config.signType': [{ required: true, message: '请传入签名算法类型', trigger: 'blur' }],
+  'config.mode': [{ required: true, message: '公钥类型不能为空', trigger: 'blur' }],
+  'config.privateKey': [{ required: true, message: '请输入商户私钥', trigger: 'blur' }],
+  'config.alipayPublicKey': [
+    { required: true, message: '请输入支付宝公钥字符串', trigger: 'blur' }
+  ],
+  'config.appCertContent': [{ required: true, message: '请上传商户公钥应用证书', trigger: 'blur' }],
+  'config.alipayPublicCertContent': [
+    { required: true, message: '请上传支付宝公钥证书', trigger: 'blur' }
+  ],
+  'config.rootCertContent': [{ required: true, message: '请上传指定根证书', trigger: 'blur' }]
+}
+
+const fileAccept = '.crt'
+
+const formRef = ref()
+
+const open = async (appId, code) => {
+  dialogVisible.value = true
+  formLoading.value = true
+  reset(appId, code)
+
+  try {
+    const data = await getChannel(appId, code)
+    if (data && data.id) {
+      formData.value = data
+      formData.value.config = JSON.parse(data.config)
+    }
+    title.value = !formData.value.id ? '创建支付渠道' : '编辑支付渠道'
+  } finally {
+    formLoading.value = false
+  }
+}
+
+defineExpose({ open })
+
+const close = () => {
+  dialogVisible.value = false
+  reset(undefined, undefined)
+}
+
+const submitForm = async () => {
+  const valid = await formRef.value.validate()
+  if (!valid) return
+
+  const data: any = { ...formData.value }
+  data.config = JSON.stringify(formData.value.config)
+  if (!data.id) {
+    await createChannel(data)
+    message.success('新增成功')
+  } else {
+    await updateChannel(data)
+    message.success('修改成功')
+  }
+
+  emit('success')
+  close()
+}
+
+/** 重置表单 */
+const reset = (appId, code) => {
+  formData.value = {
+    appId: appId,
+    code: code,
+    status: CommonStatusEnum.ENABLE,
+    remark: '',
+    feeRate: null,
+    config: {
+      appId: '',
+      serverUrl: null,
+      signType: 'RSA2',
+      mode: null,
+      privateKey: '',
+      alipayPublicKey: '',
+      appCertContent: '',
+      alipayPublicCertContent: '',
+      rootCertContent: ''
+    }
+  }
+  // formRef.value?.resetFields()
+}
+
+const fileBeforeUpload = (file) => {
+  let format = '.' + file.name.split('.')[1]
+  if (format !== fileAccept) {
+    message.error(`请上传指定格式"${fileAccept}"文件`)
+    return false
+  }
+  let isRightSize = file.size / 1024 / 1024 < 2
+  if (!isRightSize) {
+    message.error('文件大小超过 2MB')
+  }
+  return isRightSize
+}
+
+const appCertUpload = (event) => {
+  const readFile = new FileReader()
+  readFile.onload = (e: any) => {
+    formData.value.config.appCertContent = e.target.result
+  }
+  readFile.readAsText(event.file)
+}
+
+const alipayPublicCertUpload = (event) => {
+  const readFile = new FileReader()
+  readFile.onload = (e: any) => {
+    formData.value.config.alipayPublicCertContent = e.target.result
+  }
+  readFile.readAsText(event.file)
+}
+
+const rootCertUpload = (event) => {
+  const readFile = new FileReader()
+  readFile.onload = (e: any) => {
+    formData.value.config.rootCertContent = e.target.result
+  }
+  readFile.readAsText(event.file)
+}
+</script>

+ 130 - 0
src/views/pay/app/components/mockChannelForm.vue

@@ -0,0 +1,130 @@
+<template>
+  <div>
+    <el-dialog
+      v-model:visible="dialogVisible"
+      :title="title"
+      @closed="close"
+      append-to-body
+      destroy-on-close
+      width="800px"
+    >
+      <el-form
+        ref="formRef"
+        :model="formData"
+        :rules="rules"
+        label-width="100px"
+        v-loading="formLoading"
+      >
+        <el-form-item label-width="180px" label="渠道状态" prop="status">
+          <el-radio-group v-model="formData.status">
+            <el-radio
+              v-for="dict in getDictOptions(DICT_TYPE.COMMON_STATUS)"
+              :key="parseInt(dict.value)"
+              :label="parseInt(dict.value)"
+            >
+              {{ dict.label }}
+            </el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label-width="180px" label="备注" prop="remark">
+          <el-input v-model="formData.remark" :style="{ width: '100%' }" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="close">取消</el-button>
+        <el-button type="primary" @click="submitForm">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+<script lang="ts" setup name="MockChannelForm">
+import { createChannel, getChannel, updateChannel } from '@/api/pay/channel'
+import { CommonStatusEnum } from '@/utils/constants'
+import { DICT_TYPE, getDictOptions } from '@/utils/dict'
+
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false)
+const formLoading = ref(false)
+const title = ref('')
+const formData = ref<any>({
+  appId: '',
+  code: '',
+  status: undefined,
+  feeRate: 0,
+  remark: '',
+  config: {
+    name: 'mock-conf'
+  }
+})
+
+const rules = {
+  status: [{ required: true, message: '渠道状态不能为空', trigger: 'blur' }]
+}
+
+const formRef = ref()
+
+const emit = defineEmits(['success'])
+
+const open = async (appId, code) => {
+  dialogVisible.value = true
+  formLoading.value = true
+  reset(appId, code)
+
+  try {
+    const data = await getChannel(appId, code)
+
+    if (data && data.id) {
+      formData.value = data
+      formData.value.config = JSON.parse(data.config)
+    }
+    title.value = !formData.value.id ? '创建支付渠道' : '编辑支付渠道'
+  } finally {
+    formLoading.value = false
+  }
+}
+
+const close = () => {
+  dialogVisible.value = false
+  reset(undefined, undefined)
+}
+
+const submitForm = async () => {
+  const valid = await formRef.value?.validate()
+  if (!valid) {
+    return
+  }
+  const data = { ...formData.value }
+  data.config = JSON.stringify(formData.value.config)
+  if (!data.id) {
+    createChannel(data).then(() => {
+      message.success('新增成功')
+      emit('success')
+      close()
+    })
+  } else {
+    updateChannel(data).then(() => {
+      message.success('修改成功')
+      emit('success')
+      close()
+    })
+  }
+}
+
+/** 重置表单 */
+const reset = (appId, code) => {
+  formData.value = {
+    appId: appId,
+    code: code,
+    status: CommonStatusEnum.ENABLE,
+    remark: '',
+    feeRate: 0,
+    config: {
+      name: 'mock-conf'
+    }
+  }
+  formRef.value?.resetFields()
+}
+
+defineExpose({ open })
+</script>

+ 343 - 0
src/views/pay/app/components/weixinChannelForm.vue

@@ -0,0 +1,343 @@
+<template>
+  <div>
+    <el-dialog
+      v-model="dialogVisible"
+      :title="title"
+      @close="close"
+      append-to-body
+      destroy-on-close
+      width="800px"
+    >
+      <el-form
+        ref="formRef"
+        :model="formData"
+        :rules="rules"
+        label-width="120px"
+        v-loading="formLoading"
+      >
+        <el-form-item label-width="180px" label="渠道费率" prop="feeRate">
+          <el-input
+            v-model="formData.feeRate"
+            placeholder="请输入渠道费率"
+            clearable
+            :style="{ width: '100%' }"
+          >
+            <template #append>%</template>
+          </el-input>
+        </el-form-item>
+        <el-form-item label-width="180px" label="公众号 APPID" prop="config.appId">
+          <el-input
+            v-model="formData.config.appId"
+            placeholder="请输入公众号 APPID"
+            clearable
+            :style="{ width: '100%' }"
+          />
+        </el-form-item>
+        <el-form-item label-width="180px" label="商户号" prop="config.mchId">
+          <el-input v-model="formData.config.mchId" :style="{ width: '100%' }" />
+        </el-form-item>
+        <el-form-item label-width="180px" label="渠道状态" prop="status">
+          <el-radio-group v-model="formData.status">
+            <el-radio
+              v-for="dict in getDictOptions(DICT_TYPE.COMMON_STATUS)"
+              :key="parseInt(dict.value)"
+              :label="parseInt(dict.value)"
+            >
+              {{ dict.label }}
+            </el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label-width="180px" label="API 版本" prop="config.apiVersion">
+          <el-radio-group v-model="formData.config.apiVersion">
+            <el-radio label="v2">v2</el-radio>
+            <el-radio label="v3">v3</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <div v-if="formData.config.apiVersion === 'v2'">
+          <el-form-item label-width="180px" label="商户密钥" prop="config.mchKey">
+            <el-input
+              v-model="formData.config.mchKey"
+              placeholder="请输入商户密钥"
+              clearable
+              :style="{ width: '100%' }"
+              type="textarea"
+              :autosize="{ minRows: 8, maxRows: 8 }"
+            />
+          </el-form-item>
+          <el-form-item
+            label-width="180px"
+            label="apiclient_cert.p12 证书"
+            prop="config.keyContent"
+          >
+            <el-input
+              v-model="formData.config.keyContent"
+              type="textarea"
+              placeholder="请上传 apiclient_cert.p12 证书"
+              readonly
+              :autosize="{ minRows: 8, maxRows: 8 }"
+              :style="{ width: '100%' }"
+            />
+          </el-form-item>
+          <el-form-item label-width="180px" label="">
+            <el-upload
+              :limit="1"
+              accept=".p12"
+              action=""
+              :before-upload="p12FileBeforeUpload"
+              :http-request="keyContentUpload"
+            >
+              <el-button size="small" type="primary" icon="el-icon-upload">点击上传</el-button>
+            </el-upload>
+          </el-form-item>
+        </div>
+        <div v-if="formData.config.apiVersion === 'v3'">
+          <el-form-item label-width="180px" label="API V3 密钥" prop="config.apiV3Key">
+            <el-input
+              v-model="formData.config.apiV3Key"
+              placeholder="请输入 API V3 密钥"
+              clearable
+              :style="{ width: '100%' }"
+              type="textarea"
+              :autosize="{ minRows: 8, maxRows: 8 }"
+            />
+          </el-form-item>
+          <el-form-item
+            label-width="180px"
+            label="apiclient_key.perm 证书"
+            prop="config.privateKeyContent"
+          >
+            <el-input
+              v-model="formData.config.privateKeyContent"
+              type="textarea"
+              placeholder="请上传 apiclient_key.perm 证书"
+              readonly
+              :autosize="{ minRows: 8, maxRows: 8 }"
+              :style="{ width: '100%' }"
+            />
+          </el-form-item>
+          <el-form-item label-width="180px" label="" prop="privateKeyContentFile">
+            <el-upload
+              ref="privateKeyContentFile"
+              :limit="1"
+              accept=".pem"
+              action=""
+              :before-upload="pemFileBeforeUpload"
+              :http-request="privateKeyContentUpload"
+            >
+              <el-button size="small" type="primary" icon="el-icon-upload">点击上传</el-button>
+            </el-upload>
+          </el-form-item>
+          <el-form-item
+            label-width="180px"
+            label="apiclient_cert.perm证书"
+            prop="config.privateCertContent"
+          >
+            <el-input
+              v-model="formData.config.privateCertContent"
+              type="textarea"
+              placeholder="请上传apiclient_cert.perm证书"
+              readonly
+              :autosize="{ minRows: 8, maxRows: 8 }"
+              :style="{ width: '100%' }"
+            />
+          </el-form-item>
+          <el-form-item label-width="180px" label="" prop="privateCertContentFile">
+            <el-upload
+              ref="privateCertContentFile"
+              :limit="1"
+              accept=".pem"
+              action=""
+              :before-upload="pemFileBeforeUpload"
+              :http-request="privateCertContentUpload"
+            >
+              <el-button size="small" type="primary" icon="el-icon-upload">点击上传</el-button>
+            </el-upload>
+          </el-form-item>
+        </div>
+        <el-form-item label-width="180px" label="备注" prop="remark">
+          <el-input v-model="formData.remark" :style="{ width: '100%' }" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="close">取消</el-button>
+        <el-button type="primary" @click="submitForm">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+<script lang="ts" setup name="WeixinChannelForm">
+import { createChannel, getChannel, updateChannel } from '@/api/pay/channel'
+import { CommonStatusEnum } from '@/utils/constants'
+import { DICT_TYPE, getDictOptions } from '@/utils/dict'
+
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false)
+const formLoading = ref(false)
+const title = ref('')
+const formData = ref<any>({
+  appId: '',
+  code: '',
+  status: undefined,
+  feeRate: undefined,
+  remark: '',
+  config: {
+    appId: '',
+    mchId: '',
+    apiVersion: '',
+    mchKey: '',
+    keyContent: '',
+    privateKeyContent: '',
+    privateCertContent: '',
+    apiV3Key: ''
+  }
+})
+const formRef = ref()
+
+const emit = defineEmits(['success'])
+
+const rules = {
+  feeRate: [{ required: true, message: '请输入渠道费率', trigger: 'blur' }],
+  status: [{ required: true, message: '渠道状态不能为空', trigger: 'blur' }],
+  'config.mchId': [{ required: true, message: '请传入商户号', trigger: 'blur' }],
+  'config.appId': [{ required: true, message: '请输入公众号APPID', trigger: 'blur' }],
+  'config.apiVersion': [{ required: true, message: 'API版本不能为空', trigger: 'blur' }],
+  'config.mchKey': [{ required: true, message: '请输入商户密钥', trigger: 'blur' }],
+  'config.keyContent': [
+    { required: true, message: '请上传 apiclient_cert.p12 证书', trigger: 'blur' }
+  ],
+  'config.privateKeyContent': [
+    { required: true, message: '请上传 apiclient_key.perm 证书', trigger: 'blur' }
+  ],
+  'config.privateCertContent': [
+    { required: true, message: '请上传 apiclient_cert.perm证 书', trigger: 'blur' }
+  ],
+  'config.apiV3Key': [{ required: true, message: '请上传 api V3 密钥值', trigger: 'blur' }]
+}
+
+const open = async (appId, code) => {
+  dialogVisible.value = true
+  formLoading.value = true
+  reset(appId, code)
+
+  try {
+    const data = await getChannel(appId, code)
+    if (data && data.id) {
+      formData.value = data
+      formData.value.config = JSON.parse(data.config)
+    }
+    title.value = !formData.value.id ? '创建支付渠道' : '编辑支付渠道'
+  } finally {
+    formLoading.value = false
+  }
+}
+
+const close = () => {
+  dialogVisible.value = false
+  reset(undefined, undefined)
+}
+
+const submitForm = async () => {
+  const valid = await formRef.value.validate()
+  if (!valid) {
+    return
+  }
+  const data: any = { ...formData.value }
+  data.config = JSON.stringify(formData.value.config)
+  if (!data.id) {
+    createChannel(data).then(() => {
+      message.alertSuccess('新增成功')
+      emit('success')
+      close()
+    })
+  } else {
+    updateChannel(data).then(() => {
+      message.alertSuccess('修改成功')
+      emit('success')
+      close()
+    })
+  }
+}
+
+/** 重置表单 */
+const reset = (appId, code) => {
+  formData.value = {
+    appId: appId,
+    code: code,
+    status: CommonStatusEnum.ENABLE,
+    feeRate: undefined,
+    remark: '',
+    config: {
+      appId: '',
+      mchId: '',
+      apiVersion: '',
+      mchKey: '',
+      keyContent: '',
+      privateKeyContent: '',
+      privateCertContent: '',
+      apiV3Key: ''
+    }
+  }
+  formRef.value?.resetFields()
+}
+
+/**
+ * apiclient_cert.p12、apiclient_cert.pem、apiclient_key.pem 上传前的校验
+ */
+const fileBeforeUpload = (file, fileAccept) => {
+  let format = '.' + file.name.split('.')[1]
+  if (format !== fileAccept) {
+    debugger
+    message.error('请上传指定格式"' + fileAccept + '"文件')
+    return false
+  }
+  let isRightSize = file.size / 1024 / 1024 < 2
+  if (!isRightSize) {
+    message.error('文件大小超过 2MB')
+  }
+  return isRightSize
+}
+
+const p12FileBeforeUpload = (file) => {
+  fileBeforeUpload(file, '.p12')
+}
+
+const pemFileBeforeUpload = (file) => {
+  fileBeforeUpload(file, '.pem')
+}
+
+/**
+ * 读取 apiclient_key.pem 到 privateKeyContent 字段
+ */
+const privateKeyContentUpload = (event) => {
+  const readFile = new FileReader()
+  readFile.onload = (e: any) => {
+    formData.value.config.privateKeyContent = e.target.result
+  }
+  readFile.readAsText(event.file)
+}
+
+/**
+ * 读取 apiclient_cert.pem 到 privateCertContent 字段
+ */
+const privateCertContentUpload = (event) => {
+  const readFile = new FileReader()
+  readFile.onload = (e: any) => {
+    formData.value.config.privateCertContent = e.target.result
+  }
+  readFile.readAsText(event.file)
+}
+
+/**
+ * 读取 apiclient_cert.p12 到 keyContent 字段
+ */
+const keyContentUpload = (event) => {
+  const readFile = new FileReader()
+  readFile.onload = (e: any) => {
+    formData.value.config.keyContent = e.target.result.split(',')[1]
+  }
+  readFile.readAsDataURL(event.file) // 读成 base64
+}
+
+defineExpose({ open })
+</script>

+ 441 - 132
src/views/pay/app/index.vue

@@ -1,155 +1,464 @@
 <template>
+  <!-- 搜索 -->
   <ContentWrap>
-    <!-- 列表 -->
-    <XTable @register="registerTable">
-      <template #toolbar_buttons>
-        <!-- 操作:新增 -->
-        <XButton
-          type="primary"
-          preIcon="ep:zoom-in"
-          :title="t('action.add')"
-          v-hasPermi="['pay:app:create']"
-          @click="handleCreate()"
-        />
-        <!-- 操作:导出 -->
-        <XButton
-          type="warning"
-          preIcon="ep:download"
-          :title="t('action.export')"
-          v-hasPermi="['pay:app:export']"
-          @click="exportList('应用信息.xls')"
-        />
-      </template>
-      <template #actionbtns_default="{ row }">
-        <!-- 操作:修改 -->
-        <XTextButton
-          preIcon="ep:edit"
-          :title="t('action.edit')"
-          v-hasPermi="['pay:app:update']"
-          @click="handleUpdate(row.id)"
-        />
-        <!-- 操作:详情 -->
-        <XTextButton
-          preIcon="ep:view"
-          :title="t('action.detail')"
-          v-hasPermi="['pay:app:query']"
-          @click="handleDetail(row.id)"
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="应用名" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入应用名"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
         />
-        <!-- 操作:删除 -->
-        <XTextButton
-          preIcon="ep:delete"
-          :title="t('action.del')"
-          v-hasPermi="['pay:app:delete']"
-          @click="deleteData(row.id)"
+      </el-form-item>
+      <el-form-item label="开启状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          placeholder="请选择开启状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
         />
-      </template>
-    </XTable>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"> <Icon icon="ep:search" class="mr-5px" />搜索 </el-button>
+        <el-button @click="resetQuery"> <Icon icon="ep:refresh" class="mr-5px" />重置 </el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['system:tenant:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['system:tenant:export']"
+        >
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
+      </el-form-item>
+    </el-form>
   </ContentWrap>
 
-  <XModal v-model="dialogVisible" :title="dialogTitle">
-    <!-- 对话框(添加 / 修改) -->
-    <Form
-      v-if="['create', 'update'].includes(actionType)"
-      :schema="allSchemas.formSchema"
-      :rules="rules"
-      ref="formRef"
-    />
-    <!-- 对话框(详情) -->
-    <Descriptions
-      v-if="actionType === 'detail'"
-      :schema="allSchemas.detailSchema"
-      :data="detailData"
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="应用编号" align="center" prop="id" />
+      <el-table-column label="应用名" align="center" prop="name" />
+      <el-table-column label="开启状态" align="center" prop="status">
+        <template #default="scope">
+          <el-switch
+            v-model="scope.row.status"
+            :active-value="0"
+            :inactive-value="1"
+            @change="handleStatusChange(scope.row)"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="支付宝配置" align="center">
+        <el-table-column :label="PayChannelEnum.ALIPAY_APP.name" align="center">
+          <template #default="scope">
+            <el-button
+              type="success"
+              v-if="isChannelExists(scope.row.channelCodes, PayChannelEnum.ALIPAY_APP.code)"
+              @click="openChannelForm(scope.row, PayChannelEnum.ALIPAY_APP.code, PayType.ALIPAY)"
+              circle
+            >
+              <Icon icon="ep:check" />
+            </el-button>
+            <el-button
+              v-else
+              type="danger"
+              circle
+              @click="openChannelForm(scope.row, PayChannelEnum.ALIPAY_APP.code, PayType.ALIPAY)"
+            >
+              <Icon icon="ep:close" />
+            </el-button>
+          </template>
+        </el-table-column>
+        <el-table-column :label="PayChannelEnum.ALIPAY_PC.name" align="center">
+          <template #default="scope">
+            <el-button
+              type="success"
+              circle
+              v-if="isChannelExists(scope.row.channelCodes, PayChannelEnum.ALIPAY_PC.code)"
+              @click="openChannelForm(scope.row, PayChannelEnum.ALIPAY_PC.code, PayType.ALIPAY)"
+            >
+              <Icon icon="ep:check" />
+            </el-button>
+            <el-button
+              v-else
+              type="danger"
+              circle
+              @click="openChannelForm(scope.row, PayChannelEnum.ALIPAY_PC.code, PayType.ALIPAY)"
+            >
+              <Icon icon="ep:close" />
+            </el-button>
+          </template>
+        </el-table-column>
+        <el-table-column :label="PayChannelEnum.ALIPAY_WAP.name" align="center">
+          <template #default="scope">
+            <el-button
+              type="success"
+              circle
+              v-if="isChannelExists(scope.row.channelCodes, PayChannelEnum.ALIPAY_WAP.code)"
+              @click="openChannelForm(scope.row, PayChannelEnum.ALIPAY_WAP.code, PayType.ALIPAY)"
+            >
+              <Icon icon="ep:check" />
+            </el-button>
+            <el-button
+              v-else
+              type="danger"
+              circle
+              @click="openChannelForm(scope.row, PayChannelEnum.ALIPAY_WAP.code, PayType.ALIPAY)"
+            >
+              <Icon icon="ep:close" />
+            </el-button>
+          </template>
+        </el-table-column>
+        <el-table-column :label="PayChannelEnum.ALIPAY_QR.name" align="center">
+          <template #default="scope">
+            <el-button
+              type="success"
+              circle
+              v-if="isChannelExists(scope.row.channelCodes, PayChannelEnum.ALIPAY_QR.code)"
+              @click="openChannelForm(scope.row, PayChannelEnum.ALIPAY_QR.code, PayType.ALIPAY)"
+            >
+              <Icon icon="ep:check" />
+            </el-button>
+            <el-button
+              v-else
+              type="danger"
+              circle
+              @click="openChannelForm(scope.row, PayChannelEnum.ALIPAY_QR.code, PayType.ALIPAY)"
+            >
+              <Icon icon="ep:close" />
+            </el-button>
+          </template>
+        </el-table-column>
+        <el-table-column :label="PayChannelEnum.ALIPAY_BAR.name" align="center">
+          <template #default="scope">
+            <el-button
+              type="success"
+              circle
+              v-if="isChannelExists(scope.row.channelCodes, PayChannelEnum.ALIPAY_BAR.code)"
+              @click="openChannelForm(scope.row, PayChannelEnum.ALIPAY_BAR.code, PayType.ALIPAY)"
+            >
+              <Icon icon="ep:check" />
+            </el-button>
+            <el-button
+              v-else
+              type="danger"
+              circle
+              @click="openChannelForm(scope.row, PayChannelEnum.ALIPAY_BAR.code, PayType.ALIPAY)"
+            >
+              <Icon icon="ep:close" />
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table-column>
+      <el-table-column label="微信配置" align="center">
+        <el-table-column :label="PayChannelEnum.WX_LITE.name" align="center">
+          <template #default="scope">
+            <el-button
+              type="success"
+              circle
+              v-if="isChannelExists(scope.row.channelCodes, PayChannelEnum.WX_LITE.code)"
+              @click="openChannelForm(scope.row, PayChannelEnum.WX_LITE.code, PayType.WECHAT)"
+            >
+              <Icon icon="ep:check" />
+            </el-button>
+            <el-button
+              v-else
+              type="danger"
+              circle
+              @click="openChannelForm(scope.row, PayChannelEnum.WX_LITE.code, PayType.WECHAT)"
+            >
+              <Icon icon="ep:close" />
+            </el-button>
+          </template>
+        </el-table-column>
+        <el-table-column :label="PayChannelEnum.WX_PUB.name" align="center">
+          <template #default="scope">
+            <el-button
+              type="success"
+              circle
+              v-if="isChannelExists(scope.row.channelCodes, PayChannelEnum.WX_PUB.code)"
+              @click="openChannelForm(scope.row, PayChannelEnum.WX_PUB.code, PayType.WECHAT)"
+            >
+              <Icon icon="ep:check" />
+            </el-button>
+            <el-button
+              v-else
+              type="danger"
+              circle
+              @click="openChannelForm(scope.row, PayChannelEnum.WX_PUB.code, PayType.WECHAT)"
+            >
+              <Icon icon="ep:close" />
+            </el-button>
+          </template>
+        </el-table-column>
+        <el-table-column :label="PayChannelEnum.WX_APP.name" align="center">
+          <template #default="scope">
+            <el-button
+              type="success"
+              circle
+              v-if="isChannelExists(scope.row.channelCodes, PayChannelEnum.WX_APP.code)"
+              @click="openChannelForm(scope.row, PayChannelEnum.WX_APP.code, PayType.WECHAT)"
+            >
+              <Icon icon="ep:check" />
+            </el-button>
+            <el-button
+              v-else
+              type="danger"
+              circle
+              @click="openChannelForm(scope.row, PayChannelEnum.WX_APP.code, PayType.WECHAT)"
+            >
+              <Icon icon="ep:close" />
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table-column>
+      <el-table-column label="模拟支付配置" align="center">
+        <el-table-column :label="PayChannelEnum.MOCK.name" align="center">
+          <template #default="scope">
+            <el-button
+              type="success"
+              circle
+              v-if="isChannelExists(scope.row.channelCodes, PayChannelEnum.MOCK.code)"
+              @click="openChannelForm(scope.row, PayChannelEnum.MOCK.code)"
+              ><Icon icon="ep:check"
+            /></el-button>
+            <el-button
+              v-else
+              type="danger"
+              circle
+              @click="openChannelForm(scope.row, PayChannelEnum.MOCK.code)"
+              ><Icon icon="ep:close"
+            /></el-button>
+          </template>
+        </el-table-column>
+      </el-table-column>
+      <el-table-column label="操作" align="center" min-width="110" fixed="right">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['system:tenant:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['system:tenant:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
     />
-    <!-- 操作按钮 -->
-    <template #footer>
-      <!-- 按钮:保存 -->
-      <XButton
-        v-if="['create', 'update'].includes(actionType)"
-        type="primary"
-        :title="t('action.save')"
-        :loading="actionLoading"
-        @click="submitForm()"
-      />
-      <!-- 按钮:关闭 -->
-      <XButton :loading="actionLoading" :title="t('dialog.close')" @click="dialogVisible = false" />
-    </template>
-  </XModal>
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <AppForm ref="formRef" @success="getList" />
+  <AlipayChannelForm ref="alipayFormRef" @success="getList" />
+  <WeixinChannelForm ref="weixinFormRef" @success="getList" />
+  <MockChannelForm ref="mockFormRef" @success="getList" />
 </template>
-<script setup lang="ts" name="PayApp">
-import type { FormExpose } from '@/components/Form'
-import { rules, allSchemas } from './app.data'
-import * as AppApi from '@/api/pay/app'
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import download from '@/utils/download'
+import * as PayappApi from '@/api/pay/app'
+import AppForm from './components/AppForm.vue'
+import { PayChannelEnum, PayType } from '@/utils/constants'
+import AlipayChannelForm from './components/alipayChannelForm.vue'
+import WeixinChannelForm from './components/weixinChannelForm.vue'
+import MockChannelForm from './components/mockChannelForm.vue'
+import { CommonStatusEnum } from '@/utils/constants'
+
+defineOptions({ name: 'PayApp' })
 
-const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const alipayFormRef = ref()
+const weixinFormRef = ref()
+const mockFormRef = ref()
 
-// 列表相关的变量
-const [registerTable, { reload, deleteData, exportList }] = useXTable({
-  allSchemas: allSchemas,
-  getListApi: AppApi.getAppPageApi,
-  deleteApi: AppApi.deleteAppApi,
-  exportListApi: AppApi.exportAppApi
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  status: undefined,
+  remark: undefined,
+  payNotifyUrl: undefined,
+  refundNotifyUrl: undefined,
+  merchantName: undefined,
+  createTime: []
 })
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+const channelParam = reactive({
+  loading: false,
+  appId: null, // 应用 ID
+  payCode: null, // 渠道编码
+  // 商户对象
+  payMerchant: {
+    id: null, // 编号
+    name: null // 名称
+  }
+}) // 微信组件传参参数
 
-// ========== CRUD 相关 ==========
-const actionLoading = ref(false) // 遮罩层
-const actionType = ref('') // 操作按钮的类型
-const dialogVisible = ref(false) // 是否显示弹出层
-const dialogTitle = ref('edit') // 弹出层标题
-const formRef = ref<FormExpose>() // 表单 Ref
-const detailData = ref() // 详情 Ref
-
-// 设置标题
-const setDialogTile = (type: string) => {
-  dialogTitle.value = t('action.' + type)
-  actionType.value = type
-  dialogVisible.value = true
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await PayappApi.getAppPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
 }
 
-// 新增操作
-const handleCreate = () => {
-  setDialogTile('create')
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
 }
 
-// 修改操作
-const handleUpdate = async (rowId: number) => {
-  setDialogTile('update')
-  // 设置数据
-  const res = await AppApi.getAppApi(rowId)
-  unref(formRef)?.setValues(res)
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
 }
 
-// 详情操作
-const handleDetail = async (rowId: number) => {
-  setDialogTile('detail')
-  const res = await AppApi.getAppApi(rowId)
-  detailData.value = res
+// 用户状态修改
+const handleStatusChange = async (row: any) => {
+  let text = row.status === CommonStatusEnum.ENABLE ? '启用' : '停用'
+
+  try {
+    await message.confirm('确认要"' + text + '""' + row.name + '"应用吗?')
+    await PayappApi.changeAppStatus({ id: row.id, status: row.status })
+    message.success(text + '成功')
+  } catch {
+    row.status =
+      row.status === CommonStatusEnum.ENABLE ? CommonStatusEnum.DISABLE : CommonStatusEnum.ENABLE
+  }
 }
 
-// 提交按钮
-const submitForm = async () => {
-  const elForm = unref(formRef)?.getElFormRef()
-  if (!elForm) return
-  elForm.validate(async (valid) => {
-    if (valid) {
-      actionLoading.value = true
-      // 提交请求
-      try {
-        const data = unref(formRef)?.formModel as AppApi.AppVO
-        if (actionType.value === 'create') {
-          await AppApi.createAppApi(data)
-          message.success(t('common.createSuccess'))
-        } else {
-          await AppApi.updateAppApi(data)
-          message.success(t('common.updateSuccess'))
-        }
-        dialogVisible.value = false
-      } finally {
-        actionLoading.value = false
-        // 刷新列表
-        await reload()
-      }
-    }
-  })
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
 }
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await PayappApi.deleteApp(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await PayappApi.exportApp(queryParams)
+    download.excel(data, '支付应用信息.xls')
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/**
+ * 根据渠道编码判断渠道列表中是否存在
+ *
+ * @param channels 渠道列表
+ * @param channelCode 渠道编码
+ */
+const isChannelExists = (channels, channelCode) => {
+  if (!channels) {
+    return false
+  }
+  return channels.indexOf(channelCode) !== -1
+}
+
+/**
+ * 新增支付渠道信息
+ */
+const openChannelForm = async (row, payCode, type) => {
+  channelParam.loading = false
+  channelParam.appId = row.id
+  channelParam.payCode = payCode
+  channelParam.payMerchant = row.payMerchant
+
+  switch (type) {
+    case PayType.ALIPAY:
+      alipayFormRef.value.open(row.id, payCode)
+      break
+
+    case PayType.WECHAT:
+      weixinFormRef.value.open(row.id, payCode)
+      break
+
+    case PayType.MOCK:
+      mockFormRef.value.open(row.id, payCode)
+      break
+  }
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+})
 </script>

+ 321 - 112
src/views/pay/order/index.vue

@@ -1,130 +1,339 @@
 <template>
   <ContentWrap>
-    <!-- 列表 -->
-    <XTable @register="registerTable">
-      <template #toolbar_buttons>
-        <!-- 操作:新增 -->
-        <XButton
-          type="primary"
-          preIcon="ep:zoom-in"
-          :title="t('action.add')"
-          v-hasPermi="['pay:order:create']"
-          @click="handleCreate()"
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="100px"
+    >
+      <el-form-item label="所属商户" prop="merchantId">
+        <el-select
+          v-model="queryParams.merchantId"
+          clearable
+          placeholder="请选择所属商户"
+          class="!w-240px"
+        >
+          <el-option
+            v-for="item in merchantList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="应用编号" prop="appId">
+        <el-select
+          clearable
+          v-model="queryParams.appId"
+          placeholder="请选择应用信息"
+          class="!w-240px"
+        >
+          <el-option v-for="item in appList" :key="item.id" :label="item.name" :value="item.id" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="渠道编码" prop="channelCode">
+        <el-select
+          v-model="queryParams.channelCode"
+          placeholder="请输入渠道编码"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.PAY_CHANNEL_CODE_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="商户订单编号" prop="merchantOrderId">
+        <el-input
+          v-model="queryParams.merchantOrderId"
+          placeholder="请输入商户订单编号"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
         />
-        <!-- 操作:导出 -->
-        <XButton
-          type="warning"
-          preIcon="ep:download"
-          :title="t('action.export')"
-          v-hasPermi="['pay:order:export']"
-          @click="exportList('订单数据.xls')"
+      </el-form-item>
+      <el-form-item label="渠道订单号" prop="channelOrderNo">
+        <el-input
+          v-model="queryParams.channelOrderNo"
+          placeholder="请输入渠道订单号"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
         />
-      </template>
-      <template #actionbtns_default="{ row }">
-        <!-- 操作:详情 -->
-        <XTextButton
-          preIcon="ep:view"
-          :title="t('action.detail')"
-          v-hasPermi="['pay:order:query']"
-          @click="handleDetail(row.id)"
+      </el-form-item>
+      <el-form-item label="支付状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          placeholder="请选择支付状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.PAY_ORDER_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="退款状态" prop="refundStatus">
+        <el-select
+          v-model="queryParams.refundStatus"
+          placeholder="请选择退款状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.PAY_ORDER_REFUND_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="回调商户状态" prop="notifyStatus">
+        <el-select
+          v-model="queryParams.notifyStatus"
+          placeholder="请选择订单回调商户状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.PAY_ORDER_NOTIFY_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
         />
-      </template>
-    </XTable>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['system:tenant:export']"
+        >
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
+      </el-form-item>
+    </el-form>
   </ContentWrap>
-  <XModal
-    v-model="dialogVisible"
-    :title="dialogTitle"
-    :height="['create', 'update'].includes(actionType) ? '99%' : ''"
-  >
-    <!-- 对话框(添加 / 修改) -->
-    <Form
-      v-if="['create', 'update'].includes(actionType)"
-      :schema="allSchemas.formSchema"
-      :rules="rules"
-      ref="formRef"
-    />
-    <!-- 对话框(详情) -->
-    <Descriptions
-      v-if="actionType === 'detail'"
-      :schema="allSchemas.detailSchema"
-      :data="detailData"
-    />
-    <!-- 操作按钮 -->
-    <template #footer>
-      <!-- 按钮:保存 -->
-      <XButton
-        v-if="['create', 'update'].includes(actionType)"
-        type="primary"
-        :title="t('action.save')"
-        :loading="actionLoading"
-        @click="submitForm()"
+
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="订单编号" align="center" prop="id" />
+      <el-table-column label="商户名称" align="center" prop="merchantName" width="120" />
+      <el-table-column label="应用名称" align="center" prop="appName" width="120" />
+      <el-table-column label="渠道名称" align="center" prop="channelCodeName" width="120" />
+      <el-table-column label="渠道订单号" align="center" prop="merchantOrderId" width="120" />
+      <el-table-column label="商品标题" align="center" prop="subject" width="250" />
+      <el-table-column label="商品描述" align="center" prop="body" width="250" />
+      <el-table-column label="异步通知地址" align="center" prop="notifyUrl" width="250" />
+      <el-table-column label="回调状态" align="center" prop="notifyStatus">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PAY_ORDER_NOTIFY_STATUS" :value="scope.row.notifyStatus" />
+        </template>
+      </el-table-column>
+      <el-table-column label="支付订单" width="280">
+        <template #default="scope">
+          <p class="order-font">
+            <el-tag>商户</el-tag>
+            {{ scope.row.merchantOrderId }}
+          </p>
+          <p class="order-font">
+            <el-tag type="warning">支付</el-tag>
+            {{ scope.row.channelOrderNo }}
+          </p>
+        </template>
+      </el-table-column>
+      <el-table-column label="支付金额" align="center" prop="amount">
+        <template #default="scope">
+          ¥{{ parseFloat(scope.row.amount / 100).toFixed(2) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="手续金额" align="center" prop="channelFeeAmount">
+        <template #default="scope">
+          ¥{{ parseFloat(scope.row.channelFeeAmount / 100).toFixed(2) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="退款金额" align="center" prop="refundAmount">
+        <template #default="scope">
+          ¥{{ parseFloat(scope.row.refundAmount / 100).toFixed(2) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="支付状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PAY_ORDER_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="回调状态" align="center" prop="notifyStatus">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PAY_ORDER_NOTIFY_STATUS" :value="scope.row.notifyStatus" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        width="180"
+        :formatter="dateFormatter"
+      />
+      <el-table-column
+        label="支付时间"
+        align="center"
+        prop="successTime"
+        width="180"
+        :formatter="dateFormatter"
       />
-      <!-- 按钮:关闭 -->
-      <XButton :loading="actionLoading" :title="t('dialog.close')" @click="dialogVisible = false" />
-    </template>
-  </XModal>
+      <el-table-column label="操作" align="center" fixed="right">
+        <template #default="scope">
+          <el-button
+            type="primary"
+            link
+            @click="openDetail(scope.row.id)"
+            v-hasPermi="['pay:order:query']"
+          >
+            详情
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:预览 -->
+  <OrderDetail ref="detailRef" @success="getList" />
 </template>
-<script setup lang="ts" name="PayOrder">
-import type { FormExpose } from '@/components/Form'
-import { rules, allSchemas } from './order.data'
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import * as MerchantApi from '@/api/pay/merchant'
 import * as OrderApi from '@/api/pay/order'
+import OrderDetail from './OrderDetail.vue'
+import download from '@/utils/download'
+
+defineOptions({ name: 'PayOrder' })
 
-const { t } = useI18n() // 国际化
-// 列表相关的变量
-const [registerTable, { reload, exportList }] = useXTable({
-  allSchemas: allSchemas,
-  getListApi: OrderApi.getOrderPageApi,
-  exportListApi: OrderApi.exportOrderApi
-})
-// ========== CRUD 相关 ==========
-const actionLoading = ref(false) // 遮罩层
-const actionType = ref('') // 操作按钮的类型
-const dialogVisible = ref(false) // 是否显示弹出层
-const dialogTitle = ref('edit') // 弹出层标题
-const formRef = ref<FormExpose>() // 表单 Ref
-const detailData = ref() // 详情 Ref
 const message = useMessage() // 消息弹窗
-// 设置标题
-const setDialogTile = (type: string) => {
-  dialogTitle.value = t('action.' + type)
-  actionType.value = type
-  dialogVisible.value = true
+
+const loading = ref(false) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  merchantId: undefined,
+  appId: undefined,
+  channelId: undefined,
+  channelCode: undefined,
+  merchantOrderId: undefined,
+  subject: undefined,
+  body: undefined,
+  notifyUrl: undefined,
+  notifyStatus: undefined,
+  amount: undefined,
+  channelFeeRate: undefined,
+  channelFeeAmount: undefined,
+  status: undefined,
+  userIp: undefined,
+  successExtensionId: undefined,
+  refundStatus: undefined,
+  refundTimes: undefined,
+  refundAmount: undefined,
+  channelUserId: undefined,
+  channelOrderNo: undefined,
+  expireTime: [],
+  successTime: [],
+  notifyTime: [],
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出等待
+const merchantList = ref([]) // 商户列表
+const appList = ref([]) // 支付应用列表集合
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
 }
 
-// 新增操作
-const handleCreate = () => {
-  setDialogTile('create')
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await OrderApi.getOrderPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
 }
 
-// 详情操作
-const handleDetail = async (rowId: number) => {
-  setDialogTile('detail')
-  const res = await OrderApi.getOrderApi(rowId)
-  detailData.value = res
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
 }
-// 提交新增/修改的表单
-const submitForm = async () => {
-  const elForm = unref(formRef)?.getElFormRef()
-  if (!elForm) return
-  elForm.validate(async (valid) => {
-    if (valid) {
-      actionLoading.value = true
-      // 提交请求
-      try {
-        const data = unref(formRef)?.formModel as OrderApi.OrderVO
-        if (actionType.value === 'create') {
-          await OrderApi.createOrderApi(data)
-          message.success(t('common.createSuccess'))
-        } else {
-          await OrderApi.updateOrderApi(data)
-          message.success(t('common.updateSuccess'))
-        }
-        dialogVisible.value = false
-      } finally {
-        actionLoading.value = false
-        await reload()
-      }
-    }
-  })
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await OrderApi.exportOrder(queryParams)
+    download.excel(data, '支付订单.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
 }
+
+/** 预览详情 */
+const detailRef = ref()
+const openDetail = (id: number) => {
+  detailRef.value.open(id)
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+  // 加载商户列表
+  // merchantList.value = await MerchantApi.getMerchantListByName()
+  // 加载 App 列表
+  // TODO 芋艿:候选少一个查询应用列表的接口
+  // appList.value = await AppApi.getAppListByMerchantId()
+})
 </script>
+<style>
+.order-font {
+  font-size: 12px;
+  padding: 2px 0;
+}
+</style>