Files
thebet365/apps/admin/src/components/PlayerWalletLedgerDialog.vue
Mars a8ee28fcce feat: replace watermarks with corner tags, improve parlay layout and admin polish
- MatchDetailView: replace StatusWatermark with top-right solid status tags, remove match-hero background image
- ParlayPanel: replace StatusWatermark with corner tags, improve toggle button style and layout spacing, fix overflow clipping
- Admin: wallet ledger dialog, agent manager, and player page refinements

🤖 Generated with [Qoder][https://qoder.com]
2026-06-10 16:24:01 +08:00

305 lines
8.8 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useAuthStore } from '../stores/auth';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
import AdminTableEmpty from './AdminTableEmpty.vue';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import { walletTxTypeKey } from '../utils/walletTx';
interface WalletTxRow {
id: string;
transactionId: string;
transactionType: string;
amount: string;
balanceBefore: string;
balanceAfter: string;
frozenBefore: string;
frozenAfter: string;
betNo: string | null;
operatorUsername: string | null;
remark: string | null;
createdAt: string;
}
const props = defineProps<{
modelValue: boolean;
playerId: string;
playerUsername?: string | null;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
}>();
const { t, locale, localeTag } = useAdminLocale();
const auth = useAuthStore();
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
});
const dialogTitle = computed(() => {
const name = props.playerUsername?.trim() || props.playerId;
return t('user.wallet_ledger_dialog_title', { name });
});
const walletApiPath = computed(() =>
auth.isAdmin.value ? '/admin/wallet/transactions' : '/agent/wallet/ledger-transactions',
);
const items = ref<WalletTxRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const loading = ref(false);
const typeCategory = ref('');
const dateRange = ref<[Date, Date] | null>(null);
function walletTypeLabel(type: string) {
const key = walletTxTypeKey(type);
return key ? t(key) : type;
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function dateParams() {
if (!dateRange.value?.length) return {};
const [from, to] = dateRange.value;
const end = new Date(to);
end.setHours(23, 59, 59, 999);
return {
dateFrom: from.toISOString(),
dateTo: end.toISOString(),
};
}
async function load() {
if (!props.playerId) return;
loading.value = true;
try {
const { data } = await api.get(walletApiPath.value, {
params: {
page: page.value,
pageSize: pageSize.value,
playerId: props.playerId,
typeCategory: typeCategory.value || undefined,
...dateParams(),
},
});
items.value = (data.data?.items ?? []) as WalletTxRow[];
total.value = data.data?.total ?? 0;
} catch {
items.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
void load();
}
function resetFilters() {
typeCategory.value = '';
dateRange.value = null;
page.value = 1;
}
watch(
() => [props.modelValue, props.playerId] as const,
([open, id]) => {
if (open && id) {
resetFilters();
void load();
}
},
);
</script>
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="980px"
destroy-on-close
class="player-wallet-ledger-dialog"
append-to-body
>
<el-form inline class="ledger-filter">
<el-form-item :label="t('finance.filter.date_range')">
<el-date-picker
v-model="dateRange"
type="daterange"
:start-placeholder="t('common.to')"
:end-placeholder="t('common.to')"
value-format="YYYY-MM-DD"
style="width: 240px"
/>
</el-form-item>
<el-form-item :label="t('finance.filter.type_category')">
<el-select v-model="typeCategory" clearable :placeholder="t('finance.filter.type_category_all')" style="width: 120px">
<el-option :label="t('finance.filter.type_category_deposit')" value="deposit" />
<el-option :label="t('finance.filter.type_category_bet')" value="bet" />
<el-option :label="t('finance.filter.type_category_cashback')" value="cashback" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<div class="table-wrap">
<el-table v-loading="loading" :key="`${locale}-${playerId}`" :data="items" stripe max-height="420">
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column :label="t('audit.col.time')" min-width="150">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.tx_id')" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.transactionId }}</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_type')" min-width="88">
<template #default="{ row }">{{ walletTypeLabel(row.transactionType) }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.balance_change')" min-width="96" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
<span :class="parseFloat(row.amount) >= 0 ? 'amt-pos' : 'amt-neg'">
{{ formatAmount(row.amount) }}
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.balance_before')" min-width="92" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.balanceBefore)" placement="top">
<span>{{ formatAmount(row.balanceBefore) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.balance_after')" min-width="92" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.balanceAfter)" placement="top">
<span>{{ formatAmount(row.balanceAfter) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.frozen_before')" min-width="92" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.frozenBefore)" placement="top">
<span>{{ formatAmount(row.frozenBefore) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.frozen_after')" min-width="92" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.frozenAfter)" placement="top">
<span>{{ formatAmount(row.frozenAfter) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.reference')" min-width="110" show-overflow-tooltip>
<template #default="{ row }">
<router-link
v-if="row.betNo"
:to="{ path: '/bets', query: { keyword: row.betNo } }"
class="bet-link"
@click="visible = false"
>
{{ row.betNo }}
</router-link>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('agent.credit_tx.col.operator')" min-width="88">
<template #default="{ row }">{{ row.operatorUsername ?? '—' }}</template>
</el-table-column>
<el-table-column :label="t('user.field.remark')" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark ?? '—' }}</template>
</el-table-column>
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="() => load()"
@size-change="() => { page = 1; load(); }"
/>
</div>
</el-dialog>
</template>
<style scoped>
.ledger-filter {
margin-bottom: 8px;
}
.ledger-filter :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 8px;
}
.table-wrap {
margin: 0 -2px;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.amt-pos {
color: var(--el-color-success);
font-weight: 600;
}
.amt-neg {
color: var(--el-color-danger);
font-weight: 600;
}
.bet-link {
color: var(--el-color-primary);
text-decoration: none;
}
.bet-link:hover {
text-decoration: underline;
}
</style>
<style>
.player-wallet-ledger-dialog .el-dialog__header {
padding: 12px 16px 6px;
margin-right: 0;
}
.player-wallet-ledger-dialog .el-dialog__body {
padding: 10px 12px;
}
.player-wallet-ledger-dialog .el-dialog__headerbtn {
top: 10px;
}
</style>