修复账户账单日期选择器无法打开日历 #206

Merged
qiuxiang merged 1 commits from fix/qiuxiang-account-bill-date-picker into main 2026-09-18 17:54:52 +08:00
2 changed files with 62 additions and 4 deletions

View File

@ -16,7 +16,22 @@
</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>
<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 has-picker">
<flat-picker ref="startDate" :value="filters.start" :config="startDateConfig" aria-label="开始日期" placeholder="开始日期" @on-change="(_, value) => changeDate('start', value)" />
<button v-if="filters.start" type="button" class="tw-date-clear" title="清除开始日期" aria-label="清除开始日期" @click="changeDate('start', '')"><span class="material-icons-outlined" aria-hidden="true">close</span></button>
<button 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 has-picker">
<flat-picker ref="endDate" :value="filters.end" :config="endDateConfig" aria-label="结束日期" placeholder="结束日期" @on-change="(_, value) => changeDate('end', value)" />
<button v-if="filters.end" type="button" class="tw-date-clear" title="清除结束日期" aria-label="清除结束日期" @click="changeDate('end', '')"><span class="material-icons-outlined" aria-hidden="true">close</span></button>
<button 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">关键词(记录编码 / 订单 / 备注)<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>
@ -55,15 +70,21 @@
<script>
import TableColumnSettings from './TableColumnSettings.vue';
import { Select, Option } from 'element-ui';
import flatPicker from 'vue-flatpickr-component';
import { Mandarin } from 'flatpickr/dist/l10n/zh';
import 'flatpickr/dist/flatpickr.css';
const SORTABLE = ['occurred_at','income','expense','balance','record_code'];
const dateConfig = {dateFormat:'Y-m-d',locale:Mandarin,disableMobile:true,allowInput:false,onReady:(_,value,picker)=>picker.calendarContainer.classList.add('account-bill-calendar')};
const emptyFilters = billType => ({store_name:'',bill_item:'',start:'',end:'',keyword:'',keyword_match:billType==='douyin'?'exact-record':'contains'});
export default {
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} },
name:'AccountBills', components:{ TableColumnSettings, flatPicker, [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);},
startDateConfig(){return {...dateConfig,maxDate:this.filters.end||null};},
endDateConfig(){return {...dateConfig,minDate:this.filters.start||null};},
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);},
@ -77,7 +98,8 @@ export default {
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.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();},
openDatePicker(refName){const picker=this.$refs[refName];if(picker&&picker.fp)picker.fp.open();},
changeDate(field,value){const date=value||'';if(this.filters[field]===date)return;this.filters[field]=date;return this.applyFilters();},
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})}`;},
@ -102,6 +124,11 @@ export default {
.tw-keyword-controls>input{min-width:0}
@media(max-width:900px){.tw-tools{flex:0 1 auto;width:100%}}
.table-workspace{container-type:inline-size}
.tw-date-control{width:14rem;max-width:100%}
.tw-date-control.has-picker input{flex:1;width:0;min-width:0;cursor:pointer;background:#fff}
.tw-date-picker{flex:none}
.tw-date-clear{display:flex;align-items:center;justify-content:center;width:1.5rem;flex:none;padding:0;border:1px solid #d8d2c7;border-left:0;border-right:0;background:#fff;color:#595959;cursor:pointer}
.tw-date-clear .material-icons-outlined{font-size:1rem}
@container(max-width:1200px){
.table-workspace .tw-filter{grid-template-columns:repeat(2,minmax(0,1fr))}
.table-workspace .tw-date{grid-column:1/-1}
@ -118,6 +145,11 @@ export default {
</style>
<style>
.account-bill-calendar{font-family:"Microsoft YaHei",sans-serif;border:1px solid #d8d2c7;border-radius:8px;background:#fff}
.account-bill-calendar.open{opacity:1}
.account-bill-calendar .flatpickr-day.selected,.account-bill-calendar .flatpickr-day.selected:hover{background:#856632;border-color:#856632}
.account-bill-calendar .flatpickr-day.today{border-color:#856632}
.account-bill-calendar .flatpickr-day:hover,.account-bill-calendar .flatpickr-day:focus{background:#f7f0de;border-color:#d8d2c7}
.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)}

View File

@ -10,7 +10,7 @@ const sfc = compiler.parseComponent(source);
function component(get) {
const navigations = [];
const context = {
TableColumnSettings: {}, Select: { name: 'ElSelect' }, Option: { name: 'ElOption' },
TableColumnSettings: {}, Select: { name: 'ElSelect' }, Option: { name: 'ElOption' }, flatPicker: {}, Mandarin: {},
URLSearchParams, window: { axios: { get }, location: { assign: url => navigations.push(url) } },
};
vm.createContext(context);
@ -95,3 +95,29 @@ test('late response cannot replace newer filtered result or its export scope', a
assert.equal(instance.resultFilters.keyword, '新条件');
assert.equal(instance.canExport, true);
});
test('calendar selection submits both bounds, resets pagination and preserves dates in full export', async () => {
const requests = [];
const { instance, navigations } = component(async (_, request) => { requests.push(request.params); return response(); });
instance.billType = 'jd-transactions';
instance.currentPage = 3;
await instance.changeDate('start', '2026-08-01');
await instance.changeDate('end', '2026-08-02');
assert.equal(requests.at(-1).start, '2026-08-01');
assert.equal(requests.at(-1).end, '2026-08-02');
assert.equal(requests.at(-1).page, 1);
assert.equal(instance.startDateConfig.maxDate, '2026-08-02');
assert.equal(instance.endDateConfig.minDate, '2026-08-01');
instance.exportRows();
const params = new URL(navigations[0], 'https://example.test').searchParams;
assert.equal(params.get('start'), '2026-08-01');
assert.equal(params.get('end'), '2026-08-02');
assert.equal(params.has('page'), false);
await instance.changeDate('end', '');
assert.equal(requests.at(-1).end, '');
assert.equal(instance.startDateConfig.maxDate, null);
let opened = false;
instance.$refs = { startDate: { fp: { open: () => { opened = true; } } } };
instance.openDatePicker('startDate');
assert.equal(opened, true);
});