Files
one-pipe-system/src/views/asset-management/asset-information/components/WalletTransactionCard.vue
sexygoat e04a283319
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m38s
fix: 新增代理系列授权-建议售价使用套餐
2026-06-12 10:41:37 +08:00

501 lines
14 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>
<ElCard shadow="never" class="info-card wallet-info" v-loading="walletLoading">
<template #header>
<div class="card-header wallet-header">
<div class="header-left">
<span class="wallet-title">钱包</span>
<ElTag v-if="walletInfo" :type="getWalletStatusType(walletInfo.status)" size="small">
{{ walletInfo.status_text || '-' }}
</ElTag>
<template v-if="walletInfo">
<ElDivider class="wallet-divider" direction="vertical" />
<span class="balance-item"
>💰 总余额 <strong>{{ formatAmount(walletInfo.balance) }}</strong></span
>
<ElDivider class="wallet-divider" direction="vertical" />
<span class="balance-item"
> 可用 <strong>{{ formatAmount(walletInfo.available_balance) }}</strong></span
>
<ElDivider class="wallet-divider" direction="vertical" />
<span class="balance-item"
>🔒 冻结 <strong>{{ formatAmount(walletInfo.frozen_balance) }}</strong></span
>
</template>
</div>
<div v-if="!props.boundDeviceId" class="filter-section">
<ElSelect
v-model="filterForm.transaction_type"
placeholder="交易类型"
clearable
class="transaction-type-select"
@change="handleFilterChange"
>
<ElOption label="充值" value="recharge" />
<ElOption label="扣款" value="deduct" />
<ElOption label="退款" value="refund" />
</ElSelect>
<ElDatePicker
v-model="filterForm.date_range"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
class="date-range-picker"
/>
<ElButton type="primary" @click="handleQuery">查询</ElButton>
<ElButton @click="handleReset()">重置</ElButton>
<ElTag type="info" size="small"> {{ total }} </ElTag>
</div>
</div>
</template>
<!-- 绑定设备提示 -->
<ElEmpty v-if="props.boundDeviceId" :image-size="100">
<template #description>
<span>该卡已绑定设备请点击 </span>
<ElButton
type="primary"
link
style="font-size: 15px; font-weight: 600"
@click="emit('navigateToDevice', props.boundDeviceNo!)"
>{{ props.boundDeviceNo }}</ElButton
>
<span> 进行查看</span>
</template>
</ElEmpty>
<div v-else v-loading="loading" class="wallet-table-wrapper">
<ElTable v-if="hasTransactions" :data="transactionList" class="wallet-table" border>
<ElTableColumn label="交易类型" width="100" align="center">
<template #default="scope">
<ElTag :type="getTransactionTypeTag(scope.row.transaction_type)" size="small">
{{ scope.row.transaction_type_text }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="变动金额" width="120" align="right">
<template #default="scope">
<span
:style="{
color: scope.row.amount > 0 ? '#67c23a' : '#f56c6c',
fontWeight: 600
}"
>
{{ formatAmount(scope.row.amount) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="变动前余额" width="120" align="right">
<template #default="scope">
{{ formatAmount(scope.row.balance_before) }}
</template>
</ElTableColumn>
<ElTableColumn label="变动后余额" width="120" align="right">
<template #default="scope">
{{ formatAmount(scope.row.balance_after) }}
</template>
</ElTableColumn>
<ElTableColumn label="关联业务" width="100" align="center">
<template #default="scope">
<ElTag v-if="scope.row.reference_type" type="info" size="small">
{{ scope.row.reference_type === 'recharge' ? '充值' : '订单' }}
</ElTag>
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn prop="reference_no" label="业务编号" min-width="180" show-overflow-tooltip>
<template #default="scope">
<span v-if="scope.row.reference_no" class="reference-no-link">
{{ scope.row.reference_no }}
</span>
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn prop="remark" label="备注" min-width="150" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.remark || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="创建时间" width="170" show-overflow-tooltip>
<template #default="scope">
{{ formatDateTime(scope.row.created_at) }}
</template>
</ElTableColumn>
</ElTable>
<div v-else class="wallet-empty-state">
<ElEmpty v-if="hasLoadedTransactions" description="暂无钱包流水数据" :image-size="100" />
</div>
<!-- 分页器 -->
<div v-if="total > 0" class="pagination-wrapper">
<ElPagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handlePageSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</ElCard>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
ElCard,
ElTable,
ElTableColumn,
ElTag,
ElButton,
ElSelect,
ElOption,
ElDatePicker,
ElPagination,
ElEmpty,
ElMessage,
ElDivider
} from 'element-plus'
import { useAssetFormatters } from '../composables/useAssetFormatters'
import { formatDateTime } from '@/utils/business/format'
import { AssetService } from '@/api/modules'
import type {
AssetWalletTransactionItem,
AssetWalletTransactionListResponse,
AssetWalletResponse,
AssetWalletTransactionParams,
TransactionType
} from '@/types/api'
interface Props {
assetIdentifier: string
searchToken?: number
walletInfo: AssetWalletResponse | null
walletLoading?: boolean
boundDeviceId?: number | null
boundDeviceNo?: string
}
const props = withDefaults(defineProps<Props>(), {
walletLoading: false,
boundDeviceId: undefined,
boundDeviceNo: ''
})
const emit = defineEmits<{
(e: 'navigateToDevice', deviceNo: string): void
}>()
const { formatAmount, getTransactionTypeTag, getWalletStatusType } = useAssetFormatters()
const DEFAULT_PAGE_SIZE = 20
const createDefaultPagination = () => ({ page: 1, page_size: DEFAULT_PAGE_SIZE })
const createDefaultFilterForm = () => ({
transaction_type: null as TransactionType | null,
date_range: null as [Date, Date] | null
})
const createDefaultQueryParams = (): AssetWalletTransactionParams => ({
page: 1,
page_size: DEFAULT_PAGE_SIZE,
transaction_type: null,
start_time: null,
end_time: null
})
// State
const loading = ref(false)
const hasLoadedTransactions = ref(false)
const transactionList = ref<AssetWalletTransactionItem[]>([])
const total = ref(0)
const hasTransactions = computed(() => transactionList.value.length > 0)
const pagination = ref(createDefaultPagination())
const filterForm = ref(createDefaultFilterForm())
const queryParams = ref<AssetWalletTransactionParams>(createDefaultQueryParams())
const clearTransactions = () => {
hasLoadedTransactions.value = false
transactionList.value = []
total.value = 0
pagination.value = createDefaultPagination()
}
const normalizeTransactionResponse = (data?: AssetWalletTransactionListResponse | null) => ({
items: data?.items ?? data?.list ?? [],
total: data?.total ?? 0,
page: data?.page ?? 1,
pageSize: data?.size ?? data?.page_size ?? queryParams.value.page_size ?? DEFAULT_PAGE_SIZE
})
const loadTransactions = async (preserveDisplayState: boolean = true) => {
if (!props.assetIdentifier || props.boundDeviceId) {
clearTransactions()
return
}
if (!preserveDisplayState) {
clearTransactions()
}
try {
loading.value = true
const response = await AssetService.getWalletTransactions(
props.assetIdentifier,
queryParams.value
)
if (response.code === 0 && response.data) {
const normalized = normalizeTransactionResponse(response.data)
transactionList.value = normalized.items
total.value = normalized.total
pagination.value = {
page: normalized.page,
page_size: normalized.pageSize
}
queryParams.value.page = normalized.page
queryParams.value.page_size = normalized.pageSize
} else {
transactionList.value = []
total.value = 0
pagination.value = createDefaultPagination()
}
hasLoadedTransactions.value = true
} catch (error: any) {
if (error?.response?.status === 403) {
ElMessage.warning('企业账号禁止查看钱包流水')
} else {
console.error('获取钱包流水失败:', error)
}
} finally {
loading.value = false
}
}
const resetQueryState = () => {
filterForm.value = createDefaultFilterForm()
queryParams.value = createDefaultQueryParams()
pagination.value = createDefaultPagination()
}
const handleQuery = () => {
queryParams.value.page = 1
pagination.value.page = 1
queryParams.value.transaction_type = filterForm.value.transaction_type
if (filterForm.value.date_range && filterForm.value.date_range.length === 2) {
queryParams.value.start_time = filterForm.value.date_range[0].toISOString()
queryParams.value.end_time = filterForm.value.date_range[1].toISOString()
} else {
queryParams.value.start_time = null
queryParams.value.end_time = null
}
void loadTransactions(true)
}
const handleFilterChange = () => handleQuery()
const handleReset = (preserveDisplayState: boolean = true) => {
resetQueryState()
if (props.boundDeviceId) {
clearTransactions()
return
}
void loadTransactions(preserveDisplayState)
}
const handlePageSizeChange = (size: number) => {
queryParams.value.page_size = size
queryParams.value.page = 1
pagination.value.page = 1
void loadTransactions(true)
}
const handlePageChange = (page: number) => {
queryParams.value.page = page
void loadTransactions(true)
}
watch(
() => [props.assetIdentifier, props.searchToken, props.boundDeviceId] as const,
([assetIdentifier], previousValue) => {
if (!assetIdentifier) {
resetQueryState()
clearTransactions()
return
}
const previousAssetIdentifier = previousValue?.[0]
const preserveDisplayState =
Boolean(previousAssetIdentifier) && assetIdentifier === previousAssetIdentifier
handleReset(preserveDisplayState)
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.info-card {
&.wallet-info {
.wallet-header {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: space-between;
.header-left {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
.wallet-title {
font-size: 15px;
font-weight: 500;
}
.wallet-divider {
flex-shrink: 0;
}
.balance-item {
font-size: 13px;
color: var(--el-text-color-regular);
strong {
font-weight: 600;
color: var(--el-text-color-primary);
}
}
}
.filter-section {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: flex-end;
min-width: 0;
.transaction-type-select {
width: 150px;
}
.date-range-picker {
width: min(360px, 100%);
}
}
}
.wallet-table-wrapper {
overflow-x: auto;
.wallet-empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 220px;
}
.wallet-table {
width: max-content;
min-width: 900px;
:deep(.el-table__header) {
th {
font-weight: 600;
color: var(--el-text-color-primary, #374151);
}
}
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
overflow-x: auto;
}
}
@media (width <= 1200px) {
.wallet-header {
flex-direction: column;
align-items: flex-start;
.header-left,
.filter-section {
width: 100%;
}
.filter-section {
justify-content: flex-start;
}
}
}
@media (width <= 768px) {
.wallet-header {
flex-direction: column;
align-items: stretch;
.header-left,
.filter-section {
width: 100%;
}
.filter-section {
flex-direction: column;
align-items: stretch;
:deep(.el-select),
:deep(.el-date-editor),
.el-button,
.el-tag {
width: 100% !important;
}
.el-button {
margin-left: 0;
}
}
.header-left {
flex-direction: column;
gap: 6px;
align-items: flex-start;
.wallet-divider {
display: none;
}
.balance-item {
width: 100%;
}
}
}
.wallet-table-wrapper {
.wallet-table {
min-width: 900px;
}
.pagination-wrapper {
justify-content: flex-start;
overflow-x: auto;
}
}
}
@media (width <= 480px) {
.wallet-table-wrapper {
padding: 0 12px;
margin-right: -12px;
margin-left: -12px;
}
}
}
}
</style>