优化结构

This commit is contained in:
Yvan
2026-08-21 22:54:40 +08:00
parent e4ba0dced9
commit 430feaaff6
20 changed files with 604 additions and 20 deletions
@@ -166,6 +166,14 @@
<span class="save-state">
{{ selected.configured ? '配置已创建' : '尚未保存配置' }}
</span>
<el-button
:icon="Connection"
:loading="isTesting(selected)"
:disabled="isBusy(selected)"
@click="testSelected"
>
测试连接
</el-button>
<el-button
type="primary"
:icon="Check"
@@ -195,7 +203,8 @@ import {
} from '@element-plus/icons-vue'
import {
getIntegrationConfigs,
saveIntegrationConfig
saveIntegrationConfig,
testIntegrationConfig
} from '@/api/integration'
defineOptions({ name: 'IntegrationConfig' })
@@ -311,6 +320,7 @@ const load = async () => {
const isBusy = (item) => Boolean(pending[operationKey(item)])
const isSaving = (item) => pending[operationKey(item)] === 'save'
const isToggling = (item) => pending[operationKey(item)] === 'toggle'
const isTesting = (item) => pending[operationKey(item)] === 'test'
const numberConstraint = (fieldKey) =>
NUMBER_CONSTRAINTS[fieldKey] || { min: undefined, max: undefined }
@@ -429,6 +439,28 @@ const saveSelected = async () => {
}
}
const testSelected = async () => {
const item = selected.value
if (!item || isBusy(item) || !validate(item, true)) return
const key = operationKey(item)
pending[key] = 'test'
try {
const res = await testIntegrationConfig(item.kind, item.provider, {
enabled: true,
config: item.config
})
if (res.code !== 0) {
ElMessage.error(res.msg || '连接测试失败')
return
}
ElMessage.success(`${item.name || providerMeta(item).name} 连接测试成功`)
} catch {
ElMessage.error('连接测试失败')
} finally {
delete pending[key]
}
}
const toggleIntegration = async (item, enabled) => {
if (isBusy(item)) return
const previous = item.enabled
+72 -8
View File
@@ -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; }
+2 -1
View File
@@ -419,9 +419,10 @@ async function syncOrder(order) {
}
async function retryFulfillment(order) {
if (isRowBusy(order)) return
setRowAction(order, 'fulfill-confirm')
try {
await ElMessageBox.confirm(`确认${fulfillmentActionText(order)}订单 ${order.tradeNo} 吗?`, fulfillmentActionText(order), { type: 'warning', confirmButtonText: '确认执行' })
} catch { return }
} catch { setRowAction(order, ''); return }
setRowAction(order, 'fulfill')
try {
const res = await fulfillPaymentOrder({ provider: order.provider, tradeNo: order.tradeNo })