优化结构

This commit is contained in:
Yvan
2026-08-21 21:37:30 +08:00
parent a2ce3ae218
commit e4ba0dced9
41 changed files with 3646 additions and 364 deletions
+17 -5
View File
@@ -3,25 +3,36 @@ import service from '@/utils/request'
export const getPaymentOrders = (params) => service({
url: '/payment/orders',
method: 'get',
params
params,
donNotShowLoading: true
})
export const getPaymentOrder = (data) => service({
url: '/payment/order',
method: 'post',
data
data,
donNotShowLoading: true
})
export const queryPaymentOrder = (data) => service({
url: '/payment/query',
method: 'post',
data
data,
donNotShowLoading: true
})
export const refundPaymentOrder = (data) => service({
url: '/payment/refund',
method: 'post',
data
data,
donNotShowLoading: true
})
export const fulfillPaymentOrder = (data) => service({
url: '/payment/fulfill',
method: 'post',
data,
donNotShowLoading: true
})
export const testPaymentProvider = (provider) => service({
@@ -34,5 +45,6 @@ export const testPaymentProvider = (provider) => service({
? { baseURL: `${window.location.origin}/` }
: {}),
url: `/payment/providers/${encodeURIComponent(provider)}/test`,
method: 'post'
method: 'post',
donNotShowLoading: true
})
+2
View File
@@ -57,6 +57,8 @@
"/src/view/system/state.vue": "State",
"/src/view/systemTools/integration/config.vue": "IntegrationConfig",
"/src/view/systemTools/logViewer/index.vue": "LogViewer",
"/src/view/systemTools/payment/config.vue": "PaymentConfig",
"/src/view/systemTools/payment/orders.vue": "PaymentOrders",
"/src/view/systemTools/sysError/sysError.vue": "SysError",
"/src/view/systemTools/system/system.vue": "Config",
"/src/view/systemTools/timedTask/index.vue": "TimedTask",
@@ -32,7 +32,7 @@
<small>{{ providerMeta(item).protocol }}</small>
</span>
<span class="provider-state" :class="{ enabled: item.enabled }">
{{ item.enabled ? '运行中' : '已停用' }}
{{ item.enabled ? '已启用' : '已停用' }}
</span>
</button>
</aside>
@@ -390,6 +390,11 @@ const hasMaskedSecret = (item) =>
const markSaved = (item) => {
item.configured = true
for (const field of item.fields || []) {
if (field.secret && !isMissing(item.config[field.key])) {
item.config[field.key] = '******'
}
}
item._savedEnabled = item.enabled
item._savedConfig = cloneConfig(item.config)
}
+400 -75
View File
@@ -1,107 +1,432 @@
<template>
<div class="integration-config-page">
<div class="page-heading">
<div><h2>支付渠道配置</h2><p>统一管理支付渠道凭证、接口地址和默认交易参数</p></div>
<el-button :loading="loading" :icon="Refresh" @click="load">刷新</el-button>
</div>
<div class="kra-table-box payment-config-page">
<header class="page-heading">
<div>
<h2>支付渠道配置</h2>
<p>管理渠道凭证、回调地址和测试交易参数。</p>
</div>
<el-button :icon="Refresh" :loading="loading" :disabled="busy" @click="refreshConfigs">刷新</el-button>
</header>
<div v-loading="loading" class="config-layout">
<aside class="provider-panel">
<div class="panel-title">渠道 <span>{{ configs.length }}</span></div>
<button v-for="item in configs" :key="item.provider" type="button" class="provider-item" :class="{ active: selected?.provider === item.provider }" @click="select(item)">
<span class="provider-copy"><strong>{{ item.name || item.provider }}</strong><small>{{ item.provider }}</small></span>
<el-tag :type="item.enabled ? 'success' : 'info'" size="small">{{ item.enabled ? '启用' : '停用' }}</el-tag>
<aside class="provider-panel" aria-label="支付渠道列表">
<div class="panel-heading"><span>渠道</span><span>{{ configs.length }}</span></div>
<button
v-for="item in configs"
:key="item.provider"
type="button"
class="provider-item"
:class="{ active: item.provider === selectedProvider }"
@click="selectProvider(item.provider)"
>
<span class="provider-copy">
<strong>{{ item.name || providerText(item.provider) }}</strong>
<small>{{ item.provider }}</small>
</span>
<span class="provider-state" :class="{ enabled: item.enabled }">
{{ item.enabled ? '已启用' : '已停用' }}
</span>
</button>
</aside>
<section v-if="selected" class="editor-panel">
<div class="editor-heading">
<div><div class="editor-title">{{ selected.name || selected.provider }}</div><div class="editor-subtitle">{{ selected.description || `provider: ${selected.provider}` }}</div></div>
<el-switch v-model="selected.enabled" active-text="启用渠道" @change="save" />
</div>
<el-alert v-if="selected.configured" title="密钥字段已脱敏,保留 ****** 表示继续使用当前密钥。" type="info" :closable="false" class="editor-alert" />
<el-alert v-else title="该渠道尚未保存,填写字段后保存即可创建配置。" type="warning" :closable="false" class="editor-alert" />
<el-form label-position="top" class="config-form">
<header class="editor-heading">
<div class="editor-title-group">
<div class="editor-title-row">
<h3>{{ selected.name || providerText(selected.provider) }}</h3>
<el-tag v-if="isDirty(selected)" type="warning" effect="plain">未保存</el-tag>
<el-tag v-else-if="selected.enabled" type="success" effect="plain">运行中</el-tag>
</div>
<p>{{ selected.description || selected.provider }}</p>
</div>
<div class="enable-control">
<span>{{ selected.enabled ? '已启用' : '已停用' }}</span>
<el-switch
:model-value="selected.enabled"
:loading="operation === 'toggle'"
:disabled="busy"
aria-label="启用支付渠道"
@change="toggleProvider"
/>
</div>
</header>
<el-alert
v-if="hasMaskedSecret(selected)"
type="info"
:closable="false"
show-icon
class="editor-alert"
title="凭证已脱敏;保留 ****** 将继续使用当前凭证。"
/>
<el-alert
v-else-if="!selected.configured"
type="warning"
:closable="false"
show-icon
class="editor-alert"
title="该渠道尚未保存。填写配置并保存后才能启用或测试。"
/>
<el-form :model="selected.config" label-position="top" class="config-form" @submit.prevent>
<div class="field-grid">
<el-form-item v-for="field in selected.fields" :key="field.key" :label="field.label" :required="field.required">
<template #label><span>{{ field.label }}</span><span class="field-key">{{ field.key }}</span></template>
<el-select v-if="field.type === 'select'" v-model="selected.config[field.key]" class="field-control" filterable>
<el-option v-for="option in field.options" :key="String(option.value)" :label="option.label" :value="option.value" />
<el-form-item
v-for="field in selected.fields || []"
:key="field.key"
:required="field.required"
:error="fieldError(field.key)"
>
<template #label>
<span class="field-label"><span>{{ field.label }}</span><small>{{ field.key }}</small></span>
</template>
<el-select
v-if="field.type === 'select'"
v-model="selected.config[field.key]"
class="field-control"
filterable
:placeholder="field.placeholder || '请选择'"
@update:model-value="clearFieldError(field.key)"
>
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" />
</el-select>
<el-switch v-else-if="field.type === 'switch'" v-model="selected.config[field.key]" />
<el-input v-else-if="field.type === 'textarea'" v-model="selected.config[field.key]" class="field-control" type="textarea" :rows="4" :show-password="field.secret" spellcheck="false" />
<el-input v-else v-model="selected.config[field.key]" class="field-control" :type="field.secret ? 'password' : field.type === 'number' ? 'number' : 'text'" :show-password="field.secret" spellcheck="false" />
<el-switch
v-else-if="field.type === 'switch'"
v-model="selected.config[field.key]"
@update:model-value="clearFieldError(field.key)"
/>
<el-input-number
v-else-if="field.type === 'number'"
v-model="selected.config[field.key]"
class="field-control"
:min="field.key === 'test_amount' ? 1 : 0"
:step="1"
controls-position="right"
@update:model-value="clearFieldError(field.key)"
/>
<el-input
v-else-if="field.type === 'textarea'"
v-model="selected.config[field.key]"
class="field-control"
type="textarea"
:rows="field.secret ? 5 : 4"
:placeholder="field.placeholder"
spellcheck="false"
@update:model-value="clearFieldError(field.key)"
/>
<el-input
v-else
v-model="selected.config[field.key]"
class="field-control"
:type="field.secret ? 'password' : 'text'"
:show-password="field.secret"
:placeholder="field.placeholder"
spellcheck="false"
@update:model-value="clearFieldError(field.key)"
/>
<p v-if="fieldHint(field)" class="field-hint">{{ fieldHint(field) }}</p>
</el-form-item>
</div>
</el-form>
<div class="editor-actions">
<el-button type="primary" :loading="saving" :icon="Check" @click="save">保存配置</el-button>
<el-button :loading="testing" :icon="Connection" @click="testProvider">测试渠道</el-button>
<el-button v-if="selected.configured" type="danger" plain :icon="Delete" @click="remove">删除配置</el-button>
<el-button text :icon="DocumentCopy" @click="copyConfig">复制 JSON</el-button>
</div>
<footer class="editor-actions">
<span class="save-state">{{ saveStateText }}</span>
<el-button
:icon="Connection"
:loading="operation === 'test'"
:disabled="busy || !selected.configured || isDirty(selected)"
@click="testProvider"
>
测试渠道
</el-button>
<el-button
v-if="selected.configured"
type="danger"
plain
:icon="Delete"
:loading="operation === 'delete'"
:disabled="busy"
@click="remove"
>
删除配置
</el-button>
<el-button
type="primary"
:icon="Check"
:loading="operation === 'save'"
:disabled="busy || !isDirty(selected)"
@click="saveSelected"
>
保存配置
</el-button>
</footer>
</section>
<el-empty v-else description="暂无支付渠道" />
</div>
<el-dialog v-model="testVisible" title="渠道测试结果" width="min(620px, calc(100vw - 32px))" destroy-on-close>
<div v-if="testResult" class="test-result">
<el-result
:icon="testResult.passed ? 'success' : 'error'"
:title="testResultTitle"
:sub-title="testResult.tradeNo ? `测试订单:${testResult.tradeNo}` : undefined"
/>
<div class="test-stages">
<div v-for="stage in testResult.stages || []" :key="`${stage.name}-${stage.tradeNo || ''}`" class="test-stage">
<el-icon :class="`stage-${stage.status}`"><component :is="stageIcon(stage.status)" /></el-icon>
<div><strong>{{ stageName(stage.name) }}</strong><p>{{ stage.message || stageStatusText(stage.status) }}</p></div>
<span v-if="stage.durationMs != null">{{ stage.durationMs }} ms</span>
</div>
</div>
</div>
<template #footer><el-button @click="testVisible = false">关闭</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Connection, Delete, DocumentCopy, Refresh } from '@element-plus/icons-vue'
import { Check, CircleCheck, CircleClose, Connection, Delete, MoreFilled, Refresh } from '@element-plus/icons-vue'
import { deleteIntegrationConfig, getIntegrationConfigs, saveIntegrationConfig } from '@/api/integration'
import { testPaymentProvider } from '@/api/payment'
const configs = ref([]); const selectedProvider = ref(''); const loading = ref(false); const saving = ref(false); const testing = ref(false)
const selected = computed(() => configs.value.find((item) => item.provider === selectedProvider.value) || configs.value[0])
const select = (item) => { selectedProvider.value = item.provider }
const load = async () => { loading.value = true; try { const res = await getIntegrationConfigs('payment'); if (res.code === 0) { configs.value = (res.data || []).map((item) => ({ ...item, config: { ...(item.config || {}) } })); if (!configs.value.some((item) => item.provider === selectedProvider.value)) selectedProvider.value = configs.value[0]?.provider || '' } } finally { loading.value = false } }
const save = async () => { if (!selected.value) return; saving.value = true; try { const res = await saveIntegrationConfig('payment', selected.value.provider, { enabled: selected.value.enabled, config: selected.value.config }); if (res.code === 0) { ElMessage.success('支付渠道配置已保存'); await load() } } finally { saving.value = false } }
const testProvider = async () => {
if (!selected.value) return
const environment = String(selected.value.config?.environment || '').toLowerCase()
if (environment === 'production' || environment === 'prod') {
try { await ElMessageBox.confirm('当前渠道使用生产环境,测试会真实创建最小金额订单。确认使用专用测试商户执行吗?', '生产环境测试确认', { type: 'warning', confirmButtonText: '确认测试' }) } catch { return }
}
testing.value = true
try {
const res = await testPaymentProvider(selected.value.provider)
const result = res.data || {}
const stages = (result.stages || []).map((stage) => `${stage.status === 'passed' ? '通过' : stage.status === 'skipped' ? '跳过' : '失败'}:${stage.name}${stage.message ? ` - ${stage.message}` : ''}${stage.durationMs != null ? ` (${stage.durationMs}ms)` : ''}`).join('\n')
if (res.code === 0 && result.passed) {
const title = result.fullFlow ? '渠道完整链路测试通过' : '渠道连通性测试通过(完整支付链路未完成)'
ElMessage.success({ message: `${title}\n${stages}`, duration: 9000, showClose: true })
}
else ElMessage.error({ message: `渠道测试未通过\n${stages || res.msg || '未知错误'}`, duration: 9000, showClose: true })
} finally { testing.value = false }
defineOptions({ name: 'PaymentConfig' })
const PROVIDER_NAMES = {
alipay: '支付宝', 'alipay-v3': '支付宝 V3', 'wechat-v2': '微信支付 V2',
'wechat-v3': '微信支付 V3', 'apple-iap': 'Apple IAP', douyin: '抖音支付',
qq: 'QQ 钱包', allinpay: '通联支付', lakala: '拉卡拉', paypal: 'PayPal',
saobei: '扫呗', chinaums: '银联商务', sft: '商福通', 'supper-pay': 'Supper Pay',
'wechat-game-pay': '微信小游戏支付', 'douyin-game-pay': '抖音小游戏支付'
}
const remove = async () => { if (!selected.value) return; await ElMessageBox.confirm(`确认删除 ${selected.value.name || selected.value.provider} 配置吗?`, '删除配置', { type: 'warning' }); const res = await deleteIntegrationConfig('payment', selected.value.provider); if (res.code === 0) { ElMessage.success('配置已删除'); await load() } }
const copyConfig = async () => { if (!selected.value) return; await navigator.clipboard.writeText(JSON.stringify(selected.value.config || {}, null, 2)); ElMessage.success('JSON 已复制') }
load()
const STAGE_NAMES = { config: '配置校验', test_settings: '测试参数', adapter: '渠道适配器', local_order: '本地测试订单', create: '渠道下单', query: '渠道查单', refund: '渠道退款' }
const configs = ref([])
const selectedProvider = ref('')
const loading = ref(false)
const operation = ref('')
const errors = reactive({})
const testVisible = ref(false)
const testResult = ref(null)
let loadRequestID = 0
const selected = computed(() => configs.value.find((item) => item.provider === selectedProvider.value) || configs.value[0])
const busy = computed(() => Boolean(operation.value))
const saveStateText = computed(() => {
if (!selected.value) return ''
if (!selected.value.configured) return '尚未创建配置'
if (isDirty(selected.value)) return '存在未保存的修改'
return selected.value.enabled ? '配置已保存,渠道已启用' : '配置已保存,渠道已停用'
})
const testResultTitle = computed(() => {
if (!testResult.value?.passed) return '渠道测试未通过'
const refundStage = testResult.value.stages?.find((stage) => stage.name === 'refund')
if (refundStage?.status === 'passed' && /受理|接受|等待|pending|processing/i.test(refundStage.message || '')) return '支付链路已受理,等待退款确认'
return testResult.value.fullFlow ? '完整支付链路测试通过' : '渠道连通性测试通过'
})
const cloneConfig = (value) => JSON.parse(JSON.stringify(value || {}))
const normalizeConfig = (item) => {
const normalized = { ...item, enabled: Boolean(item.enabled), configured: Boolean(item.configured), config: cloneConfig(item.config), fields: Array.isArray(item.fields) ? item.fields : [] }
normalized._savedEnabled = normalized.enabled
normalized._savedConfig = cloneConfig(normalized.config)
return normalized
}
const providerText = (provider) => PROVIDER_NAMES[provider] || provider
const fieldError = (key) => errors[key] || ''
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) => {
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 地址。'
return field.description || ''
}
const stageName = (name) => STAGE_NAMES[name] || name
const stageStatusText = (status) => status === 'passed' ? '通过' : status === 'skipped' ? '跳过' : '失败'
const stageIcon = (status) => status === 'passed' ? CircleCheck : status === 'failed' ? CircleClose : MoreFilled
function validate(item, enabled = item.enabled) {
Object.keys(errors).forEach((key) => delete errors[key])
let valid = true
for (const field of item.fields || []) {
const value = item.config[field.key]
let message = ''
if (enabled && field.required && isEmpty(value)) message = `请填写${field.label}`
else if (field.type === 'number' && !isEmpty(value) && (!Number.isFinite(Number(value)) || Number(value) < 0)) message = `${field.label}必须是非负数`
else if (field.key === 'test_amount' && !isEmpty(value) && Number(value) <= 0) message = '测试金额必须大于 0'
else if (field.key === 'test_extra' && String(value || '').trim()) {
try {
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) {
try {
const url = new URL(String(value))
if (!['http:', 'https:'].includes(url.protocol)) message = `${field.label}必须使用 HTTP 或 HTTPS`
} catch { message = `${field.label}格式无效` }
}
if (!item.configured && field.secret && value === '******') message = '请重新填写' + field.label
if (message) { errors[field.key] = message; valid = false }
}
if (!valid) ElMessage.warning('请先修正配置项')
return valid
}
async function load() {
if (loading.value) return
const requestID = ++loadRequestID
loading.value = true
try {
const res = await getIntegrationConfigs('payment')
if (requestID !== loadRequestID || res.code !== 0) return
configs.value = (res.data || []).map(normalizeConfig)
if (!configs.value.some((item) => item.provider === selectedProvider.value)) selectedProvider.value = configs.value[0]?.provider || ''
} catch {
// The request layer already presents transport errors.
} finally { loading.value = false }
}
async function refreshConfigs() {
if (busy.value) return
if (selected.value && isDirty(selected.value)) {
try {
await ElMessageBox.confirm('当前渠道有未保存修改,刷新会丢失这些修改。确认刷新吗?', '刷新支付配置', { type: 'warning', confirmButtonText: '确认刷新' })
} catch { return }
}
await load()
}
async function selectProvider(provider) {
if (provider === selectedProvider.value || busy.value) return
if (selected.value && isDirty(selected.value)) {
try {
await ElMessageBox.confirm('当前渠道有未保存修改,切换后会丢失这些修改。确认切换吗?', '切换支付渠道', { type: 'warning', confirmButtonText: '确认切换' })
} catch { return }
}
Object.keys(errors).forEach((key) => delete errors[key])
selectedProvider.value = provider
}
async function persist(item, type) {
if (busy.value || !validate(item, item.enabled)) return false
operation.value = type
try {
const res = await saveIntegrationConfig('payment', item.provider, { enabled: item.enabled, config: item.config })
if (res.code !== 0) return false
const provider = item.provider
await load()
selectedProvider.value = provider
return true
} catch { return false }
finally { operation.value = '' }
}
async function saveSelected() {
if (!selected.value) return
if (await persist(selected.value, 'save')) ElMessage.success('支付渠道配置已保存')
}
async function toggleProvider(enabled) {
const item = selected.value
if (!item || busy.value) return
const previous = item.enabled
item.enabled = Boolean(enabled)
if (item.enabled && !validate(item, true)) { item.enabled = previous; return }
if (await persist(item, 'toggle')) {
ElMessage.success(`${item.name || providerText(item.provider)} 已${item.enabled ? '启用' : '停用'}`)
return
}
item.enabled = previous
ElMessage.warning('渠道状态未改变')
}
async function testProvider() {
const item = selected.value
if (!item || busy.value) return
if (isDirty(item)) { ElMessage.warning('请先保存当前配置'); return }
if (!item.config?.test_mode) { ElMessage.warning('请先开启“允许执行渠道测试”并保存'); return }
const environment = String(item.config?.environment || '').toLowerCase()
if (environment === 'production' || environment === 'prod') {
operation.value = 'test-confirm'
try {
await ElMessageBox.confirm('当前渠道使用生产环境,测试会真实创建最小金额订单。确认使用专用测试商户执行吗?', '生产环境测试确认', { type: 'warning', confirmButtonText: '确认测试' })
} catch { operation.value = ''; return }
}
operation.value = 'test'
try {
const res = await testPaymentProvider(item.provider)
testResult.value = res.data || { passed: false, stages: [{ name: 'test', status: 'failed', message: res.msg || '未知错误' }] }
testVisible.value = true
} catch {
// The request layer already presents transport errors.
} finally { operation.value = '' }
}
async function remove() {
const item = selected.value
if (!item || busy.value) return
try {
await ElMessageBox.confirm(`删除 ${item.name || providerText(item.provider)} 配置后,该渠道将立即不可用。确认继续吗?`, '删除支付配置', { type: 'warning', confirmButtonText: '确认删除' })
} catch { return }
operation.value = 'delete'
try {
const res = await deleteIntegrationConfig('payment', item.provider)
if (res.code !== 0) return
ElMessage.success('支付渠道配置已删除')
await load()
} catch {
// The request layer already presents transport errors.
} finally { operation.value = '' }
}
onMounted(load)
</script>
<style scoped>
.integration-config-page { padding: 4px 0 24px; }
.page-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
.page-heading h2 { margin: 0; color: var(--el-text-color-primary); font-size: 20px; font-weight: 600; }
.page-heading p { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
.config-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 620px; border: 1px solid var(--el-border-color-lighter); background: var(--el-bg-color); }
.provider-panel { border-right: 1px solid var(--el-border-color-lighter); padding: 14px 10px; }
.panel-title { display: flex; justify-content: space-between; padding: 2px 10px 12px; color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
.panel-title span { color: var(--el-text-color-secondary); font-weight: 400; }
.provider-item { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 54px; padding: 9px 10px; border: 0; border-left: 3px solid transparent; background: transparent; color: inherit; text-align: left; cursor: pointer; }
.payment-config-page { min-height: 640px; }
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.page-heading h2 { margin: 0; color: var(--el-text-color-primary); font-size: 20px; font-weight: 600; letter-spacing: 0; }
.page-heading p { margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
.config-layout { display: grid; grid-template-columns: 248px minmax(0, 1fr); min-height: 560px; overflow: hidden; border: 1px solid var(--el-border-color-lighter); background: var(--el-bg-color); }
.provider-panel { padding: 12px 9px; border-right: 1px solid var(--el-border-color-lighter); background: var(--el-fill-color-blank); }
.panel-heading { display: flex; justify-content: space-between; padding: 4px 10px 11px; color: var(--el-text-color-secondary); font-size: 12px; }
.provider-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; min-height: 58px; padding: 8px 10px; border: 0; border-left: 3px solid transparent; border-radius: 4px; background: transparent; color: inherit; text-align: left; cursor: pointer; }
.provider-item:hover { background: var(--el-fill-color-light); }
.provider-item.active { border-left-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.provider-copy { display: grid; gap: 3px; min-width: 0; }
.provider-copy { display: grid; min-width: 0; gap: 3px; }
.provider-copy strong { overflow: hidden; color: var(--el-text-color-primary); font-size: 14px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.provider-copy small { color: var(--el-text-color-secondary); font-size: 11px; }
.editor-panel { min-width: 0; padding: 22px 28px 24px; }
.editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding-bottom: 18px; border-bottom: 1px solid var(--el-border-color-lighter); }
.editor-title { color: var(--el-text-color-primary); font-size: 18px; font-weight: 600; }
.editor-subtitle { margin-top: 5px; color: var(--el-text-color-secondary); font-size: 13px; }
.editor-alert { margin: 18px 0; }
.provider-state { color: var(--el-text-color-placeholder); font-size: 11px; white-space: nowrap; }
.provider-state.enabled { color: var(--el-color-success); }
.editor-panel { display: flex; min-width: 0; flex-direction: column; padding: 22px 28px 20px; }
.editor-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding-bottom: 18px; border-bottom: 1px solid var(--el-border-color-lighter); }
.editor-title-group { min-width: 0; }
.editor-title-row { display: flex; align-items: center; gap: 9px; }
.editor-title-row h3 { margin: 0; color: var(--el-text-color-primary); font-size: 18px; font-weight: 600; letter-spacing: 0; }
.editor-title-group p { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
.enable-control { display: flex; align-items: center; gap: 10px; min-height: 32px; color: var(--el-text-color-regular); font-size: 13px; white-space: nowrap; }
.editor-alert { margin-top: 18px; }
.config-form { flex: 1; padding-top: 20px; }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 22px; }
.field-key { margin-left: 8px; color: var(--el-text-color-placeholder); font-size: 11px; font-weight: 400; }
.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%; }
.editor-actions { display: flex; align-items: center; gap: 10px; padding-top: 8px; border-top: 1px solid var(--el-border-color-lighter); }
@media (max-width: 900px) { .config-layout { grid-template-columns: 1fr; } .provider-panel { border-right: 0; border-bottom: 1px solid var(--el-border-color-lighter); max-height: 260px; overflow-y: auto; } .field-grid { grid-template-columns: 1fr; } .editor-panel { padding: 18px; } }
.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; }
.test-result :deep(.el-result) { padding: 8px 24px 20px; }
.test-stages { border-top: 1px solid var(--el-border-color-lighter); }
.test-stage { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: start; gap: 10px; padding: 13px 4px; border-bottom: 1px solid var(--el-border-color-lighter); }
.test-stage strong { color: var(--el-text-color-primary); font-size: 13px; font-weight: 600; }
.test-stage p { margin: 3px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.5; }
.test-stage > span { color: var(--el-text-color-placeholder); font-size: 11px; white-space: nowrap; }
.stage-passed { color: var(--el-color-success); } .stage-failed { color: var(--el-color-danger); } .stage-skipped { color: var(--el-text-color-placeholder); }
@media (max-width: 900px) { .config-layout { grid-template-columns: 1fr; } .provider-panel { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; max-height: 270px; overflow-y: auto; border-right: 0; border-bottom: 1px solid var(--el-border-color-lighter); } .panel-heading { display: none; } .provider-state { display: none; } .editor-panel { padding: 20px; } .field-grid { grid-template-columns: 1fr; } }
@media (max-width: 560px) { .payment-config-page { min-height: 0; } .page-heading { align-items: flex-start; } .provider-panel { grid-template-columns: 1fr; } .provider-state { display: inline; } .editor-panel { padding: 18px 14px; } .editor-heading { align-items: stretch; flex-direction: column; gap: 14px; } .enable-control { justify-content: space-between; } .editor-actions { align-items: stretch; flex-direction: column; } .save-state { margin-right: 0; } .editor-actions :deep(.el-button) { margin-left: 0; } }
</style>
+503 -47
View File
@@ -1,68 +1,524 @@
<template>
<div class="payment-orders">
<div class="kra-search-box">
<el-form :inline="true" :model="searchInfo" @submit.prevent="reload">
<el-form-item label="渠道"><el-input v-model="searchInfo.provider" placeholder="alipay / wechat-v3" clearable /></el-form-item>
<el-form-item label="商户订单号"><el-input v-model="searchInfo.tradeNo" clearable /></el-form-item>
<el-form-item label="业务类型"><el-input v-model="searchInfo.businessType" clearable /></el-form-item>
<el-form-item label="业务 ID"><el-input v-model="searchInfo.businessId" clearable /></el-form-item>
<el-form-item label="支付状态"><el-select v-model="searchInfo.paymentStatus" clearable placeholder="全部" style="width: 130px"><el-option v-for="item in paymentStatuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="退款状态"><el-select v-model="searchInfo.refundStatus" clearable placeholder="全部" style="width: 130px"><el-option v-for="item in refundStatuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item><el-button type="primary" icon="search" @click="reload">查询</el-button><el-button icon="refresh" @click="reset">重置</el-button></el-form-item>
<header class="page-heading">
<div>
<h2>支付订单</h2>
<p>核对收款、发货与退款状态,处理需要人工介入的订单。</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="load()">刷新</el-button>
</header>
<section class="status-summary" aria-label="当前页订单概览">
<div v-for="item in pageSummary" :key="item.label" class="summary-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<small>{{ item.hint }}</small>
</div>
</section>
<div class="kra-search-box filter-band">
<el-form :model="searchInfo" label-position="top" @submit.prevent="reload">
<div class="filter-grid">
<el-form-item label="支付渠道">
<el-select v-model="searchInfo.provider" clearable filterable placeholder="全部渠道">
<el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="商户订单号">
<el-input v-model="searchInfo.tradeNo" clearable placeholder="支持模糊查询" />
</el-form-item>
<el-form-item label="业务类型">
<el-input v-model="searchInfo.businessType" clearable placeholder="精确匹配" />
</el-form-item>
<el-form-item label="业务 ID">
<el-input v-model="searchInfo.businessId" clearable placeholder="支持模糊查询" />
</el-form-item>
<el-form-item label="支付状态">
<el-select v-model="searchInfo.paymentStatus" clearable placeholder="全部状态">
<el-option v-for="item in paymentStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="退款状态">
<el-select v-model="searchInfo.refundStatus" clearable placeholder="全部状态">
<el-option v-for="item in refundStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<div class="filter-actions">
<el-button type="primary" :icon="Search" @click="reload">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
</div>
</div>
</el-form>
</div>
<div class="kra-table-box">
<el-table v-loading="loading" :data="rows" row-key="ID" stripe>
<el-table-column prop="tradeNo" label="商户订单号" min-width="190" show-overflow-tooltip />
<el-table-column prop="provider" label="渠道" width="120" />
<el-table-column prop="subject" label="商品/标题" min-width="180" show-overflow-tooltip />
<el-table-column label="金额" width="130"><template #default="scope">{{ formatAmount(scope.row.amount, scope.row.currency) }}</template></el-table-column>
<el-table-column label="支付状态" width="110"><template #default="scope"><el-tag :type="statusType(scope.row.paymentStatus)">{{ statusText(scope.row.paymentStatus) }}</el-tag></template></el-table-column>
<el-table-column label="发货" width="110"><template #default="scope"><el-tag :type="fulfillmentType(scope.row.fulfillmentStatus)">{{ fulfillmentText(scope.row.fulfillmentStatus) }}</el-tag></template></el-table-column>
<el-table-column label="退款" width="110"><template #default="scope"><el-tag :type="refundType(scope.row.refundStatus)">{{ refundText(scope.row.refundStatus) }}</el-tag></template></el-table-column>
<el-table-column label="创建时间" width="180"><template #default="scope">{{ formatDate(scope.row.createdAt) }}</template></el-table-column>
<el-table-column label="操作" fixed="right" width="210"><template #default="scope"><el-button link type="primary" @click="openDetail(scope.row)">详情</el-button><el-button link type="primary" @click="refreshOrder(scope.row)">同步</el-button><el-button v-if="canRefund(scope.row)" link type="warning" @click="openRefund(scope.row)">退款</el-button><el-button v-if="canFulfill(scope.row)" link type="success" @click="showFulfillmentHint">发货</el-button></template></el-table-column>
<div class="kra-table-box order-table-band">
<div class="table-heading">
<div><strong>订单明细</strong><span>共 {{ total }} 笔</span></div>
<span v-if="issueCount" class="issue-count">{{ issueCount }} 笔需要关注</span>
</div>
<el-table v-loading="loading" :data="rows" row-key="ID" stripe :row-class-name="rowClassName">
<el-table-column label="订单" min-width="250">
<template #default="scope">
<div class="order-cell">
<button type="button" class="order-link" @click="openDetail(scope.row)">{{ scope.row.tradeNo || '-' }}</button>
<span>{{ scope.row.subject || '未提供商品标题' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="业务" min-width="165">
<template #default="scope">
<div class="business-cell"><span>{{ scope.row.businessType || '-' }}</span><small>{{ scope.row.businessId || '-' }}</small></div>
</template>
</el-table-column>
<el-table-column label="渠道" width="135">
<template #default="scope">
<div class="provider-cell"><span>{{ providerText(scope.row.provider) }}</span><small>{{ scope.row.provider || '-' }}</small></div>
</template>
</el-table-column>
<el-table-column label="金额" width="145" align="right">
<template #default="scope">
<div class="amount-cell">
<strong>{{ formatAmount(scope.row.amount, scope.row.currency) }}</strong>
<small v-if="scope.row.paidAmount">实付 {{ formatAmount(scope.row.paidAmount, scope.row.currency) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="支付" width="115" align="center">
<template #default="scope"><el-tag :type="paymentStatusType(scope.row.paymentStatus)">{{ paymentStatusText(scope.row.paymentStatus) }}</el-tag></template>
</el-table-column>
<el-table-column label="发货" width="115" align="center">
<template #default="scope"><el-tag :type="fulfillmentStatusType(scope.row.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(scope.row.fulfillmentStatus, scope.row) }}</el-tag></template>
</el-table-column>
<el-table-column label="退款" width="120" align="center">
<template #default="scope"><el-tag :type="refundStatusType(scope.row.refundStatus)" effect="plain">{{ refundStatusText(scope.row.refundStatus) }}</el-tag></template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="scope">{{ formatDateValue(scope.row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" :fixed="operationFixed" width="310">
<template #default="scope">
<div class="row-actions">
<el-button link type="primary" :icon="View" :disabled="isRowBusy(scope.row)" @click="openDetail(scope.row)">详情</el-button>
<el-button v-if="canSync(scope.row)" link type="primary" :icon="RefreshRight" :loading="rowAction(scope.row) === 'sync'" :disabled="isRowBusy(scope.row) && rowAction(scope.row) !== 'sync'" @click="syncOrder(scope.row)">同步</el-button>
<el-button v-if="canRefund(scope.row)" link type="warning" :icon="Money" :disabled="isRowBusy(scope.row)" @click="openRefund(scope.row)">退款</el-button>
<el-button v-if="canFulfill(scope.row)" link type="success" :icon="Promotion" :loading="rowAction(scope.row) === 'fulfill'" :disabled="isRowBusy(scope.row) && rowAction(scope.row) !== 'fulfill'" @click="retryFulfillment(scope.row)">{{ fulfillmentActionText(scope.row) }}</el-button>
</div>
</template>
</el-table-column>
<template #empty><el-empty description="没有符合条件的支付订单" /></template>
</el-table>
<div class="kra-pagination"><el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 30, 50, 100]" :total="total" layout="total, sizes, prev, pager, next, jumper" @current-change="changePage" @size-change="changeSize" /></div>
<div class="kra-pagination">
<el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 30, 50, 100]" :total="total" :layout="paginationLayout" @current-change="changePage" @size-change="changeSize" />
</div>
</div>
<el-drawer v-model="detailVisible" title="订单详情" size="560px" destroy-on-close>
<el-descriptions v-if="detail" :column="2" border>
<el-descriptions-item label="商户订单号" :span="2">{{ detail.tradeNo || '-' }}</el-descriptions-item><el-descriptions-item label="渠道">{{ detail.provider || '-' }}</el-descriptions-item><el-descriptions-item label="支付方式">{{ detail.paymentMode || '-' }}</el-descriptions-item><el-descriptions-item label="业务类型">{{ detail.businessType || '-' }}</el-descriptions-item><el-descriptions-item label="业务 ID">{{ detail.businessId || '-' }}</el-descriptions-item><el-descriptions-item label="商品标题" :span="2">{{ detail.subject || '-' }}</el-descriptions-item><el-descriptions-item label="订单金额">{{ formatAmount(detail.amount, detail.currency) }}</el-descriptions-item><el-descriptions-item label="实付金额">{{ formatAmount(detail.paidAmount, detail.currency) }}</el-descriptions-item><el-descriptions-item label="支付状态"><el-tag :type="statusType(detail.paymentStatus)">{{ statusText(detail.paymentStatus) }}</el-tag></el-descriptions-item><el-descriptions-item label="发货状态"><el-tag :type="fulfillmentType(detail.fulfillmentStatus)">{{ fulfillmentText(detail.fulfillmentStatus) }}</el-tag></el-descriptions-item><el-descriptions-item label="退款状态"><el-tag :type="refundType(detail.refundStatus)">{{ refundText(detail.refundStatus) }}</el-tag></el-descriptions-item><el-descriptions-item label="已退款金额">{{ formatAmount(detail.refundedAmount, detail.currency) }}</el-descriptions-item><el-descriptions-item label="第三方订单号" :span="2">{{ detail.providerTradeNo || '-' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatDate(detail.createdAt) }}</el-descriptions-item><el-descriptions-item label="支付时间">{{ formatDate(detail.paidAt) }}</el-descriptions-item><el-descriptions-item v-if="detail.lastError" label="最近错误" :span="2"><span class="error-text">{{ detail.lastError }}</span></el-descriptions-item>
</el-descriptions>
<el-drawer v-model="detailVisible" title="支付订单详情" :size="drawerSize" destroy-on-close>
<div v-loading="detailLoading" class="order-detail">
<template v-if="detail">
<div class="detail-toolbar">
<div class="detail-statuses">
<el-tag :type="paymentStatusType(detail.paymentStatus)">{{ paymentStatusText(detail.paymentStatus) }}</el-tag>
<el-tag :type="fulfillmentStatusType(detail.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(detail.fulfillmentStatus, detail) }}</el-tag>
<el-tag :type="refundStatusType(detail.refundStatus)" effect="plain">{{ refundStatusText(detail.refundStatus) }}</el-tag>
</div>
<div class="detail-actions">
<el-button v-if="canSync(detail)" :icon="RefreshRight" :loading="rowAction(detail) === 'sync'" :disabled="isRowBusy(detail) && rowAction(detail) !== 'sync'" @click="syncOrder(detail)">同步状态</el-button>
<el-button v-if="canFulfill(detail)" type="success" plain :icon="Promotion" :loading="rowAction(detail) === 'fulfill'" :disabled="isRowBusy(detail) && rowAction(detail) !== 'fulfill'" @click="retryFulfillment(detail)">{{ fulfillmentActionText(detail) }}</el-button>
<el-button v-if="canRefund(detail)" type="warning" plain :icon="Money" :disabled="isRowBusy(detail)" @click="openRefund(detail)">申请退款</el-button>
</div>
</div>
<el-alert v-if="detail.lastError" type="error" :closable="false" show-icon class="detail-error" :title="detail.lastError" />
<h3 class="section-heading">订单信息</h3>
<el-descriptions :column="detailColumns" border>
<el-descriptions-item label="本地记录 ID">{{ detail.ID || '-' }}</el-descriptions-item>
<el-descriptions-item label="支付方式">{{ paymentModeText(detail.paymentMode) }}</el-descriptions-item>
<el-descriptions-item label="商户订单号" :span="detailColumns">
<span class="copy-value"><span>{{ detail.tradeNo || '-' }}</span><el-button v-if="detail.tradeNo" link :icon="CopyDocument" aria-label="复制商户订单号" @click="copyText(detail.tradeNo)" /></span>
</el-descriptions-item>
<el-descriptions-item label="第三方交易号" :span="detailColumns">
<span class="copy-value"><span>{{ detail.providerTradeNo || '-' }}</span><el-button v-if="detail.providerTradeNo" link :icon="CopyDocument" aria-label="复制第三方交易号" @click="copyText(detail.providerTradeNo)" /></span>
</el-descriptions-item>
<el-descriptions-item label="支付渠道">{{ providerText(detail.provider) }}({{ detail.provider || '-' }})</el-descriptions-item>
<el-descriptions-item label="平台状态">{{ detail.providerStatus || '-' }}</el-descriptions-item>
<el-descriptions-item label="业务类型">{{ detail.businessType || '-' }}</el-descriptions-item>
<el-descriptions-item label="业务 ID">{{ detail.businessId || '-' }}</el-descriptions-item>
<el-descriptions-item label="商品标题" :span="detailColumns">{{ detail.subject || '-' }}</el-descriptions-item>
</el-descriptions>
<h3 class="section-heading">金额明细</h3>
<div class="amount-grid">
<div v-for="item in detailAmounts" :key="item.label" class="amount-item"><span>{{ item.label }}</span><strong>{{ item.value }}</strong></div>
</div>
<el-alert v-if="!detail.amountBreakdownKnown && detail.paymentStatus === 'paid'" type="info" :closable="false" class="amount-alert" title="支付渠道未返回完整的实付、优惠与结算拆分。" />
<h3 class="section-heading">处理时间</h3>
<el-timeline class="order-timeline">
<el-timeline-item v-for="item in detailTimeline" :key="item.label" :timestamp="item.time" :type="item.type">{{ item.label }}</el-timeline-item>
</el-timeline>
</template>
</div>
</el-drawer>
<el-dialog v-model="refundVisible" title="申请退款" width="440px" destroy-on-close>
<el-form ref="refundFormRef" :model="refundForm" :rules="refundRules" label-width="100px"><el-form-item label="订单号"><el-input :model-value="refundForm.tradeNo" disabled /></el-form-item><el-form-item label="可退金额"><span>{{ formatAmount(refundForm.maxAmount, refundForm.currency) }}</span></el-form-item><el-form-item label="退款金额" prop="amount"><el-input-number v-model="refundForm.amount" :min="1" :max="refundForm.maxAmount" :step="1" controls-position="right" style="width: 100%" /><div class="form-tip">单位为最小货币单位(例如人民币分)</div></el-form-item></el-form>
<template #footer><el-button @click="refundVisible = false">取消</el-button><el-button type="warning" :loading="refundLoading" @click="submitRefund">确认退款</el-button></template>
<el-dialog v-model="refundVisible" title="申请退款" width="min(460px, calc(100vw - 32px))" destroy-on-close :close-on-click-modal="!refundSubmitting" :close-on-press-escape="!refundSubmitting">
<div class="refund-summary">
<span>商户订单号</span><strong>{{ refundForm.tradeNo || '-' }}</strong>
<span>可退金额</span><strong>{{ formatAmount(refundForm.maxAmount, refundForm.currency) }}</strong>
</div>
<el-form ref="refundFormRef" :model="refundForm" :rules="refundRules" label-position="top" @submit.prevent="submitRefund">
<el-form-item label="退款金额" prop="amountText">
<el-input v-model="refundForm.amountText" inputmode="decimal" autocomplete="off" :placeholder="refundAmountPlaceholder">
<template #prepend>{{ refundForm.currency || 'CNY' }}</template>
</el-input>
<span class="form-tip">{{ refundPrecisionText }}</span>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="refundSubmitting" @click="refundVisible = false">取消</el-button>
<el-button type="warning" :icon="Money" :loading="refundSubmitting" @click="submitRefund">确认退款</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getPaymentOrders, getPaymentOrder, queryPaymentOrder, refundPaymentOrder } from '@/api/payment'
import { CopyDocument, Money, Promotion, Refresh, RefreshRight, Search, View } from '@element-plus/icons-vue'
import { fulfillPaymentOrder, getPaymentOrder, getPaymentOrders, queryPaymentOrder, refundPaymentOrder } from '@/api/payment'
import { formatDate } from '@/utils/format'
const page = ref(1); const pageSize = ref(10); const total = ref(0); const rows = ref([]); const loading = ref(false); const searchInfo = ref({}); const detail = ref(null); const detailVisible = ref(false); const refundVisible = ref(false); const refundLoading = ref(false); const refundFormRef = ref(); const refundForm = ref({ amount: 0, maxAmount: 0 })
const paymentStatuses = [{ value: 'initialized', label: '初始化' }, { value: 'pending', label: '待支付' }, { value: 'paid', label: '已支付' }, { value: 'failed', label: '失败' }, { value: 'closed', label: '已关闭' }]
const refundStatuses = [{ value: 'none', label: '未退款' }, { value: 'processing', label: '退款中' }, { value: 'partially_refunded', label: '部分退款' }, { value: 'refunded', label: '已退款' }, { value: 'failed', label: '退款失败' }]
const refundRules = { amount: [{ required: true, message: '请输入退款金额', trigger: 'blur' }, { validator: (_rule, value, callback) => value > 0 && value <= refundForm.value.maxAmount ? callback() : callback(new Error('退款金额超出可退范围')), trigger: 'change' }] }
const formatAmount = (value, currency = 'CNY') => `${((Number(value) || 0) / 100).toFixed(2)} ${currency || ''}`.trim(); const statusText = (s) => ({ initialized: '初始化', pending: '待支付', paid: '已支付', failed: '失败', closed: '已关闭' }[s] || s || '-'); const statusType = (s) => ({ paid: 'success', failed: 'danger', closed: 'info' }[s] || 'warning'); const fulfillmentText = (s) => ({ pending: '待发货', processing: '发货中', succeeded: '已发货', failed: '发货失败' }[s] || s || '-'); const fulfillmentType = (s) => ({ succeeded: 'success', failed: 'danger', processing: 'warning' }[s] || 'info'); const refundText = (s) => ({ none: '未退款', processing: '退款中', partially_refunded: '部分退款', refunded: '已退款', failed: '退款失败' }[s] || s || '-'); const refundType = (s) => ({ refunded: 'success', failed: 'danger', processing: 'warning', partially_refunded: 'warning' }[s] || 'info')
const canRefund = (r) => r.paymentStatus === 'paid' && !['refunded', 'processing'].includes(r.refundStatus) && Number(r.amount || 0) > Number(r.refundedAmount || 0); const canFulfill = (r) => r.paymentStatus === 'paid' && r.fulfillmentStatus !== 'succeeded'
const load = async () => { loading.value = true; try { const res = await getPaymentOrders({ page: page.value, pageSize: pageSize.value, ...searchInfo.value }); if (res.code === 0) { rows.value = res.data?.list || []; total.value = res.data?.total || 0; page.value = res.data?.page || page.value; pageSize.value = res.data?.pageSize || pageSize.value } } finally { loading.value = false } }
const reload = () => { page.value = 1; load() }; const reset = () => { searchInfo.value = {}; reload() }; const changePage = (v) => { page.value = v; load() }; const changeSize = (v) => { pageSize.value = v; page.value = 1; load() }
const openDetail = async (row) => { detail.value = row; detailVisible.value = true; const res = await getPaymentOrder({ provider: row.provider, tradeNo: row.tradeNo }); if (res.code === 0 && res.data) detail.value = res.data }
const refreshOrder = async (row) => { const res = await queryPaymentOrder({ provider: row.provider, tradeNo: row.tradeNo }); if (res.code === 0) { ElMessage.success('已同步支付状态'); await load() } }
const openRefund = (row) => { const maxAmount = Number(row.amount || 0) - Number(row.refundedAmount || 0); refundForm.value = { provider: row.provider, tradeNo: row.tradeNo, currency: row.currency, maxAmount, amount: maxAmount }; refundVisible.value = true }
const submitRefund = async () => { await refundFormRef.value?.validate(); await ElMessageBox.confirm('退款操作将调用支付渠道,确认继续吗?', '确认退款', { type: 'warning' }); refundLoading.value = true; try { const res = await refundPaymentOrder({ provider: refundForm.value.provider, tradeNo: refundForm.value.tradeNo, amount: refundForm.value.amount }); if (res.code === 0) { ElMessage.success('退款请求已提交'); refundVisible.value = false; await load() } } finally { refundLoading.value = false } }
const showFulfillmentHint = () => ElMessage.info('发货接口已预留,待业务模块注册发货处理器后启用。')
load()
defineOptions({ name: 'PaymentOrders' })
const PAYMENT_STATUS_META = {
initialized: { label: '初始化', type: 'info' }, pending: { label: '待支付', type: 'warning' },
paid: { label: '已支付', type: 'success' }, failed: { label: '支付失败', type: 'danger' },
closed: { label: '已关闭', type: 'info' }, partially_refunded: { label: '部分退款', type: 'warning' },
refunded: { label: '已退款', type: 'info' }
}
const FULFILLMENT_STATUS_META = {
pending: { label: '待发货', type: 'info' }, processing: { label: '发货中', type: 'warning' },
succeeded: { label: '已发货', type: 'success' }, failed: { label: '发货失败', type: 'danger' }
}
const REFUND_STATUS_META = {
none: { label: '未退款', type: 'info' }, processing: { label: '请求处理中', type: 'warning' },
pending: { label: '渠道处理中', type: 'warning' }, partial: { label: '部分退款', type: 'warning' },
succeeded: { label: '已退款', type: 'success' }, failed: { label: '退款失败', type: 'danger' }
}
const providerOptions = [
['alipay', '支付宝'], ['alipay-v3', '支付宝 V3'], ['wechat-v2', '微信支付 V2'], ['wechat-v3', '微信支付 V3'],
['apple-iap', 'Apple IAP'], ['douyin', '抖音支付'], ['qq', 'QQ 钱包'], ['allinpay', '通联支付'],
['lakala', '拉卡拉'], ['paypal', 'PayPal'], ['saobei', '扫呗'], ['chinaums', '银联商务'], ['sft', '商福通'],
['supper-pay', 'Supper Pay'], ['wechat-game-pay', '微信小游戏支付'], ['douyin-game-pay', '抖音小游戏支付'], ['internal', '内部支付']
].map(([value, label]) => ({ value, label }))
const providerNames = Object.fromEntries(providerOptions.map((item) => [item.value, item.label]))
const paymentStatusOptions = Object.entries(PAYMENT_STATUS_META).map(([value, item]) => ({ value, label: item.label }))
const refundStatusOptions = Object.entries(REFUND_STATUS_META).map(([value, item]) => ({ value, label: item.label }))
const ZERO_DECIMAL_CURRENCIES = new Set(['BIF', 'CLP', 'DJF', 'GNF', 'ISK', 'JPY', 'KMF', 'KRW', 'PYG', 'RWF', 'UGX', 'UYI', 'VND', 'VUV', 'XAF', 'XOF', 'XPF'])
const THREE_DECIMAL_CURRENCIES = new Set(['BHD', 'IQD', 'JOD', 'KWD', 'LYD', 'OMR', 'TND'])
const FOUR_DECIMAL_CURRENCIES = new Set(['CLF', 'UYW'])
const MAX_SAFE_MINOR_AMOUNT = Number.MAX_SAFE_INTEGER
const { width } = useWindowSize()
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const rows = ref([])
const loading = ref(false)
const detail = ref(null)
const detailVisible = ref(false)
const detailLoading = ref(false)
const refundVisible = ref(false)
const refundLoading = ref(false)
const refundSubmitting = ref(false)
const refundFormRef = ref()
const rowActions = reactive({})
const searchInfo = reactive({ provider: '', tradeNo: '', businessType: '', businessId: '', paymentStatus: '', refundStatus: '' })
const refundForm = reactive({ provider: '', tradeNo: '', currency: 'CNY', maxAmount: 0, amountText: '' })
let loadRequestID = 0
let detailRequestID = 0
const issueCount = computed(() => rows.value.filter(isIssueOrder).length)
const pageSummary = computed(() => [
{ label: '本页订单', value: rows.value.length, hint: `全部 ${total.value} 笔` },
{ label: '待支付', value: rows.value.filter((row) => ['initialized', 'pending'].includes(row.paymentStatus)).length, hint: '尚未确认收款' },
{ label: '已确认收款', value: rows.value.filter((row) => ['paid', 'partially_refunded', 'refunded'].includes(row.paymentStatus)).length, hint: '含部分或全额退款' },
{ label: '需要关注', value: issueCount.value, hint: '支付、发货或退款异常' }
])
const paginationLayout = computed(() => width.value < 720 ? 'total, prev, pager, next' : 'total, sizes, prev, pager, next, jumper')
const operationFixed = computed(() => width.value >= 1180 ? 'right' : false)
const drawerSize = computed(() => width.value < 720 ? '96%' : '720px')
const detailColumns = computed(() => width.value < 720 ? 1 : 2)
const refundPrecisionText = computed(() => currencyMinorDigits(refundForm.currency) === 0 ? '该币种仅支持整数金额' : `最多支持 ${currencyMinorDigits(refundForm.currency)} 位小数`)
const refundAmountPlaceholder = computed(() => currencyMinorDigits(refundForm.currency) === 0 ? '例如 100' : '例如 100.00')
const detailAmounts = computed(() => {
if (!detail.value) return []
const order = detail.value
const currency = order.currency
const items = [
{ label: '原始金额', value: formatAmount(order.originalAmount || order.amount, currency) },
{ label: '应付金额', value: formatAmount(order.amount, currency) },
{ label: '已付金额', value: formatAmount(order.paidAmount, currency) },
{ label: '已退款', value: formatAmount(order.refundedAmount, currency) }
]
if (order.refundRequestedAmount) items.push({ label: '退款处理中', value: formatAmount(order.refundRequestedAmount, currency) })
if (order.amountBreakdownKnown) items.push(
{ label: '付款人实付', value: formatAmount(order.payerPaidAmount, order.payerCurrency || currency) },
{ label: '现金支付', value: formatAmount(order.cashPaidAmount, order.payerCurrency || currency) },
{ label: '积分支付', value: formatAmount(order.pointPaidAmount, order.payerCurrency || currency) },
{ label: '优惠合计', value: formatAmount(order.discountAmount, currency) },
{ label: '渠道优惠', value: formatAmount(order.providerDiscountAmount, currency) },
{ label: '商户优惠', value: formatAmount(order.merchantDiscountAmount, currency) },
{ label: '结算金额', value: formatAmount(order.settlementAmount, currency) }
)
return items
})
const detailTimeline = computed(() => {
if (!detail.value) return []
return [
{ label: '订单创建', value: detail.value.createdAt, type: 'primary' },
{ label: '支付确认', value: detail.value.paidAt, type: 'success' },
{ label: '业务发货', value: detail.value.fulfilledAt, type: 'success' },
{ label: '退款确认', value: detail.value.refundedAt, type: 'warning' },
{ label: '最后更新', value: detail.value.updatedAt, type: 'info' }
].filter((item) => item.value).map((item) => ({ ...item, time: formatDateValue(item.value) }))
})
const refundRules = { amountText: [{ required: true, message: '请输入退款金额', trigger: 'blur' }, { validator: validateRefundAmount, trigger: ['blur', 'change'] }] }
function currencyMinorDigits(currency) {
const code = String(currency || 'CNY').trim().toUpperCase()
if (ZERO_DECIMAL_CURRENCIES.has(code)) return 0
if (THREE_DECIMAL_CURRENCIES.has(code)) return 3
if (FOUR_DECIMAL_CURRENCIES.has(code)) return 4
return 2
}
function toIntegerAmount(value) {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return 0
return Math.trunc(numeric)
}
function formatAmount(value, currency = 'CNY') {
const code = String(currency || 'CNY').trim().toUpperCase()
const digits = currencyMinorDigits(code)
const scale = 10 ** digits
const amount = toIntegerAmount(value)
const absolute = Math.abs(amount)
const whole = Math.floor(absolute / scale).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
const fraction = digits > 0 ? `.${String(absolute % scale).padStart(digits, '0')}` : ''
return `${amount < 0 ? '-' : ''}${whole}${fraction} ${code}`
}
function formatAmountInput(value, currency) {
const digits = currencyMinorDigits(currency)
const scale = 10 ** digits
const amount = toIntegerAmount(value)
const whole = Math.floor(amount / scale)
return digits === 0 ? String(whole) : `${whole}.${String(amount % scale).padStart(digits, '0')}`
}
function parseAmountToMinor(value, currency) {
const text = String(value || '').trim()
const digits = currencyMinorDigits(currency)
const pattern = digits === 0 ? /^\d+$/ : new RegExp(`^\\d+(?:\\.\\d{1,${digits}})?$`)
if (!pattern.test(text)) throw new Error(digits === 0 ? '请输入整数金额' : `金额最多保留 ${digits} 位小数`)
const [whole, fraction = ''] = text.split('.')
const wholeAmount = Number(whole)
const fractionAmount = Number(fraction.padEnd(digits, '0') || '0')
const minor = wholeAmount * (10 ** digits) + fractionAmount
if (!Number.isSafeInteger(minor) || minor > MAX_SAFE_MINOR_AMOUNT) throw new Error('退款金额超出前端可安全处理范围')
if (minor <= 0) throw new Error('退款金额必须大于 0')
return minor
}
function validateRefundAmount(_rule, value, callback) {
try {
const amount = parseAmountToMinor(value, refundForm.currency)
if (amount > refundForm.maxAmount) return callback(new Error(`退款金额不能超过 ${formatAmount(refundForm.maxAmount, refundForm.currency)}`))
callback()
} catch (error) { callback(error) }
}
function providerText(provider) { return providerNames[provider] || provider || '-' }
function paymentStatusText(status) { return PAYMENT_STATUS_META[status]?.label || status || '-' }
function paymentStatusType(status) { return PAYMENT_STATUS_META[status]?.type || 'info' }
function fulfillmentStatusText(status, order) {
if (status === 'pending' && order?.paymentStatus !== 'paid') return '未触发'
return FULFILLMENT_STATUS_META[status]?.label || status || '-'
}
function fulfillmentStatusType(status) { return FULFILLMENT_STATUS_META[status]?.type || 'info' }
function refundStatusText(status) { return REFUND_STATUS_META[status]?.label || status || '-' }
function refundStatusType(status) { return REFUND_STATUS_META[status]?.type || 'info' }
function paymentModeText(mode) { return mode === 'internal' ? '内部支付' : mode === 'external' ? '外部渠道' : mode || '-' }
function formatDateValue(value) { return value ? formatDate(value) || '-' : '-' }
function remainingRefundAmount(order) { return Math.max(0, Number(order?.amount || 0) - Number(order?.refundedAmount || 0)) }
function canRefund(order) { return ['paid', 'partially_refunded'].includes(order?.paymentStatus) && ['none', 'partial', 'failed'].includes(order?.refundStatus) && remainingRefundAmount(order) > 0 }
function canFulfill(order) { return order?.paymentStatus === 'paid' && ['pending', 'processing', 'failed'].includes(order?.fulfillmentStatus) }
function canSync(order) { return ['initialized', 'pending', 'paid', 'partially_refunded', 'refunded', 'failed'].includes(order?.paymentStatus) }
function fulfillmentActionText(order) { return ['processing', 'failed'].includes(order?.fulfillmentStatus) ? '重试发货' : '执行发货' }
function isIssueOrder(order) { return order?.paymentStatus === 'failed' || order?.fulfillmentStatus === 'failed' || order?.refundStatus === 'failed' || Boolean(order?.lastError) }
function rowClassName({ row }) { return isIssueOrder(row) ? 'is-payment-issue' : '' }
function actionKey(order) { return `${order?.provider || ''}\u0000${order?.tradeNo || ''}` }
function rowAction(order) { return rowActions[actionKey(order)] || '' }
function isRowBusy(order) { return Boolean(rowAction(order)) }
function setRowAction(order, action) { const key = actionKey(order); if (action) rowActions[key] = action; else delete rowActions[key] }
function requestFilters() {
return Object.fromEntries(Object.entries(searchInfo).filter(([, value]) => typeof value === 'string' && value.trim()).map(([key, value]) => [key, value.trim()]))
}
async function load(options = {}) {
const requestID = ++loadRequestID
if (!options.silent) loading.value = true
try {
const res = await getPaymentOrders({ page: page.value, pageSize: pageSize.value, ...requestFilters() })
if (requestID !== loadRequestID || res.code !== 0) return
rows.value = res.data?.list || []
total.value = Number(res.data?.total || 0)
page.value = Number(res.data?.page || page.value)
pageSize.value = Number(res.data?.pageSize || pageSize.value)
} catch {
// The request layer already presents transport errors.
} finally {
if (requestID === loadRequestID) loading.value = false
}
}
function reload() { page.value = 1; load() }
function reset() { Object.assign(searchInfo, { provider: '', tradeNo: '', businessType: '', businessId: '', paymentStatus: '', refundStatus: '' }); reload() }
function changePage(value) { page.value = value; load() }
function changeSize(value) { pageSize.value = value; page.value = 1; load() }
async function loadDetail(order) {
if (!order?.provider || !order?.tradeNo) return
const requestID = ++detailRequestID
const provider = order.provider
const tradeNo = order.tradeNo
detailLoading.value = true
try {
const res = await getPaymentOrder({ provider, tradeNo })
if (requestID === detailRequestID && detail.value?.provider === provider && detail.value?.tradeNo === tradeNo && res.code === 0 && res.data) detail.value = res.data
} catch {
// The request layer already presents transport errors.
} finally {
if (requestID === detailRequestID) detailLoading.value = false
}
}
function openDetail(order) { detail.value = { ...order }; detailVisible.value = true; loadDetail(order) }
async function refreshVisibleDetail(order) {
if (detailVisible.value && detail.value?.provider === order.provider && detail.value?.tradeNo === order.tradeNo) await loadDetail(order)
}
async function syncOrder(order) {
if (isRowBusy(order)) return
setRowAction(order, 'sync')
try {
const res = await queryPaymentOrder({ provider: order.provider, tradeNo: order.tradeNo })
if (res.code !== 0) return
ElMessage.success(res.data?.orderStatus ? `订单状态已同步:${paymentStatusText(res.data.orderStatus)}` : '订单状态已同步')
await load({ silent: true })
await refreshVisibleDetail(order)
} catch {
// The request layer already presents transport errors.
} finally { setRowAction(order, '') }
}
async function retryFulfillment(order) {
if (isRowBusy(order)) return
try {
await ElMessageBox.confirm(`确认${fulfillmentActionText(order)}订单 ${order.tradeNo} 吗?`, fulfillmentActionText(order), { type: 'warning', confirmButtonText: '确认执行' })
} catch { return }
setRowAction(order, 'fulfill')
try {
const res = await fulfillPaymentOrder({ provider: order.provider, tradeNo: order.tradeNo })
if (res.code !== 0) return
ElMessage.success(res.data?.duplicate ? '该订单已完成发货,无需重复处理' : '发货处理已完成')
await load({ silent: true })
await refreshVisibleDetail(order)
} catch {
// The request layer already presents transport errors.
} finally { setRowAction(order, '') }
}
function openRefund(order) {
const maxAmount = remainingRefundAmount(order)
Object.assign(refundForm, { provider: order.provider, tradeNo: order.tradeNo, currency: order.currency || 'CNY', maxAmount, amountText: formatAmountInput(maxAmount, order.currency || 'CNY') })
refundVisible.value = true
nextTick(() => refundFormRef.value?.clearValidate())
}
async function submitRefund() {
if (refundSubmitting.value) return
refundSubmitting.value = true
const valid = await refundFormRef.value?.validate().catch(() => false)
if (!valid) { refundSubmitting.value = false; return }
let amount
try { amount = parseAmountToMinor(refundForm.amountText, refundForm.currency) }
catch (error) { ElMessage.warning(error.message); refundSubmitting.value = false; return }
try {
await ElMessageBox.confirm(`确认退款 ${formatAmount(amount, refundForm.currency)} 吗?提交后将调用支付渠道。`, '确认退款', { type: 'warning', confirmButtonText: '确认退款' })
} catch { refundSubmitting.value = false; return }
refundLoading.value = true
const order = { provider: refundForm.provider, tradeNo: refundForm.tradeNo }
try {
const res = await refundPaymentOrder({ provider: refundForm.provider, tradeNo: refundForm.tradeNo, amount })
if (res.code !== 0) return
ElMessage.success(res.data?.refundStatus === 'succeeded' ? '退款已完成' : '退款申请已提交,等待渠道确认')
refundVisible.value = false
await load({ silent: true })
await refreshVisibleDetail(order)
} catch {
// The request layer already presents transport errors.
} finally { refundLoading.value = false; refundSubmitting.value = false }
}
async function copyText(value) {
try { await navigator.clipboard.writeText(String(value)); ElMessage.success('已复制') }
catch { ElMessage.warning('复制失败,请手动选择文本') }
}
onMounted(load)
</script>
<style scoped>
.payment-orders { min-width: 980px; }
.error-text { color: var(--el-color-danger); word-break: break-word; }
.form-tip { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.4; margin-top: 4px; }
.payment-orders { min-width: 0; padding-bottom: 24px; }
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
.page-heading h2 { margin: 0; color: var(--el-text-color-primary); font-size: 20px; font-weight: 600; letter-spacing: 0; }
.page-heading p { margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
.status-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; overflow: hidden; margin-bottom: 16px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-border-color-lighter); }
.summary-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 3px 10px; min-width: 0; padding: 14px 16px; background: var(--el-bg-color); }
.summary-item span { color: var(--el-text-color-regular); font-size: 13px; }
.summary-item strong { grid-row: span 2; color: var(--el-text-color-primary); font-size: 24px; font-weight: 600; line-height: 1; }
.summary-item small { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.filter-band { margin-bottom: 16px; }
.filter-grid { display: grid; grid-template-columns: repeat(3, minmax(160px, 1fr)); gap: 0 14px; }
.filter-grid :deep(.el-form-item) { margin-bottom: 12px; }
.filter-grid :deep(.el-select), .filter-grid :deep(.el-input) { width: 100%; }
.filter-actions { display: flex; align-items: flex-end; gap: 8px; padding-bottom: 12px; }
.order-table-band { min-width: 0; overflow: hidden; }
.table-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 12px; }
.table-heading > div { display: flex; align-items: baseline; gap: 9px; }
.table-heading strong { color: var(--el-text-color-primary); font-size: 15px; font-weight: 600; }
.table-heading span { color: var(--el-text-color-secondary); font-size: 12px; }
.issue-count { color: var(--el-color-danger) !important; }
.order-cell, .business-cell, .provider-cell, .amount-cell { display: grid; min-width: 0; gap: 4px; }
.order-link { overflow: hidden; padding: 0; border: 0; background: transparent; color: var(--el-color-primary); font: inherit; font-weight: 500; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.order-cell > span, .business-cell small, .provider-cell small, .amount-cell small { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.business-cell > span, .provider-cell > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.amount-cell { justify-items: end; }
.amount-cell strong { color: var(--el-text-color-primary); font-weight: 600; white-space: nowrap; }
.row-actions { display: flex; align-items: center; min-height: 32px; white-space: nowrap; }
.row-actions :deep(.el-button + .el-button) { margin-left: 8px; }
.payment-orders :deep(.el-table__row.is-payment-issue > td.el-table__cell) { background: var(--el-color-danger-light-9); }
.kra-pagination { overflow-x: auto; }
.order-detail { min-height: 220px; }
.detail-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
.detail-statuses, .detail-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.detail-error { margin-top: 16px; }
.section-heading { margin: 22px 0 12px; color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; letter-spacing: 0; }
.copy-value { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; word-break: break-all; }
.amount-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-top: 1px solid var(--el-border-color-lighter); border-left: 1px solid var(--el-border-color-lighter); }
.amount-item { display: grid; min-width: 0; gap: 5px; padding: 12px 14px; border-right: 1px solid var(--el-border-color-lighter); border-bottom: 1px solid var(--el-border-color-lighter); }
.amount-item span { color: var(--el-text-color-secondary); font-size: 12px; }
.amount-item strong { overflow: hidden; color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.amount-alert { margin-top: 12px; }
.order-timeline { margin: 0; padding-top: 4px; }
.refund-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 14px; margin-bottom: 18px; padding: 12px 14px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-fill-color-lighter); }
.refund-summary span { color: var(--el-text-color-secondary); font-size: 12px; }
.refund-summary strong { overflow-wrap: anywhere; color: var(--el-text-color-primary); font-size: 13px; text-align: right; }
.form-tip { width: 100%; margin-top: 5px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.4; }
@media (max-width: 1000px) { .filter-grid { grid-template-columns: repeat(2, minmax(160px, 1fr)); } .amount-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .page-heading { align-items: flex-start; } .page-heading p { max-width: 250px; } .status-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .summary-item { padding: 12px; } .summary-item strong { font-size: 20px; } .filter-grid { grid-template-columns: 1fr; } .filter-actions { align-items: stretch; padding-bottom: 4px; } .filter-actions :deep(.el-button) { flex: 1; } .detail-toolbar { align-items: stretch; flex-direction: column; } .detail-actions :deep(.el-button) { margin-left: 0; } .amount-grid { grid-template-columns: 1fr; } }
@media (max-width: 440px) { .status-summary { grid-template-columns: 1fr; } .table-heading { align-items: flex-start; flex-direction: column; gap: 5px; } .refund-summary { grid-template-columns: 1fr; } .refund-summary strong { text-align: left; } }
</style>