Files
jk8_admin/web/src/views/backend/user/moneyLog/dailyReport.vue

428 lines
13 KiB
Vue

<template>
<div class="default-main daily-report">
<section class="report-filter">
<header>{{ t('user.moneyLog.dailyReport.title') }}</header>
<div class="filter-content">
<div class="date-fields">
<label>{{ t('user.moneyLog.dailyReport.startDate') }}:</label>
<el-date-picker
v-model="filters.start"
type="date"
value-format="YYYY-MM-DD"
:clearable="false"
@change="loadReport"
/>
<label>{{ t('user.moneyLog.dailyReport.endDate') }}:</label>
<el-date-picker
v-model="filters.end"
type="date"
value-format="YYYY-MM-DD"
:clearable="false"
@change="loadReport"
/>
</div>
<div class="period-tabs">
<button
v-for="period in periods"
:key="period.value"
type="button"
:class="{ active: filters.period === period.value }"
@click="changePeriod(period.value)"
>
{{ t(period.label) }}
</button>
</div>
</div>
</section>
<div v-loading="loading" class="report-table-wrap">
<table class="report-table" :style="{ minWidth: `${tableMinWidth}px` }">
<thead>
<tr>
<th rowspan="2" class="date-column">{{ t('user.moneyLog.dailyReport.date') }}</th>
<th colspan="2">{{ t('user.moneyLog.dailyReport.deposit') }}</th>
<th colspan="2">{{ t('user.moneyLog.dailyReport.withdraw') }}</th>
<th rowspan="2" class="single-column">{{ t('user.moneyLog.dailyReport.winLose') }}</th>
<th colspan="2">{{ t('user.moneyLog.dailyReport.unclaim') }}</th>
<th rowspan="2" class="single-column">{{ t('user.moneyLog.dailyReport.activeMember') }}</th>
<th rowspan="2" class="single-column">{{ t('user.moneyLog.dailyReport.newDeposit') }}</th>
<th rowspan="2" class="single-column">{{ t('user.moneyLog.dailyReport.newRegister') }}</th>
<th v-for="game in games" :key="game.id" colspan="2" class="reward-heading">
{{ t('user.moneyLog.dailyReport.reward', { game: game.name }) }}
</th>
</tr>
<tr>
<template v-for="group in subHeaderGroups" :key="group">
<th class="count-column">{{ t('user.moneyLog.dailyReport.count') }}</th>
<th class="total-column">{{ t('user.moneyLog.dailyReport.total') }}</th>
</template>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.date">
<td class="date-cell">{{ row.date }}</td>
<td class="deposit-value">{{ row.depositCount }}</td>
<td class="deposit-value">{{ money(row.depositTotal, true) }}</td>
<td class="withdraw-value">{{ row.withdrawCount }}</td>
<td class="withdraw-value">{{ money(row.withdrawTotal, true) }}</td>
<td :class="row.winLose >= 0 ? 'positive-value' : 'negative-value'">{{ signedMoney(row.winLose) }}</td>
<td :class="{ 'unclaim-value': row.unclaimCount > 0 }">{{ row.unclaimCount }}</td>
<td :class="{ 'unclaim-value': row.unclaimTotal > 0 }">{{ money(row.unclaimTotal, true) }}</td>
<td>{{ row.activeMember }}</td>
<td>{{ row.newDeposit }}</td>
<td>{{ row.newRegister }}</td>
<template v-for="reward in row.rewards" :key="reward.gameId">
<td class="reward-cell">{{ reward.count }}</td>
<td class="reward-cell">{{ money(reward.total) }}</td>
</template>
</tr>
<tr v-if="rows.length === 0">
<td class="empty-row" :colspan="columnCount">{{ t('user.moneyLog.dailyReport.noData') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { dailyReport } from '/@/api/backend/user/moneyLog'
defineOptions({
name: 'user/moneyLog/dailyReport',
})
type Period = 'daily' | 'monthly' | 'yearly'
interface DailyReportRow {
date: string
depositCount: number
depositTotal: number
withdrawCount: number
withdrawTotal: number
winLose: number
unclaimCount: number
unclaimTotal: number
activeMember: number
newDeposit: number
newRegister: number
rewards: { gameId: string; count: number; total: number }[]
}
interface DailyReportSourceRow {
date?: string
deposit_count?: number | string
deposit_total?: number | string
withdraw_count?: number | string
withdraw_total?: number | string
win_lose?: number | string
unclaim_count?: number | string
unclaim_total?: number | string
active_member?: number | string
new_deposit?: number | string
new_register?: number | string
[key: string]: number | string | undefined
}
const { t } = useI18n()
const formatDate = (date: Date) => {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
const now = new Date()
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1)
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0)
const filters = reactive({
start: formatDate(firstDay),
end: formatDate(lastDay),
period: 'daily' as Period,
})
const periods: { value: Period; label: string }[] = [
{ value: 'daily', label: 'user.moneyLog.dailyReport.daily' },
{ value: 'monthly', label: 'user.moneyLog.dailyReport.monthly' },
{ value: 'yearly', label: 'user.moneyLog.dailyReport.yearly' },
]
const games = ref<{ id: string; name: string }[]>([])
const subHeaderGroups = computed(() => ['deposit', 'withdraw', 'unclaim', ...games.value.map((game) => game.id)])
const columnCount = computed(() => 11 + games.value.length * 2)
const tableMinWidth = computed(() => 1596 + games.value.length * 230)
const rows = ref<DailyReportRow[]>([])
const loading = ref(false)
const toNumber = (value: unknown) => {
const number = Number(value)
return Number.isFinite(number) ? number : 0
}
const normalizeResponse = (responseData: any) => {
const source = responseData?.data ?? responseData ?? {}
const gameMap = source.games && typeof source.games === 'object' ? source.games : {}
const normalizedGames = Object.entries(gameMap).map(([id, name]) => ({
id,
name: String(name),
}))
const list = Array.isArray(source.list) ? source.list : []
return {
games: normalizedGames,
rows: list.map((row: DailyReportSourceRow) => ({
date: String(row.date ?? ''),
depositCount: toNumber(row.deposit_count),
depositTotal: toNumber(row.deposit_total),
withdrawCount: toNumber(row.withdraw_count),
withdrawTotal: toNumber(row.withdraw_total),
winLose: toNumber(row.win_lose),
unclaimCount: toNumber(row.unclaim_count),
unclaimTotal: toNumber(row.unclaim_total),
activeMember: toNumber(row.active_member),
newDeposit: toNumber(row.new_deposit),
newRegister: toNumber(row.new_register),
rewards: normalizedGames.map((game) => ({
gameId: game.id,
count: toNumber(row[`game_${game.id}_count`]),
total: toNumber(row[`game_${game.id}_total`]),
})),
})),
}
}
const loadReport = async () => {
loading.value = true
try {
const response = await dailyReport({
start: filters.start,
end: filters.end,
type: filters.period,
})
const normalized = normalizeResponse(response.data)
games.value = normalized.games
rows.value = normalized.rows
} finally {
loading.value = false
}
}
const changePeriod = (period: Period) => {
if (filters.period === period) return
filters.period = period
loadReport()
}
const money = (value: number, currency = false) => {
const formatted = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(Number(value) || 0)
return currency ? `AUD${formatted}` : formatted
}
const signedMoney = (value: number) => {
const number = Number(value) || 0
return `${number >= 0 ? '+' : '-'}${money(Math.abs(number))}`
}
onMounted(loadReport)
</script>
<style scoped lang="scss">
.daily-report {
--report-dark: #333;
--report-border: #ddd;
--report-strong-border: #999;
--report-muted: #ddd;
--report-surface: var(--el-bg-color);
padding: 20px;
color: var(--el-text-color-primary);
font-size: 14px;
}
.report-filter {
margin-bottom: 20px;
border: 1px solid var(--report-border);
background: var(--report-surface);
header {
min-height: 37px;
padding: 9px 8px;
background: var(--report-dark);
color: #fff;
font-weight: 700;
}
}
.filter-content {
min-height: 92px;
padding: 13px;
}
.date-fields {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
label {
font-weight: 700;
white-space: nowrap;
}
:deep(.el-date-editor) {
width: 200px;
margin-right: 0;
}
}
.period-tabs {
display: flex;
width: fit-content;
overflow: hidden;
border: 1px solid #999;
border-radius: 3px;
button {
width: 100px;
height: 32px;
border: 0;
border-right: 1px solid #999;
background: var(--el-fill-color-light);
color: var(--el-text-color-primary);
cursor: pointer;
&:last-child {
border-right: 0;
}
&:hover {
background: var(--el-fill-color);
}
&.active {
background: #bbb;
color: #000;
}
}
}
.report-table-wrap {
overflow-x: auto;
}
.report-table {
width: 100%;
min-width: 2516px;
border-collapse: collapse;
th,
td {
height: 38px;
padding: 8px;
border: 1px solid var(--report-border);
text-align: left;
white-space: nowrap;
}
thead {
background: var(--report-dark);
color: #fff;
th {
border-color: #eee;
background: var(--report-dark);
font-weight: 700;
vertical-align: middle;
}
}
tbody td {
background: var(--report-surface);
color: var(--el-text-color-primary);
}
.date-column,
.date-cell {
width: 236px;
}
.date-cell,
.reward-cell {
border-color: var(--report-strong-border);
background: var(--report-muted);
color: #333;
}
.single-column {
min-width: 140px;
}
.count-column {
width: 69px;
}
.total-column {
min-width: 145px;
}
.reward-heading {
min-width: 230px;
}
.deposit-value,
.positive-value {
color: #3cb371;
}
.withdraw-value,
.negative-value {
color: #ff5349;
}
.unclaim-value {
color: #9457c5;
}
.empty-row {
height: 150px;
background: var(--report-surface);
color: var(--el-text-color-secondary);
text-align: center;
}
}
@at-root html.dark {
.daily-report {
--report-dark: #2b2b2b;
--report-border: var(--el-border-color);
--report-strong-border: #606266;
--report-muted: #333;
}
.period-tabs button.active {
background: #555;
color: #fff;
}
.report-table .date-cell,
.report-table .reward-cell {
color: var(--el-text-color-primary);
}
}
@media (max-width: 768px) {
.daily-report {
padding: 14px;
}
.date-fields {
align-items: flex-start;
flex-direction: column;
}
}
</style>