72 lines
15 KiB
Vue
72 lines
15 KiB
Vue
<template>
|
||
<div class="table-workspace">
|
||
<div class="tw-stats">
|
||
<div v-for="stat in stats" :key="stat.label" class="tw-stat"><div class="tw-label">{{ stat.label }}</div><div class="tw-value" :class="stat.className">{{ stat.value }}</div><div class="tw-note">{{ stat.note }}</div></div>
|
||
</div>
|
||
<form class="tw-filter" @submit.prevent="applyFilters">
|
||
<label>店铺<input v-model.trim="filters.store_name" placeholder="店铺名称" /></label>
|
||
<label>账单项目<input v-model.trim="filters.bill_item" placeholder="账单项目" /></label>
|
||
<label class="tw-date">发生时间<span><input v-model="filters.start" type="date" aria-label="开始日期" /><b>至</b><input v-model="filters.end" type="date" aria-label="结束日期" /></span></label>
|
||
<label class="tw-keyword">关键词(记录编码 / 订单 / 备注)<input v-model.trim="filters.keyword" placeholder="输入关键词" /></label>
|
||
<div class="tw-actions"><button class="tw-button tw-primary" type="submit">查询</button><button class="tw-button" type="button" @click="resetFilters">重置</button></div>
|
||
</form>
|
||
<section class="tw-panel" :aria-label="`${columnsTitle || '账户'}账单明细`">
|
||
<div v-if="loadError" class="tw-alert" role="alert"><span>账单加载失败,请重试</span><button type="button" @click="fetch">重新加载</button></div>
|
||
<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>
|
||
<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>
|
||
<div ref="scroll" class="tw-scroll">
|
||
<table v-if="!loading && !loadError && configuredColumns.length" class="tw-table">
|
||
<thead>
|
||
<tr v-if="visibleGroups.length" class="tw-groups"><th v-for="(group,index) in visibleGroups" :key="`${group.label}-${index}`" :colspan="group.span" scope="colgroup">{{ group.label }}</th></tr>
|
||
<tr class="tw-columns" :class="{ 'is-single': !visibleGroups.length }"><th v-for="column in visibleColumns" :key="column.key" :class="{ 'is-number': isNumber(column.kind), 'is-sortable': sortableField(column.field) }" scope="col" @click="maybeSort(column)">{{ column.label }} <span v-if="sortableField(column.field)" class="tw-sort" aria-hidden="true">{{ sortMark(column.field) }}</span></th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="row in rows" :key="row.id"><td v-for="column in visibleColumns" :key="column.key" :class="cellClass(column)"><span v-if="column.kind === 'tag'" class="tw-tag">{{ displayValue(row,column) }}</span><template v-else>{{ displayValue(row,column) }}</template></td></tr>
|
||
<tr v-if="!rows.length"><td :colspan="visibleColumns.length" class="tw-empty">没有符合条件的账单记录</td></tr>
|
||
</tbody>
|
||
</table>
|
||
<div v-else-if="loading" class="tw-empty" role="status">加载中…</div><div v-else-if="!loadError" class="tw-empty">暂无账单字段</div>
|
||
</div>
|
||
<div v-if="rows.length" class="tw-pager"><div>第 {{ rangeStart }} - {{ rangeEnd }} 条 / 共 {{ total }} 条</div><div class="tw-page-actions"><label>每页 <select v-model.number="perPage" @change="changePage(1)"><option :value="10">10</option><option :value="20">20</option><option :value="50">50</option></select></label><button type="button" :disabled="currentPage<=1" title="第一页" @click="changePage(1)">«</button><button type="button" :disabled="currentPage<=1" title="上一页" @click="changePage(currentPage-1)">‹</button><span>{{ currentPage }} / {{ lastPage }}</span><button type="button" :disabled="currentPage>=lastPage" title="下一页" @click="changePage(currentPage+1)">›</button><button type="button" :disabled="currentPage>=lastPage" title="最后一页" @click="changePage(lastPage)">»</button></div></div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import TableColumnSettings from './TableColumnSettings.vue';
|
||
const SORTABLE = ['occurred_at','income','expense','balance','record_code'];
|
||
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,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:''},sort:{key:'occurred_at',dir:'desc'},exportMode:'filtered-visible'};},
|
||
computed:{
|
||
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:'期末余额(最新)',value:this.money(this.summary.balance),note:'最新一条记录',className:''},{label:'交易笔数',value:this.summary.count,note:'当前筛选结果',className:''}];},
|
||
rangeStart(){return this.total===0?0:(this.currentPage-1)*this.perPage+1;},rangeEnd(){return Math.min(this.currentPage*this.perPage,this.total);}
|
||
},
|
||
mounted(){this.fetch();},
|
||
methods:{
|
||
async fetch(){this.loading=true;this.loadError=false;try{const response=await window.axios.get(this.apiBase,{params:{...this.filters,sort:this.sort.key,dir:this.sort.dir,limit:this.perPage,page:this.currentPage}});const body=response.data||{};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){this.rows=[];this.total=0;this.summary={income:0,expense:0,balance:0,count:0};this.loadError=true;}finally{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(){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;});},
|
||
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.filters);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,'""')}"`;}
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
.table-workspace{padding:1rem;color:#171717;font:400 .8125rem/1.5 "JetBrains Mono","Microsoft YaHei","PingFang SC",monospace}.tw-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.75rem;margin-bottom:.75rem}.tw-stat{padding:.875rem 1rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-label,.tw-note,.tw-count{color:#595959;font-size:.75rem}.tw-value{margin-top:.375rem;font-size:1.375rem;font-weight:700;font-variant-numeric:tabular-nums}.tw-note{margin-top:.1875rem}.is-income{color:#53793e}.is-expense{color:#b80000}.tw-filter{display:flex;flex-wrap:wrap;gap:.75rem;margin-bottom:.75rem;padding:.875rem 1rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-filter>label{display:flex;flex-direction:column;gap:.25rem;color:#595959;font-size:.75rem;font-weight:500}.tw-filter input,.tw-tools select{height:2.125rem;min-width:10rem;padding:0 .625rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff;color:#171717;font:400 .8125rem/1 inherit}.tw-filter input:focus,.tw-tools select:focus{border-color:#856632;box-shadow:0 0 0 3px rgba(133,102,50,.2);outline:none}.tw-filter .tw-keyword{flex:1;min-width:14rem}.tw-keyword input{width:100%}.tw-date span,.tw-actions,.tw-tools,.tw-page-actions{display:flex;align-items:center;gap:.5rem}.tw-date b{font-weight:400}.tw-date input{min-width:8.5rem}.tw-actions{align-self:flex-end;margin-left:auto}.tw-button,.tw-page-actions button{display:inline-flex;align-items:center;justify-content:center;gap:.375rem;height:2.125rem;padding:0 .875rem;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff;color:#171717;font:500 .8125rem/1 inherit;cursor:pointer}.tw-button:hover{border-color:#856632;color:#856632}.tw-primary{border-color:#856632;background:#856632;color:#fff}.tw-primary:hover{background:#6a5129;color:#fff}.tw-button .material-icons-outlined{font-size:1.125rem}.tw-panel{overflow:visible;border:1px solid #d8d2c7;border-radius:.5rem;background:#fff}.tw-alert{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin:.75rem;padding:.75rem;border:1px solid #b80000;border-radius:.5rem;background:#fff4f4;color:#b80000}.tw-alert button{border:0;background:transparent;color:#b80000;font:inherit;font-weight:600;cursor:pointer}.tw-toolbar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;min-height:3.5rem;padding:.625rem .875rem;border-bottom:1px solid #d8d2c7}.tw-toolbar strong{margin-right:.625rem;font-size:.875rem}.tw-tools{flex-wrap:wrap;justify-content:flex-end}.tw-tools>select{min-width:13.75rem}.tw-scroll{position:relative;max-height:min(62vh,42rem);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.tw-table{width:max-content;min-width:100%;border-collapse:separate;border-spacing:0;font-size:.78125rem}.tw-table th,.tw-table td{min-width:7.5rem;padding:.5rem .625rem;border-right:1px solid #eeeae2;border-bottom:1px solid #eeeae2;white-space:nowrap}.tw-table thead th{position:sticky;z-index:4;background:#f7f0de;color:#171717;font-weight:600}.tw-groups th{top:0;height:2rem;text-align:center;color:#856632;font-size:.75rem}.tw-columns th{top:2rem;height:2.25rem;text-align:left;background:#fbfaf7;box-shadow:inset 0 -1px #d8d2c7}.tw-columns.is-single th{top:0}.tw-columns th.is-sortable{cursor:pointer}.tw-columns th.is-sortable:hover,.tw-sort{color:#856632}.tw-table tbody tr:hover{background:#f7f0de}.tw-table .is-number{text-align:right;font-variant-numeric:tabular-nums}.tw-table .is-id{color:#595959}.tw-tag{display:inline-block;padding:.0625rem .375rem;border-radius:.25rem;background:#f7f0de;color:#856632}.tw-empty{padding:3rem 1rem!important;text-align:center!important;color:#595959}.tw-pager{display:flex;align-items:center;justify-content:space-between;gap:.75rem;min-height:3.5rem;padding:.625rem .875rem;border-top:1px solid #d8d2c7;color:#595959}.tw-page-actions select{height:1.875rem;border:1px solid #d8d2c7;border-radius:.375rem}.tw-page-actions button{width:1.875rem;height:1.875rem;padding:0}.tw-page-actions button:disabled{cursor:not-allowed;opacity:.45}@media(max-width:900px){.table-workspace{padding:.75rem}.tw-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.tw-filter>label,.tw-filter .tw-keyword{width:100%}.tw-filter input{width:100%}.tw-date input{min-width:0}.tw-actions{margin-left:0}.tw-toolbar,.tw-pager{align-items:flex-start;flex-direction:column}.tw-tools{justify-content:flex-start}.tw-scroll{max-height:58vh;width:100%}}@media(max-width:520px){.tw-stats{grid-template-columns:1fr}.tw-date span{align-items:stretch;flex-direction:column}.tw-tools>select{min-width:0;max-width:100%}.tw-page-actions{flex-wrap:wrap}}
|
||
.tw-toolbar { flex-wrap: wrap; }
|
||
.tw-tools { flex: 1 1 24rem; min-width: 0; }
|
||
</style>
|