@ -5,23 +5,23 @@
< / div >
< form class = "tw-filter" @submit.prevent ="applyFilters" >
< label > 店铺
< select v-if = "usesMirrorOptions && !filterOptionsError" v-model="filters.store_name" aria-label="店铺筛选" :aria-busy="filterOptionsLoading ? 'true' : 'false'" @change="applyFilters" >
< option value = "" > { { filterOptionsLoading ? '店铺加载中…' : '全部店铺' } } < / option >
< option v-for ="shop in filterOptions.shops" :key="shop" :value ="shop" > {{ shop }} < / option >
< / select >
< el-select v-if = "usesMirrorOptions && !filterOptionsError" v-model="filters.store_name" filterable clearable :loading="filterOptionsLoading" placeholder="全部店铺" aria-label="店铺筛选" popper-class="account-bill-filter-options" no-match-text="没有匹配的店铺" no-data-text="暂无店铺" @change="applyFilters" >
< el-option v-for = "shop in filterOptions.shops" :key="shop" :label="shop" :value="shop" / >
< / el-select >
< input v -else v -model .trim = " filters.store_name " placeholder = "店铺名称" / >
< / label >
< label > 账单项目
< select v-if = "usesMirrorOptions && !filterOptionsError" v-model="filters.bill_item" aria-label="账单项目筛选" :aria-busy="filterOptionsLoading ? 'true' : 'false'" @change="applyFilters" >
< option value = "" > { { filterOptionsLoading ? '账单项目加载中…' : '全部账单项目' } } < / option >
< option v-for ="item in filterOptions.bill_items" :key="item" :value ="item" > {{ item }} < / option >
< / select >
< el-select v-if = "usesMirrorOptions && !filterOptionsError" v-model="filters.bill_item" filterable clearable :loading="filterOptionsLoading" placeholder="全部账单项目" aria-label="账单项目筛选" popper-class="account-bill-filter-options" no-match-text="没有匹配的账单项目" no-data-text="暂无账单项目" @change="applyFilters" >
< el-option v-for = "item in filterOptions.bill_items" :key="item" :label="item" :value="item" / >
< / el-select >
< input v -else v -model .trim = " filters.bill_item " placeholder = "账单项目" / >
< / label >
< div class = "tw-filter-field tw-date" > < span class = "tw-filter-label" > { { [ 'jd-transactions' , 'jd-wallet' ] . includes ( billType ) ? '费用结算时间' : '发生时间' } } < / span > < div class = "tw-date-row" > < span class = "tw-date-control" : class = "{ 'has-picker': isDouyin }" > < input ref = "startDate" v-model = "filters.start" type="date" aria-label="开始日期" :max="filters.end || undefined" :aria-invalid="invalidDateRange ? 'true' : 'false'" @change="applyFilters" /><button v-if="isDouyin" type="button" class="tw-date-picker" title="选择开始日期" aria-label="选择开始日期" @click="openDatePicker('startDate')"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button></span><b>至</b><span class="tw-date-control" :class="{ 'has-picker': isDouyin }"><input ref="endDate" v-model="filters.end" type="date" aria-label="结束日期" :min="filters.start || undefined" :aria-invalid="invalidDateRange ? 'true' : 'false'" @change="applyFilters" /><button v-if="isDouyin" type="button" class="tw-date-picker" title="选择结束日期" aria-label="选择结束日期" @click="openDatePicker('endDate')"><span class="material-icons-outlined" aria-hidden="true" > calendar_today < / span > < / button > < / span > < / div > < / div >
< label class = "tw-keyword" > 关键词 ( 记录编码 / 订单 / 备注 ) < input v -model .trim = " filters.keyword " placeholder = "输入关键词" / > < / label >
< label class = "tw-keyword" > 关键词 ( 记录编码 / 订单 / 备注 ) < span class = "tw-keyword-controls" > < select v-if = "isDouyin" v-model="filters.keyword_match" aria-label="关键词匹配方式" @change="applyFilters"><option value="exact-record">记录编码精确匹配</option><option value="contains">综合关键词包含匹配</option></select><input v-model.trim="filters.keyword" :placeholder="isDouyin && filters.keyword_match === 'exact-record' ? '输入完整记录编码' : '输入关键词'" aria-label="关键词" / > < / span > < / label >
< div class = "tw-actions" > < button class = "tw-button tw-primary" type = "submit" :disabled = "invalidDateRange" > { { loading ? '查询中…' : '查询' } } < / button > < button class = "tw-button" type = "button" @click ="resetFilters" > 重置 < / button > < / div >
< p v-if = "invalidDateRange" class="tw-filter-error" role="alert" > 结束日期不能早于开始日期 < / p >
< p v-if = "filterOptionsError" class="tw-filter-error" role="alert">筛选选项加载失败,可输入名称查询。<button type="button" class="tw-button" @click="fetchFilterOptions" > 重试选项 < / button > < / p >
< p v-if = "filtersDirty && !loading" class="tw-note" role="status" > 筛选条件已修改 , 查询后可导出对应结果 。 < / p >
< / form >
< section class = "tw-panel" : aria -label = " ` $ { columnsTitle | | ' 账户 ' } 账单明细 ` " >
< div v-if = "['jd-transactions','jd-wallet'].includes(billType) && sync" class="tw-note" > 来源 : {{ sync.source }} · 已接入月份 : {{ ( sync.periods | | [ ] ) .join ( ' 、 ' ) }} · 导入时间 : {{ sync.last_succeeded_at }} < / div >
@ -29,8 +29,8 @@
< div class = "tw-toolbar" >
< div > < strong > { { sourceGroups . length ? '账单明细' : columnsTitle } } < / strong > < span v-if = "!loading" class="tw-count" > {{ total }} 条 < / span > < / div >
< div class = "tw-tools" v-if = "sourceColumns.length" >
< select v-model = "exportMode" title="选择导出范围" aria-label="导出范围"><option value="page-visible">当前页 · 当前显示列</option><option value="filtered-visible">全部筛选结果 · 当前显示列</option><option value="filtered-all"> 全部筛选结果 · 全部字段 < / option > < / select >
< button type = "button" class = "tw-button" @click="exportRows" > < span class = "material-icons-outlined" aria -hidden = " true " > download < / span > 导出< / button >
< select v-model = "exportMode" title="选择导出范围" aria-label="导出范围"><option value="page-visible">当前页 ( {{ rows.length }} 条) · 当前显示列</option><option value="filtered-visible">全部筛选结果( {{ total }} 条) · 当前显示列</option><option value="filtered-all"> 全部筛选结果 ( {{ total }} 条 ) · 全部字段 < / option > < / select >
< button type = "button" class = "tw-button" :disabled= "!canExport" @click="exportRows" > < span class = "material-icons-outlined" aria -hidden = " true " > download < / span > { { exportMode === 'page-visible' ? ' 导出当前页' : '导出全部筛选结果' } } < / button >
< table-column-settings :columns = "sourceColumns" :user-key = "userKey" page -key = " store -receiving -bills " :tab-key = "billType" table -key = " bill -details " @ change = "configuredColumns = $event" / >
< / div >
< / div >
@ -54,14 +54,18 @@
< script >
import TableColumnSettings from './TableColumnSettings.vue' ;
import { Select , Option } from 'element-ui' ;
const SORTABLE = [ 'occurred_at' , 'income' , 'expense' , 'balance' , 'record_code' ] ;
const emptyFilters = billType => ( { store _name : '' , bill _item : '' , start : '' , end : '' , keyword : '' , keyword _match : billType === 'douyin' ? 'exact-record' : 'contains' } ) ;
export default {
name : 'AccountBills' , components : { TableColumnSettings } , props : { apiBase : { type : String , required : true } , billType : { type : String , required : true } , userKey : { type : [ String , Number ] , required : true } } ,
data ( ) { return { loading : false , loadError : false , sync : null , rows : [ ] , sourceColumns : [ ] , configuredColumns : [ ] , columnsTitle : '' , sourceGroups : [ ] , summary : { income : 0 , expense : 0 , balance : 0 , count : 0 } , total : 0 , currentPage : 1 , lastPage : 1 , perPage : 20 , filters : { store _name : '' , bill _item : '' , start : '' , end : '' , keyword : '' } , filterOptions : { shops : [ ] , bill _items : [ ] } , filterOptionsLoading : false , filterOptionsError : false , requestSerial : 0 , sort : { key : 'occurred_at' , dir : 'desc' } , exportMode : 'filtered-visible' } ; } ,
name : 'AccountBills' , components : { TableColumnSettings , [ Select . name ] : Select , [ Option . name ] : Option } , props : { apiBase : { type : String , required : true } , billType : { type : String , required : true } , userKey : { type : [ String , Number ] , required : true } } ,
data ( ) { return { loading : false , loadError : false , sync : null , rows : [ ] , sourceColumns : [ ] , configuredColumns : [ ] , columnsTitle : '' , sourceGroups : [ ] , summary : { income : 0 , expense : 0 , balance : 0 , count : 0 } , total : 0 , currentPage : 1 , lastPage : 1 , perPage : 20 , filters : emptyFilters ( this . billType ) , queryFilters : emptyFilters ( this . billType ) , resultFilters : null , filterOptions : { shops : [ ] , bill _items : [ ] } , filterOptionsLoading : false , filterOptionsError : false , requestSerial : 0 , sort : { key : 'occurred_at' , dir : 'desc' } , exportMode : 'filtered-visible' } ; } ,
computed : {
isDouyin ( ) { return this . billType === 'douyin' ; } ,
usesMirrorOptions ( ) { return [ 'douyin' , 'pinduoduo' , 'dewu' , 'jd-transactions' , 'jd-wallet' ] . indexOf ( this . billType ) !== - 1 ; } ,
invalidDateRange ( ) { return Boolean ( this . filters . start && this . filters . end && this . filters . start > this . filters . end ) ; } ,
filtersDirty ( ) { return ! this . resultFilters || Object . keys ( this . filters ) . some ( key => this . filters [ key ] !== this . resultFilters [ key ] ) ; } ,
canExport ( ) { return ! this . loading && ! this . loadError && ! this . invalidDateRange && ! this . filtersDirty && this . total > 0 && ( this . exportMode === 'filtered-all' || this . visibleColumns . length > 0 ) ; } ,
visibleColumns ( ) { return this . configuredColumns . filter ( column => column . visible ) ; } ,
visibleGroups ( ) { return this . visibleColumns . reduce ( ( groups , column ) => { if ( ! column . group ) return groups ; const last = groups [ groups . length - 1 ] ; if ( last && last . label === column . group ) last . span += 1 ; else groups . push ( { label : column . group , span : 1 } ) ; return groups ; } , [ ] ) ; } ,
stats ( ) { return [ { label : '收入合计' , value : this . money ( this . summary . income ) , note : ` ${ this . summary . count } 笔交易 ` , className : 'is-income' } , { label : '支出合计' , value : this . money ( this . summary . expense ) , note : '按当前筛选' , className : 'is-expense' } , { label : this . billType === 'jd-transactions' ? '收支净额' : ( this . billType === 'jd-wallet' ? '账户余额合计(最新)' : '期末余额(最新)' ) , value : this . money ( this . summary . balance ) , note : this . billType === 'jd-transactions' ? '按当前筛选' : ( this . billType === 'jd-wallet' ? '筛选结果中每个店铺账户最新一笔' : '最新一条记录' ) , className : '' } , { label : '交易笔数' , value : this . summary . count , note : '当前筛选结果' , className : '' } ] ; } ,
@ -70,14 +74,14 @@ export default {
mounted ( ) { if ( this . usesMirrorOptions ) this . fetchFilterOptions ( ) ; this . fetch ( ) ; } ,
methods : {
async fetchFilterOptions ( ) { this . filterOptionsLoading = true ; this . filterOptionsError = false ; try { const response = await window . axios . get ( ` ${ this . apiBase } /filter-options ` ) ; const body = response . data || { } ; this . filterOptions = { shops : Array . isArray ( body . shops ) ? body . shops : [ ] , bill _items : Array . isArray ( body . bill _items ) ? body . bill _items : [ ] } ; } catch ( error ) { this . filterOptionsError = true ; } finally { this . filterOptionsLoading = false ; } } ,
async fetch ( ) { const requestId = ++ this . requestSerial ; this . loading = true ; this . loadError = false ; try { const response = await window . axios . get ( this . apiBase , { params : { ... this . f ilters, sort : this . sort . key , dir : this . sort . dir , limit : this . perPage , page : this . currentPage } } ) ; if ( requestId !== this . requestSerial ) return ; const body = response . data || { } ; this . sync = body . sync || null ; this . rows = body . data || [ ] ; this . total = body . total || 0 ; this . currentPage = body . current _page || 1 ; this . lastPage = body . last _page || 1 ; this . perPage = body . per _page || this . perPage ; this . summary = body . summary || { income : 0 , expense : 0 , balance : 0 , count : 0 } ; if ( body . columns ) { this . columnsTitle = body . columns . title || '' ; this . sourceGroups = body . columns . groups || [ ] ; this . sourceColumns = this . attachGroups ( body . columns . columns || body . columns || [ ] , this . sourceGroups ) ; } } catch ( error ) { if ( requestId !== this . requestSerial ) return ; this . rows = [ ] ; this . total = 0 ; this . sync = null ; this . summary = { income : 0 , expense : 0 , balance : 0 , count : 0 } ; this . loadError = true ; } finally { if ( requestId === this . requestSerial ) this . loading = false ; } } ,
async fetch ( ) { const requestId = ++ this . requestSerial ; const requestedFilters = { ... this . queryFilters } ; this . loading = true ; this . loadError = false ; try { const response = await window . axios . get ( this . apiBase , { params : { ... requestedF ilters, sort : this . sort . key , dir : this . sort . dir , limit : this . perPage , page : this . currentPage } } ) ; if ( requestId !== this . requestSerial ) return ; const body = response . data || { } ; this . resultFilters = requestedFilters ; this . sync = body . sync || null ; this . rows = body . data || [ ] ; this . total = body . total || 0 ; this . currentPage = body . current _page || 1 ; this . lastPage = body . last _page || 1 ; this . perPage = body . per _page || this . perPage ; this . summary = body . summary || { income : 0 , expense : 0 , balance : 0 , count : 0 } ; if ( body . columns ) { this . columnsTitle = body . columns . title || '' ; this . sourceGroups = body . columns . groups || [ ] ; this . sourceColumns = this . attachGroups ( body . columns . columns || body . columns || [ ] , this . sourceGroups ) ; } } catch ( error ) { if ( requestId !== this . requestSerial ) return ; this . rows = [ ] ; this . total = 0 ; this . sync = null ; this . summary = { income : 0 , expense : 0 , balance : 0 , count : 0 } ; this . loadError = true ; } finally { if ( requestId === this . requestSerial ) this . loading = false ; } } ,
attachGroups ( columns , groups ) { let index = 0 ; return columns . map ( ( column , columnIndex ) => { while ( groups [ index ] && columnIndex >= groups . slice ( 0 , index + 1 ) . reduce ( ( sum , group ) => sum + Number ( group . span || 0 ) , 0 ) ) index += 1 ; return { ... column , key : column . field || ` column- ${ columnIndex } ` , group : groups [ index ] ? groups [ index ] . label : '' } ; } ) ; } ,
applyFilters ( ) { if ( this . invalidDateRange ) return ; this . currentPage= 1 ; this . fetch ( ) ; } , resetFilters ( ) { this . filters = { store _name : '' , bill _item : '' , start : '' , end : '' , keyword : '' } ; this . applyFilters ( ) ; } , changePage ( page ) { this . currentPage = page ; this . fetch ( ) ; this . $nextTick ( ( ) => { if ( this . $refs . scroll ) this . $refs . scroll . scrollTop = 0 ; } ) ; } ,
applyFilters ( ) { if ( this . invalidDateRange ) return ; this . queryFilters= { ... this . filters } ; this . currentPage= 1 ; return this . fetch ( ) ; } , resetFilters ( ) { this . filters = emptyFilters ( this . billType ) ; return this . applyFilters ( ) ; } , changePage ( page ) { this . currentPage = page ; this . fetch ( ) ; this . $nextTick ( ( ) => { if ( this . $refs . scroll ) this . $refs . scroll . scrollTop = 0 ; } ) ; } ,
openDatePicker ( refName ) { const input = this . $refs [ refName ] ; if ( ! input ) return ; input . focus ( ) ; if ( typeof input . showPicker === 'function' ) { try { input . showPicker ( ) ; return ; } catch ( error ) { /* Fall through for embedded browsers without picker support. */ } } input . click ( ) ; } ,
maybeSort ( column ) { if ( ! this . sortableField ( column . field ) ) return ; this . sort . dir = this . sort . key === column . field && this . sort . dir === 'desc' ? 'asc' : 'desc' ; this . sort . key = column . field ; this . applyFilters ( ) ; } , sortableField ( field ) { return SORTABLE . indexOf ( field ) !== - 1 ; } , sortMark ( field ) { return this . sort . key === field ? ( this . sort . dir === 'asc' ? '↑' : '↓' ) : '↕' ; } ,
getValue ( row , field ) { if ( ! field ) return '' ; if ( field . indexOf ( 'payload.' ) === 0 ) { const payload = row . payload || { } ; const value = payload [ field . slice ( 8 ) ] ; return value === null || value === undefined ? '' : value ; } const value = row [ field ] ; return value === null || value === undefined ? '' : value ; } , rawValue ( row , column ) { return this . getValue ( row , column . field ) ; } ,
displayValue ( row , column ) { const value = this . rawValue ( row , column ) ; if ( value === '' ) return '—' ; if ( column . kind === 'money_in' ) return Number ( value ) > 0 ? ` + ${ this . money ( value ) } ` : '—' ; if ( column . kind === 'money_out' ) return Number ( value ) > 0 ? ` - ${ this . money ( value ) } ` : '—' ; if ( [ 'balance' , 'money' ] . indexOf ( column . kind ) !== - 1 ) return this . money ( value ) ; return value ; } , cellClass ( column ) { return { 'is-number' : this . isNumber ( column . kind ) , 'is-id' : column . kind === 'num' , 'is-income' : column . kind === 'money_in' , 'is-expense' : column . kind === 'money_out' } ; } , isNumber ( kind ) { return [ 'money' , 'money_in' , 'money_out' , 'balance' , 'num' ] . indexOf ( kind ) !== - 1 ; } , money ( value ) { return ` ¥ ${ Number ( value || 0 ) . toLocaleString ( 'zh-CN' , { minimumFractionDigits : 2 , maximumFractionDigits : 2 } )} ` ; } ,
exportRows ( ) { if ( this . exportMode === 'page-visible' ) { this . downloadCurrentPage ( ) ; return ; } const params = new URLSearchParams ( this . f ilters) ; params . set ( 'export' , '1' ) ; params . set ( 'sort' , this . sort . key ) ; params . set ( 'dir' , this . sort . dir ) ; if ( this . exportMode === 'filtered-visible' ) this . visibleColumns . forEach ( column => params . append ( 'columns[]' , column . field ) ) ; window . location . assign ( ` ${ this . apiBase } ? ${ params . toString ( ) } ` ) ; } ,
exportRows ( ) { if ( ! this . canExport ) return ; if ( this . exportMode === 'page-visible' ) { this . downloadCurrentPage ( ) ; return ; } const params = new URLSearchParams ( this . resultF ilters) ; params . set ( 'export' , '1' ) ; params . set ( 'sort' , this . sort . key ) ; params . set ( 'dir' , this . sort . dir ) ; if ( this . exportMode === 'filtered-visible' ) this . visibleColumns . forEach ( column => params . append ( 'columns[]' , column . field ) ) ; window . location . assign ( ` ${ this . apiBase } ? ${ params . toString ( ) } ` ) ; } ,
downloadCurrentPage ( ) { const csvRows = [ this . visibleColumns . map ( column => column . label ) , ... this . rows . map ( row => this . visibleColumns . map ( column => this . rawValue ( row , column ) ) ) ] ; const csv = csvRows . map ( values => values . map ( ( value , index ) => this . csvCell ( value , this . visibleColumns [ index ] ) ) . join ( ',' ) ) . join ( '\r\n' ) ; const link = document . createElement ( 'a' ) ; link . href = URL . createObjectURL ( new Blob ( [ '\ufeff' , csv ] , { type : 'text/csv;charset=utf-8' } ) ) ; link . download = ` ${ this . billType } _account_bills_page_ ${ this . currentPage } .csv ` ; link . click ( ) ; URL . revokeObjectURL ( link . href ) ; } , csvCell ( value , column ) { const text = String ( value === null || value === undefined ? '' : value ) ; const safe = column . kind === 'num' && /^(?:0\d|\d{12,}$)/ . test ( text ) ? ` \ t ${ text } ` : ( /^[=+\-@]/ . test ( text ) ? ` ' ${ text } ` : text ) ; return ` " ${ safe . replace ( /"/g , '""' ) } " ` ; }
}
} ;
@ -88,4 +92,20 @@ export default {
. tw - toolbar { flex - wrap : wrap ; }
. tw - tools { flex : 1 1 24 rem ; min - width : 0 ; }
. tw - filter { display : grid ; grid - template - columns : minmax ( 10 rem , 1 fr ) minmax ( 10 rem , 1 fr ) auto minmax ( 14 rem , 2 fr ) auto ; align - items : end } . tw - filter > label , . tw - filter - field { min - width : 0 } . tw - actions { align - self : auto ; justify - self : end ; margin - left : 0 } @ media ( max - width : 900 px ) { . tw - filter { grid - template - columns : 1 fr } . tw - actions { justify - self : start } }
. tw - filter : : v - deep . el - select { width : 100 % ; min - width : 0 }
. tw - filter : : v - deep . el - select . el - input _ _inner { height : 34 px ; min - width : 0 ; padding - right : 30 px ; border - color : # d8d2c7 ; border - radius : 8 px ; color : # 171717 ; font : inherit }
. tw - filter : : v - deep . el - select . el - input . is - focus . el - input _ _inner , . tw - filter : : v - deep . el - select . el - input _ _inner : focus { border - color : # 856632 }
. tw - filter : : v - deep . el - input _ _icon { line - height : 34 px }
. tw - filter > . tw - note , . tw - filter > . tw - filter - error { grid - column : 1 / - 1 ; margin : 0 }
. tw - keyword - controls { display : flex ; flex - wrap : wrap ; gap : 4 px ; min - width : 0 }
. tw - keyword - controls > select { min - width : 0 ; width : 100 % }
. tw - keyword - controls > input { min - width : 0 }
@ media ( max - width : 900 px ) { . tw - tools { flex : 0 1 auto ; width : 100 % } }
< / style >
< style >
. account - bill - filter - options . el - select - dropdown _ _item . selected { color : # 856632 }
. account - bill - filter - options . el - select - dropdown _ _item . hover , . account - bill - filter - options . el - select - dropdown _ _item : hover { background : # f7f0de }
. account - bill - filter - options { max - width : calc ( 100 vw - 32 px ) }
. account - bill - filter - options . el - select - dropdown _ _item { max - width : 100 % ; text - overflow : ellipsis ; overflow : hidden }
< / style >