Files
kra-new/web/src/view/systemTools/payment/orders.vue
T
2026-08-29 08:40:15 +08:00

704 lines
51 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.
<template>
<div class="kra-table-box payment-orders">
<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 systemSummary" :key="item.label" class="summary-item" :class="`is-${item.tone}`">
<div class="summary-copy"><span>{{ item.label }}</span><small>{{ item.hint }}</small></div>
<strong>{{ item.value }}</strong>
<el-icon class="summary-icon"><component :is="item.icon" /></el-icon>
</div>
</section>
<section class="filter-band">
<el-form :model="searchInfo" label-position="top" @submit.prevent="reload">
<div class="primary-filter-grid">
<el-form-item label="订单搜索" class="keyword-filter">
<el-input v-model="searchInfo.keyword" clearable placeholder="订单号、渠道单号、业务 ID 或商品标题" :prefix-icon="Search" />
</el-form-item>
<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-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>
<div class="filter-actions">
<el-button type="primary" :icon="Search" @click="reload">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
<el-button text :icon="Filter" @click="advancedVisible = !advancedVisible">{{ advancedVisible ? '收起' : '更多筛选' }}</el-button>
</div>
</div>
<el-collapse-transition>
<div v-show="advancedVisible" class="advanced-filter-grid">
<el-form-item label="商户订单号"><el-input v-model="searchInfo.tradeNo" clearable placeholder="支持模糊查询" /></el-form-item>
<el-form-item label="第三方交易号"><el-input v-model="searchInfo.providerTradeNo" 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.refundStatus" clearable placeholder="全部状态">
<el-option v-for="item in refundStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="创建时间" class="date-filter">
<el-date-picker v-model="searchInfo.createdRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
</el-form-item>
<el-form-item label="异常订单" class="issue-filter"><el-checkbox v-model="searchInfo.issueOnly">只看需要人工关注的订单</el-checkbox></el-form-item>
</div>
</el-collapse-transition>
<div class="quick-range">
<span>时间快捷筛选</span>
<el-radio-group v-model="quickRange" size="small" @change="applyQuickRange">
<el-radio-button value="today">今天</el-radio-button>
<el-radio-button value="7d">近 7 天</el-radio-button>
<el-radio-button value="30d">近 30 天</el-radio-button>
<el-radio-button value="90d">近 90 天</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
</el-radio-group>
</div>
</el-form>
</section>
<section class="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-if="!isMobile" v-loading="loading" :data="rows" row-key="ID" :row-class-name="rowClassName">
<el-table-column label="订单与交易" min-width="285">
<template #default="scope">
<div class="order-cell">
<span class="order-title-line"><button type="button" class="order-link" @click="openDetail(scope.row)">{{ scope.row.tradeNo || '-' }}</button><el-tag v-if="isDemoOrder(scope.row)" size="small" type="info" effect="plain">演示</el-tag></span>
<span v-if="scope.row.providerTradeNo">渠道单号 {{ scope.row.providerTradeNo }}</span>
<span>{{ scope.row.businessType || '-' }} · {{ scope.row.businessId || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="商品与业务" min-width="220">
<template #default="scope">
<div class="business-cell"><span>{{ scope.row.subject || '未提供商品标题' }}</span><small>{{ scope.row.businessType || '-' }} · {{ scope.row.businessId || '-' }}</small></div>
</template>
</el-table-column>
<el-table-column label="金额与结算" width="165" 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>
<small v-if="scope.row.refundedAmount" class="refund-amount">已退 {{ formatAmount(scope.row.refundedAmount, scope.row.currency) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="230">
<template #default="scope">
<div class="status-stack">
<el-tag size="small" :type="paymentStatusType(scope.row.paymentStatus)">{{ paymentStatusText(scope.row.paymentStatus) }}</el-tag>
<el-tag size="small" :type="fulfillmentStatusType(scope.row.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(scope.row.fulfillmentStatus, scope.row) }}</el-tag>
<el-tag v-if="scope.row.refundStatus !== 'none'" size="small" :type="refundStatusType(scope.row.refundStatus)" effect="plain">{{ refundStatusText(scope.row.refundStatus) }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="渠道与时间" width="190">
<template #default="scope">
<div class="provider-cell"><span>{{ providerText(scope.row.provider) }}</span><small>{{ formatDateValue(scope.row.createdAt) }}</small><small v-if="scope.row.paidAt">支付 {{ formatDateValue(scope.row.paidAt) }}</small></div>
</template>
</el-table-column>
<el-table-column label="操作" :fixed="operationFixed" width="154" align="right">
<template #default="scope">
<div class="row-actions">
<el-tooltip content="查看详情"><el-button circle :icon="View" aria-label="查看订单详情" :disabled="isRowBusy(scope.row)" @click="openDetail(scope.row)" /></el-tooltip>
<el-tooltip v-if="canSync(scope.row)" content="同步渠道状态"><el-button circle :icon="RefreshRight" aria-label="同步渠道状态" :loading="rowAction(scope.row) === 'sync'" :disabled="isRowBusy(scope.row) && rowAction(scope.row) !== 'sync'" @click="syncOrder(scope.row)" /></el-tooltip>
<el-dropdown v-if="canRefund(scope.row) || canFulfill(scope.row)" trigger="click" @command="(command) => handleRowCommand(command, scope.row)">
<el-button circle :icon="MoreFilled" :disabled="isRowBusy(scope.row)" aria-label="更多订单操作" />
<template #dropdown><el-dropdown-menu>
<el-dropdown-item command="refund" :disabled="!canRefund(scope.row)" :icon="Money">申请退款</el-dropdown-item>
<el-dropdown-item command="fulfill" :disabled="!canFulfill(scope.row)" :icon="Promotion">{{ fulfillmentActionText(scope.row) }}</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
</el-table-column>
<template #empty><el-empty description="没有符合条件的支付订单" /></template>
</el-table>
<div v-else v-loading="loading" class="mobile-order-list">
<article v-for="order in rows" :key="order.ID" class="mobile-order" :class="{ 'is-payment-issue': isIssueOrder(order) }">
<button type="button" class="mobile-order-main" @click="openDetail(order)">
<span class="mobile-order-title"><strong>{{ order.subject || '未提供商品标题' }}</strong><small>{{ providerText(order.provider) }}</small></span>
<span class="mobile-order-amount">{{ formatAmount(order.amount, order.currency) }}</span>
</button>
<div class="mobile-order-identities"><span>{{ order.tradeNo }}</span><small v-if="order.providerTradeNo">{{ order.providerTradeNo }}</small></div>
<div class="mobile-order-meta"><span>{{ order.businessType || '-' }} · {{ order.businessId || '-' }}</span><span>{{ formatDateValue(order.createdAt) }}</span></div>
<div class="mobile-order-footer">
<div class="status-stack">
<el-tag v-if="isDemoOrder(order)" size="small" type="info" effect="plain">演示</el-tag>
<el-tag size="small" :type="paymentStatusType(order.paymentStatus)">{{ paymentStatusText(order.paymentStatus) }}</el-tag>
<el-tag size="small" :type="fulfillmentStatusType(order.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(order.fulfillmentStatus, order) }}</el-tag>
<el-tag v-if="order.refundStatus !== 'none'" size="small" :type="refundStatusType(order.refundStatus)" effect="plain">{{ refundStatusText(order.refundStatus) }}</el-tag>
</div>
<div class="mobile-order-actions">
<el-button link type="primary" :icon="View" :disabled="isRowBusy(order)" @click="openDetail(order)">详情</el-button>
<el-button v-if="canSync(order)" link type="primary" :icon="RefreshRight" :loading="rowAction(order) === 'sync'" :disabled="isRowBusy(order) && rowAction(order) !== 'sync'" @click="syncOrder(order)">同步</el-button>
<el-button v-if="canRefund(order)" link type="warning" :icon="Money" :disabled="isRowBusy(order)" @click="openRefund(order)">退款</el-button>
<el-button v-if="canFulfill(order)" link type="success" :icon="Promotion" :loading="rowAction(order) === 'fulfill'" :disabled="isRowBusy(order) && rowAction(order) !== 'fulfill'" @click="retryFulfillment(order)">{{ fulfillmentActionText(order) }}</el-button>
</div>
</div>
</article>
<el-empty v-if="!loading && !rows.length" description="没有符合条件的支付订单" />
</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>
</section>
<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 v-if="isDemoOrder(detail)" type="info" effect="plain">演示订单</el-tag>
<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-navigation">
<el-tooltip content="上一笔"><el-button circle :icon="ArrowLeft" aria-label="上一笔订单" :disabled="detailIndex <= 0" @click="navigateDetail(-1)" /></el-tooltip>
<span>{{ detailIndex >= 0 ? `${detailIndex + 1} / ${rows.length}` : '-' }}</span>
<el-tooltip content="下一笔"><el-button circle :icon="ArrowRight" aria-label="下一笔订单" :disabled="detailIndex < 0 || detailIndex >= rows.length - 1" @click="navigateDetail(1)" /></el-tooltip>
</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.key" :timestamp="item.time" :type="item.type">
<div class="event-heading"><strong>{{ item.label }}</strong><el-tag size="small" effect="plain">{{ item.source }}</el-tag></div>
<p v-if="item.message" class="event-message">{{ item.message }}</p>
<div v-if="item.meta.length" class="event-meta"><span v-for="meta in item.meta" :key="meta">{{ meta }}</span></div>
</el-timeline-item>
</el-timeline>
</template>
</div>
</el-drawer>
<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-item label="退款原因" prop="reason">
<el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="用于订单事件审计和售后追踪" />
</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 { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, ArrowRight, CircleCheck, CopyDocument, Filter, Money, MoreFilled, Promotion, Refresh, RefreshRight, Search, Tickets, View, Wallet, WarningFilled } from '@element-plus/icons-vue'
import { fulfillPaymentOrder, getPaymentOrder, getPaymentOrders, getPaymentOrderSummary, queryPaymentOrder, refundPaymentOrder } from '@/api/payment'
import { formatDate } from '@/utils/format'
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 EVENT_META = {
order_created: { label: '订单创建', type: 'primary' },
provider_created: { label: '渠道下单完成', type: 'primary' },
payment_synchronized: { label: '支付状态同步', type: 'success' },
payment_callback: { label: '支付平台通知', type: 'success' },
fulfillment_started: { label: '开始业务发货', type: 'warning' },
fulfillment_finished: { label: '业务发货完成', type: 'success' },
refund_started: { label: '发起退款', type: 'warning' },
refund_submitted: { label: '退款已提交渠道', type: 'warning' },
refund_confirmed: { label: '退款结果确认', type: 'warning' }
}
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]))
Object.assign(providerNames, { admin: '后台人工操作', client: '客户端', system: '系统任务', internal: '内部支付' })
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', 'POINT', '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 summary = ref({ orderCount: 0, pendingCount: 0, paidCount: 0, refundedCount: 0, issueCount: 0, currencies: [] })
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 advancedVisible = ref(width.value >= 720)
const quickRange = ref('7d')
const searchInfo = reactive({ keyword: '', provider: '', tradeNo: '', providerTradeNo: '', businessType: '', businessId: '', paymentStatus: '', refundStatus: '', createdRange: defaultDateRange(7), issueOnly: false })
const refundForm = reactive({ provider: '', tradeNo: '', currency: 'CNY', maxAmount: 0, amountText: '', reason: '' })
let loadRequestID = 0
let detailRequestID = 0
watch(width, (value, previous) => {
if (value < 720 && previous >= 720) advancedVisible.value = false
})
const issueCount = computed(() => Number(summary.value.issueCount || 0))
const primaryCurrency = computed(() => summary.value.currencies?.[0] || { currency: 'CNY', netAmount: 0, paidAmount: 0, refundedAmount: 0 })
const currencyHint = computed(() => summary.value.currencies?.length > 1 ? `另含 ${summary.value.currencies.length - 1} 种币种` : `已退款 ${formatAmount(primaryCurrency.value.refundedAmount, primaryCurrency.value.currency)}`)
const systemSummary = computed(() => [
{ label: '实际净到账', value: formatAmount(primaryCurrency.value.netAmount, primaryCurrency.value.currency), hint: currencyHint.value, tone: 'success', icon: Wallet },
{ label: '支付成功订单', value: Number(summary.value.paidCount || 0), hint: `待支付 ${Number(summary.value.pendingCount || 0)} 笔`, tone: 'primary', icon: CircleCheck },
{ label: '订单总数', value: Number(summary.value.orderCount || 0), hint: `退款订单 ${Number(summary.value.refundedCount || 0)} 笔`, tone: 'neutral', icon: Tickets },
{ label: '需要关注', value: issueCount.value, hint: '支付、发货或退款异常', tone: 'danger', icon: WarningFilled }
])
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 isMobile = computed(() => width.value < 720)
const drawerSize = computed(() => width.value < 720 ? '96%' : '720px')
const detailColumns = computed(() => width.value < 720 ? 1 : 2)
const detailIndex = computed(() => detail.value ? rows.value.findIndex((item) => item.provider === detail.value.provider && item.tradeNo === detail.value.tradeNo) : -1)
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 []
if (detail.value.events?.length) return detail.value.events.map((event) => {
const meta = EVENT_META[event.type] || { label: event.type || '支付事件', type: 'info' }
const operator = event.operatorName || (event.operatorId ? `用户 #${event.operatorId}` : '')
return {
key: event.id || `${event.type}-${event.createdAt}`,
label: meta.label, type: meta.type, source: providerText(event.source), message: event.message,
time: formatDateValue(event.createdAt),
meta: [event.providerStatus ? `渠道状态 ${event.providerStatus}` : '', event.amount ? formatAmount(event.amount, event.currency || detail.value.currency) : '', operator, event.clientIp ? `IP ${event.clientIp}` : '', event.deviceId ? `设备 ${event.deviceId}` : ''].filter(Boolean)
}
})
return [
{ key: 'created', label: '订单创建', value: detail.value.createdAt, type: 'primary', source: 'system', meta: [] },
{ key: 'paid', label: '支付确认', value: detail.value.paidAt, type: 'success', source: detail.value.provider, meta: [] },
{ key: 'fulfilled', label: '业务发货', value: detail.value.fulfilledAt, type: 'success', source: 'system', meta: [] },
{ key: 'refunded', label: '退款确认', value: detail.value.refundedAt, type: 'warning', source: detail.value.provider, meta: [] }
].filter((item) => item.value).map((item) => ({ ...item, source: providerText(item.source), time: formatDateValue(item.value) }))
})
const refundRules = {
amountText: [{ required: true, message: '请输入退款金额', trigger: 'blur' }, { validator: validateRefundAmount, trigger: ['blur', 'change'] }],
reason: [{ required: true, message: '请输入退款原因', trigger: 'blur' }, { min: 2, max: 200, message: '退款原因应为 2 至 200 个字符', trigger: 'blur' }]
}
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 formatLocalDate(value) {
const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, '0')
const day = String(value.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function defaultDateRange(days) {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - Math.max(0, days - 1))
return [formatLocalDate(start), formatLocalDate(end)]
}
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 isDemoOrder(order) { return order?.businessType === 'demo_subscription' && String(order?.tradeNo || '').startsWith('DEMO-PAY-') }
function canRefund(order) { return !isDemoOrder(order) && ['paid', 'partially_refunded'].includes(order?.paymentStatus) && ['none', 'partial', 'failed'].includes(order?.refundStatus) && remainingRefundAmount(order) > 0 }
function canFulfill(order) { return !isDemoOrder(order) && order?.paymentStatus === 'paid' && ['pending', 'processing', 'failed'].includes(order?.fulfillmentStatus) }
function canSync(order) { return !isDemoOrder(order) && ['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() {
const filters = Object.fromEntries(Object.entries(searchInfo)
.filter(([key, value]) => key !== 'createdRange' && ((typeof value === 'string' && value.trim()) || value === true))
.map(([key, value]) => [key, typeof value === 'string' ? value.trim() : value]))
if (searchInfo.createdRange?.length === 2) {
filters.createdFrom = searchInfo.createdRange[0]
filters.createdTo = searchInfo.createdRange[1]
}
return filters
}
async function load(options = {}) {
const requestID = ++loadRequestID
if (!options.silent) loading.value = true
try {
const filters = requestFilters()
const [ordersResult, summaryResult] = await Promise.allSettled([
getPaymentOrders({ page: page.value, pageSize: pageSize.value, ...filters }),
getPaymentOrderSummary(filters)
])
if (requestID !== loadRequestID) return
const ordersRes = ordersResult.status === 'fulfilled' ? ordersResult.value : null
const summaryRes = summaryResult.status === 'fulfilled' ? summaryResult.value : null
if (ordersRes?.code === 0) {
rows.value = ordersRes.data?.list || []
total.value = Number(ordersRes.data?.total || 0)
page.value = Number(ordersRes.data?.page || page.value)
pageSize.value = Number(ordersRes.data?.pageSize || pageSize.value)
}
if (summaryRes?.code === 0 && summaryRes.data) summary.value = summaryRes.data
} catch {
// The request layer already presents transport errors.
} finally {
if (requestID === loadRequestID) loading.value = false
}
}
function reload() { page.value = 1; load() }
function reset() {
quickRange.value = '7d'
Object.assign(searchInfo, { keyword: '', provider: '', tradeNo: '', providerTradeNo: '', businessType: '', businessId: '', paymentStatus: '', refundStatus: '', createdRange: defaultDateRange(7), issueOnly: false })
reload()
}
function applyQuickRange(value) {
if (value === 'all') searchInfo.createdRange = []
else searchInfo.createdRange = defaultDateRange(value === 'today' ? 1 : Number.parseInt(value, 10))
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) }
function navigateDetail(offset) {
const next = rows.value[detailIndex.value + offset]
if (next) openDetail(next)
}
function handleRowCommand(command, order) {
if (command === 'refund' && canRefund(order)) openRefund(order)
if (command === 'fulfill' && canFulfill(order)) retryFulfillment(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) || !canSync(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
setRowAction(order, 'fulfill-confirm')
try {
await ElMessageBox.confirm(`确认${fulfillmentActionText(order)}订单 ${order.tradeNo} 吗?`, fulfillmentActionText(order), { type: 'warning', confirmButtonText: '确认执行' })
} catch { setRowAction(order, ''); 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) {
if (isRowBusy(order) || !canRefund(order)) return
const maxAmount = remainingRefundAmount(order)
Object.assign(refundForm, { provider: order.provider, tradeNo: order.tradeNo, currency: order.currency || 'CNY', maxAmount, amountText: formatAmountInput(maxAmount, order.currency || 'CNY'), reason: '' })
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, reason: refundForm.reason.trim() })
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: 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: 10px; margin-bottom: 16px; }
.summary-item { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; min-width: 0; min-height: 76px; overflow: hidden; padding: 13px 48px 13px 15px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-bg-color); }
.summary-item::before { position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--summary-color); content: ''; }
.summary-copy { display: grid; min-width: 0; gap: 5px; }
.summary-item span { color: var(--el-text-color-regular); font-size: 12px; }
.summary-item strong { color: var(--el-text-color-primary); font-size: 22px; font-weight: 650; line-height: 1; white-space: nowrap; }
.summary-item small { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.summary-icon { position: absolute; top: 12px; right: 13px; width: 27px; height: 27px; border-radius: 6px; background: color-mix(in srgb, var(--summary-color) 12%, transparent); color: var(--summary-color); font-size: 16px; }
.summary-item.is-success { --summary-color: var(--el-color-success); }
.summary-item.is-primary { --summary-color: var(--el-color-primary); }
.summary-item.is-neutral { --summary-color: var(--el-color-info); }
.summary-item.is-danger { --summary-color: var(--el-color-danger); }
.filter-band { margin-bottom: 16px; }
.primary-filter-grid { display: grid; grid-template-columns: minmax(280px, 2fr) minmax(160px, 1fr) minmax(160px, 1fr) auto; gap: 0 14px; }
.advanced-filter-grid { display: grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap: 0 14px; padding-top: 3px; border-top: 1px solid var(--el-border-color-lighter); }
.primary-filter-grid :deep(.el-form-item), .advanced-filter-grid :deep(.el-form-item) { margin-bottom: 12px; }
.primary-filter-grid :deep(.el-select), .primary-filter-grid :deep(.el-input), .advanced-filter-grid :deep(.el-select), .advanced-filter-grid :deep(.el-input) { width: 100%; }
.issue-filter { align-self: end; }
.date-filter :deep(.el-date-editor) { width: 100%; }
.filter-actions { display: flex; align-items: flex-end; gap: 8px; padding-bottom: 12px; }
.quick-range { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding-top: 10px; border-top: 1px solid var(--el-border-color-lighter); color: var(--el-text-color-secondary); font-size: 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: 5px; }
.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-title-line { display: flex; align-items: center; gap: 6px; min-width: 0; }
.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; }
.refund-amount { color: var(--el-color-danger) !important; }
.status-stack { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; min-height: 24px; }
.row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-height: 32px; white-space: nowrap; }
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.payment-orders :deep(.el-table__cell) { padding-top: 12px; padding-bottom: 12px; }
.payment-orders :deep(.el-table__row.is-payment-issue > td.el-table__cell) { background: var(--el-color-danger-light-9); }
.mobile-order-list { display: grid; gap: 10px; min-height: 160px; }
.mobile-order { overflow: hidden; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-bg-color); }
.mobile-order.is-payment-issue { border-color: var(--el-color-danger-light-5); background: var(--el-color-danger-light-9); }
.mobile-order-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; width: 100%; padding: 13px 13px 8px; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.mobile-order-title { display: grid; min-width: 0; gap: 4px; }
.mobile-order-title strong { overflow: hidden; color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.mobile-order-title small { color: var(--el-text-color-secondary); font-size: 11px; }
.mobile-order-amount { color: var(--el-text-color-primary); font-size: 15px; font-weight: 650; white-space: nowrap; }
.mobile-order-identities { display: grid; gap: 3px; padding: 0 13px 9px; color: var(--el-color-primary); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; word-break: break-all; }
.mobile-order-identities small { color: var(--el-text-color-secondary); font-size: 10px; }
.mobile-order-meta { display: flex; justify-content: space-between; gap: 10px; padding: 8px 13px; border-top: 1px solid var(--el-border-color-lighter); color: var(--el-text-color-secondary); font-size: 11px; }
.mobile-order-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mobile-order-footer { display: grid; gap: 8px; padding: 9px 13px 10px; border-top: 1px solid var(--el-border-color-lighter); }
.mobile-order-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.mobile-order-actions :deep(.el-button + .el-button) { margin-left: 0; }
.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, .detail-navigation { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.detail-navigation { margin-left: auto; color: var(--el-text-color-secondary); font-size: 12px; }
.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; }
.event-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; color: var(--el-text-color-primary); }
.event-heading strong { font-size: 13px; font-weight: 600; }
.event-message { margin: 6px 0 0; color: var(--el-text-color-regular); font-size: 12px; line-height: 1.55; }
.event-meta { display: flex; flex-wrap: wrap; gap: 5px 12px; margin-top: 6px; color: var(--el-text-color-secondary); font-size: 11px; }
.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: 1100px) { .primary-filter-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); } .advanced-filter-grid { grid-template-columns: repeat(2, minmax(160px, 1fr)); } .filter-actions { align-items: stretch; } .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)); gap: 8px; } .summary-item { grid-template-columns: 1fr; align-content: center; gap: 7px; min-height: 82px; padding: 11px 40px 11px 12px; } .summary-copy { gap: 3px; } .summary-item strong { font-size: 19px; } .summary-icon { top: 10px; right: 10px; } .primary-filter-grid, .advanced-filter-grid { grid-template-columns: 1fr; } .filter-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding-bottom: 4px; } .filter-actions :deep(.el-button) { width: 100%; margin: 0; } .quick-range { align-items: stretch; flex-direction: column; } .quick-range :deep(.el-radio-group) { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: 100%; } .quick-range :deep(.el-radio-button__inner) { width: 100%; } .table-heading { padding-bottom: 10px; } .detail-toolbar { align-items: stretch; flex-direction: column; } .detail-navigation { justify-content: center; order: 3; margin-left: 0; } .detail-actions :deep(.el-button) { margin-left: 0; } .amount-grid { grid-template-columns: 1fr; } }
@media (max-width: 440px) { .summary-item { min-height: 64px; } .table-heading { align-items: flex-start; flex-direction: column; gap: 5px; } .mobile-order-meta { align-items: flex-start; flex-direction: column; gap: 4px; } .refund-summary { grid-template-columns: 1fr; } .refund-summary strong { text-align: left; } }
@media (max-width: 360px) { .status-summary { grid-template-columns: 1fr; } }
</style>