@@ -1,282 +1,410 @@
< template >
< div class = "default-main ba-table-box " >
< el-alert class = "ba-table-alert" v-if = "baTable.table.remark" :title="baTable.table.remark" type="info" show -icon / >
< div class = "default-main annual-report " >
< div class = "report-heading" >
< h1 > { { t ( 'user.moneyLog.annualReport.title' ) } } < / h1 >
< el-select v-model = "selectedYear" class="year-select" @change="loadReport" >
< el -option v-for = "year in yearOptions" :key="year" :label="year" :value="year" / >
< / el-select >
< / div >
< TableHeader
: buttons = "['refresh', 'add', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
: quick -search -placeholder = "
t ( ' Quick search placeholder ' , { fields : t ( ' user.moneyLog.User name ' ) + ' / ' + t ( ' user.moneyLog.User nickname ' ) } )
"
>
< el-button v-i f = "!isEmpty(state.userInfo)" v-blur class="table-header-operate" >
< span class = "table-header-operate-text" >
{ { state . userInfo . username + '(ID:' + state . userInfo . id + ') ' + t ( 'user.moneyLog.balance' ) + ':' + state . userInfo . money } }
< / span >
< / el-button >
< / TableHeader >
< div v-loading = "loading" class="report-content" >
< div class = "report-table-wrap" >
< table class = "report-table" >
< thead >
< tr >
< th > < / th >
< th v-for = "month in monthLabels" :key="month" > {{ month }} < / th >
< th > { { t ( 'user.moneyLog.annualReport.year' ) } } < / th >
< / tr >
< / thead >
< tbody >
< tr v-for = "metric in metrics" :key="metric.key" >
< th > { { t ( metric . label ) } } < / th >
< td v-for = "(value, index) in reportData[metric.key]" :key="index" >
{{ formatValue ( value , metric.currency ) }}
< / td >
< td class = "year-total" > { { formatValue ( reportTotals [ metric . key ] , metric . currency ) } } < / td >
< / tr >
< / tbody >
< / table >
< / div >
< Table / >
< div class = "chart-card" >
< h2 > { { selectedYear } } { { t ( 'user.moneyLog.annualReport.depositWithdraw' ) } } < / h2 >
< div ref = "depositChartRef" class = "report-chart" > < / div >
< / div >
< PopupForm / >
< el-dialog v-model = "historyDialog.visible" title="Transaction Edit History" width="720px" >
< p class = "dialog-note" > Transaction edit history is only kept for the most recent 12 months . < / p >
< p class = "dialog-note" > Transaction 的编辑历史记录仅保存最近 12 个月 。 < / p >
< el-table v-loading = "historyDialog.loading" :data="historyDialog.rows" border size="small" empty-text="No Record" >
< el -table -column prop = "id" label = "Tx ID" width = "100" / >
< el-table-column prop = "editedBy" label = "Edit By" width = "120" / >
< el-table-column prop = "editedTime" label = "Edit Time" width = "170" / >
< el-table-column prop = "changes" label = "Changes (Old → New)" / >
< / el-table >
< template # footer >
< el-button @click ="historyDialog.visible = false" > CANCEL < / el -button >
< / template >
< / el-dialog >
< div class = "chart-card" >
< h2 > { { selectedYear } } { { t ( 'user.moneyLog.annualReport.transactionActive' ) } } < / h2 >
< div ref = "transactionChartRef" class = "report-chart" > < / div >
< / div >
< / div >
< / div >
< / template >
< script setup lang = "ts" >
import { debounce , isEmpty , parseInt } from 'lodash-e s'
import { provide , reactive , watch } from 'vue'
import * as echarts from 'echart s'
import { computed , nextTick , onBeforeUnmount , onMounted , reactive , ref , watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router '
import PopupForm from './popupForm.vue'
import { add , annualReport , url } from '/@/api/backend/user/moneyLog' // 🌟 已修复:移除了别名前面的绝对斜杠
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
import scoreLog from '/@/lang/backend/zh-cn/user/scoreLog'
import { defaultOptButtons } from '/@/components/table'
import { annualReport } from '/@/api/backend/user/moneyLog '
defineOptions ( {
name : 'user/moneyLog' ,
name : 'user/moneyLog/annualReport ' ,
} )
const { t , tm } = useI18n ( )
const route = useRoute ( )
const defalutUser = ( route . query . user _id ? ? '' ) as string
const state = reactive ( {
userInfo : { } as anyObj ,
} )
interface HistoryRow {
id : number | string
editedBy : string
editedTime : string
changes : string
type MetricKey =
| 'deposit'
| 'withdraw'
| 'transactionDeposit'
| 'transactionWithdraw'
| 'activePlayer'
| 'firstDeposit'
| 'unclaimReceipt'
| 'unclaimAmount'
type ReportData = Record < MetricKey , number [ ] >
type ReportTotals = Record < MetricKey , number >
interface ReportTableRow {
title : string
year _total : number | string
[ key : ` month_ ${ number } ` ] : number | string
}
const historyDialog = reactive ( {
visible : false ,
loading : false ,
rows : [ ] as HistoryRow [ ] ,
} )
const gameTypeMap = scoreLog . game _type as Record < string , string >
const optButtons : OptButton [ ] = [
{
render : 'tipButton' ,
name : 'history' ,
title : 'History' ,
text : '' ,
type : 'primary' ,
icon : 'fa fa-history' ,
class : 'table-row-history' ,
disabledTip : false ,
click : ( row : TableRow ) => {
openHistory ( row )
} ,
} ,
... defaultOptButtons ( [ 'edit' ] ) ,
... defaultOptButtons ( [ 'delete' ] ) ,
const { locale , t } = useI18n ( )
const currentYear = new Date ( ) . getFullYear ( )
const selectedYear = ref ( currentYear )
const loading = ref ( false )
const depositChartRef = ref < HTMLElement > ( )
const transactionChartRef = ref < HTMLElement > ( )
let depositChart : echarts . ECharts | undefined
let transactionChart : echarts . ECharts | undefined
const monthLabels = computed ( ( ) => [
t( 'user.moneyLog.annualReport.months.jan' ) ,
t( 'user.moneyLog.annualReport.months.feb' ) ,
t( 'user.moneyLog.annualReport.months.mar' ) ,
t ( 'user.moneyLog.annualReport.months.apr' ) ,
t ( 'user.moneyLog.annualReport.months.may' ) ,
t ( 'user.moneyLog.annualReport.months.jun' ) ,
t ( 'user.moneyLog.annualReport.months.jul' ) ,
t ( 'user.moneyLog.annualReport.months.aug' ) ,
t ( 'user.moneyLog.annualReport.months.sep' ) ,
t ( 'user.moneyLog.annualReport.months.oct' ) ,
t ( 'user.moneyLog.annualReport.months.nov' ) ,
t ( 'user.moneyLog.annualReport.months.dec' ) ,
] )
const metrics : { key : MetricKey ; label : string ; currency : boolean } [ ] = [
{ key : 'deposit' , label : 'user.moneyLog.annualReport.deposit' , currency : true } ,
{ key : 'withdraw' , label : 'user.moneyLog.annualReport.withdraw' , currency : true } ,
{ key : 'transactionDeposit' , label : 'user.moneyLog.annualReport.transactionDeposit' , currency : false } ,
{ key : 'transactionWithdraw' , label : 'user.moneyLog.annualReport.transactionWithdraw' , currency : false } ,
{ key : 'activePlayer' , label : 'user.moneyLog.annualReport.activePlayer' , currency : false } ,
{ key : 'firstDeposit' , label : 'user.moneyLog.annualReport.firstDeposit' , currency : false } ,
{ key : 'unclaimReceipt' , label : 'user.moneyLog.annualReport.unclaimReceipt' , currency : false } ,
{ key : 'unclaimAmount' , label : 'user.moneyLog.annualReport.unclaimAmount' , currency : true } ,
]
const createEmptyReport = ( ) : ReportData => ( {
deposit : Array ( 12 ) . fill ( 0 ) ,
withdraw : Array ( 12 ) . fill ( 0 ) ,
transactionDeposit : Array ( 12 ) . fill ( 0 ) ,
transactionWithdraw : Array ( 12 ) . fill ( 0 ) ,
activePlayer : Array ( 12 ) . fill ( 0 ) ,
firstDeposit : Array ( 12 ) . fill ( 0 ) ,
unclaimReceipt : Array ( 12 ) . fill ( 0 ) ,
unclaimAmount : Array ( 12 ) . fill ( 0 ) ,
} )
const createEmptyTotals = ( ) : ReportTotals => ( {
deposit : 0 ,
withdraw : 0 ,
transactionDeposit : 0 ,
transactionWithdraw : 0 ,
activePlayer : 0 ,
firstDeposit : 0 ,
unclaimReceipt : 0 ,
unclaimAmount : 0 ,
} )
const reportData = reactive < ReportData > ( createEmptyReport ( ) )
const reportTotals = reactive < ReportTotals > ( createEmptyTotals ( ) )
const yearOptions = computed ( ( ) => Array . from ( { length : 8 } , ( _ , index ) => currentYear - index ) )
const titleMetricMap : Record < string , MetricKey > = {
DEPOSIT : 'deposit' ,
WITHDRAW : 'withdraw' ,
'TRANSACTION (DEPOSIT)' : 'transactionDeposit' ,
'TRANSACTION (WITHDRAW)' : 'transactionWithdraw' ,
'ACTIVE PLAYER' : 'activePlayer' ,
'FIRST DEPOSIT' : 'firstDeposit' ,
'UNCLAIM RECEIPT' : 'unclaimReceipt' ,
'UNCLAIM AMOUNT' : 'unclaimAmount' ,
}
const toNumber = ( value : unknown ) => {
const number = Number ( value )
return Number . isFinite ( number ) ? number : 0
}
const f ormatDateTime = ( value : unknown ) => {
if ( typeof value === 'string' && value . trim ( ) && ! Number . isFinite ( Number ( value ) ) ) {
return value
}
const n ormalizeReport = ( responseData : any ) => {
const data = createEmptyReport ( )
const totals = createEmptyTotals ( )
const reportTable = responseData ? . report _table ? ? responseData ? . data ? . report _table
const timestamp = toNumber ( value )
if ( ! timestamp ) return ''
if ( ! Array . isArray ( reportTable ) ) return { data , totals }
const date = new Date ( timestamp * 1000 )
const pad = ( number : number ) => number . toS tring ( ) . padStart ( 2 , '0' )
return ` ${ date . getFullYear ( ) } - ${ pad ( date . getMonth ( ) + 1 ) } - ${ pad ( date . getDate ( ) ) } ${ pad ( date . getHours ( ) ) } : ${ pad ( date . getMinutes ( ) ) } : ${ pad ( date . getSeconds ( ) ) } `
reportTable . forEach ( ( row : ReportTableRow ) => {
const key = titleMetricMap [ String ( row . title || '' ) . trim ( ) . toUpperCase ( ) ]
if ( ! key ) return
data [ key ] = Array . from ( { length : 12 } , ( _ , index ) => toNumber ( row [ ` month_ ${ index + 1 } ` ] ) )
totals [ key ] = toNumber ( row . year _total )
} )
return { data , totals }
}
const stringifyChange = ( history : Record < string , unknown > ) => {
if ( history . bank _befter !== undefined || history . bank _after !== undefined ) {
return ` Bank: ${ history . bank _befter ? ? '' } → ${ history . bank _after ? ? '' } `
}
const formatValue = ( value : number , currency : boolean ) =>
new Intl . NumberFormat ( undefined , {
minimumFractionDigits : currency ? 2 : 0 ,
maximumFractionDigits : currency ? 2 : 0 ,
} ) . format ( value )
return JSON . stringify ( history )
}
const mapHistory = ( history : Record < string , unknown > ) : HistoryRow => ( {
id : ( history . id || history . money _log _id || '' ) as number | string ,
editedBy : String ( history . admin _name || '' ) ,
editedTime : formatDateTime ( history . create _time ) ,
changes : s tringifyChange ( history ) ,
const chartBaseOption = ( ) : echarts . EChartsOption => ( {
animationDuration : 500 ,
grid : { left : 58 , right : 24 , top : 52 , bottom : 70 } ,
toolbox : {
right : 14 ,
feature : { saveAsImage : { title : t ( 'user.moneyLog.annualReport.download' ) } } ,
} ,
tooltip : { trigger : 'axis' } ,
xAxis : {
type : 'category' ,
boundaryGap : false ,
data : monthLabels . value ,
axisLine : { lineStyle : { color : '#d8d8d8' } } ,
axisLabel : { color : '#777' } ,
} ,
yAxis : {
type : 'value' ,
splitLine : { lineStyle : { color : '#ededed' } } ,
axisLabel : { color : '#777' } ,
} ,
dataZoom : [ { type : 'slider' , height : 18 , bottom : 14 , start : 0 , end : 100 } , { type : 'inside' } ] ,
} )
const openHistory = ( row : TableRow ) => {
historyDialog . visible = true
historyDialog . loading = true
historyDialog . rows = [ ]
annualReport( { id : row . id } )
. then ( ( r es) => {
const data = Array . isArray ( res . data ) ? res . data : res . data ? . list
historyDialog . rows = Array . isArray ( data ) ? data . map ( ( item ) => mapHistory ( item as Rec ord < string , unknown > ) ) : [ ]
} )
. finally ( ( ) => {
historyDialog . loading = false
} )
}
// 🌟 核心修改点:在实例化之前强行改掉 API 的 index 映射路径
const myTableApi = new baTableApi ( url )
myTableApi . actionUrl . set ( 'index' , 'admin/user.MoneyLog/annualReport' )
const baTable = new baTableClass (
myTableApi , // 🌟 传入已经被提前覆写好映射的驱动实例
{
column : [
{ type : 'selection' , align : 'center' , operator : false } ,
{
label : t ( 'user.moneyLog.Created by' ) ,
prop : 'admin.username' ,
align : 'center' ,
width : 70 ,
formatter : ( row : TableRow ) => {
return row . admin ? . username || 'web'
const renderCharts = ( ) => {
if ( depositChartRef . value ) {
depositChart || = echarts . init ( depositChartRef . value )
depositChart . setOption ( {
... chartBaseOption ( ) ,
legend : { top : 10 , data : [ t ( 'user.moneyLog. annualReport.deposit' ) , t ( 'user.moneyLog.annualReport.withdraw' ) ] } ,
seri es : [
{
name : t ( 'user.moneyLog.annual Rep ort.deposit' ) ,
type : 'line' ,
smooth : true ,
symbolSize : 6 ,
data : reportData . deposit ,
itemStyle : { color : '#32b36b' } ,
lineStyle : { width : 2 } ,
areaStyle : { color : 'rgba(50, 179, 107, 0.22)' } ,
} ,
} ,
{ label : t ( 'user.moneyLog.User name' ) , prop : 'user.jk_username' , align : 'center' , operator : 'LIKE' , operatorPlaceholder : t ( 'Fuzzy query' ) } ,
{ label : t ( 'user.moneyLog.Change balance' ) , prop : 'money' , align : 'center' , operator : 'RANGE' , sortable : 'custom' } ,
{ label : t ( 'user.moneyLog.bank_id' ) , prop : 'bank.bank_name' , align : 'center' , operator : 'LIKE' , operatorPlaceholder : t ( 'Fuzzy query' ) } ,
{ label : t ( 'user.moneyLog.Transaction id' ) , prop : 'transaction_id' , align : 'center' , operator : 'RANGE' } ,
{
label : t ( 'user.moneyLog.Game Ticket' ) ,
prop : 'scoreLog' ,
align : 'center' ,
formatter : ( row : TableRow ) => {
if ( ! row . scoreLog || row . scoreLog . length === 0 ) return '-'
return row . scoreLog
. map ( ( item : any ) => {
const gameName = gameTypeMap [ String ( item . game _type ) ] || item . game _type
return ` ${ gameName } : ${ item . score } `
} )
. join ( ' | ' )
{
name : t ( 'user.moneyLog.annualReport.withdraw' ) ,
type : 'line' ,
smooth : true ,
symbolSize : 6 ,
data : reportData . withdraw ,
itemStyle : { color : '#ef5b5b' } ,
lineStyle : { width : 2 } ,
areaStyle : { color : 'rgba(239, 91, 91, 0.18)' } ,
} ,
} ,
{
label : t ( 'user.moneyLog.type' ) ,
prop : 'type' ,
align : 'center' ,
operator : 'eq' ,
sortable : false ,
render : 'tag' ,
custom : {
'1' : 'success' ,
'2' : 'danger' ,
'3' : 'success' ,
'4' : 'danger' ,
} ,
replaceValue : { ... tm ( 'user.moneyLog.type_list' ) } ,
} ,
{
label : t ( 'user.moneyLog.remarks' ) ,
prop : 'memo' ,
align : 'center' ,
operator : 'LIKE' ,
operatorPlaceholder : t ( 'Fuzzy query' ) ,
showOverflowTooltip : true ,
} ,
{ label : t ( 'Create time' ) , prop : 'create_time' , align : 'center' , render : 'datetime' , sortable : 'custom' , operator : 'RANGE' , width : 160 } ,
{ label : t ( 'Operate' ) , align : 'center' , width : '130' , render : 'buttons' , buttons : optButtons } ,
] ,
dblClickNotEditColumn : [ 'all' ] ,
} ,
{
defaultItems : {
user _id : defalutUser ,
memo : '' ,
} ,
}
)
// 表单提交后
baTable . after . onSubmit = ( ) => {
getUserInfo ( baTable . comSearch . form . user _id )
}
baTable . after . onTableHeaderAction = ( { event } ) => {
if ( event == 'refresh' ) {
getUserInfo ( baTable . comSearch . form . user _id )
}
}
baTable . before . onTableAction = ( { event } ) => {
// 公共搜索逻辑
if ( event === 'com-search' ) {
baTable . table . filter ! . search = baTable . getComSearchData ( )
for ( const key in baTable . table . filter ! . search ) {
if ( [ 'money' , 'before' , 'after' ] . includes ( baTable . table . filter ! . search [ key ] . field ) ) {
const val = ( baTable . table . filter ! . search [ key ] . val as string ) . split ( ',' )
const newVal : ( string | number ) [ ] = [ ]
for ( const k in val ) {
newVal . push ( isNaN ( parseFloat ( val [ k ] ) ) ? '' : parseFloat ( val [ k ] ) * 100 )
}
baTable . table . filter ! . search [ key ] . val = newVal . join ( ',' )
}
}
baTable . onTableHeaderAction ( 'refresh' , { event : 'com-search' , data : baTable . table . filter ! . search } )
return false
}
}
// 如果年报接口返回了特殊的聚合统计数据(如 search_result) , 在这里捕获存进表格
baTable . after . onTableAction = ( { event , res } ) => {
if ( event === 'get-data' && res && res . data ) {
if ( res . data . search _result ) {
baTable . table . extend = {
... baTable . table . extend ,
searchResult : res . data . search _result
}
}
}
}
baTable . mount ( )
baTable . getData ( )
provide ( 'baTable' , baTable )
const getUserInfo = debounce ( ( userId : string ) => {
if ( userId && parseInt ( userId ) > 0 ) {
add ( userId ) . then ( ( res ) => {
state . userInfo = res . data . user
] ,
} )
} else {
state . userInfo = { }
}
} , 300 )
getUserInfo ( baTable . comSearch . form . user _id )
watch (
( ) => baTable . comSearch . form . user _id ,
( newVal ) => {
baTable . form . defaultItems ! . user _id = newVal
getUserInfo ( newVal )
if ( transactionChartRef . value ) {
transactionChart || = echarts . init ( transactionChartRef . value )
transactionChart . setOption ( {
... chartBaseOption ( ) ,
xAxis : { ... ( chartBaseOption ( ) . xAxis as object ) , boundaryGap : true } ,
legend : {
top : 10 ,
data : [
t ( 'user.moneyLog.annualReport.activePlayer' ) ,
t ( 'user.moneyLog.annualReport.transactionDeposit' ) ,
t ( 'user.moneyLog.annualReport.transactionWithdraw' ) ,
] ,
} ,
series : [
{
name : t ( 'user.moneyLog.annualReport.activePlayer' ) ,
type : 'bar' ,
data : reportData . activePlayer ,
itemStyle : { color : '#4f8edc' } ,
} ,
{
name : t ( 'user.moneyLog.annualReport.transactionDeposit' ) ,
type : 'bar' ,
data : reportData . transactionDeposit ,
itemStyle : { color : '#32b36b' } ,
} ,
{
name : t ( 'user.moneyLog.annualReport.transactionWithdraw' ) ,
type : 'bar' ,
data : reportData . transactionWithdraw ,
itemStyle : { color : '#f2b441' } ,
} ,
] ,
} )
}
)
}
const loadReport = async ( ) => {
loading . value = true
try {
const response = await annualReport ( { year : selectedYear . value } )
const normalized = normalizeReport ( response . data )
Object . assign ( reportData , normalized . data )
Object . assign ( reportTotals , normalized . totals )
await nextTick ( )
renderCharts ( )
} finally {
loading . value = false
}
}
const resizeCharts = ( ) => {
depositChart ? . resize ( )
transactionChart ? . resize ( )
}
watch ( locale , async ( ) => {
await nextTick ( )
renderCharts ( )
} )
onMounted ( ( ) => {
window . addEventListener ( 'resize' , resizeCharts )
loadReport ( )
} )
onBeforeUnmount ( ( ) => {
window . removeEventListener ( 'resize' , resizeCharts )
depositChart ? . dispose ( )
transactionChart ? . dispose ( )
} )
< / script >
< style scoped lang = "scss" > < / style >
< style scoped lang = "scss" >
. annual - report {
padding : 22 px ;
color : # 333 ;
}
. report - heading {
display : flex ;
align - items : center ;
justify - content : space - between ;
margin - bottom : 18 px ;
h1 {
margin : 0 ;
font - size : 22 px ;
font - weight : 600 ;
letter - spacing : 0.3 px ;
}
}
. year - select {
width : 120 px ;
}
. report - content {
min - height : 420 px ;
}
. report - table - wrap {
overflow - x : auto ;
margin - bottom : 18 px ;
background : # fff ;
}
. report - table {
width : 100 % ;
min - width : 1080 px ;
border - collapse : collapse ;
table - layout : fixed ;
font - size : 12 px ;
th ,
td {
height : 39 px ;
padding : 5 px 7 px ;
border : 1 px solid # dedede ;
text - align : center ;
white - space : nowrap ;
}
thead th {
background : # f7f7f7 ;
color : # 555 ;
font - weight : 600 ;
}
thead th : first - child {
width : 178 px ;
}
tbody th {
background : # fafafa ;
text - align : left ;
font - weight : 600 ;
line - height : 18 px ;
white - space : normal ;
overflow - wrap : anywhere ;
word - break : break - word ;
}
. year - total {
background : # fafafa ;
font - weight : 600 ;
}
}
. chart - card {
margin - bottom : 18 px ;
border : 1 px solid # dedede ;
background : # fff ;
h2 {
margin : 0 ;
padding : 12 px 16 px ;
border - bottom : 1 px solid # e5e5e5 ;
font - size : 14 px ;
font - weight : 600 ;
}
}
. report - chart {
width : 100 % ;
height : 300 px ;
}
@ media ( max - width : 768 px ) {
. annual - report {
padding : 14 px ;
}
. report - heading h1 {
font - size : 18 px ;
}
. report - chart {
height : 260 px ;
}
}
< / style >