mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat(export): redesign time range selector with nested dialog
This commit is contained in:
@@ -2071,6 +2071,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-header-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-range-trigger {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(var(--primary-rgb), 0.45);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-range-arrow {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.scope-tag-row {
|
.scope-tag-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2301,6 +2337,96 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-range-dialog {
|
||||||
|
width: min(520px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 64px);
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--card-bg);
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-range-dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-range-preset-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-range-preset-item {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-range-custom-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-range-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes exportSpin {
|
@keyframes exportSpin {
|
||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
@@ -2438,6 +2564,14 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-range-preset-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-range-custom-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.task-center-modal-overlay {
|
.task-center-modal-overlay {
|
||||||
padding: 12px 10px;
|
padding: 12px 10px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type ContentCardType = ContentType | 'sns'
|
|||||||
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' | 'custom'
|
||||||
|
|
||||||
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'
|
||||||
@@ -415,6 +416,52 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 formatDateInputValue = (date: Date): string => {
|
||||||
const y = date.getFullYear()
|
const y = date.getFullYear()
|
||||||
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
@@ -1108,6 +1155,8 @@ function ExportPage() {
|
|||||||
const [snsExportImages, setSnsExportImages] = useState(false)
|
const [snsExportImages, setSnsExportImages] = useState(false)
|
||||||
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 [timeRangePreset, setTimeRangePreset] = useState<DateRangePreset>('all')
|
||||||
|
|
||||||
const [options, setOptions] = useState<ExportOptions>({
|
const [options, setOptions] = useState<ExportOptions>({
|
||||||
format: 'json',
|
format: 'json',
|
||||||
@@ -2175,14 +2224,11 @@ 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)
|
||||||
|
setTimeRangePreset('all')
|
||||||
|
|
||||||
setOptions(prev => {
|
setOptions(prev => {
|
||||||
const nextDateRange = prev.dateRange ?? (() => {
|
const nextDateRange = prev.dateRange ?? createDefaultDateRange()
|
||||||
const now = new Date()
|
|
||||||
const start = new Date(now)
|
|
||||||
start.setHours(0, 0, 0, 0)
|
|
||||||
return { start, end: now }
|
|
||||||
})()
|
|
||||||
|
|
||||||
const next: ExportOptions = {
|
const next: ExportOptions = {
|
||||||
...prev,
|
...prev,
|
||||||
@@ -2218,8 +2264,84 @@ function ExportPage() {
|
|||||||
|
|
||||||
const closeExportDialog = useCallback(() => {
|
const closeExportDialog = useCallback(() => {
|
||||||
setExportDialog(prev => ({ ...prev, open: false }))
|
setExportDialog(prev => ({ ...prev, open: false }))
|
||||||
|
setIsTimeRangeDialogOpen(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const applyTimeRangePreset = useCallback((preset: Exclude<DateRangePreset, 'custom'>) => {
|
||||||
|
setTimeRangePreset(preset)
|
||||||
|
if (preset === 'all') {
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
useAllTime: true,
|
||||||
|
dateRange: prev.dateRange ?? createDefaultDateRange()
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const range = createDateRangeByPreset(preset)
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: range
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const activateCustomTimeRange = useCallback(() => {
|
||||||
|
setTimeRangePreset('custom')
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: prev.dateRange ?? createDefaultDateRange()
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateCustomDateRangeStart = useCallback((value: string) => {
|
||||||
|
const start = parseDateInput(value, false)
|
||||||
|
setTimeRangePreset('custom')
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: prev.dateRange
|
||||||
|
? {
|
||||||
|
start,
|
||||||
|
end: prev.dateRange.end < start ? parseDateInput(value, true) : prev.dateRange.end
|
||||||
|
}
|
||||||
|
: { start, end: new Date() }
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateCustomDateRangeEnd = useCallback((value: string) => {
|
||||||
|
const end = parseDateInput(value, true)
|
||||||
|
setTimeRangePreset('custom')
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: prev.dateRange
|
||||||
|
? {
|
||||||
|
start: prev.dateRange.start > end ? parseDateInput(value, false) : prev.dateRange.start,
|
||||||
|
end
|
||||||
|
}
|
||||||
|
: { start: new Date(), end }
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const timeRangeSummaryLabel = useMemo(() => {
|
||||||
|
if (options.useAllTime) return '默认导出全部时间'
|
||||||
|
if (timeRangePreset === 'today') return '今天'
|
||||||
|
if (timeRangePreset === 'yesterday') return '昨天'
|
||||||
|
if (timeRangePreset === 'last3days') return '最近三天'
|
||||||
|
if (timeRangePreset === 'last7days') return '最近一周'
|
||||||
|
if (timeRangePreset === 'last30days') return '最近一个月'
|
||||||
|
if (options.dateRange) {
|
||||||
|
return `${formatDateInputValue(options.dateRange.start)} 至 ${formatDateInputValue(options.dateRange.end)}`
|
||||||
|
}
|
||||||
|
return '自定义时间范围'
|
||||||
|
}, [options.useAllTime, options.dateRange, timeRangePreset])
|
||||||
|
|
||||||
|
const isTimeRangePresetActive = useCallback((preset: DateRangePreset): boolean => {
|
||||||
|
if (preset === 'all') return options.useAllTime
|
||||||
|
return !options.useAllTime && timeRangePreset === preset
|
||||||
|
}, [options.useAllTime, timeRangePreset])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = onOpenSingleExport((payload) => {
|
const unsubscribe = onOpenSingleExport((payload) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -4351,57 +4473,17 @@ function ExportPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="dialog-section">
|
<div className="dialog-section">
|
||||||
<h4>时间范围</h4>
|
<div className="section-header-action">
|
||||||
<div className="switch-row">
|
<h4>时间范围</h4>
|
||||||
<span>导出全部时间</span>
|
<button
|
||||||
<label className="switch">
|
type="button"
|
||||||
<input
|
className="time-range-trigger"
|
||||||
type="checkbox"
|
onClick={() => setIsTimeRangeDialogOpen(true)}
|
||||||
checked={options.useAllTime}
|
>
|
||||||
onChange={(event) => setOptions(prev => ({ ...prev, useAllTime: event.target.checked }))}
|
<span>{timeRangeSummaryLabel}</span>
|
||||||
/>
|
<span className="time-range-arrow">></span>
|
||||||
<span className="switch-slider"></span>
|
</button>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!options.useAllTime && options.dateRange && (
|
|
||||||
<div className="date-range-row">
|
|
||||||
<label>
|
|
||||||
开始
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={formatDateInputValue(options.dateRange.start)}
|
|
||||||
onChange={(event) => {
|
|
||||||
const start = parseDateInput(event.target.value, false)
|
|
||||||
setOptions(prev => ({
|
|
||||||
...prev,
|
|
||||||
dateRange: prev.dateRange ? {
|
|
||||||
start,
|
|
||||||
end: prev.dateRange.end < start ? parseDateInput(event.target.value, true) : prev.dateRange.end
|
|
||||||
} : { start, end: new Date() }
|
|
||||||
}))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
结束
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={formatDateInputValue(options.dateRange.end)}
|
|
||||||
onChange={(event) => {
|
|
||||||
const end = parseDateInput(event.target.value, true)
|
|
||||||
setOptions(prev => ({
|
|
||||||
...prev,
|
|
||||||
dateRange: prev.dateRange ? {
|
|
||||||
start: prev.dateRange.start > end ? parseDateInput(event.target.value, false) : prev.dateRange.start,
|
|
||||||
end
|
|
||||||
} : { start: new Date(), end }
|
|
||||||
}))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shouldShowMediaSection && (
|
{shouldShowMediaSection && (
|
||||||
@@ -4462,6 +4544,83 @@ function ExportPage() {
|
|||||||
<Download size={14} /> 创建导出任务
|
<Download size={14} /> 创建导出任务
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isTimeRangeDialogOpen && (
|
||||||
|
<div className="time-range-dialog-overlay" onClick={() => setIsTimeRangeDialogOpen(false)}>
|
||||||
|
<div className="time-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="time-range-dialog-header">
|
||||||
|
<h4>时间范围设置</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="close-icon-btn"
|
||||||
|
onClick={() => setIsTimeRangeDialogOpen(false)}
|
||||||
|
aria-label="关闭时间范围设置"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="time-range-preset-list">
|
||||||
|
{([
|
||||||
|
{ value: 'all', label: '默认导出全部时间' },
|
||||||
|
{ value: 'today', label: '今天' },
|
||||||
|
{ value: 'yesterday', label: '昨天' },
|
||||||
|
{ value: 'last3days', label: '最近三天' },
|
||||||
|
{ value: 'last7days', label: '最近一周' },
|
||||||
|
{ value: 'last30days', label: '最近一个月' },
|
||||||
|
{ value: 'custom', label: '自定义' }
|
||||||
|
] as Array<{ value: DateRangePreset; label: string }>).map((preset) => {
|
||||||
|
const isActive = isTimeRangePresetActive(preset.value)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.value}
|
||||||
|
type="button"
|
||||||
|
className={`time-range-preset-item ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (preset.value === 'custom') {
|
||||||
|
activateCustomTimeRange()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyTimeRangePreset(preset.value)
|
||||||
|
setIsTimeRangeDialogOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{preset.label}</span>
|
||||||
|
{isActive && <Check size={14} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!options.useAllTime && timeRangePreset === 'custom' && options.dateRange && (
|
||||||
|
<div className="time-range-custom-row">
|
||||||
|
<label>
|
||||||
|
开始日期
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formatDateInputValue(options.dateRange.start)}
|
||||||
|
onChange={(event) => updateCustomDateRangeStart(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
结束日期
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formatDateInputValue(options.dateRange.end)}
|
||||||
|
onChange={(event) => updateCustomDateRangeEnd(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="time-range-dialog-actions">
|
||||||
|
<button type="button" className="secondary-btn" onClick={() => setIsTimeRangeDialogOpen(false)}>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
|
|||||||
Reference in New Issue
Block a user