Files
Xboard/admin-frontend/src/views/system/SystemPaymentEditorDrawer.vue
T
yinjianm f7cef30b9c feat(admin-frontend): 完成订阅与系统管理真实工作台
补齐订单、优惠券、主题、插件、公告与支付管理页面,
接入对应后台接口、路由入口与工具层类型定义。
同时修复套餐页开关初始化误写问题,避免浏览即触发写操作。

在订阅协议侧为 Stash 导出增加 AnyTLS 版本守卫,
未知版本或低于 3.3.0 时不再导出该协议,并补充回归测试与知识记录。
2026-04-24 16:52:41 +08:00

369 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { getPaymentForm, savePayment } from '@/api/admin'
import type {
AdminPaymentConfigFields,
AdminPaymentListItem,
} from '@/types/api'
import {
createEmptyPaymentForm,
extractPaymentConfigValues,
normalizePaymentConfigFields,
toPaymentFormModel,
toPaymentSavePayload,
type PaymentFormModel,
} from '@/utils/payments'
const props = defineProps<{
visible: boolean
mode: 'create' | 'edit'
payment?: AdminPaymentListItem | null
paymentMethods: string[]
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: [message: string]
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const configLoading = ref(false)
const hydrating = ref(false)
const currentFields = ref<AdminPaymentConfigFields>({})
const initialPaymentMethod = ref('')
const form = reactive<PaymentFormModel>(createEmptyPaymentForm())
const drawerTitle = computed(() => props.mode === 'create' ? '添加支付方式' : '编辑支付方式')
const configEntries = computed(() => Object.entries(currentFields.value))
const iconPreview = computed(() => form.icon.trim())
const rules = computed<FormRules<PaymentFormModel>>(() => ({
name: [{ required: true, message: '请输入显示名称', trigger: 'blur' }],
payment: [{ required: true, message: '请选择支付接口', trigger: 'change' }],
notifyDomain: [
{
validator: (_rule, value, callback) => {
const normalized = String(value || '').trim()
if (!normalized) {
callback()
return
}
try {
const target = new URL(normalized)
if (!/^https?:$/.test(target.protocol)) {
callback(new Error('通知域名仅支持 http 或 https'))
return
}
callback()
} catch {
callback(new Error('请输入有效的通知域名'))
}
},
trigger: 'blur',
},
],
handlingFeePercent: [
{
validator: (_rule, value, callback) => {
if (value === null || value === undefined || value === '') {
callback()
return
}
const numeric = Number(value)
if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) {
callback(new Error('百分比手续费需在 0-100 之间'))
return
}
callback()
},
trigger: 'blur',
},
],
handlingFeeFixed: [
{
validator: (_rule, value, callback) => {
if (value === null || value === undefined || value === '') {
callback()
return
}
const numeric = Number(value)
if (!Number.isFinite(numeric) || numeric < 0 || !Number.isInteger(numeric)) {
callback(new Error('固定手续费需为大于等于 0 的整数'))
return
}
callback()
},
trigger: 'blur',
},
],
}))
function closeDrawer() {
emit('update:visible', false)
}
async function loadDynamicConfig(method: string, paymentId?: number) {
if (!method) {
currentFields.value = {}
form.config = {}
return
}
configLoading.value = true
try {
const response = await getPaymentForm({
payment: method,
...(paymentId ? { id: paymentId } : {}),
})
const normalizedFields = normalizePaymentConfigFields(response.data)
currentFields.value = normalizedFields
form.config = extractPaymentConfigValues(normalizedFields)
} catch (error) {
currentFields.value = {}
form.config = {}
ElMessage.error(error instanceof Error ? error.message : '支付接口配置加载失败')
} finally {
configLoading.value = false
}
}
async function initializeForm() {
hydrating.value = true
Object.assign(form, createEmptyPaymentForm())
Object.assign(form, toPaymentFormModel(props.payment))
initialPaymentMethod.value = props.payment?.payment || form.payment
await loadDynamicConfig(form.payment, props.payment?.id)
await nextTick()
formRef.value?.clearValidate()
hydrating.value = false
}
function updateConfigValue(key: string, value: string) {
form.config = {
...form.config,
[key]: value,
}
}
async function reloadCurrentConfig() {
if (!form.payment) {
return
}
const paymentId = props.mode === 'edit' && form.payment === initialPaymentMethod.value
? props.payment?.id
: undefined
await loadDynamicConfig(form.payment, paymentId)
}
async function handleSubmit() {
const instance = formRef.value
if (!instance) {
return
}
const valid = await instance.validate().catch(() => false)
if (!valid) {
return
}
if (configLoading.value) {
ElMessage.warning('支付接口配置仍在加载,请稍后再试')
return
}
if (!configEntries.value.length) {
ElMessage.error('当前支付接口配置未加载成功,请重新选择支付接口')
return
}
submitting.value = true
try {
await savePayment(toPaymentSavePayload(form, currentFields.value))
const message = props.mode === 'create' ? '支付方式已创建' : '支付方式已更新'
ElMessage.success(message)
emit('success', message)
closeDrawer()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '支付方式保存失败')
} finally {
submitting.value = false
}
}
watch(
() => props.visible,
(visible) => {
if (!visible) {
return
}
void initializeForm()
},
)
watch(
() => form.payment,
(nextValue, previousValue) => {
if (!props.visible || hydrating.value || !nextValue || nextValue === previousValue) {
return
}
const paymentId = props.mode === 'edit' && nextValue === initialPaymentMethod.value
? props.payment?.id
: undefined
void loadDynamicConfig(nextValue, paymentId)
},
)
</script>
<template>
<ElDrawer
:model-value="props.visible"
:title="drawerTitle"
size="min(560px, 100vw)"
destroy-on-close
class="payment-editor-drawer"
@close="closeDrawer"
@update:model-value="emit('update:visible', $event)"
>
<div class="drawer-shell">
<div class="drawer-copy">
<p>支付配置</p>
<h2>{{ drawerTitle }}</h2>
<span>根据当前 Laravel `/payment/*` 接口维护支付方式与网关参数</span>
</div>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="drawer-form"
>
<div class="drawer-grid">
<ElFormItem label="显示名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入支付方式显示名称" />
</ElFormItem>
<ElFormItem label="图标URL">
<ElInput v-model="form.icon" placeholder="https://cdn.example.com/payment.png" />
<div v-if="iconPreview" class="icon-preview">
<img :src="iconPreview" alt="支付图标预览" />
<span>图标预览</span>
</div>
</ElFormItem>
<ElFormItem label="通知域名" prop="notifyDomain">
<ElInput v-model="form.notifyDomain" placeholder="https://pay.example.com" />
<p class="field-helper">仅填写通知域名与协议实际回调路径会由后端自动拼接</p>
</ElFormItem>
<ElFormItem label="百分比手续费 (%)" prop="handlingFeePercent">
<ElInputNumber
v-model="form.handlingFeePercent"
:min="0"
:max="100"
:precision="2"
:controls="false"
class="full-width"
placeholder="0-100"
/>
</ElFormItem>
<ElFormItem label="固定手续费" prop="handlingFeeFixed">
<ElInputNumber
v-model="form.handlingFeeFixed"
:min="0"
:precision="0"
:controls="false"
class="full-width"
placeholder="请输入固定手续费"
/>
</ElFormItem>
<ElFormItem label="支付接口" prop="payment">
<ElSelect v-model="form.payment" placeholder="请选择支付接口">
<ElOption
v-for="method in props.paymentMethods"
:key="method"
:label="method"
:value="method"
/>
</ElSelect>
</ElFormItem>
</div>
<section class="config-panel" v-loading="configLoading">
<header class="section-header">
<div>
<h3>支付配置</h3>
<span>根据当前支付接口动态加载配置字段保持与后端插件表单契约一致</span>
</div>
<ElButton :disabled="!form.payment" @click="reloadCurrentConfig">
重新拉取配置
</ElButton>
</header>
<div v-if="!form.payment" class="config-empty">
<strong>请选择支付接口</strong>
<span>选择接口后会在这里加载对应的支付网关配置字段</span>
</div>
<div v-else-if="configEntries.length" class="config-grid">
<div
v-for="[key, field] in configEntries"
:key="key"
class="config-field"
:class="{ 'is-full': field.type === 'text' }"
>
<ElFormItem :label="field.label">
<ElInput
v-if="field.type !== 'text'"
:model-value="form.config[key]"
:placeholder="field.placeholder || `请输入${field.label}`"
@update:model-value="updateConfigValue(key, String($event || ''))"
/>
<ElInput
v-else
:model-value="form.config[key]"
type="textarea"
:rows="4"
:placeholder="field.placeholder || `请输入${field.label}`"
@update:model-value="updateConfigValue(key, String($event || ''))"
/>
<p v-if="field.description" class="field-helper">
{{ field.description }}
</p>
</ElFormItem>
</div>
</div>
<div v-else class="config-empty">
<strong>当前接口未返回配置字段</strong>
<span>请确认该支付插件已启用或点击重新拉取配置重试</span>
</div>
</section>
</ElForm>
</div>
<template #footer>
<div class="drawer-footer">
<ElButton @click="closeDrawer">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
{{ props.mode === 'create' ? '提交' : '保存修改' }}
</ElButton>
</div>
</template>
</ElDrawer>
</template>
<style scoped lang="scss" src="./SystemPaymentEditorDrawer.scss"></style>