修复账户账单筛选与导出范围一致性 #204

Merged
qiuxiang merged 1 commits from fix/qiuxiang-account-bill-filter-export into main 2026-09-18 16:56:09 +08:00
6 changed files with 175 additions and 16 deletions

View File

@ -358,6 +358,7 @@ class AccountBills extends Controller
'start' => ['nullable', 'date_format:Y-m-d'],
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
'keyword' => ['nullable', 'string', 'max:255'],
'keyword_match' => ['nullable', 'in:contains,exact-record'],
]);
return array_map(static fn (mixed $value): string => trim((string) $value), $filters);

View File

@ -124,6 +124,15 @@ class DouyinAuthoritativeBillRepository
if (($filters['start'] ?? '') !== '') $query->where($date, '>=', $filters['start'] . ' 00:00:00');
if (($filters['end'] ?? '') !== '') $query->where($date, '<', date('Y-m-d', strtotime($filters['end'] . ' +1 day')) . ' 00:00:00');
if (($filters['keyword'] ?? '') !== '') {
if (($filters['keyword_match'] ?? '') === 'exact-record') {
$keyword = (string) $filters['keyword'];
if ($legacy && (! preg_match('/^(0|[1-9]\d{0,9})$/', $keyword) || (float) $keyword > 2147483647)) {
$query->whereRaw('1 = 0');
} else {
$query->where($legacy ? 'b.Id' : 'record_code', $keyword);
}
return;
}
$value = '%' . $this->escape($filters['keyword']) . '%';
$columns = $legacy ? ['b.Id', 'b.auto_id', 'b.fee_name', 'b.primary_key', 'b.p_auto_id', 'b.so_id', 'b.sku_id', 'b.shop_sku_id', 'b.shop_i_id', 'b.channel']
: ['record_code', 'order_no', 'bill_item', 'row_data'];

View File

@ -393,6 +393,15 @@ class StoreReceivingAlipayBillMirrorRepository
$query->where('b.fee_time', '<', date('Y-m-d', strtotime($filters['end'] . ' +1 day')) . ' 00:00:00');
}
if (($filters['keyword'] ?? '') !== '') {
if (($filters['keyword_match'] ?? '') === 'exact-record') {
$keyword = (string) $filters['keyword'];
if (! preg_match('/^(0|[1-9]\d{0,9})$/', $keyword) || (float) $keyword > 2147483647) {
$query->whereRaw('1 = 0');
} else {
$query->where('b.Id', $keyword);
}
return;
}
$value = '%' . $this->escapeLike((string) $filters['keyword']) . '%';
$query->where(function (Builder $query) use ($value): void {
foreach (['b.Id', 'b.auto_id', 'b.fee_name', 'b.primary_key', 'b.p_auto_id', 'b.so_id', 'b.sku_id', 'b.shop_sku_id', 'b.shop_i_id', 'b.channel'] as $column) {

View File

@ -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.filters,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:{...requestedFilters,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.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()}`);},
exportRows(){if(!this.canExport)return;if(this.exportMode==='page-visible'){this.downloadCurrentPage();return;}const params=new URLSearchParams(this.resultFilters);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 24rem; min-width: 0; }
.tw-filter{display:grid;grid-template-columns:minmax(10rem,1fr) minmax(10rem,1fr) auto minmax(14rem,2fr) 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:900px){.tw-filter{grid-template-columns:1fr}.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:34px;min-width:0;padding-right:30px;border-color:#d8d2c7;border-radius:8px;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:34px}
.tw-filter>.tw-note,.tw-filter>.tw-filter-error{grid-column:1/-1;margin:0}
.tw-keyword-controls{display:flex;flex-wrap:wrap;gap:4px;min-width:0}
.tw-keyword-controls>select{min-width:0;width:100%}
.tw-keyword-controls>input{min-width:0}
@media(max-width:900px){.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(100vw - 32px)}
.account-bill-filter-options .el-select-dropdown__item{max-width:100%;text-overflow:ellipsis;overflow:hidden}
</style>

View File

@ -72,6 +72,29 @@ class DouyinAuthoritativeBillTest extends TestCase
$this->assertSame(2, $repo->paginate($filters, 'occurred_at', 'asc', 1, 10)['total']);
}
public function test_exact_record_filter_preserves_channel_period_and_other_filters(): void
{
$this->legacy(3, '2026-09-01 00:00:00', 9);
$this->legacy(30, '2026-09-01 00:00:00', 10);
$repo = app(StoreReceivingDouyinBillMirrorRepository::class);
$exact = ['keyword' => '3', 'keyword_match' => 'exact-record'];
$this->assertSame(1, $repo->paginate($exact, 'record_code', 'asc', 1, 20)['total']);
$this->assertSame(2, $repo->paginate(['keyword' => '3'], 'record_code', 'asc', 1, 20)['total']);
foreach (['3invalid', '03', '2147483648', '2000000000000000101'] as $code) {
$this->assertSame(0, $repo->paginate(array_replace($exact, ['keyword' => $code]), 'record_code', 'asc', 1, 20)['total']);
}
$this->import([$this->row('2000000000000000101', '10.00')], 1, '10.00', '0.00');
$exact['keyword'] = '2000000000000000101';
$this->assertSame(1, $repo->paginate($exact, 'record_code', 'asc', 1, 20)['total']);
$this->assertCount(1, iterator_to_array($repo->exportRows($exact, 'record_code', 'asc')));
foreach ([['store_name' => '其他店'], ['bill_item' => '其他项目'], ['start' => '2026-09-01'], ['end' => '2026-07-31']] as $extra) {
$this->assertSame(0, $repo->paginate($exact + $extra, 'record_code', 'asc', 1, 20)['total']);
$this->assertCount(0, iterator_to_array($repo->exportRows($exact + $extra, 'record_code', 'asc')));
}
$this->db()->table('ops_jst_original_bill_info')->where('Id', 3)->update(['channel' => 'OtherChannel']);
$this->assertSame(0, $repo->paginate(['keyword' => '3', 'keyword_match' => 'exact-record'], 'record_code', 'asc', 1, 20)['total']);
}
public function test_invalid_counts_totals_duplicate_ids_and_dates_do_not_publish_partial_data(): void
{
$row = $this->row('2000000000000000101', '10.00');

View File

@ -0,0 +1,97 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const { test } = require('node:test');
const compiler = require('vue-template-compiler');
const source = fs.readFileSync('resources/assets/js/components/business/AccountBills.vue', 'utf8');
const sfc = compiler.parseComponent(source);
function component(get) {
const navigations = [];
const context = {
TableColumnSettings: {}, Select: { name: 'ElSelect' }, Option: { name: 'ElOption' },
URLSearchParams, window: { axios: { get }, location: { assign: url => navigations.push(url) } },
};
vm.createContext(context);
const definition = vm.runInContext(sfc.script.content.replace(/^import .*;$/gm, '').replace('export default', 'globalThis.definition =') + '\ndefinition;', context);
const instance = { ...definition.data.call({ billType: 'douyin' }), apiBase: '/bills/douyin', billType: 'douyin', $nextTick: () => {} };
for (const [name, method] of Object.entries(definition.methods)) instance[name] = method.bind(instance);
for (const [name, getter] of Object.entries(definition.computed)) Object.defineProperty(instance, name, { get: getter.bind(instance) });
instance.configuredColumns = [{ field: 'record_code', visible: true }];
return { instance, navigations };
}
const response = (total = 51) => ({ data: { data: [{ record_code: '1' }], total, current_page: 1, last_page: 3, per_page: 20 } });
test('template compiles searchable filters and export scopes', () => {
assert.deepEqual(compiler.compile(sfc.template.content).errors, []);
});
test('all filtered export uses queried filters and omits pagination', async () => {
const { instance, navigations } = component(async () => response());
instance.filters = { store_name: '测试店', bill_item: '货款', start: '2026-08-01', end: '2026-08-31', keyword: '订单号' };
await instance.applyFilters();
instance.currentPage = 2;
instance.exportRows();
const url = new URL(navigations[0], 'https://example.test');
for (const [key, value] of Object.entries(instance.filters)) assert.equal(url.searchParams.get(key), value);
assert.equal(url.searchParams.get('export'), '1');
assert.equal(url.searchParams.has('page'), false);
assert.equal(url.searchParams.has('limit'), false);
assert.deepEqual(url.searchParams.getAll('columns[]'), ['record_code']);
instance.exportMode = 'filtered-all';
instance.exportRows();
assert.equal(new URL(navigations[1], 'https://example.test').searchParams.has('columns[]'), false);
});
test('unsubmitted keyword cannot export a different result and pagination preserves the query', async () => {
const requests = [];
const { instance, navigations } = component(async (_, request) => { requests.push(request.params); return response(); });
await instance.applyFilters();
instance.filters.keyword = '尚未查询';
instance.exportRows();
assert.equal(navigations.length, 0);
instance.currentPage = 2;
await instance.fetch();
assert.equal(requests[1].keyword, '');
assert.equal(instance.filtersDirty, true);
await instance.applyFilters();
assert.equal(instance.currentPage, 1);
assert.equal(instance.canExport, true);
});
test('current-page export stays separate; loading, errors and empty results cannot export', async () => {
const { instance, navigations } = component(async () => response());
await instance.applyFilters();
let pages = 0;
instance.downloadCurrentPage = () => pages++;
instance.exportMode = 'page-visible';
instance.exportRows();
assert.equal(pages, 1);
assert.equal(navigations.length, 0);
for (const field of ['loading', 'loadError']) {
instance[field] = true;
instance.exportRows();
assert.equal(pages, 1);
instance[field] = false;
}
instance.total = 0;
assert.equal(instance.canExport, false);
});
test('late response cannot replace newer filtered result or its export scope', async () => {
const pending = [];
const { instance } = component(() => new Promise(resolve => pending.push(resolve)));
instance.filters.keyword = '旧条件';
const first = instance.applyFilters();
instance.filters.keyword = '新条件';
const second = instance.applyFilters();
pending[1](response(25));
await second;
pending[0](response(100));
await first;
assert.equal(instance.total, 25);
assert.equal(instance.resultFilters.keyword, '新条件');
assert.equal(instance.canExport, true);
});