[update]新增customerReport页面

This commit is contained in:
2026-06-11 10:17:12 +08:00
parent c3d64d43bb
commit 3120c56620
9 changed files with 584 additions and 6 deletions

View File

@@ -35,3 +35,18 @@ export function dailyReport(params: { start: string; end: string; type: 'daily'
params,
})
}
export function customerReport(params: {
start: string
end: string
username: string
lose_rebate: string
page: number
limit: number
}) {
return createAxios({
url: url + 'customerReport',
method: 'get',
params,
})
}

View File

@@ -11,6 +11,7 @@ export default {
[adminBaseRoutePath + '/user/rule']: ['./backend/${lang}/auth/rule.ts'],
[adminBaseRoutePath + '/user/moneyLog/annualReport']: ['./backend/${lang}/user/moneyLog.ts'],
[adminBaseRoutePath + '/user/moneyLog/dailyReport']: ['./backend/${lang}/user/moneyLog.ts'],
[adminBaseRoutePath + '/user/moneyLog/customerReport']: ['./backend/${lang}/user/moneyLog.ts'],
[adminBaseRoutePath + '/user/submittedReward']: ['./backend/${lang}/user/submittedReward.ts'],
[adminBaseRoutePath + '/user/scoreLog']: ['./backend/${lang}/user/moneyLog.ts'],
[adminBaseRoutePath + '/crud/crud']: ['./backend/${lang}/crud/log.ts', './backend/${lang}/crud/state.ts'],

View File

@@ -79,4 +79,31 @@ export default {
count: 'COUNT',
total: 'TOTAL',
},
customerReport: {
title: 'CUSTOMER REPORT',
noData: 'No customer report data',
startDate: 'Start Date',
endDate: 'End Date',
fullUsername: 'Full Username',
loseRebate: 'LOSE REBATE',
options: '(Options)',
rebatePlaceholder: '(Options) percentage %',
option: 'option',
search: 'Search',
clear: 'Clear',
dateOfData: 'Date of data',
allTime: 'All Time',
registerDate: 'REGISTER DATE',
username: 'USERNAME',
deposit: 'DEPOSIT',
withdraw: 'WITHDRAW',
winLose: 'WIN / LOSE',
referral: 'REFERRAL (Downline)',
reward: 'REWARD ({game})',
count: 'Count',
amount: 'Amount',
totalRecords: 'Total {total} records',
show: 'Show',
entries: 'entries',
},
}

View File

@@ -78,4 +78,31 @@ export default {
count: '笔数',
total: '总额',
},
customerReport: {
title: '客户报表',
noData: '暂无客户报表数据',
startDate: '开始日期',
endDate: '结束日期',
fullUsername: '完整用户名',
loseRebate: '亏损返利',
options: '(可选)',
rebatePlaceholder: '(可选)百分比 %',
option: '可选',
search: '搜索',
clear: '清空',
dateOfData: '数据日期',
allTime: '全部时间',
registerDate: '注册日期',
username: '用户名',
deposit: '存款',
withdraw: '提款',
winLose: '输赢',
referral: '推荐(下线)',
reward: '奖励({game}',
count: '笔数',
amount: '金额',
totalRecords: '共 {total} 条记录',
show: '每页显示',
entries: '条',
},
}

View File

@@ -59,6 +59,7 @@ export default {
Report: 'Report',
'Annual Report': 'Annual Report',
'Daily Report': 'Daily Report',
'Customer Report': 'Customer Report',
'银行账户管理': 'Bank Account Management',
'Bank Account Management': 'Bank Account Management',
'权限管理': 'Permission Management',

View File

@@ -60,6 +60,7 @@ export default {
Report: '报表',
'Annual Report': '年度报表',
'Daily Report': '日报表',
'Customer Report': '客户报表',
'银行账户管理': '银行账户管理',
'Bank Account Management': '银行账户管理',
'权限管理': '权限管理',

View File

@@ -10,7 +10,7 @@
</el-sub-menu>
</template>
<template v-else>
<el-menu-item @click="onClickMenu(menu)" :index="getMenuKey(menu)" :key="getMenuKey(menu)">
<el-menu-item @click="onClickMenuItem(menu)" :index="getMenuKey(menu)" :key="getMenuKey(menu)">
<Icon :color="config.getColorVal('menuColor')" :name="menu.meta?.icon ? menu.meta?.icon : config.layout.menuDefaultIcon" />
<span>{{ menuTitle(menu) }}</span>
</el-menu-item>
@@ -22,8 +22,9 @@
import { ElNotification } from 'element-plus'
import { useI18n } from 'vue-i18n'
import type { RouteRecordRaw } from 'vue-router'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
import { useConfig } from '/@/stores/config'
import { getFirstRoute, getMenuKey, onClickMenu } from '/@/utils/router'
import { getFirstRoute, getMenuKey, onClickMenu, routePush } from '/@/utils/router'
const { t, te } = useI18n()
const config = useConfig()
@@ -50,6 +51,25 @@ const menuTitle = (menu: RouteRecordRaw) => {
return te(title) ? t(title) : title
}
const reportMenuPaths: Record<string, string> = {
'Annual Report': '/user/moneyLog/annualReport',
'年度报表': '/user/moneyLog/annualReport',
'Daily Report': '/user/moneyLog/dailyReport',
'日报表': '/user/moneyLog/dailyReport',
'Customer Report': '/user/moneyLog/customerReport',
'客户报表': '/user/moneyLog/customerReport',
}
const onClickMenuItem = (menu: RouteRecordRaw) => {
const title = typeof menu.meta?.title === 'string' ? menu.meta.title : ''
const reportPath = reportMenuPaths[title]
if (reportPath) {
routePush(adminBaseRoutePath + reportPath)
return
}
onClickMenu(menu)
}
/**
* sub-menu-item 被点击 - 用于单栏布局和双栏布局
* 顶栏菜单:点击时打开第一个菜单

View File

@@ -132,6 +132,24 @@ export const handleAdminRoute = (routes: any) => {
navTabs.fillAuthNode(handleAuthNode(routes, menuAdminBaseRoute))
}
const reportRouteName = (route: any) => {
if (typeof route.component !== 'string') return ''
const match = route.component.match(/\/user\/moneyLog\/(annualReport|dailyReport|customerReport)\.vue$/)
return match?.[1] || ''
}
const normalizeRoutePath = (route: any) => {
const reportName = reportRouteName(route)
if (!reportName) return route.path
return String(route.path).replace(/[^/]+$/, reportName)
}
const normalizeRouteName = (route: any) => {
const reportName = reportRouteName(route)
if (!reportName) return route.name
return String(route.name).replace(/[^/]+$/, reportName)
}
/**
* 获取菜单的paths
*/
@@ -179,14 +197,16 @@ const handleMenuRule = (routes: any, pathPrefix = '/', type = ['menu', 'menu_dir
) {
continue
}
const currentPath = ['link', 'iframe'].includes(routes[key].menu_type) ? routes[key].url : pathPrefix + routes[key].path
const currentPath = ['link', 'iframe'].includes(routes[key].menu_type)
? routes[key].url
: pathPrefix + normalizeRoutePath(routes[key])
let children: RouteRecordRaw[] = []
if (routes[key].children && routes[key].children.length > 0) {
children = handleMenuRule(routes[key].children, pathPrefix, type)
}
menuRule.push({
path: currentPath,
name: routes[key].name,
name: normalizeRouteName(routes[key]),
component: routes[key].component,
meta: {
id: routes[key].id,
@@ -262,7 +282,8 @@ export const addRouteItem = (viewsComponent: Record<string, any>, route: any, pa
path = (isAdminApp() ? adminBaseRoute.path : memberCenterBaseRoute.path) + '/iframe/' + encodeURIComponent(route.url)
component = () => import('/@/layouts/common/router-view/iframe.vue')
} else {
path = parentName ? route.path : '/' + route.path
const routePath = normalizeRoutePath(route)
path = parentName ? routePath : '/' + routePath
component = viewsComponent[route.component]
}
@@ -280,7 +301,7 @@ export const addRouteItem = (viewsComponent: Record<string, any>, route: any, pa
const routeBaseInfo: RouteRecordRaw = {
path: path,
name: route.name,
name: normalizeRouteName(route),
component: component,
meta: {
title: route.title,

View File

@@ -0,0 +1,465 @@
<template>
<div class="default-main customer-report">
<section class="report-filter">
<header>{{ t('user.moneyLog.customerReport.title') }}</header>
<div class="filter-content">
<div class="filter-item">
<label>{{ t('user.moneyLog.customerReport.startDate') }}:</label>
<el-date-picker v-model="filters.start" type="date" value-format="YYYY-MM-DD" clearable />
</div>
<div class="filter-item">
<label>{{ t('user.moneyLog.customerReport.endDate') }}:</label>
<el-date-picker v-model="filters.end" type="date" value-format="YYYY-MM-DD" clearable />
</div>
<div class="filter-item">
<label>{{ t('user.moneyLog.customerReport.fullUsername') }}:</label>
<el-input v-model="filters.username" :placeholder="t('user.moneyLog.customerReport.options')" clearable />
</div>
<div class="filter-item">
<label>{{ t('user.moneyLog.customerReport.loseRebate') }}:</label>
<el-input
v-model="filters.loseRebate"
:placeholder="t('user.moneyLog.customerReport.rebatePlaceholder')"
clearable
/>
</div>
<div class="filter-actions">
<el-button type="primary" @click="search">{{ t('user.moneyLog.customerReport.search') }}</el-button>
<el-button @click="clear">{{ t('user.moneyLog.customerReport.clear') }}</el-button>
</div>
</div>
</section>
<div class="date-summary">
<strong>{{ t('user.moneyLog.customerReport.dateOfData') }}:</strong>
{{ dateSummary }}
</div>
<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.customerReport.registerDate') }} </th>
<th rowspan="2" class="username-column">{{ t('user.moneyLog.customerReport.username') }}</th>
<th colspan="2">{{ t('user.moneyLog.customerReport.deposit') }}</th>
<th colspan="2">{{ t('user.moneyLog.customerReport.withdraw') }}</th>
<th rowspan="2">{{ t('user.moneyLog.customerReport.winLose') }} </th>
<th rowspan="2">
{{ t('user.moneyLog.customerReport.loseRebate') }}
<small>({{ t('user.moneyLog.customerReport.option') }})</small>
</th>
<th rowspan="2">{{ t('user.moneyLog.customerReport.referral') }} </th>
<th v-for="game in games" :key="game.id" colspan="2" class="reward-heading">
{{ t('user.moneyLog.customerReport.reward', { game: game.name }) }}
</th>
</tr>
<tr>
<template v-for="group in subHeaderGroups" :key="group">
<th>{{ t('user.moneyLog.customerReport.count') }} </th>
<th>{{ t('user.moneyLog.customerReport.amount') }} </th>
</template>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="`${row.registerDate}-${row.username}`">
<td>{{ row.registerDate }}</td>
<td class="username-cell"><span class="user-icon"></span>{{ row.username }}</td>
<td class="deposit-value">{{ row.depositCount }}</td>
<td class="deposit-value">{{ money(row.depositAmount, true) }}</td>
<td class="withdraw-value">{{ row.withdrawCount }}</td>
<td class="withdraw-value">{{ money(row.withdrawAmount, true) }}</td>
<td :class="row.winLose >= 0 ? 'positive-value' : 'negative-value'">{{ signedMoney(row.winLose) }}</td>
<td>{{ money(row.loseRebate) }}</td>
<td>{{ row.referral }}</td>
<template v-for="reward in row.rewards" :key="reward.gameId">
<td class="reward-cell">{{ reward.count }}</td>
<td class="reward-cell">{{ money(reward.amount) }}</td>
</template>
</tr>
<tr v-if="rows.length === 0">
<td class="empty-row" :colspan="columnCount">{{ t('user.moneyLog.customerReport.noData') }}</td>
</tr>
</tbody>
</table>
</div>
<div class="table-footer">
<div>{{ t('user.moneyLog.customerReport.totalRecords', { total }) }}</div>
<el-pagination
v-model:current-page="currentPage"
small
background
layout="prev, pager, next"
:page-size="pageSize"
:page-count="lastPage"
@current-change="loadReport"
/>
<div class="page-size">
<span>{{ t('user.moneyLog.customerReport.show') }}</span>
<el-select v-model="pageSize" @change="changePageSize">
<el-option v-for="size in pageSizes" :key="size" :label="size" :value="size" />
</el-select>
<span>{{ t('user.moneyLog.customerReport.entries') }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { customerReport } from '/@/api/backend/user/moneyLog'
defineOptions({
name: 'user/moneyLog/customerReport',
})
interface CustomerReportRow {
registerDate: string
username: string
depositCount: number
depositAmount: number
withdrawCount: number
withdrawAmount: number
winLose: number
loseRebate: number
referral: number
rewards: { gameId: string; count: number; amount: number }[]
}
interface CustomerReportSourceRow {
register_date?: string
username?: string
deposit_count?: number | string
deposit_total?: number | string
withdraw_count?: number | string
withdraw_total?: number | string
win_lose?: number | string
lose_rebate?: number | string
referral_count?: number | string
[key: string]: number | string | undefined
}
const { t } = useI18n()
const games = ref<{ id: string; name: string }[]>([])
const subHeaderGroups = computed(() => ['deposit', 'withdraw', ...games.value.map((game) => game.id)])
const columnCount = computed(() => 9 + games.value.length * 2)
const tableMinWidth = computed(() => 1580 + games.value.length * 250)
const pageSizes = [100, 500, 1000, 5000]
const rows = ref<CustomerReportRow[]>([])
const total = ref(0)
const currentPage = ref(1)
const lastPage = ref(1)
const pageSize = ref(100)
const loading = ref(false)
const filters = reactive({
start: '',
end: '',
username: '',
loseRebate: '',
})
const appliedFilters = reactive({ ...filters })
const dateSummary = computed(() => {
if (appliedFilters.start && appliedFilters.end) return `${appliedFilters.start} to ${appliedFilters.end}`
if (appliedFilters.start) return `${appliedFilters.start} -`
if (appliedFilters.end) return `- ${appliedFilters.end}`
return t('user.moneyLog.customerReport.allTime')
})
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: CustomerReportSourceRow) => ({
registerDate: String(row.register_date ?? ''),
username: String(row.username ?? ''),
depositCount: toNumber(row.deposit_count),
depositAmount: toNumber(row.deposit_total),
withdrawCount: toNumber(row.withdraw_count),
withdrawAmount: toNumber(row.withdraw_total),
winLose: toNumber(row.win_lose),
loseRebate: toNumber(row.lose_rebate),
referral: toNumber(row.referral_count),
rewards: normalizedGames.map((game) => ({
gameId: game.id,
count: toNumber(row[`game_${game.id}_count`]),
amount: toNumber(row[`game_${game.id}_total`]),
})),
})),
total: toNumber(source.count ?? source.total ?? list.length),
currentPage: toNumber(source.current_page) || currentPage.value,
lastPage: toNumber(source.last_page) || 1,
}
}
const loadReport = async () => {
loading.value = true
try {
const response = await customerReport({
start: appliedFilters.start,
end: appliedFilters.end,
username: appliedFilters.username,
lose_rebate: appliedFilters.loseRebate,
page: currentPage.value,
limit: pageSize.value,
})
const normalized = normalizeResponse(response.data)
games.value = normalized.games
rows.value = normalized.rows
total.value = normalized.total
currentPage.value = normalized.currentPage
lastPage.value = normalized.lastPage
} finally {
loading.value = false
}
}
const search = async () => {
Object.assign(appliedFilters, filters)
currentPage.value = 1
await loadReport()
}
const clear = async () => {
Object.assign(filters, {
start: '',
end: '',
username: '',
loseRebate: '',
})
await search()
}
const changePageSize = async () => {
currentPage.value = 1
await 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
if (number === 0) return money(0)
return `${number > 0 ? '+' : '-'}${money(Math.abs(number))}`
}
onMounted(loadReport)
</script>
<style scoped lang="scss">
.customer-report {
--report-dark: #333;
--report-border: #d8d8d8;
--report-reward: #e5e5e5;
--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 {
display: grid;
grid-template-columns: 335px 335px;
gap: 6px 18px;
min-height: 120px;
padding: 13px 20px;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
label {
width: 115px;
flex-shrink: 0;
font-weight: 700;
text-align: right;
white-space: nowrap;
}
:deep(.el-date-editor),
:deep(.el-input) {
width: 200px;
}
}
.filter-actions {
grid-column: 2;
display: flex;
gap: 16px;
.el-button {
width: 160px;
margin: 0;
}
}
.date-summary {
margin-bottom: 8px;
}
.report-table-wrap {
overflow-x: auto;
}
.report-table {
width: 100%;
min-width: 2500px;
border-collapse: collapse;
th,
td {
height: 38px;
padding: 8px;
border: 1px solid var(--report-border);
text-align: left;
white-space: nowrap;
}
thead th {
border-color: #eee;
background: var(--report-dark);
color: #fff;
font-weight: 700;
vertical-align: middle;
small {
display: block;
font-size: 11px;
}
}
tbody td {
background: var(--report-surface);
}
.date-column {
width: 225px;
}
.username-column {
width: 160px;
}
.reward-heading {
min-width: 250px;
}
.reward-cell {
background: var(--report-reward);
}
.username-cell {
display: flex;
align-items: center;
gap: 8px;
}
.user-icon {
color: var(--el-text-color-primary);
}
.deposit-value,
.positive-value {
color: #35ad78;
}
.withdraw-value,
.negative-value {
color: #e05a55;
}
.empty-row {
height: 150px;
color: var(--el-text-color-secondary);
text-align: center;
}
}
.table-footer {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin-top: 18px;
}
.page-size {
display: flex;
align-items: center;
gap: 8px;
.el-select {
width: 90px;
}
}
@at-root html.dark {
.customer-report {
--report-dark: #2b2b2b;
--report-border: var(--el-border-color);
--report-reward: #333;
}
}
@media (max-width: 768px) {
.customer-report {
padding: 14px;
}
.filter-content {
display: flex;
flex-direction: column;
}
.filter-item {
align-items: flex-start;
flex-direction: column;
label {
width: auto;
text-align: left;
}
}
.filter-actions {
width: 100%;
}
}
</style>