feat: multi-tier agent hierarchy, wallet ledger, and player UX polish
Add configurable agent max level and default sub-agent credit ratio, per-agent block direct player login on suspend, admin/agent wallet transaction views, and match detail my-bets section with refreshed player card styling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
285
apps/admin/src/components/PlayerWalletLedgerDialog.vue
Normal file
285
apps/admin/src/components/PlayerWalletLedgerDialog.vue
Normal file
@@ -0,0 +1,285 @@
|
||||
<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="960px"
|
||||
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')" width="92">
|
||||
<template #default="{ row }">{{ walletTypeLabel(row.transactionType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('finance.col.balance_change')" width="100" 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')" width="96" 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')" width="96" 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')" width="96" 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')" width="96" 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: 12px;
|
||||
}
|
||||
|
||||
.ledger-filter :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user