mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
feat(settings): unify export date range defaults
This commit is contained in:
254
src/components/Export/ExportDateRangeDialog.scss
Normal file
254
src/components/Export/ExportDateRangeDialog.scss
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
.export-date-range-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
z-index: 1015;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog {
|
||||||
|
width: min(480px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 64px);
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary-solid, var(--bg-primary));
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog-close-btn {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-preset-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-preset-item {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-mode-banner {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
&.range {
|
||||||
|
border-color: rgba(var(--primary-rgb), 0.4);
|
||||||
|
background: rgba(var(--primary-rgb), 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-panel {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-date-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-date-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
&.invalid {
|
||||||
|
border-color: #e84d4d;
|
||||||
|
box-shadow: 0 0 0 1px rgba(232, 77, 77, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-nav {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-weekdays {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-days {
|
||||||
|
margin-top: 4px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-day {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
min-height: 20px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&.outside {
|
||||||
|
color: var(--text-quaternary);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.14);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog-btn {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.export-date-range-calendar-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
340
src/components/Export/ExportDateRangeDialog.tsx
Normal file
340
src/components/Export/ExportDateRangeDialog.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Check, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
EXPORT_DATE_RANGE_PRESETS,
|
||||||
|
WEEKDAY_SHORT_LABELS,
|
||||||
|
addMonths,
|
||||||
|
buildCalendarCells,
|
||||||
|
cloneExportDateRangeSelection,
|
||||||
|
createDateRangeByPreset,
|
||||||
|
createDefaultDateRange,
|
||||||
|
formatCalendarMonthTitle,
|
||||||
|
formatDateInputValue,
|
||||||
|
isSameDay,
|
||||||
|
parseDateInputValue,
|
||||||
|
startOfDay,
|
||||||
|
endOfDay,
|
||||||
|
toMonthStart,
|
||||||
|
type ExportDateRangePreset,
|
||||||
|
type ExportDateRangeSelection
|
||||||
|
} from '../../utils/exportDateRange'
|
||||||
|
import './ExportDateRangeDialog.scss'
|
||||||
|
|
||||||
|
interface ExportDateRangeDialogProps {
|
||||||
|
open: boolean
|
||||||
|
value: ExportDateRangeSelection
|
||||||
|
title?: string
|
||||||
|
onClose: () => void
|
||||||
|
onConfirm: (value: ExportDateRangeSelection) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
|
||||||
|
startPanelMonth: Date
|
||||||
|
endPanelMonth: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({
|
||||||
|
...cloneExportDateRangeSelection(value),
|
||||||
|
startPanelMonth: toMonthStart(value.dateRange.start),
|
||||||
|
endPanelMonth: toMonthStart(value.dateRange.end)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ExportDateRangeDialog({
|
||||||
|
open,
|
||||||
|
value,
|
||||||
|
title = '时间范围设置',
|
||||||
|
onClose,
|
||||||
|
onConfirm
|
||||||
|
}: ExportDateRangeDialogProps) {
|
||||||
|
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value))
|
||||||
|
const [dateInput, setDateInput] = useState({
|
||||||
|
start: formatDateInputValue(value.dateRange.start),
|
||||||
|
end: formatDateInputValue(value.dateRange.end)
|
||||||
|
})
|
||||||
|
const [dateInputError, setDateInputError] = useState({ start: false, end: false })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const nextDraft = buildDialogDraft(value)
|
||||||
|
setDraft(nextDraft)
|
||||||
|
setDateInput({
|
||||||
|
start: formatDateInputValue(nextDraft.dateRange.start),
|
||||||
|
end: formatDateInputValue(nextDraft.dateRange.end)
|
||||||
|
})
|
||||||
|
setDateInputError({ start: false, end: false })
|
||||||
|
}, [open, value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setDateInput({
|
||||||
|
start: formatDateInputValue(draft.dateRange.start),
|
||||||
|
end: formatDateInputValue(draft.dateRange.end)
|
||||||
|
})
|
||||||
|
setDateInputError({ start: false, end: false })
|
||||||
|
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
|
||||||
|
|
||||||
|
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
|
||||||
|
if (preset === 'all') {
|
||||||
|
const previewRange = createDefaultDateRange()
|
||||||
|
setDraft(prev => ({
|
||||||
|
...prev,
|
||||||
|
preset,
|
||||||
|
useAllTime: true,
|
||||||
|
dateRange: previewRange,
|
||||||
|
startPanelMonth: toMonthStart(previewRange.start),
|
||||||
|
endPanelMonth: toMonthStart(previewRange.end)
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = createDateRangeByPreset(preset)
|
||||||
|
setDraft(prev => ({
|
||||||
|
...prev,
|
||||||
|
preset,
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: range,
|
||||||
|
startPanelMonth: toMonthStart(range.start),
|
||||||
|
endPanelMonth: toMonthStart(range.end)
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateDraftStart = useCallback((targetDate: Date) => {
|
||||||
|
const start = startOfDay(targetDate)
|
||||||
|
setDraft(prev => {
|
||||||
|
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: {
|
||||||
|
start,
|
||||||
|
end: nextEnd
|
||||||
|
},
|
||||||
|
startPanelMonth: toMonthStart(start),
|
||||||
|
endPanelMonth: toMonthStart(nextEnd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateDraftEnd = useCallback((targetDate: Date) => {
|
||||||
|
const end = endOfDay(targetDate)
|
||||||
|
setDraft(prev => {
|
||||||
|
const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
|
||||||
|
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: {
|
||||||
|
start: nextStart,
|
||||||
|
end: nextEnd
|
||||||
|
},
|
||||||
|
startPanelMonth: toMonthStart(nextStart),
|
||||||
|
endPanelMonth: toMonthStart(nextEnd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const commitStartFromInput = useCallback(() => {
|
||||||
|
const parsed = parseDateInputValue(dateInput.start)
|
||||||
|
if (!parsed) {
|
||||||
|
setDateInputError(prev => ({ ...prev, start: true }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDateInputError(prev => ({ ...prev, start: false }))
|
||||||
|
updateDraftStart(parsed)
|
||||||
|
}, [dateInput.start, updateDraftStart])
|
||||||
|
|
||||||
|
const commitEndFromInput = useCallback(() => {
|
||||||
|
const parsed = parseDateInputValue(dateInput.end)
|
||||||
|
if (!parsed) {
|
||||||
|
setDateInputError(prev => ({ ...prev, end: true }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDateInputError(prev => ({ ...prev, end: false }))
|
||||||
|
updateDraftEnd(parsed)
|
||||||
|
}, [dateInput.end, updateDraftEnd])
|
||||||
|
|
||||||
|
const shiftPanelMonth = useCallback((panel: 'start' | 'end', delta: number) => {
|
||||||
|
setDraft(prev => (
|
||||||
|
panel === 'start'
|
||||||
|
? { ...prev, startPanelMonth: addMonths(prev.startPanelMonth, delta) }
|
||||||
|
: { ...prev, endPanelMonth: addMonths(prev.endPanelMonth, delta) }
|
||||||
|
))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isRangeModeActive = !draft.useAllTime
|
||||||
|
const modeText = isRangeModeActive
|
||||||
|
? '当前导出模式:按时间范围导出'
|
||||||
|
: '当前导出模式:全部时间导出(选择下方日期将切换为按时间范围导出)'
|
||||||
|
|
||||||
|
const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => {
|
||||||
|
if (preset === 'all') return draft.useAllTime
|
||||||
|
return !draft.useAllTime && draft.preset === preset
|
||||||
|
}, [draft])
|
||||||
|
|
||||||
|
const startPanelCells = useMemo(() => buildCalendarCells(draft.startPanelMonth), [draft.startPanelMonth])
|
||||||
|
const endPanelCells = useMemo(() => buildCalendarCells(draft.endPanelMonth), [draft.endPanelMonth])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="export-date-range-dialog-overlay" onClick={onClose}>
|
||||||
|
<div className="export-date-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="export-date-range-dialog-header">
|
||||||
|
<h4>{title}</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-date-range-dialog-close-btn"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭时间范围设置"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="export-date-range-preset-list">
|
||||||
|
{EXPORT_DATE_RANGE_PRESETS.map((preset) => {
|
||||||
|
const active = isPresetActive(preset.value)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.value}
|
||||||
|
type="button"
|
||||||
|
className={`export-date-range-preset-item ${active ? 'active' : ''}`}
|
||||||
|
onClick={() => applyPreset(preset.value)}
|
||||||
|
>
|
||||||
|
<span>{preset.label}</span>
|
||||||
|
{active && <Check size={14} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`export-date-range-mode-banner ${isRangeModeActive ? 'range' : 'all'}`}>
|
||||||
|
{modeText}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="export-date-range-calendar-grid">
|
||||||
|
<section className="export-date-range-calendar-panel">
|
||||||
|
<div className="export-date-range-calendar-panel-header">
|
||||||
|
<div className="export-date-range-calendar-date-label">
|
||||||
|
<span>起始日期</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
|
||||||
|
value={dateInput.start}
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value
|
||||||
|
setDateInput(prev => ({ ...prev, start: nextValue }))
|
||||||
|
if (dateInputError.start) {
|
||||||
|
setDateInputError(prev => ({ ...prev, start: false }))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter') return
|
||||||
|
event.preventDefault()
|
||||||
|
commitStartFromInput()
|
||||||
|
}}
|
||||||
|
onBlur={commitStartFromInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-nav">
|
||||||
|
<button type="button" onClick={() => shiftPanelMonth('start', -1)} aria-label="上个月">‹</button>
|
||||||
|
<span>{formatCalendarMonthTitle(draft.startPanelMonth)}</span>
|
||||||
|
<button type="button" onClick={() => shiftPanelMonth('start', 1)} aria-label="下个月">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-weekdays">
|
||||||
|
{WEEKDAY_SHORT_LABELS.map(label => (
|
||||||
|
<span key={`start-weekday-${label}`}>{label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-days">
|
||||||
|
{startPanelCells.map((cell) => {
|
||||||
|
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.start)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`start-${cell.date.getTime()}`}
|
||||||
|
type="button"
|
||||||
|
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
|
||||||
|
onClick={() => updateDraftStart(cell.date)}
|
||||||
|
>
|
||||||
|
{cell.date.getDate()}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="export-date-range-calendar-panel">
|
||||||
|
<div className="export-date-range-calendar-panel-header">
|
||||||
|
<div className="export-date-range-calendar-date-label">
|
||||||
|
<span>截止日期</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
|
||||||
|
value={dateInput.end}
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value
|
||||||
|
setDateInput(prev => ({ ...prev, end: nextValue }))
|
||||||
|
if (dateInputError.end) {
|
||||||
|
setDateInputError(prev => ({ ...prev, end: false }))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter') return
|
||||||
|
event.preventDefault()
|
||||||
|
commitEndFromInput()
|
||||||
|
}}
|
||||||
|
onBlur={commitEndFromInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-nav">
|
||||||
|
<button type="button" onClick={() => shiftPanelMonth('end', -1)} aria-label="上个月">‹</button>
|
||||||
|
<span>{formatCalendarMonthTitle(draft.endPanelMonth)}</span>
|
||||||
|
<button type="button" onClick={() => shiftPanelMonth('end', 1)} aria-label="下个月">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-weekdays">
|
||||||
|
{WEEKDAY_SHORT_LABELS.map(label => (
|
||||||
|
<span key={`end-weekday-${label}`}>{label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-days">
|
||||||
|
{endPanelCells.map((cell) => {
|
||||||
|
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`end-${cell.date.getTime()}`}
|
||||||
|
type="button"
|
||||||
|
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
|
||||||
|
onClick={() => updateDraftEnd(cell.date)}
|
||||||
|
>
|
||||||
|
{cell.date.getDate()}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="export-date-range-dialog-actions">
|
||||||
|
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-date-range-dialog-btn primary"
|
||||||
|
onClick={() => onConfirm(cloneExportDateRangeSelection(draft))}
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -40,7 +40,16 @@ import {
|
|||||||
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||||||
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
||||||
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||||
|
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
|
||||||
import type { SnsPost } from '../types/sns'
|
import type { SnsPost } from '../types/sns'
|
||||||
|
import {
|
||||||
|
cloneExportDateRange,
|
||||||
|
createDefaultDateRange,
|
||||||
|
createDefaultExportDateRangeSelection,
|
||||||
|
getExportDateRangeLabel,
|
||||||
|
resolveExportDateRangeConfig,
|
||||||
|
type ExportDateRangeSelection
|
||||||
|
} from '../utils/exportDateRange'
|
||||||
import './ExportPage.scss'
|
import './ExportPage.scss'
|
||||||
|
|
||||||
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
||||||
@@ -53,17 +62,6 @@ type SnsRankMode = 'likes' | 'comments'
|
|||||||
type SessionLayout = 'shared' | 'per-session'
|
type SessionLayout = 'shared' | 'per-session'
|
||||||
|
|
||||||
type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname'
|
type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname'
|
||||||
type DateRangePreset =
|
|
||||||
| 'all'
|
|
||||||
| 'today'
|
|
||||||
| 'yesterday'
|
|
||||||
| 'last3days'
|
|
||||||
| 'last7days'
|
|
||||||
| 'last30days'
|
|
||||||
| 'last1year'
|
|
||||||
| 'last2years'
|
|
||||||
| 'custom'
|
|
||||||
type CalendarCell = { date: Date; inCurrentMonth: boolean }
|
|
||||||
|
|
||||||
type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||||
type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson'
|
type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson'
|
||||||
@@ -158,14 +156,6 @@ interface ExportDialogState {
|
|||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimeRangeDialogDraft {
|
|
||||||
preset: DateRangePreset
|
|
||||||
useAllTime: boolean
|
|
||||||
dateRange: { start: Date; end: Date }
|
|
||||||
startPanelMonth: Date
|
|
||||||
endPanelMonth: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||||
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
|
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
|
||||||
const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
|
const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
|
||||||
@@ -463,126 +453,6 @@ const formatRecentExportTime = (timestamp?: number, now = Date.now()): string =>
|
|||||||
return formatAbsoluteDate(timestamp)
|
return formatAbsoluteDate(timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
const startOfDay = (date: Date): Date => {
|
|
||||||
const next = new Date(date)
|
|
||||||
next.setHours(0, 0, 0, 0)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const endOfDay = (date: Date): Date => {
|
|
||||||
const next = new Date(date)
|
|
||||||
next.setHours(23, 59, 59, 999)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const createDefaultDateRange = (): { start: Date; end: Date } => {
|
|
||||||
const now = new Date()
|
|
||||||
return {
|
|
||||||
start: startOfDay(now),
|
|
||||||
end: now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createDateRangeByPreset = (
|
|
||||||
preset: Exclude<DateRangePreset, 'all' | 'custom'>,
|
|
||||||
now = new Date()
|
|
||||||
): { start: Date; end: Date } => {
|
|
||||||
const end = new Date(now)
|
|
||||||
const baseStart = startOfDay(now)
|
|
||||||
|
|
||||||
if (preset === 'today') {
|
|
||||||
return { start: baseStart, end }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preset === 'yesterday') {
|
|
||||||
const yesterday = new Date(baseStart)
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1)
|
|
||||||
return {
|
|
||||||
start: yesterday,
|
|
||||||
end: endOfDay(yesterday)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preset === 'last1year' || preset === 'last2years') {
|
|
||||||
const yearsBack = preset === 'last1year' ? 1 : 2
|
|
||||||
const start = new Date(baseStart)
|
|
||||||
const expectedMonth = start.getMonth()
|
|
||||||
start.setFullYear(start.getFullYear() - yearsBack)
|
|
||||||
// Handle leap-year fallback (e.g. Feb 29 -> Feb 28).
|
|
||||||
if (start.getMonth() !== expectedMonth) {
|
|
||||||
start.setDate(0)
|
|
||||||
}
|
|
||||||
return { start, end }
|
|
||||||
}
|
|
||||||
|
|
||||||
const daysBack = preset === 'last3days' ? 2 : preset === 'last7days' ? 6 : 29
|
|
||||||
const start = new Date(baseStart)
|
|
||||||
start.setDate(start.getDate() - daysBack)
|
|
||||||
return { start, end }
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDateInputValue = (date: Date): string => {
|
|
||||||
const y = date.getFullYear()
|
|
||||||
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
|
||||||
const d = `${date.getDate()}`.padStart(2, '0')
|
|
||||||
return `${y}-${m}-${d}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseDateInputValue = (raw: string): Date | null => {
|
|
||||||
const text = String(raw || '').trim()
|
|
||||||
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
|
|
||||||
if (!matched) return null
|
|
||||||
const year = Number(matched[1])
|
|
||||||
const month = Number(matched[2])
|
|
||||||
const day = Number(matched[3])
|
|
||||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
|
|
||||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null
|
|
||||||
const parsed = new Date(year, month - 1, day)
|
|
||||||
if (
|
|
||||||
parsed.getFullYear() !== year ||
|
|
||||||
parsed.getMonth() !== month - 1 ||
|
|
||||||
parsed.getDate() !== day
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
const toMonthStart = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), 1)
|
|
||||||
|
|
||||||
const addMonths = (date: Date, delta: number): Date => {
|
|
||||||
const next = new Date(date)
|
|
||||||
next.setMonth(next.getMonth() + delta)
|
|
||||||
return toMonthStart(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSameDay = (left: Date, right: Date): boolean => (
|
|
||||||
left.getFullYear() === right.getFullYear() &&
|
|
||||||
left.getMonth() === right.getMonth() &&
|
|
||||||
left.getDate() === right.getDate()
|
|
||||||
)
|
|
||||||
|
|
||||||
const buildCalendarCells = (monthStart: Date): CalendarCell[] => {
|
|
||||||
const firstDay = new Date(monthStart.getFullYear(), monthStart.getMonth(), 1)
|
|
||||||
const startOffset = firstDay.getDay()
|
|
||||||
const gridStart = new Date(firstDay)
|
|
||||||
gridStart.setDate(gridStart.getDate() - startOffset)
|
|
||||||
const cells: CalendarCell[] = []
|
|
||||||
for (let index = 0; index < 42; index += 1) {
|
|
||||||
const current = new Date(gridStart)
|
|
||||||
current.setDate(gridStart.getDate() + index)
|
|
||||||
cells.push({
|
|
||||||
date: current,
|
|
||||||
inCurrentMonth: current.getMonth() === monthStart.getMonth()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return cells
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatCalendarMonthTitle = (date: Date): string => `${date.getFullYear()}年${date.getMonth() + 1}月`
|
|
||||||
|
|
||||||
const WEEKDAY_SHORT_LABELS = ['日', '一', '二', '三', '四', '五', '六']
|
|
||||||
|
|
||||||
const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => {
|
const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => {
|
||||||
if (session.username.endsWith('@chatroom')) return 'group'
|
if (session.username.endsWith('@chatroom')) return 'group'
|
||||||
if (session.username.startsWith('gh_')) return 'official'
|
if (session.username.startsWith('gh_')) return 'official'
|
||||||
@@ -1412,10 +1282,8 @@ function ExportPage() {
|
|||||||
const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false)
|
const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false)
|
||||||
const [snsExportVideos, setSnsExportVideos] = useState(false)
|
const [snsExportVideos, setSnsExportVideos] = useState(false)
|
||||||
const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false)
|
const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false)
|
||||||
const [timeRangePreset, setTimeRangePreset] = useState<DateRangePreset>('all')
|
const [timeRangeSelection, setTimeRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||||
const [timeRangeDialogDraft, setTimeRangeDialogDraft] = useState<TimeRangeDialogDraft | null>(null)
|
const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||||
const [timeRangeDateInput, setTimeRangeDateInput] = useState<{ start: string; end: string }>({ start: '', end: '' })
|
|
||||||
const [timeRangeDateInputError, setTimeRangeDateInputError] = useState<{ start: boolean; end: boolean }>({ start: false, end: false })
|
|
||||||
|
|
||||||
const [options, setOptions] = useState<ExportOptions>({
|
const [options, setOptions] = useState<ExportOptions>({
|
||||||
format: 'json',
|
format: 'json',
|
||||||
@@ -1917,7 +1785,7 @@ function ExportPage() {
|
|||||||
setIsBaseConfigLoading(true)
|
setIsBaseConfigLoading(true)
|
||||||
let isReady = true
|
let isReady = true
|
||||||
try {
|
try {
|
||||||
const [savedPath, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, exportCacheScope] = await Promise.all([
|
const [savedPath, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([
|
||||||
configService.getExportPath(),
|
configService.getExportPath(),
|
||||||
configService.getExportDefaultMedia(),
|
configService.getExportDefaultMedia(),
|
||||||
configService.getExportDefaultVoiceAsText(),
|
configService.getExportDefaultVoiceAsText(),
|
||||||
@@ -1930,6 +1798,7 @@ function ExportPage() {
|
|||||||
configService.getExportLastSnsPostCount(),
|
configService.getExportLastSnsPostCount(),
|
||||||
configService.getExportWriteLayout(),
|
configService.getExportWriteLayout(),
|
||||||
configService.getExportSessionNamePrefixEnabled(),
|
configService.getExportSessionNamePrefixEnabled(),
|
||||||
|
configService.getExportDefaultDateRange(),
|
||||||
ensureExportCacheScope()
|
ensureExportCacheScope()
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1948,6 +1817,9 @@ function ExportPage() {
|
|||||||
setLastExportByContent(savedContentMap)
|
setLastExportByContent(savedContentMap)
|
||||||
setExportRecordsBySession(savedSessionRecordMap)
|
setExportRecordsBySession(savedSessionRecordMap)
|
||||||
setLastSnsExportPostCount(savedSnsPostCount)
|
setLastSnsExportPostCount(savedSnsPostCount)
|
||||||
|
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
|
||||||
|
setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
|
||||||
|
setTimeRangeSelection(resolvedDefaultDateRange)
|
||||||
await configService.setExportDefaultFormat('json')
|
await configService.setExportDefaultFormat('json')
|
||||||
|
|
||||||
if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) {
|
if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) {
|
||||||
@@ -3313,14 +3185,14 @@ function ExportPage() {
|
|||||||
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open'>) => {
|
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open'>) => {
|
||||||
setExportDialog({ open: true, ...payload })
|
setExportDialog({ open: true, ...payload })
|
||||||
setIsTimeRangeDialogOpen(false)
|
setIsTimeRangeDialogOpen(false)
|
||||||
setTimeRangePreset('all')
|
setTimeRangeSelection(exportDefaultDateRangeSelection)
|
||||||
|
|
||||||
setOptions(prev => {
|
setOptions(prev => {
|
||||||
const nextDateRange = prev.dateRange ?? createDefaultDateRange()
|
const nextDateRange = cloneExportDateRange(exportDefaultDateRangeSelection.dateRange)
|
||||||
|
|
||||||
const next: ExportOptions = {
|
const next: ExportOptions = {
|
||||||
...prev,
|
...prev,
|
||||||
useAllTime: true,
|
useAllTime: exportDefaultDateRangeSelection.useAllTime,
|
||||||
dateRange: nextDateRange
|
dateRange: nextDateRange
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3348,219 +3220,22 @@ function ExportPage() {
|
|||||||
|
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [])
|
}, [exportDefaultDateRangeSelection])
|
||||||
|
|
||||||
const closeExportDialog = useCallback(() => {
|
const closeExportDialog = useCallback(() => {
|
||||||
setExportDialog(prev => ({ ...prev, open: false }))
|
setExportDialog(prev => ({ ...prev, open: false }))
|
||||||
setIsTimeRangeDialogOpen(false)
|
setIsTimeRangeDialogOpen(false)
|
||||||
setTimeRangeDialogDraft(null)
|
|
||||||
setTimeRangeDateInput({ start: '', end: '' })
|
|
||||||
setTimeRangeDateInputError({ start: false, end: false })
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const buildTimeRangeDialogDraft = useCallback((): TimeRangeDialogDraft => {
|
|
||||||
const dateRange = options.dateRange ?? createDefaultDateRange()
|
|
||||||
return {
|
|
||||||
preset: timeRangePreset,
|
|
||||||
useAllTime: options.useAllTime,
|
|
||||||
dateRange: {
|
|
||||||
start: new Date(dateRange.start),
|
|
||||||
end: new Date(dateRange.end)
|
|
||||||
},
|
|
||||||
startPanelMonth: toMonthStart(dateRange.start),
|
|
||||||
endPanelMonth: toMonthStart(dateRange.end)
|
|
||||||
}
|
|
||||||
}, [options.dateRange, options.useAllTime, timeRangePreset])
|
|
||||||
|
|
||||||
const openTimeRangeDialog = useCallback(() => {
|
const openTimeRangeDialog = useCallback(() => {
|
||||||
const draft = buildTimeRangeDialogDraft()
|
|
||||||
setTimeRangeDialogDraft(draft)
|
|
||||||
setIsTimeRangeDialogOpen(true)
|
setIsTimeRangeDialogOpen(true)
|
||||||
}, [buildTimeRangeDialogDraft])
|
}, [])
|
||||||
|
|
||||||
const closeTimeRangeDialog = useCallback(() => {
|
const closeTimeRangeDialog = useCallback(() => {
|
||||||
setIsTimeRangeDialogOpen(false)
|
setIsTimeRangeDialogOpen(false)
|
||||||
setTimeRangeDialogDraft(null)
|
|
||||||
setTimeRangeDateInput({ start: '', end: '' })
|
|
||||||
setTimeRangeDateInputError({ start: false, end: false })
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const applyTimeRangePresetToDraft = useCallback((preset: Exclude<DateRangePreset, 'custom'>) => {
|
const timeRangeSummaryLabel = useMemo(() => getExportDateRangeLabel(timeRangeSelection), [timeRangeSelection])
|
||||||
setTimeRangeDialogDraft(prev => {
|
|
||||||
const base = prev ?? buildTimeRangeDialogDraft()
|
|
||||||
if (preset === 'all') {
|
|
||||||
const previewRange = createDefaultDateRange()
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
preset,
|
|
||||||
useAllTime: true,
|
|
||||||
dateRange: {
|
|
||||||
start: previewRange.start,
|
|
||||||
end: previewRange.end
|
|
||||||
},
|
|
||||||
startPanelMonth: toMonthStart(previewRange.start),
|
|
||||||
endPanelMonth: toMonthStart(previewRange.end)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const range = createDateRangeByPreset(preset)
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
preset,
|
|
||||||
useAllTime: false,
|
|
||||||
dateRange: {
|
|
||||||
start: range.start,
|
|
||||||
end: range.end
|
|
||||||
},
|
|
||||||
startPanelMonth: toMonthStart(range.start),
|
|
||||||
endPanelMonth: toMonthStart(range.end)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [buildTimeRangeDialogDraft])
|
|
||||||
|
|
||||||
const handleTimeRangePresetClick = useCallback((preset: Exclude<DateRangePreset, 'custom'>) => {
|
|
||||||
applyTimeRangePresetToDraft(preset)
|
|
||||||
}, [applyTimeRangePresetToDraft])
|
|
||||||
|
|
||||||
const updateTimeRangeDraftStart = useCallback((targetDate: Date) => {
|
|
||||||
const start = startOfDay(targetDate)
|
|
||||||
setTimeRangeDialogDraft(prev => {
|
|
||||||
const base = prev ?? buildTimeRangeDialogDraft()
|
|
||||||
const nextEnd = base.dateRange.end < start ? endOfDay(start) : base.dateRange.end
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
preset: 'custom',
|
|
||||||
useAllTime: false,
|
|
||||||
dateRange: {
|
|
||||||
start,
|
|
||||||
end: nextEnd
|
|
||||||
},
|
|
||||||
startPanelMonth: toMonthStart(start),
|
|
||||||
endPanelMonth: toMonthStart(nextEnd)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [buildTimeRangeDialogDraft])
|
|
||||||
|
|
||||||
const updateTimeRangeDraftEnd = useCallback((targetDate: Date) => {
|
|
||||||
const end = endOfDay(targetDate)
|
|
||||||
setTimeRangeDialogDraft(prev => {
|
|
||||||
const base = prev ?? buildTimeRangeDialogDraft()
|
|
||||||
const isAllTimeMode = base.useAllTime
|
|
||||||
const nextStart = isAllTimeMode
|
|
||||||
? startOfDay(targetDate)
|
|
||||||
: base.dateRange.start
|
|
||||||
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
preset: 'custom',
|
|
||||||
useAllTime: false,
|
|
||||||
dateRange: {
|
|
||||||
start: nextStart,
|
|
||||||
end: nextEnd
|
|
||||||
},
|
|
||||||
startPanelMonth: toMonthStart(nextStart),
|
|
||||||
endPanelMonth: toMonthStart(nextEnd)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [buildTimeRangeDialogDraft])
|
|
||||||
|
|
||||||
const commitTimeRangeStartFromInput = useCallback(() => {
|
|
||||||
const parsed = parseDateInputValue(timeRangeDateInput.start)
|
|
||||||
if (!parsed) {
|
|
||||||
setTimeRangeDateInputError(prev => ({ ...prev, start: true }))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setTimeRangeDateInputError(prev => ({ ...prev, start: false }))
|
|
||||||
updateTimeRangeDraftStart(parsed)
|
|
||||||
}, [timeRangeDateInput.start, updateTimeRangeDraftStart])
|
|
||||||
|
|
||||||
const commitTimeRangeEndFromInput = useCallback(() => {
|
|
||||||
const parsed = parseDateInputValue(timeRangeDateInput.end)
|
|
||||||
if (!parsed) {
|
|
||||||
setTimeRangeDateInputError(prev => ({ ...prev, end: true }))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setTimeRangeDateInputError(prev => ({ ...prev, end: false }))
|
|
||||||
updateTimeRangeDraftEnd(parsed)
|
|
||||||
}, [timeRangeDateInput.end, updateTimeRangeDraftEnd])
|
|
||||||
|
|
||||||
const shiftTimeRangePanelMonth = useCallback((panel: 'start' | 'end', delta: number) => {
|
|
||||||
setTimeRangeDialogDraft(prev => {
|
|
||||||
const base = prev ?? buildTimeRangeDialogDraft()
|
|
||||||
if (panel === 'start') {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
startPanelMonth: addMonths(base.startPanelMonth, delta)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
endPanelMonth: addMonths(base.endPanelMonth, delta)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [buildTimeRangeDialogDraft])
|
|
||||||
|
|
||||||
const commitTimeRangeDialogDraft = useCallback(() => {
|
|
||||||
const draft = timeRangeDialogDraft ?? buildTimeRangeDialogDraft()
|
|
||||||
setTimeRangePreset(draft.preset)
|
|
||||||
setOptions(prev => ({
|
|
||||||
...prev,
|
|
||||||
useAllTime: draft.useAllTime,
|
|
||||||
dateRange: {
|
|
||||||
start: new Date(draft.dateRange.start),
|
|
||||||
end: new Date(draft.dateRange.end)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
closeTimeRangeDialog()
|
|
||||||
}, [buildTimeRangeDialogDraft, closeTimeRangeDialog, timeRangeDialogDraft])
|
|
||||||
|
|
||||||
const timeRangeSummaryLabel = useMemo(() => {
|
|
||||||
if (options.useAllTime) return '默认导出全部时间'
|
|
||||||
if (timeRangePreset === 'today') return '今天'
|
|
||||||
if (timeRangePreset === 'yesterday') return '昨天'
|
|
||||||
if (timeRangePreset === 'last3days') return '最近3天'
|
|
||||||
if (timeRangePreset === 'last7days') return '最近一周'
|
|
||||||
if (timeRangePreset === 'last30days') return '最近30 天'
|
|
||||||
if (timeRangePreset === 'last1year') return '最近一年'
|
|
||||||
if (timeRangePreset === 'last2years') return '最近两年'
|
|
||||||
if (options.dateRange) {
|
|
||||||
return `${formatDateInputValue(options.dateRange.start)} 至 ${formatDateInputValue(options.dateRange.end)}`
|
|
||||||
}
|
|
||||||
return '自定义时间范围'
|
|
||||||
}, [options.useAllTime, options.dateRange, timeRangePreset])
|
|
||||||
|
|
||||||
const activeTimeRangeDialogDraft = timeRangeDialogDraft ?? buildTimeRangeDialogDraft()
|
|
||||||
const isRangeModeActive = !activeTimeRangeDialogDraft.useAllTime
|
|
||||||
const timeRangeModeText = isRangeModeActive
|
|
||||||
? '当前导出模式:按时间范围导出'
|
|
||||||
: '当前导出模式:全部时间导出(选择下方日期将切换为按时间范围导出)'
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isTimeRangeDialogOpen) return
|
|
||||||
setTimeRangeDateInput({
|
|
||||||
start: formatDateInputValue(activeTimeRangeDialogDraft.dateRange.start),
|
|
||||||
end: formatDateInputValue(activeTimeRangeDialogDraft.dateRange.end)
|
|
||||||
})
|
|
||||||
setTimeRangeDateInputError({ start: false, end: false })
|
|
||||||
}, [
|
|
||||||
isTimeRangeDialogOpen,
|
|
||||||
activeTimeRangeDialogDraft.dateRange.start.getTime(),
|
|
||||||
activeTimeRangeDialogDraft.dateRange.end.getTime()
|
|
||||||
])
|
|
||||||
|
|
||||||
const isTimeRangePresetActive = useCallback((preset: DateRangePreset): boolean => {
|
|
||||||
if (preset === 'all') return activeTimeRangeDialogDraft.useAllTime
|
|
||||||
return !activeTimeRangeDialogDraft.useAllTime && activeTimeRangeDialogDraft.preset === preset
|
|
||||||
}, [activeTimeRangeDialogDraft])
|
|
||||||
|
|
||||||
const startPanelCells = useMemo(
|
|
||||||
() => buildCalendarCells(activeTimeRangeDialogDraft.startPanelMonth),
|
|
||||||
[activeTimeRangeDialogDraft.startPanelMonth]
|
|
||||||
)
|
|
||||||
|
|
||||||
const endPanelCells = useMemo(
|
|
||||||
() => buildCalendarCells(activeTimeRangeDialogDraft.endPanelMonth),
|
|
||||||
[activeTimeRangeDialogDraft.endPanelMonth]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = onOpenSingleExport((payload) => {
|
const unsubscribe = onOpenSingleExport((payload) => {
|
||||||
@@ -6377,174 +6052,21 @@ function ExportPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isTimeRangeDialogOpen && (
|
<ExportDateRangeDialog
|
||||||
<div className="time-range-dialog-overlay" onClick={closeTimeRangeDialog}>
|
open={isTimeRangeDialogOpen}
|
||||||
<div className="time-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
value={timeRangeSelection}
|
||||||
<div className="time-range-dialog-header">
|
onClose={closeTimeRangeDialog}
|
||||||
<h4>时间范围设置</h4>
|
onConfirm={(nextSelection) => {
|
||||||
<button
|
setTimeRangeSelection(nextSelection)
|
||||||
type="button"
|
setOptions(prev => ({
|
||||||
className="close-icon-btn"
|
...prev,
|
||||||
onClick={closeTimeRangeDialog}
|
useAllTime: nextSelection.useAllTime,
|
||||||
aria-label="关闭时间范围设置"
|
dateRange: cloneExportDateRange(nextSelection.dateRange)
|
||||||
>
|
}))
|
||||||
<X size={14} />
|
closeTimeRangeDialog()
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="time-range-preset-list">
|
|
||||||
{([
|
|
||||||
{ value: 'all', label: '全部时间' },
|
|
||||||
{ value: 'today', label: '今天' },
|
|
||||||
{ value: 'yesterday', label: '昨天' },
|
|
||||||
{ value: 'last3days', label: '最近3天' },
|
|
||||||
{ value: 'last7days', label: '最近一周' },
|
|
||||||
{ value: 'last30days', label: '最近30 天' },
|
|
||||||
{ value: 'last1year', label: '最近一年' }
|
|
||||||
] as Array<{ value: Exclude<DateRangePreset, 'custom'>; label: string }>).map((preset) => {
|
|
||||||
const isActive = isTimeRangePresetActive(preset.value)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={preset.value}
|
|
||||||
type="button"
|
|
||||||
className={`time-range-preset-item ${isActive ? 'active' : ''}`}
|
|
||||||
onClick={() => handleTimeRangePresetClick(preset.value)}
|
|
||||||
>
|
|
||||||
<span>{preset.label}</span>
|
|
||||||
{isActive && <Check size={14} />}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`time-range-mode-banner ${isRangeModeActive ? 'range' : 'all'}`}>
|
|
||||||
{timeRangeModeText}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="time-range-calendar-grid">
|
|
||||||
<section className="time-range-calendar-panel">
|
|
||||||
<div className="time-range-calendar-panel-header">
|
|
||||||
<div className="time-range-calendar-date-label">
|
|
||||||
<span>起始日期</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={`time-range-date-input ${timeRangeDateInputError.start ? 'invalid' : ''}`}
|
|
||||||
value={timeRangeDateInput.start}
|
|
||||||
placeholder="YYYY-MM-DD"
|
|
||||||
onChange={(event) => {
|
|
||||||
const nextValue = event.target.value
|
|
||||||
setTimeRangeDateInput(prev => ({ ...prev, start: nextValue }))
|
|
||||||
if (timeRangeDateInputError.start) {
|
|
||||||
setTimeRangeDateInputError(prev => ({ ...prev, start: false }))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key !== 'Enter') return
|
|
||||||
event.preventDefault()
|
|
||||||
commitTimeRangeStartFromInput()
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
commitTimeRangeStartFromInput()
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="time-range-calendar-nav">
|
|
||||||
<button type="button" onClick={() => shiftTimeRangePanelMonth('start', -1)} aria-label="上个月">‹</button>
|
|
||||||
<span>{formatCalendarMonthTitle(activeTimeRangeDialogDraft.startPanelMonth)}</span>
|
|
||||||
<button type="button" onClick={() => shiftTimeRangePanelMonth('start', 1)} aria-label="下个月">›</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="time-range-calendar-weekdays">
|
|
||||||
{WEEKDAY_SHORT_LABELS.map(label => (
|
|
||||||
<span key={`start-weekday-${label}`}>{label}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="time-range-calendar-days">
|
|
||||||
{startPanelCells.map((cell) => {
|
|
||||||
const isSelected = !activeTimeRangeDialogDraft.useAllTime &&
|
|
||||||
isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.start)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`start-${cell.date.getTime()}`}
|
|
||||||
type="button"
|
|
||||||
className={`time-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${isSelected ? 'selected' : ''}`}
|
|
||||||
onClick={() => updateTimeRangeDraftStart(cell.date)}
|
|
||||||
>
|
|
||||||
{cell.date.getDate()}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="time-range-calendar-panel">
|
|
||||||
<div className="time-range-calendar-panel-header">
|
|
||||||
<div className="time-range-calendar-date-label">
|
|
||||||
<span>截止日期</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={`time-range-date-input ${timeRangeDateInputError.end ? 'invalid' : ''}`}
|
|
||||||
value={timeRangeDateInput.end}
|
|
||||||
placeholder="YYYY-MM-DD"
|
|
||||||
onChange={(event) => {
|
|
||||||
const nextValue = event.target.value
|
|
||||||
setTimeRangeDateInput(prev => ({ ...prev, end: nextValue }))
|
|
||||||
if (timeRangeDateInputError.end) {
|
|
||||||
setTimeRangeDateInputError(prev => ({ ...prev, end: false }))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key !== 'Enter') return
|
|
||||||
event.preventDefault()
|
|
||||||
commitTimeRangeEndFromInput()
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
commitTimeRangeEndFromInput()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="time-range-calendar-nav">
|
|
||||||
<button type="button" onClick={() => shiftTimeRangePanelMonth('end', -1)} aria-label="上个月">‹</button>
|
|
||||||
<span>{formatCalendarMonthTitle(activeTimeRangeDialogDraft.endPanelMonth)}</span>
|
|
||||||
<button type="button" onClick={() => shiftTimeRangePanelMonth('end', 1)} aria-label="下个月">›</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="time-range-calendar-weekdays">
|
|
||||||
{WEEKDAY_SHORT_LABELS.map(label => (
|
|
||||||
<span key={`end-weekday-${label}`}>{label}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="time-range-calendar-days">
|
|
||||||
{endPanelCells.map((cell) => {
|
|
||||||
const isSelected = !activeTimeRangeDialogDraft.useAllTime &&
|
|
||||||
isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.end)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`end-${cell.date.getTime()}`}
|
|
||||||
type="button"
|
|
||||||
className={`time-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${isSelected ? 'selected' : ''}`}
|
|
||||||
onClick={() => updateTimeRangeDraftEnd(cell.date)}
|
|
||||||
>
|
|
||||||
{cell.date.getDate()}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="time-range-dialog-actions">
|
|
||||||
<button type="button" className="secondary-btn" onClick={closeTimeRangeDialog}>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button type="button" className="primary-btn" onClick={commitTimeRangeDialogDraft}>
|
|
||||||
确认
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -348,6 +348,51 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-time-range-field {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-time-range-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(var(--primary-rgb), 0.45);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-time-range-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-time-range-arrow {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.select-trigger {
|
.select-trigger {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ import {
|
|||||||
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2
|
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
|
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
|
||||||
|
import {
|
||||||
|
createDefaultExportDateRangeSelection,
|
||||||
|
getExportDateRangeLabel,
|
||||||
|
resolveExportDateRangeConfig,
|
||||||
|
serializeExportDateRangeConfig,
|
||||||
|
type ExportDateRangeSelection
|
||||||
|
} from '../utils/exportDateRange'
|
||||||
import './SettingsPage.scss'
|
import './SettingsPage.scss'
|
||||||
|
|
||||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'api' | 'security' | 'about' | 'analytics'
|
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'api' | 'security' | 'about' | 'analytics'
|
||||||
@@ -74,11 +82,9 @@ function SettingsPage() {
|
|||||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||||
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
||||||
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
|
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
|
||||||
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
|
|
||||||
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||||
const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false)
|
const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false)
|
||||||
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
|
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
|
|
||||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
|
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const [cachePath, setCachePath] = useState('')
|
const [cachePath, setCachePath] = useState('')
|
||||||
@@ -104,7 +110,8 @@ function SettingsPage() {
|
|||||||
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||||
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||||
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
|
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||||
|
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||||
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
||||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||||
@@ -209,9 +216,6 @@ function SettingsPage() {
|
|||||||
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
|
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
|
||||||
setShowExportFormatSelect(false)
|
setShowExportFormatSelect(false)
|
||||||
}
|
}
|
||||||
if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) {
|
|
||||||
setShowExportDateRangeSelect(false)
|
|
||||||
}
|
|
||||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||||
setShowExportExcelColumnsSelect(false)
|
setShowExportExcelColumnsSelect(false)
|
||||||
}
|
}
|
||||||
@@ -221,7 +225,7 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
|
}, [showExportFormatSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||||
@@ -331,7 +335,7 @@ function SettingsPage() {
|
|||||||
setAutoTranscribeVoice(savedAutoTranscribe)
|
setAutoTranscribeVoice(savedAutoTranscribe)
|
||||||
setTranscribeLanguages(savedTranscribeLanguages)
|
setTranscribeLanguages(savedTranscribeLanguages)
|
||||||
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
|
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
|
||||||
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
|
setExportDefaultDateRange(resolveExportDateRangeConfig(savedExportDefaultDateRange))
|
||||||
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
||||||
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false)
|
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false)
|
||||||
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
||||||
@@ -1557,13 +1561,6 @@ function SettingsPage() {
|
|||||||
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
|
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
|
||||||
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
|
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
|
||||||
]
|
]
|
||||||
const exportDateRangeOptions = [
|
|
||||||
{ value: 'today', label: '今天' },
|
|
||||||
{ value: '7d', label: '最近7天' },
|
|
||||||
{ value: '30d', label: '最近30天' },
|
|
||||||
{ value: '90d', label: '最近90天' },
|
|
||||||
{ value: 'all', label: '全部时间' }
|
|
||||||
]
|
|
||||||
const exportExcelColumnOptions = [
|
const exportExcelColumnOptions = [
|
||||||
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
|
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
|
||||||
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
||||||
@@ -1585,7 +1582,7 @@ function SettingsPage() {
|
|||||||
const renderExportTab = () => {
|
const renderExportTab = () => {
|
||||||
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||||
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
|
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
|
||||||
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
|
const exportDateRangeLabel = getExportDateRangeLabel(exportDefaultDateRange)
|
||||||
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
|
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
|
||||||
const exportConcurrencyLabel = String(exportDefaultConcurrency)
|
const exportConcurrencyLabel = String(exportDefaultConcurrency)
|
||||||
|
|
||||||
@@ -1600,7 +1597,7 @@ function SettingsPage() {
|
|||||||
className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`}
|
className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowExportFormatSelect(!showExportFormatSelect)
|
setShowExportFormatSelect(!showExportFormatSelect)
|
||||||
setShowExportDateRangeSelect(false)
|
setIsExportDateRangeDialogOpen(false)
|
||||||
setShowExportExcelColumnsSelect(false)
|
setShowExportExcelColumnsSelect(false)
|
||||||
setShowExportConcurrencySelect(false)
|
setShowExportConcurrencySelect(false)
|
||||||
}}
|
}}
|
||||||
@@ -1634,41 +1631,34 @@ function SettingsPage() {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>默认导出时间范围</label>
|
<label>默认导出时间范围</label>
|
||||||
<span className="form-hint">控制导出页面的默认时间选择</span>
|
<span className="form-hint">控制导出页面的默认时间选择</span>
|
||||||
<div className="select-field" ref={exportDateRangeDropdownRef}>
|
<div className="settings-time-range-field">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`select-trigger ${showExportDateRangeSelect ? 'open' : ''}`}
|
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowExportDateRangeSelect(!showExportDateRangeSelect)
|
|
||||||
setShowExportFormatSelect(false)
|
setShowExportFormatSelect(false)
|
||||||
setShowExportExcelColumnsSelect(false)
|
setShowExportExcelColumnsSelect(false)
|
||||||
setShowExportConcurrencySelect(false)
|
setShowExportConcurrencySelect(false)
|
||||||
|
setIsExportDateRangeDialogOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="select-value">{exportDateRangeLabel}</span>
|
<span className="settings-time-range-value">{exportDateRangeLabel}</span>
|
||||||
<ChevronDown size={16} />
|
<span className="settings-time-range-arrow">></span>
|
||||||
</button>
|
</button>
|
||||||
{showExportDateRangeSelect && (
|
</div>
|
||||||
<div className="select-dropdown">
|
</div>
|
||||||
{exportDateRangeOptions.map((option) => (
|
|
||||||
<button
|
<ExportDateRangeDialog
|
||||||
key={option.value}
|
open={isExportDateRangeDialogOpen}
|
||||||
type="button"
|
value={exportDefaultDateRange}
|
||||||
className={`select-option ${exportDefaultDateRange === option.value ? 'active' : ''}`}
|
onClose={() => setIsExportDateRangeDialogOpen(false)}
|
||||||
onClick={async () => {
|
onConfirm={async (nextSelection) => {
|
||||||
setExportDefaultDateRange(option.value)
|
setExportDefaultDateRange(nextSelection)
|
||||||
await configService.setExportDefaultDateRange(option.value)
|
await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection))
|
||||||
showMessage('已更新默认导出时间范围', true)
|
showMessage('已更新默认导出时间范围', true)
|
||||||
setShowExportDateRangeSelect(false)
|
setIsExportDateRangeDialogOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<span className="option-label">{option.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>默认导出媒体文件</label>
|
<label>默认导出媒体文件</label>
|
||||||
@@ -1726,7 +1716,7 @@ function SettingsPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||||
setShowExportFormatSelect(false)
|
setShowExportFormatSelect(false)
|
||||||
setShowExportDateRangeSelect(false)
|
setIsExportDateRangeDialogOpen(false)
|
||||||
setShowExportConcurrencySelect(false)
|
setShowExportConcurrencySelect(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1767,7 +1757,7 @@ function SettingsPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowExportConcurrencySelect(!showExportConcurrencySelect)
|
setShowExportConcurrencySelect(!showExportConcurrencySelect)
|
||||||
setShowExportFormatSelect(false)
|
setShowExportFormatSelect(false)
|
||||||
setShowExportDateRangeSelect(false)
|
setIsExportDateRangeDialogOpen(false)
|
||||||
setShowExportExcelColumnsSelect(false)
|
setShowExportExcelColumnsSelect(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// 配置服务 - 封装 Electron Store
|
// 配置服务 - 封装 Electron Store
|
||||||
import { config } from './ipc'
|
import { config } from './ipc'
|
||||||
|
import type { ExportDefaultDateRangeConfig } from '../utils/exportDateRange'
|
||||||
|
|
||||||
// 配置键名
|
// 配置键名
|
||||||
export const CONFIG_KEYS = {
|
export const CONFIG_KEYS = {
|
||||||
@@ -335,13 +336,17 @@ export async function setExportDefaultFormat(format: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取导出默认时间范围
|
// 获取导出默认时间范围
|
||||||
export async function getExportDefaultDateRange(): Promise<string | null> {
|
export async function getExportDefaultDateRange(): Promise<ExportDefaultDateRangeConfig | string | null> {
|
||||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE)
|
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE)
|
||||||
return (value as string) || null
|
if (typeof value === 'string') return value
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return value as ExportDefaultDateRangeConfig
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置导出默认时间范围
|
// 设置导出默认时间范围
|
||||||
export async function setExportDefaultDateRange(range: string): Promise<void> {
|
export async function setExportDefaultDateRange(range: ExportDefaultDateRangeConfig | string): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
341
src/utils/exportDateRange.ts
Normal file
341
src/utils/exportDateRange.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
export type ExportDateRangePreset =
|
||||||
|
| 'all'
|
||||||
|
| 'today'
|
||||||
|
| 'yesterday'
|
||||||
|
| 'last3days'
|
||||||
|
| 'last7days'
|
||||||
|
| 'last30days'
|
||||||
|
| 'last1year'
|
||||||
|
| 'last2years'
|
||||||
|
| 'custom'
|
||||||
|
|
||||||
|
export type CalendarCell = { date: Date; inCurrentMonth: boolean }
|
||||||
|
|
||||||
|
export interface ExportDateRange {
|
||||||
|
start: Date
|
||||||
|
end: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportDateRangeSelection {
|
||||||
|
preset: ExportDateRangePreset
|
||||||
|
useAllTime: boolean
|
||||||
|
dateRange: ExportDateRange
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportDefaultDateRangeConfig {
|
||||||
|
version?: 1
|
||||||
|
preset?: ExportDateRangePreset | string
|
||||||
|
useAllTime?: boolean
|
||||||
|
start?: string | number | Date | null
|
||||||
|
end?: string | number | Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXPORT_DATE_RANGE_PRESETS: Array<{
|
||||||
|
value: Exclude<ExportDateRangePreset, 'custom'>
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ value: 'all', label: '全部时间' },
|
||||||
|
{ value: 'today', label: '今天' },
|
||||||
|
{ value: 'yesterday', label: '昨天' },
|
||||||
|
{ value: 'last3days', label: '最近3天' },
|
||||||
|
{ value: 'last7days', label: '最近一周' },
|
||||||
|
{ value: 'last30days', label: '最近30天' },
|
||||||
|
{ value: 'last1year', label: '最近一年' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const PRESET_LABELS: Record<Exclude<ExportDateRangePreset, 'custom'>, string> = {
|
||||||
|
all: '全部时间',
|
||||||
|
today: '今天',
|
||||||
|
yesterday: '昨天',
|
||||||
|
last3days: '最近3天',
|
||||||
|
last7days: '最近一周',
|
||||||
|
last30days: '最近30天',
|
||||||
|
last1year: '最近一年',
|
||||||
|
last2years: '最近两年'
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGACY_PRESET_MAP: Record<string, Exclude<ExportDateRangePreset, 'custom'> | 'legacy90days'> = {
|
||||||
|
all: 'all',
|
||||||
|
today: 'today',
|
||||||
|
yesterday: 'yesterday',
|
||||||
|
last3days: 'last3days',
|
||||||
|
last7days: 'last7days',
|
||||||
|
last30days: 'last30days',
|
||||||
|
last1year: 'last1year',
|
||||||
|
last2years: 'last2years',
|
||||||
|
'7d': 'last7days',
|
||||||
|
'30d': 'last30days',
|
||||||
|
'90d': 'legacy90days'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WEEKDAY_SHORT_LABELS = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
|
|
||||||
|
export const startOfDay = (date: Date): Date => {
|
||||||
|
const next = new Date(date)
|
||||||
|
next.setHours(0, 0, 0, 0)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export const endOfDay = (date: Date): Date => {
|
||||||
|
const next = new Date(date)
|
||||||
|
next.setHours(23, 59, 59, 999)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDefaultDateRange = (): ExportDateRange => {
|
||||||
|
const now = new Date()
|
||||||
|
return {
|
||||||
|
start: startOfDay(now),
|
||||||
|
end: now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDateRangeByPreset = (
|
||||||
|
preset: Exclude<ExportDateRangePreset, 'all' | 'custom'>,
|
||||||
|
now = new Date()
|
||||||
|
): ExportDateRange => {
|
||||||
|
const end = new Date(now)
|
||||||
|
const baseStart = startOfDay(now)
|
||||||
|
|
||||||
|
if (preset === 'today') {
|
||||||
|
return { start: baseStart, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset === 'yesterday') {
|
||||||
|
const yesterday = new Date(baseStart)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
return {
|
||||||
|
start: yesterday,
|
||||||
|
end: endOfDay(yesterday)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset === 'last1year' || preset === 'last2years') {
|
||||||
|
const yearsBack = preset === 'last1year' ? 1 : 2
|
||||||
|
const start = new Date(baseStart)
|
||||||
|
const expectedMonth = start.getMonth()
|
||||||
|
start.setFullYear(start.getFullYear() - yearsBack)
|
||||||
|
if (start.getMonth() !== expectedMonth) {
|
||||||
|
start.setDate(0)
|
||||||
|
}
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysBack = preset === 'last3days' ? 2 : preset === 'last7days' ? 6 : 29
|
||||||
|
const start = new Date(baseStart)
|
||||||
|
start.setDate(start.getDate() - daysBack)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDateRangeByLastNDays = (days: number, now = new Date()): ExportDateRange => {
|
||||||
|
const end = new Date(now)
|
||||||
|
const start = startOfDay(now)
|
||||||
|
start.setDate(start.getDate() - Math.max(0, days - 1))
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDateInputValue = (date: Date): string => {
|
||||||
|
const y = date.getFullYear()
|
||||||
|
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const d = `${date.getDate()}`.padStart(2, '0')
|
||||||
|
return `${y}-${m}-${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseDateInputValue = (raw: string): Date | null => {
|
||||||
|
const text = String(raw || '').trim()
|
||||||
|
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
|
||||||
|
if (!matched) return null
|
||||||
|
const year = Number(matched[1])
|
||||||
|
const month = Number(matched[2])
|
||||||
|
const day = Number(matched[3])
|
||||||
|
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
|
||||||
|
if (month < 1 || month > 12 || day < 1 || day > 31) return null
|
||||||
|
const parsed = new Date(year, month - 1, day)
|
||||||
|
if (
|
||||||
|
parsed.getFullYear() !== year ||
|
||||||
|
parsed.getMonth() !== month - 1 ||
|
||||||
|
parsed.getDate() !== day
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toMonthStart = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), 1)
|
||||||
|
|
||||||
|
export const addMonths = (date: Date, delta: number): Date => {
|
||||||
|
const next = new Date(date)
|
||||||
|
next.setMonth(next.getMonth() + delta)
|
||||||
|
return toMonthStart(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isSameDay = (left: Date, right: Date): boolean => (
|
||||||
|
left.getFullYear() === right.getFullYear() &&
|
||||||
|
left.getMonth() === right.getMonth() &&
|
||||||
|
left.getDate() === right.getDate()
|
||||||
|
)
|
||||||
|
|
||||||
|
export const buildCalendarCells = (monthStart: Date): CalendarCell[] => {
|
||||||
|
const firstDay = new Date(monthStart.getFullYear(), monthStart.getMonth(), 1)
|
||||||
|
const startOffset = firstDay.getDay()
|
||||||
|
const gridStart = new Date(firstDay)
|
||||||
|
gridStart.setDate(gridStart.getDate() - startOffset)
|
||||||
|
const cells: CalendarCell[] = []
|
||||||
|
for (let index = 0; index < 42; index += 1) {
|
||||||
|
const current = new Date(gridStart)
|
||||||
|
current.setDate(gridStart.getDate() + index)
|
||||||
|
cells.push({
|
||||||
|
date: current,
|
||||||
|
inCurrentMonth: current.getMonth() === monthStart.getMonth()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatCalendarMonthTitle = (date: Date): string => `${date.getFullYear()}年${date.getMonth() + 1}月`
|
||||||
|
|
||||||
|
export const cloneExportDateRange = (range: ExportDateRange): ExportDateRange => ({
|
||||||
|
start: new Date(range.start),
|
||||||
|
end: new Date(range.end)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const cloneExportDateRangeSelection = (selection: ExportDateRangeSelection): ExportDateRangeSelection => ({
|
||||||
|
preset: selection.preset,
|
||||||
|
useAllTime: selection.useAllTime,
|
||||||
|
dateRange: cloneExportDateRange(selection.dateRange)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createExportDateRangeSelectionFromPreset = (
|
||||||
|
preset: Exclude<ExportDateRangePreset, 'custom'>,
|
||||||
|
now = new Date()
|
||||||
|
): ExportDateRangeSelection => {
|
||||||
|
if (preset === 'all') {
|
||||||
|
return {
|
||||||
|
preset,
|
||||||
|
useAllTime: true,
|
||||||
|
dateRange: createDefaultDateRange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
preset,
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: createDateRangeByPreset(preset, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDefaultExportDateRangeSelection = (): ExportDateRangeSelection => (
|
||||||
|
createExportDateRangeSelectionFromPreset('today')
|
||||||
|
)
|
||||||
|
|
||||||
|
const parseStoredDate = (value: unknown): Date | null => {
|
||||||
|
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||||||
|
return new Date(value)
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
const parsed = new Date(value)
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = parseDateInputValue(value)
|
||||||
|
if (normalized) return normalized
|
||||||
|
const parsed = new Date(value)
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizePreset = (raw: unknown): Exclude<ExportDateRangePreset, 'custom'> | 'legacy90days' | null => {
|
||||||
|
if (typeof raw !== 'string') return null
|
||||||
|
const normalized = LEGACY_PRESET_MAP[raw]
|
||||||
|
return normalized ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveExportDateRangeConfig = (
|
||||||
|
raw: ExportDefaultDateRangeConfig | string | null | undefined,
|
||||||
|
now = new Date()
|
||||||
|
): ExportDateRangeSelection => {
|
||||||
|
if (!raw) {
|
||||||
|
return createDefaultExportDateRangeSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const preset = normalizePreset(raw)
|
||||||
|
if (!preset) return createDefaultExportDateRangeSelection()
|
||||||
|
if (preset === 'legacy90days') {
|
||||||
|
return {
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: createDateRangeByLastNDays(90, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createExportDateRangeSelectionFromPreset(preset, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = normalizePreset(raw.preset)
|
||||||
|
if (raw.useAllTime || preset === 'all') {
|
||||||
|
return createExportDateRangeSelectionFromPreset('all', now)
|
||||||
|
}
|
||||||
|
if (preset && preset !== 'legacy90days') {
|
||||||
|
return createExportDateRangeSelectionFromPreset(preset, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset === 'legacy90days') {
|
||||||
|
return {
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: createDateRangeByLastNDays(90, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedStart = parseStoredDate(raw.start)
|
||||||
|
const parsedEnd = parseStoredDate(raw.end)
|
||||||
|
if (parsedStart && parsedEnd) {
|
||||||
|
const start = startOfDay(parsedStart)
|
||||||
|
const end = endOfDay(parsedEnd)
|
||||||
|
return {
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: {
|
||||||
|
start,
|
||||||
|
end: end < start ? endOfDay(start) : end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createDefaultExportDateRangeSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serializeExportDateRangeConfig = (
|
||||||
|
selection: ExportDateRangeSelection
|
||||||
|
): ExportDefaultDateRangeConfig => {
|
||||||
|
if (selection.useAllTime) {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
preset: 'all',
|
||||||
|
useAllTime: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.preset === 'custom') {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
start: formatDateInputValue(selection.dateRange.start),
|
||||||
|
end: formatDateInputValue(selection.dateRange.end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
preset: selection.preset,
|
||||||
|
useAllTime: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getExportDateRangeLabel = (selection: ExportDateRangeSelection): string => {
|
||||||
|
if (selection.useAllTime) return PRESET_LABELS.all
|
||||||
|
if (selection.preset !== 'custom') return PRESET_LABELS[selection.preset]
|
||||||
|
return `${formatDateInputValue(selection.dateRange.start)} 至 ${formatDateInputValue(selection.dateRange.end)}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user