feat(mileage): refine daily report controls
This commit is contained in:
@@ -83,12 +83,132 @@ function dateShift(date: string, days: number): string {
|
|||||||
return value.toISOString().slice(0, 10);
|
return value.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function monthShift(month: string, months: number): string {
|
||||||
|
const value = new Date(`${month}-01T00:00:00Z`);
|
||||||
|
value.setUTCMonth(value.getUTCMonth() + months);
|
||||||
|
return value.toISOString().slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportDateLabel(value: string): string {
|
||||||
|
const date = new Date(`${value}T00:00:00Z`);
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'short',
|
||||||
|
}).format(date).replace(/日(?=周)/, '日 ');
|
||||||
|
}
|
||||||
|
|
||||||
function initialReportDate(): string {
|
function initialReportDate(): string {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const value = params.get('mileageReportDate');
|
const value = params.get('mileageReportDate');
|
||||||
return /^\d{4}-\d{2}-\d{2}$/.test(value || '') ? value! : shanghaiYmd(-1);
|
return /^\d{4}-\d{2}-\d{2}$/.test(value || '') ? value! : shanghaiYmd(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReportDatePicker({
|
||||||
|
value,
|
||||||
|
maxDate,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
maxDate: string;
|
||||||
|
onChange: (date: string) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [month, setMonth] = useState(value.slice(0, 7));
|
||||||
|
const monthStart = new Date(`${month}-01T00:00:00Z`);
|
||||||
|
const firstGridDate = new Date(monthStart);
|
||||||
|
firstGridDate.setUTCDate(1 - monthStart.getUTCDay());
|
||||||
|
const days = Array.from({ length: 42 }, (_, index) => {
|
||||||
|
const date = new Date(firstGridDate);
|
||||||
|
date.setUTCDate(firstGridDate.getUTCDate() + index);
|
||||||
|
return date;
|
||||||
|
});
|
||||||
|
const weekdayLabels = ['日', '一', '二', '三', '四', '五', '六'];
|
||||||
|
const monthLabel = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
}).format(monthStart);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-w-0 flex-1 sm:flex-none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMonth(value.slice(0, 7));
|
||||||
|
setOpen(current => !current);
|
||||||
|
}}
|
||||||
|
className="flex h-9 w-full min-w-[190px] items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-left text-xs font-black text-slate-700 transition-colors hover:border-blue-200 hover:bg-blue-50/50 sm:w-[208px]"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-label="选择日报日期"
|
||||||
|
>
|
||||||
|
<CalendarDays size={15} className="shrink-0 text-blue-500" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{reportDateLabel(value)}</span>
|
||||||
|
<ChevronDown size={14} className={`shrink-0 text-slate-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div role="dialog" aria-label="日报日期日历" className="absolute right-0 top-full z-40 mt-2 w-[308px] rounded-xl border border-slate-200 bg-white p-3 shadow-xl">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMonth(current => monthShift(current, -1))}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-500 hover:bg-slate-100"
|
||||||
|
title="上个月"
|
||||||
|
aria-label="上个月"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="text-sm font-black text-slate-900">{monthLabel}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMonth(current => monthShift(current, 1))}
|
||||||
|
disabled={month >= maxDate.slice(0, 7)}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-500 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
title="下个月"
|
||||||
|
aria-label="下个月"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center">
|
||||||
|
{weekdayLabels.map(day => <span key={day} className="py-1 text-[10px] font-black text-slate-400">{day}</span>)}
|
||||||
|
{days.map(date => {
|
||||||
|
const dateValue = date.toISOString().slice(0, 10);
|
||||||
|
const inMonth = dateValue.slice(0, 7) === month;
|
||||||
|
const selected = dateValue === value;
|
||||||
|
const disabled = dateValue > maxDate;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={dateValue}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(dateValue);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex h-9 items-center justify-center rounded-md text-xs font-black transition-colors ${
|
||||||
|
selected
|
||||||
|
? 'bg-blue-600 text-white shadow-sm'
|
||||||
|
: inMonth
|
||||||
|
? 'text-slate-700 hover:bg-blue-50 hover:text-blue-600'
|
||||||
|
: 'text-slate-300 hover:bg-slate-50'
|
||||||
|
} disabled:cursor-not-allowed disabled:text-slate-200 disabled:hover:bg-transparent`}
|
||||||
|
>
|
||||||
|
{date.getUTCDate()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function updateReportDateInUrl(date: string): void {
|
function updateReportDateInUrl(date: string): void {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set('mileageReportDate', date);
|
url.searchParams.set('mileageReportDate', date);
|
||||||
@@ -711,7 +831,7 @@ function roundMileageForDisplay(value: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DailyReportView() {
|
export default function DailyReportView() {
|
||||||
const maxDate = shanghaiYmd(-1);
|
const maxDate = shanghaiYmd();
|
||||||
const [selectedDate, setSelectedDate] = useState(initialReportDate);
|
const [selectedDate, setSelectedDate] = useState(initialReportDate);
|
||||||
const [report, setReport] = useState<DailyMileageReport | null>(null);
|
const [report, setReport] = useState<DailyMileageReport | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -777,10 +897,12 @@ export default function DailyReportView() {
|
|||||||
<p className="mt-1 text-[10px] font-bold text-slate-400">
|
<p className="mt-1 text-[10px] font-bold text-slate-400">
|
||||||
{fmtDate(report.reportDate)} · {report.source === 'XLSX_IMPORT'
|
{fmtDate(report.reportDate)} · {report.source === 'XLSX_IMPORT'
|
||||||
? '人工台账归档'
|
? '人工台账归档'
|
||||||
|
: report.status === 'ARCHIVED'
|
||||||
|
? `归档于 ${fmtDateTime(report.generatedAt)}`
|
||||||
: `仪表数据更新至 ${fmtDateTime(report.sourceUpdatedAt)}`}
|
: `仪表数据更新至 ${fmtDateTime(report.sourceUpdatedAt)}`}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[10px] font-bold text-slate-500">
|
<p className="mt-1 text-[10px] font-bold text-slate-500">
|
||||||
统计口径:每天 00:30 自动汇总前一日里程
|
统计周期:自然日 · 汇总频率:每日 00:30 汇总前一日里程
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -794,17 +916,16 @@ export default function DailyReportView() {
|
|||||||
>
|
>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
<label className="flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 sm:flex-none">
|
<ReportDatePicker value={selectedDate} maxDate={maxDate} onChange={setSelectedDate} />
|
||||||
<CalendarDays size={15} className="shrink-0 text-blue-500" />
|
<button
|
||||||
<input
|
type="button"
|
||||||
type="date"
|
onClick={() => setSelectedDate(maxDate)}
|
||||||
max={maxDate}
|
disabled={selectedDate === maxDate}
|
||||||
value={selectedDate}
|
className="flex h-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white px-2.5 text-[11px] font-black text-blue-600 hover:border-blue-200 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||||||
onChange={event => event.target.value && setSelectedDate(event.target.value)}
|
title="回到今天"
|
||||||
className="min-w-0 border-0 bg-transparent text-xs font-black text-slate-700 outline-none"
|
>
|
||||||
aria-label="日报日期"
|
今天
|
||||||
/>
|
</button>
|
||||||
</label>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedDate(dateShift(selectedDate, 1))}
|
onClick={() => setSelectedDate(dateShift(selectedDate, 1))}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { hasDailyReportSnapshot } from './daily-report-store.js';
|
|||||||
|
|
||||||
const FIRST_CHECK_DELAY_MS = 15 * 1000;
|
const FIRST_CHECK_DELAY_MS = 15 * 1000;
|
||||||
const SUMMARY_MINUTE_OF_DAY = 30;
|
const SUMMARY_MINUTE_OF_DAY = 30;
|
||||||
|
const SUMMARY_WINDOW_MINUTES = 5;
|
||||||
let archiving = false;
|
let archiving = false;
|
||||||
|
|
||||||
function shanghaiMinuteOfDay(): number {
|
function shanghaiMinuteOfDay(): number {
|
||||||
@@ -39,7 +40,12 @@ function millisecondsUntilNextSummary(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndArchive(): Promise<void> {
|
async function checkAndArchive(): Promise<void> {
|
||||||
if (archiving || shanghaiMinuteOfDay() < SUMMARY_MINUTE_OF_DAY) return;
|
const minuteOfDay = shanghaiMinuteOfDay();
|
||||||
|
if (
|
||||||
|
archiving
|
||||||
|
|| minuteOfDay < SUMMARY_MINUTE_OF_DAY
|
||||||
|
|| minuteOfDay >= SUMMARY_MINUTE_OF_DAY + SUMMARY_WINDOW_MINUTES
|
||||||
|
) return;
|
||||||
const reportDate = previousShanghaiDate();
|
const reportDate = previousShanghaiDate();
|
||||||
archiving = true;
|
archiving = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user