|
|
|
@@ -104,12 +104,46 @@
|
|
|
|
|
controls-position="right"
|
|
|
|
|
@update:model-value="clearFieldError(field.key)"
|
|
|
|
|
/>
|
|
|
|
|
<div v-else-if="field.type === 'textarea' && field.secret" class="secret-textarea-control">
|
|
|
|
|
<el-input
|
|
|
|
|
v-if="isSecretVisible(selected, field.key)"
|
|
|
|
|
v-model="selected.config[field.key]"
|
|
|
|
|
class="field-control"
|
|
|
|
|
type="textarea"
|
|
|
|
|
:rows="5"
|
|
|
|
|
:placeholder="field.placeholder || '请输入凭证内容'"
|
|
|
|
|
autocomplete="new-password"
|
|
|
|
|
spellcheck="false"
|
|
|
|
|
@update:model-value="clearFieldError(field.key)"
|
|
|
|
|
/>
|
|
|
|
|
<el-input
|
|
|
|
|
v-else
|
|
|
|
|
:model-value="maskedTextareaValue(selected.config[field.key])"
|
|
|
|
|
class="field-control secret-textarea-display"
|
|
|
|
|
type="textarea"
|
|
|
|
|
:rows="5"
|
|
|
|
|
readonly
|
|
|
|
|
resize="none"
|
|
|
|
|
:placeholder="field.placeholder || '凭证已隐藏,点击“编辑凭证”后输入'"
|
|
|
|
|
:aria-label="`${field.label}(已隐藏)`"
|
|
|
|
|
/>
|
|
|
|
|
<el-button
|
|
|
|
|
class="secret-textarea-action"
|
|
|
|
|
text
|
|
|
|
|
type="primary"
|
|
|
|
|
:icon="isSecretVisible(selected, field.key) ? Hide : View"
|
|
|
|
|
:aria-label="isSecretVisible(selected, field.key) ? `隐藏${field.label}` : `编辑${field.label}`"
|
|
|
|
|
@click="toggleSecretVisibility(selected, field.key)"
|
|
|
|
|
>
|
|
|
|
|
{{ isSecretVisible(selected, field.key) ? '隐藏凭证' : '编辑凭证' }}
|
|
|
|
|
</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
<el-input
|
|
|
|
|
v-else-if="field.type === 'textarea'"
|
|
|
|
|
v-model="selected.config[field.key]"
|
|
|
|
|
class="field-control"
|
|
|
|
|
type="textarea"
|
|
|
|
|
:rows="field.secret ? 5 : 4"
|
|
|
|
|
:rows="4"
|
|
|
|
|
:placeholder="field.placeholder"
|
|
|
|
|
spellcheck="false"
|
|
|
|
|
@update:model-value="clearFieldError(field.key)"
|
|
|
|
@@ -124,7 +158,7 @@
|
|
|
|
|
spellcheck="false"
|
|
|
|
|
@update:model-value="clearFieldError(field.key)"
|
|
|
|
|
/>
|
|
|
|
|
<p v-if="fieldHint(field)" class="field-hint">{{ fieldHint(field) }}</p>
|
|
|
|
|
<p v-if="fieldHint(field, selected)" class="field-hint">{{ fieldHint(field, selected) }}</p>
|
|
|
|
|
</el-form-item>
|
|
|
|
|
</div>
|
|
|
|
|
</el-form>
|
|
|
|
@@ -134,7 +168,7 @@
|
|
|
|
|
<el-button
|
|
|
|
|
:icon="Connection"
|
|
|
|
|
:loading="operation === 'test'"
|
|
|
|
|
:disabled="busy || !selected.configured || isDirty(selected)"
|
|
|
|
|
:disabled="busy || !selected.enabled || !selected.configured || isDirty(selected)"
|
|
|
|
|
@click="testProvider"
|
|
|
|
|
>
|
|
|
|
|
测试渠道
|
|
|
|
@@ -188,7 +222,7 @@
|
|
|
|
|
<script setup>
|
|
|
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
|
import { Check, CircleCheck, CircleClose, Connection, Delete, MoreFilled, Refresh } from '@element-plus/icons-vue'
|
|
|
|
|
import { Check, CircleCheck, CircleClose, Connection, Delete, Hide, MoreFilled, Refresh, View } from '@element-plus/icons-vue'
|
|
|
|
|
import { deleteIntegrationConfig, getIntegrationConfigs, saveIntegrationConfig } from '@/api/integration'
|
|
|
|
|
import { testPaymentProvider } from '@/api/payment'
|
|
|
|
|
|
|
|
|
@@ -208,6 +242,7 @@ const selectedProvider = ref('')
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const operation = ref('')
|
|
|
|
|
const errors = reactive({})
|
|
|
|
|
const secretVisibility = reactive({})
|
|
|
|
|
const testVisible = ref(false)
|
|
|
|
|
const testResult = ref(null)
|
|
|
|
|
let loadRequestID = 0
|
|
|
|
@@ -240,11 +275,33 @@ const clearFieldError = (key) => { delete errors[key] }
|
|
|
|
|
const isEmpty = (value) => value === null || typeof value === 'undefined' || (typeof value === 'string' && value.trim() === '')
|
|
|
|
|
const isDirty = (item) => JSON.stringify({ enabled: item.enabled, config: item.config }) !== JSON.stringify({ enabled: item._savedEnabled, config: item._savedConfig })
|
|
|
|
|
const hasMaskedSecret = (item) => (item.fields || []).some((field) => field.secret && item.config[field.key] === '******')
|
|
|
|
|
const fieldHint = (field) => {
|
|
|
|
|
const CALLBACK_URL_KEYS = new Set(['notify_url', 'return_url', 'cancel_url', 'callback_url', 'webhook_url', 'redirect_url', 'success_url', 'failure_url'])
|
|
|
|
|
const callbackURLPattern = /(?:notify|callback|webhook|return|cancel|redirect|success|failure)_?url$/i
|
|
|
|
|
const isCallbackURLField = (field) => {
|
|
|
|
|
const key = String(field?.key || '').trim().toLowerCase()
|
|
|
|
|
return CALLBACK_URL_KEYS.has(key) || callbackURLPattern.test(key)
|
|
|
|
|
}
|
|
|
|
|
const environmentText = (item) => String(item?.config?.environment || '').trim().toLowerCase()
|
|
|
|
|
const isSandboxEnvironment = (item) => ['sandbox', 'test', 'testing', 'dev', 'development'].includes(environmentText(item))
|
|
|
|
|
const isProductionEnvironment = (item) => !isSandboxEnvironment(item)
|
|
|
|
|
const secretFieldKey = (item, fieldKey) => `${item?.provider || ''}:${fieldKey}`
|
|
|
|
|
const isSecretVisible = (item, fieldKey) => Boolean(secretVisibility[secretFieldKey(item, fieldKey)])
|
|
|
|
|
const toggleSecretVisibility = (item, fieldKey) => {
|
|
|
|
|
const key = secretFieldKey(item, fieldKey)
|
|
|
|
|
secretVisibility[key] = !secretVisibility[key]
|
|
|
|
|
}
|
|
|
|
|
const maskedTextareaValue = (value) => {
|
|
|
|
|
if (value === '******') return value
|
|
|
|
|
return isEmpty(value) ? '' : '********'
|
|
|
|
|
}
|
|
|
|
|
const hideSecrets = () => { Object.keys(secretVisibility).forEach((key) => delete secretVisibility[key]) }
|
|
|
|
|
const fieldHint = (field, item = selected.value) => {
|
|
|
|
|
if (field.key === 'test_mode') return '仅在需要执行真实渠道测试时开启。'
|
|
|
|
|
if (field.key === 'test_amount') return '使用最小货币单位,例如 CNY 1 表示 0.01 元。'
|
|
|
|
|
if (field.key === 'test_extra') return '必须是 JSON 对象;可传 openid、auth_code 等测试参数。'
|
|
|
|
|
if (field.key === 'notify_url') return '异步支付方式必须填写可被支付平台访问的 HTTPS 地址。'
|
|
|
|
|
if (isCallbackURLField(field)) {
|
|
|
|
|
return isProductionEnvironment(item) ? '生产环境回调地址必须使用 HTTPS;沙箱环境可使用 HTTP。' : '沙箱环境可使用 HTTP;切换生产环境前请改为 HTTPS。'
|
|
|
|
|
}
|
|
|
|
|
return field.description || ''
|
|
|
|
|
}
|
|
|
|
|
const stageName = (name) => STAGE_NAMES[name] || name
|
|
|
|
@@ -265,10 +322,11 @@ function validate(item, enabled = item.enabled) {
|
|
|
|
|
const parsed = JSON.parse(String(value))
|
|
|
|
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') message = '测试扩展参数必须是 JSON 对象'
|
|
|
|
|
} catch { message = '测试扩展参数必须是合法 JSON' }
|
|
|
|
|
} else if ((field.type === 'url' || field.key.endsWith('_url')) && value) {
|
|
|
|
|
} else if ((field.type === 'url' || field.key.endsWith('_url') || isCallbackURLField(field)) && value) {
|
|
|
|
|
try {
|
|
|
|
|
const url = new URL(String(value))
|
|
|
|
|
if (!['http:', 'https:'].includes(url.protocol)) message = `${field.label}必须使用 HTTP 或 HTTPS`
|
|
|
|
|
else if (isCallbackURLField(field) && isProductionEnvironment(item) && url.protocol !== 'https:') message = `${field.label}在生产环境必须使用 HTTPS`
|
|
|
|
|
} catch { message = `${field.label}格式无效` }
|
|
|
|
|
}
|
|
|
|
|
if (!item.configured && field.secret && value === '******') message = '请重新填写' + field.label
|
|
|
|
@@ -285,6 +343,7 @@ async function load() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await getIntegrationConfigs('payment')
|
|
|
|
|
if (requestID !== loadRequestID || res.code !== 0) return
|
|
|
|
|
hideSecrets()
|
|
|
|
|
configs.value = (res.data || []).map(normalizeConfig)
|
|
|
|
|
if (!configs.value.some((item) => item.provider === selectedProvider.value)) selectedProvider.value = configs.value[0]?.provider || ''
|
|
|
|
|
} catch {
|
|
|
|
@@ -310,6 +369,7 @@ async function selectProvider(provider) {
|
|
|
|
|
} catch { return }
|
|
|
|
|
}
|
|
|
|
|
Object.keys(errors).forEach((key) => delete errors[key])
|
|
|
|
|
hideSecrets()
|
|
|
|
|
selectedProvider.value = provider
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -349,9 +409,10 @@ async function toggleProvider(enabled) {
|
|
|
|
|
async function testProvider() {
|
|
|
|
|
const item = selected.value
|
|
|
|
|
if (!item || busy.value) return
|
|
|
|
|
if (!item.enabled) { ElMessage.warning('当前支付渠道已停用,请先启用并保存渠道后再执行测试。'); return }
|
|
|
|
|
if (isDirty(item)) { ElMessage.warning('请先保存当前配置'); return }
|
|
|
|
|
if (!item.config?.test_mode) { ElMessage.warning('请先开启“允许执行渠道测试”并保存'); return }
|
|
|
|
|
const environment = String(item.config?.environment || '').toLowerCase()
|
|
|
|
|
const environment = environmentText(item)
|
|
|
|
|
if (environment === 'production' || environment === 'prod') {
|
|
|
|
|
operation.value = 'test-confirm'
|
|
|
|
|
try {
|
|
|
|
@@ -417,6 +478,9 @@ onMounted(load)
|
|
|
|
|
.field-label { display: inline-flex; align-items: baseline; gap: 7px; min-width: 0; }
|
|
|
|
|
.field-label small { overflow: hidden; color: var(--el-text-color-placeholder); font-size: 11px; font-weight: 400; text-overflow: ellipsis; }
|
|
|
|
|
.field-control { width: 100%; }
|
|
|
|
|
.secret-textarea-control { width: 100%; }
|
|
|
|
|
.secret-textarea-display :deep(.el-textarea__inner) { color: var(--el-text-color-placeholder); font-family: monospace; letter-spacing: 0; }
|
|
|
|
|
.secret-textarea-action { margin: 4px 0 0; padding: 4px 0; }
|
|
|
|
|
.field-hint { width: 100%; margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.5; }
|
|
|
|
|
.editor-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding-top: 16px; border-top: 1px solid var(--el-border-color-lighter); }
|
|
|
|
|
.save-state { margin-right: auto; color: var(--el-text-color-secondary); font-size: 12px; }
|
|
|
|
|