feat: 优化 AI 对话框交互体验

- 重构 AI 输入框为底部悬浮式,聚焦展开
- 添加预览卡片支持编辑重复、颜色等选项
- 优化时区显示和日期格式化
- 添加 loading 状态和 Toast 提示
- 支持确认后自动关闭并显示成功通知

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ddshi 2026-02-13 11:53:16 +08:00
parent bc627544d8
commit 5f1c6208df
5 changed files with 630 additions and 214 deletions

View File

@ -9,23 +9,46 @@ import {
Loader,
Box,
Transition,
Button,
Badge,
Divider,
} from '@mantine/core';
import { IconSparkles, IconX, IconSend, IconMessage } from '@tabler/icons-react';
import {
IconSparkles,
IconSend,
IconCalendar,
IconRepeat,
IconFlag,
} from '@tabler/icons-react';
import { api } from '../../services/api';
import { useAppStore } from '../../stores';
import type { AIConversation } from '../../types';
import type { AIConversation, AIParsedEvent, RepeatType, PriorityType } from '../../types';
import { notifications } from '@mantine/notifications';
interface FloatingAIChatProps {
onEventCreated?: () => void;
}
export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
const [isOpen, setIsOpen] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [message, setMessage] = useState('');
const [loading, setLoading] = useState(false);
const [history, setHistory] = useState<AIConversation[]>([]);
const [parsedEvent, setParsedEvent] = useState<AIParsedEvent | null>(null);
const [showPreview, setShowPreview] = useState(false);
const addConversation = useAppStore((state) => state.addConversation);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// 编辑状态
const [editForm, setEditForm] = useState<AIParsedEvent>({
title: '',
date: '',
timezone: 'Asia/Shanghai',
is_lunar: false,
repeat_type: 'none',
type: 'reminder',
});
const scrollToBottom = () => {
scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
@ -33,7 +56,19 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
useEffect(() => {
scrollToBottom();
}, [history]);
}, [history, showPreview, loading]);
// 聚焦时展开
const handleFocus = () => {
setIsFocused(true);
};
// 点击遮罩层关闭
const handleClose = () => {
setIsFocused(false);
setShowPreview(false);
setParsedEvent(null);
};
const handleSend = async () => {
if (!message.trim() || loading) return;
@ -41,15 +76,28 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
const userMessage = message.trim();
setMessage('');
setLoading(true);
setShowPreview(false);
try {
const result = await api.ai.parse(userMessage);
// 保存解析结果用于预览
if (result.parsed) {
setParsedEvent(result.parsed as AIParsedEvent);
setEditForm(result.parsed as AIParsedEvent);
setShowPreview(true);
}
// 确保只保存response文本
const responseText = typeof result.response === 'string'
? result.response
: '已为你创建提醒';
const newConversation: AIConversation = {
id: Date.now().toString(),
user_id: '',
message: userMessage,
response: result.response,
response: responseText,
created_at: new Date().toISOString(),
};
@ -61,202 +109,451 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
}
} catch (error) {
console.error('AI parse error:', error);
notifications.show({
title: '解析失败',
message: 'AI 解析出错,请重试',
color: 'red',
});
} finally {
setLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
const handleConfirm = async () => {
if (!parsedEvent) return;
try {
const eventData = {
type: editForm.type,
title: editForm.title,
date: editForm.date,
is_lunar: editForm.is_lunar,
repeat_type: editForm.repeat_type,
content: editForm.content,
is_holiday: editForm.is_holiday,
priority: editForm.priority,
reminder_times: editForm.reminder_times,
};
await api.events.create(eventData);
notifications.show({
title: editForm.type === 'anniversary' ? '纪念日已创建' : '提醒已创建',
message: editForm.title,
color: 'green',
});
setShowPreview(false);
setParsedEvent(null);
setIsFocused(false);
setHistory([]);
setMessage('');
if (onEventCreated) {
onEventCreated();
}
} catch (error) {
console.error('Create event error:', error);
notifications.show({
title: '创建失败',
message: '请重试',
color: 'red',
});
}
};
const handleCancel = () => {
setShowPreview(false);
setParsedEvent(null);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
if (e.key === 'Escape' && isFocused) {
handleClose();
}
};
// 获取日期显示文本
const getDateDisplay = () => {
if (!editForm.date) return '未设置';
const timezone = editForm.timezone || 'Asia/Shanghai';
const dateStr = editForm.date;
const utcDate = new Date(dateStr);
if (isNaN(utcDate.getTime())) return '日期格式错误';
const localDateStr = utcDate.toLocaleString('en-US', { timeZone: timezone });
const localDate = new Date(localDateStr);
const hours = localDate.getHours();
const minutes = localDate.getMinutes();
const hasExplicitTime = hours !== 0 || minutes !== 0;
if (hasExplicitTime) {
return localDate.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: timezone,
});
}
return `${localDate.getFullYear()}-${String(localDate.getMonth() + 1).padStart(2, '0')}-${String(localDate.getDate()).padStart(2, '0')}`;
};
const getTypeDisplay = () => {
return editForm.type === 'anniversary' ? '纪念日' : '提醒';
};
return (
<>
{/* Floating button */}
<ActionIcon
size="xl"
radius="xl"
variant="filled"
onClick={() => setIsOpen(!isOpen)}
style={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 1000,
background: '#1a1a1a',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
}}
>
{isOpen ? <IconX size={24} /> : <IconMessage size={24} />}
</ActionIcon>
{/* Chat window */}
<Transition mounted={isOpen} transition="slide-up" duration={300}>
{/* 遮罩层 */}
<Transition mounted={isFocused} transition="fade" duration={200}>
{(styles) => (
<Paper
shadow="lg"
radius={4}
<Box
style={{
...styles,
position: 'fixed',
bottom: 90,
right: 24,
width: 360,
maxHeight: 'calc(100vh - 150px)',
zIndex: 1000,
inset: 0,
background: 'rgba(0, 0, 0, 0.2)',
zIndex: 1999,
}}
onClick={handleClose}
/>
)}
</Transition>
{/* 悬浮容器 - 聚焦时放大 */}
<Box
style={{
position: 'fixed',
bottom: 'min(6vh, 48px)',
left: '50%',
zIndex: 2000,
width: 'min(100% - 32px, 640px)',
transition: 'transform 0.3s ease',
transformOrigin: 'bottom center',
transform: isFocused ? 'translateX(-50%) scale(1.02)' : 'translateX(-50%) scale(1)',
}}
>
<Paper
style={{
width: '100%',
background: isFocused ? 'var(--mantine-color-body)' : 'rgba(255, 255, 255, 0.4)',
backdropFilter: isFocused ? 'none' : 'blur(8px)',
borderRadius: 16,
boxShadow: isFocused
? '0 8px 32px rgba(0, 0, 0, 0.12)'
: '0 2px 12px rgba(0, 0, 0, 0.06)',
border: '1px solid var(--mantine-color-gray-2)',
transition: 'all 0.3s ease',
overflow: 'visible',
}}
>
{/* 展开的聊天面板 */}
<Transition mounted={isFocused} transition="slide-up" duration={250}>
{(panelStyles) => (
<Box
style={{
...panelStyles,
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
marginBottom: 8,
width: '100%',
background: 'var(--mantine-color-body)',
borderRadius: 16,
maxHeight: 'min(60vh, 480px)',
display: 'flex',
flexDirection: 'column',
background: '#faf9f7',
border: '1px solid rgba(0, 0, 0, 0.06)',
overflow: 'hidden',
boxShadow: '0 -4px 24px rgba(0, 0, 0, 0.1)',
}}
>
{/* Header */}
<Group
p="md"
<Box
p="sm"
style={{
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
background: '#1a1a1a',
borderRadius: '4px 4px 0 0',
borderBottom: '1px solid var(--mantine-color-gray-1)',
background: 'var(--mantine-color-gray-0)',
}}
>
<Group gap={8}>
<IconSparkles size={16} color="#faf9f7" />
<Text
fw={400}
size="sm"
c="#faf9f7"
style={{ letterSpacing: '0.1em' }}
>
<Group justify="space-between" align="center">
<Group gap={6}>
<IconSparkles size={16} color="var(--mantine-color-blue-6)" />
<Text fw={600} size="sm" c="dark.7">
AI
</Text>
</Group>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleClose}
color="gray"
style={{ borderRadius: 8 }}
>
<IconSend size={12} style={{ transform: 'rotate(45deg)', opacity: 0.6 }} />
</ActionIcon>
</Group>
</Box>
{/* Chat history */}
{/* Chat area */}
<Box
p="md"
p="sm"
style={{
flex: 1,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '12px',
minHeight: 280,
maxHeight: 380,
gap: '8px',
minHeight: 160,
maxHeight: 320,
}}
>
{history.length === 0 ? (
<Stack align="center" justify="center" h="100%" gap="sm">
<IconSparkles size={32} color="rgba(0, 0, 0, 0.2)" />
<Text size="xs" c="#888" ta="center" style={{ letterSpacing: '0.05em' }}>
<br />
<Text span size="xs" c="#aaa">
15
{history.length === 0 && !showPreview && !loading ? (
<Stack align="center" justify="center" h="100%" gap="xs">
<IconSparkles size={24} color="var(--mantine-color-gray-4)" />
<Text size="xs" c="dimmed" ta="center">
AI
</Text>
<Text size="xxs" c="var(--mantine-color-gray-3)" ta="center">
15520
</Text>
</Stack>
) : (
history.map((conv) => (
<Box key={conv.id}>
<Group gap={8} mb={6}>
<Text size="xs" c="#666" fw={400} style={{ letterSpacing: '0.05em' }}>
<>
{history.map((conv) => (
<Box key={conv.id} style={{ marginBottom: 4 }}>
<Group gap={6} mb={4}>
<Text size="xs" c="dimmed" fw={500}>
</Text>
</Group>
<Paper
withBorder
p="sm"
radius={2}
mb="sm"
p="xs"
radius={8}
mb="xs"
style={{
background: 'rgba(0, 0, 0, 0.02)',
borderColor: 'rgba(0, 0, 0, 0.08)',
background: 'var(--mantine-color-blue-0)',
border: '1px solid var(--mantine-color-blue-1)',
maxWidth: 280,
}}
>
<Text size="xs" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
<Text size="xs" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>
{conv.message}
</Text>
</Paper>
<Group gap={8} mb={6}>
<IconSparkles size={10} color="#1a1a1a" />
<Text size="xs" c="#1a1a1a" fw={400} style={{ letterSpacing: '0.05em' }}>
<Group gap={6} mb={4}>
<IconSparkles size={10} color="var(--mantine-color-blue-5)" />
<Text size="xs" c="blue.6" fw={600}>
AI
</Text>
</Group>
<Paper
withBorder
p="sm"
radius={2}
p="xs"
radius={8}
style={{
background: 'rgba(0, 0, 0, 0.03)',
borderColor: 'rgba(0, 0, 0, 0.08)',
background: 'var(--mantine-color-gray-0)',
border: '1px solid var(--mantine-color-gray-1)',
maxWidth: 280,
}}
>
<Text size="xs" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
<Text size="xs" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>
{conv.response}
</Text>
</Paper>
</Box>
))
)}
))}
{loading && (
<Group gap={8}>
<Loader size="xs" color="#1a1a1a" />
<Text size="xs" c="#888" style={{ letterSpacing: '0.05em' }}>
<Group gap={6}>
<Loader size="sm" color="blue" />
<Text size="xs" c="dimmed">
AI ...
</Text>
</Group>
)}
<div ref={scrollRef} />
</Box>
{/* Input */}
<Group
p="md"
{showPreview && parsedEvent && (
<Box style={{ marginTop: 4 }}>
<Paper
p="sm"
radius={12}
style={{
borderTop: '1px solid rgba(0, 0, 0, 0.06)',
borderRadius: '0 0 4px 4px',
background: 'var(--mantine-color-body)',
border: '2px solid var(--mantine-color-blue-2)',
}}
>
<Stack gap="xs">
<Group justify="space-between" align="center">
<Badge size="xs" color={editForm.type === 'anniversary' ? 'blue' : 'orange'}>
{getTypeDisplay()}
</Badge>
<Text size="sm" fw={500} style={{ flex: 1, marginLeft: 8 }}>
{editForm.title || '未设置'}
</Text>
</Group>
<Divider />
<Group justify="space-between" align="center">
<Group gap={4}>
<IconCalendar size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed"></Text>
</Group>
<Text size="sm" fw={500}>{getDateDisplay()}</Text>
</Group>
<Group justify="space-between" align="center">
<Group gap={4}>
<IconRepeat size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed"></Text>
</Group>
<Group gap={2}>
{['none', 'daily', 'weekly', 'monthly', 'yearly'].map((type) => (
<Button
key={type}
variant={editForm.repeat_type === type ? 'filled' : 'subtle'}
size="compact-xs"
onClick={() => setEditForm({ ...editForm, repeat_type: type as RepeatType })}
style={{ borderRadius: 6, padding: '2px 8px' }}
>
{type === 'none' ? '不重复' :
type === 'daily' ? '每天' :
type === 'weekly' ? '每周' :
type === 'monthly' ? '每月' : '每年'}
</Button>
))}
</Group>
</Group>
{editForm.type === 'reminder' && (
<Group justify="space-between" align="center">
<Group gap={4}>
<IconFlag size={12} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed"></Text>
</Group>
<Group gap={4}>
{[
{ value: 'none', color: 'var(--mantine-color-gray-3)' },
{ value: 'red', color: '#ef4444' },
{ value: 'green', color: '#22c55e' },
{ value: 'yellow', color: '#eab308' },
].map((item) => (
<Box
key={item.value}
onClick={() => setEditForm({ ...editForm, priority: item.value as PriorityType })}
style={{
width: 16,
height: 16,
borderRadius: '50%',
background: item.color,
border: editForm.priority === item.value
? '2px solid var(--mantine-color-dark-6)'
: '1px solid var(--mantine-color-gray-2)',
cursor: 'pointer',
}}
/>
))}
</Group>
</Group>
)}
<Group justify="flex-end" gap="xs" mt={4}>
<Button
variant="subtle"
size="compact-xs"
onClick={handleCancel}
style={{ borderRadius: 6 }}
>
</Button>
<Button
size="compact-xs"
onClick={handleConfirm}
disabled={!editForm.title.trim() || !editForm.date}
style={{
background: 'var(--mantine-color-blue-6)',
borderRadius: 6,
}}
>
</Button>
</Group>
</Stack>
</Paper>
</Box>
)}
</>
)}
<div ref={scrollRef} />
</Box>
</Box>
)}
</Transition>
{/* 输入框区域 */}
<Box
p="sm"
style={{
background: 'transparent',
}}
>
<Group gap={6}>
<TextInput
ref={inputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="输入你的提醒事项..."
onKeyDown={handleKeyDown}
onFocus={handleFocus}
placeholder="输入你想记住的事情..."
size="sm"
style={{ flex: 1 }}
disabled={loading}
styles={{
input: {
borderRadius: 2,
borderColor: 'rgba(0, 0, 0, 0.1)',
background: '#faf9f7',
borderRadius: 12,
borderColor: 'var(--mantine-color-gray-2)',
background: isFocused ? 'rgba(255, 255, 255, 0.95)' : 'rgba(255, 255, 255, 0.6)',
paddingLeft: 12,
paddingRight: 12,
transition: 'all 0.3s ease',
},
}}
/>
<ActionIcon
size="lg"
radius={2}
radius={10}
variant="filled"
onClick={handleSend}
disabled={!message.trim() || loading}
style={{
background: '#1a1a1a',
background: (!message.trim() || loading)
? 'var(--mantine-color-gray-4)'
: isFocused
? 'var(--mantine-color-blue-6)'
: 'rgba(0, 122, 255, 0.5)',
width: 40,
height: 40,
transition: 'all 0.3s ease',
}}
>
<IconSend size={14} />
<IconSend size={16} color={(!message.trim() || loading) ? 'var(--mantine-color-gray-6)' : 'white'} />
</ActionIcon>
</Group>
</Box>
</Paper>
)}
</Transition>
</Box>
</>
);
}

View File

@ -1,6 +1,7 @@
import { Paper, Text, Group, Stack } from '@mantine/core';
import { Paper, Text, Group, Stack, Badge } from '@mantine/core';
import { IconRepeat, IconCalendar, IconFlag } from '@tabler/icons-react';
import type { Event } from '../../types';
import { calculateCountdown, formatCountdown } from '../../utils/countdown';
import { calculateCountdown } from '../../utils/countdown';
import { getHolidayById } from '../../constants/holidays';
interface AnniversaryCardProps {
@ -12,9 +13,32 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
const isLunar = event.is_lunar;
const repeatType = event.repeat_type;
const countdown = calculateCountdown(event.date, repeatType, isLunar);
const formattedCountdown = formatCountdown(countdown);
const holiday = event.is_holiday ? getHolidayById(event.title) || event.is_holiday : false;
// 获取下一次纪念日日期描述
const getNextDateText = () => {
if (countdown.isPast) return '已过';
if (countdown.isToday) return '今天';
return `${countdown.nextDate.getMonth() + 1}${countdown.nextDate.getDate()}${isLunar ? ' (农历)' : ''}`;
};
// 获取循环icon颜色
const getRepeatIconColor = () => {
if (repeatType === 'yearly') return '#1a1a1a'; // 年度 - 黑色
if (repeatType === 'monthly') return '#666666'; // 月度 - 灰色
if (repeatType === 'weekly') return '#999999'; // 每周 - 浅灰色
if (repeatType === 'daily') return '#bbbbbb'; // 每天 - 更浅灰色
return '';
};
// 根据节日类型设置名称颜色
const getNameColor = () => {
if (holiday) return '#c41c1c'; // 节假日红色
if (repeatType === 'yearly') return '#1a1a1a'; // 年度纪念日黑色
if (repeatType === 'monthly') return '#666666'; // 月度纪念日灰色
return '#1a1a1a'; // 默认黑色
};
return (
<Paper
p="sm"
@ -30,46 +54,61 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
>
<Group justify="space-between" wrap="nowrap">
{/* 左侧纪念日名称、循环icon、下一次日期 */}
<Stack gap={4} style={{ flex: 1 }}>
<Group gap={6}>
<Text size="xs" c="#c41c1c">{event.title}</Text>
</Group>
<Group gap="xs">
{/* Countdown */}
<Group gap={4}>
<Text
size="xs"
fw={400}
fw={500}
style={{ color: getNameColor() }}
>
{event.title}
</Text>
{/* 节假日标签 - 浅浅显示 */}
{(event.is_holiday || holiday) && (
<Badge
size="xs"
variant="light"
color="red"
style={{
letterSpacing: '0.05em',
color: countdown.isToday ? '#c41c1c' : '#666',
height: 16,
padding: '0 4px',
fontSize: '10px',
fontWeight: 400,
}}
>
{formattedCountdown}
</Text>
{/* Date display */}
</Badge>
)}
</Group>
<Group gap="xs">
{/* 循环icon */}
{repeatType !== 'none' && (
<IconRepeat
size={10}
color={getRepeatIconColor()}
style={{ marginTop: 1 }}
/>
)}
<Text size="xs" c="#999">
{countdown.isPast
? '已过'
: `${countdown.nextDate.getMonth() + 1}${countdown.nextDate.getDate()}`}
{isLunar && ' (农历)'}
{getNextDateText()}
</Text>
</Group>
</Stack>
<Group gap={6}>
{repeatType !== 'none' && (
<Text size="xs" c="#888">
{repeatType === 'yearly' ? '每年' : '每月'}
{/* 右侧:倒数剩余天数 */}
<Text
size="xs"
fw={400}
style={{
color: countdown.isToday ? '#c41c1c' : countdown.isPast ? '#999' : '#666',
letterSpacing: '0.02em',
minWidth: '3.5em',
textAlign: 'right',
}}
>
{countdown.isPast ? '已过' : `${countdown.days}`}
</Text>
)}
{(event.is_holiday || holiday) && (
<Text size="xs" c="#888">
</Text>
)}
</Group>
</Group>
</Paper>
);

View File

@ -391,6 +391,8 @@ export function HomePage() {
onDateChange={handleDateChange}
onPriorityChange={handlePriorityChange}
/>
{/* 底部空白区域 - 避免被 AI 输入框遮挡 */}
<Box style={{ height: 120 }} />
</div>
{/* Right column - Note */}
@ -441,14 +443,25 @@ export function HomePage() {
]}
value={formType}
onChange={(value) => value && setFormType(value as EventType)}
disabled={isEdit}
readOnly={isEdit}
rightSection={isEdit ? undefined : undefined}
rightSectionPointerEvents={isEdit ? 'none' : 'auto'}
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
background: isEdit ? '#eee' : '#faf9f7',
color: isEdit ? '#999' : undefined,
pointerEvents: isEdit ? 'none' : 'auto',
cursor: isEdit ? 'not-allowed' : 'pointer',
borderColor: isEdit ? '#ddd' : undefined,
},
dropdown: {
cursor: 'pointer',
},
}}
classNames={{
input: isEdit ? 'disabled-select' : undefined,
}}
/>
@ -561,16 +574,22 @@ export function HomePage() {
</Group>
</Box>
{/* Lunar switch (only for anniversaries) */}
{/* Lunar switch (only for anniversaries, disabled in edit mode) */}
{formType === 'anniversary' && (
<Switch
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
<Text size="xs" c={isEdit ? '#ccc' : '#666'} style={{ letterSpacing: '0.05em' }}>
</Text>
}
checked={formIsLunar}
onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
disabled={isEdit}
styles={{
track: {
opacity: isEdit ? 0.5 : 1,
},
}}
/>
)}

View File

@ -45,14 +45,18 @@ export interface Note {
updated_at: string;
}
// AI Parsing types
// AI Parsing types - 扩展支持所有字段
export interface AIParsedEvent {
title: string;
date: string;
timezone: string; // 时区信息
is_lunar: boolean;
repeat_type: RepeatType;
reminder_time?: string;
type: EventType;
content?: string; // 详细内容(仅提醒)
is_holiday?: boolean; // 节假日(仅纪念日)
priority?: PriorityType; // 颜色(仅提醒)
reminder_times?: string[]; // 提前提醒时间点
}
export interface AIConversation {

View File

@ -55,7 +55,7 @@ function safeCreateLunarDate(year: number, month: number, day: number): { lunar:
*/
export function calculateCountdown(
dateStr: string,
repeatType: 'yearly' | 'monthly' | 'none',
repeatType: 'yearly' | 'monthly' | 'daily' | 'weekly' | 'none',
isLunar: boolean
): CountdownResult {
const now = new Date();
@ -70,32 +70,68 @@ export function calculateCountdown(
const originalMonth = dateParts[1] - 1; // JavaScript月份从0开始
const originalDay = dateParts[2];
// 获取原始时间(小时和分钟)
const timeParts = dateStr.includes('T') ? dateStr.split('T')[1].split(':') : ['00', '00'];
const originalHours = parseInt(timeParts[0]) || 0;
const originalMinutes = parseInt(timeParts[1]) || 0;
if (isLunar) {
// 农历日期:使用安全方法创建,处理月末边界
const result = safeCreateLunarDate(originalYear, originalMonth + 1, originalDay);
if (result) {
targetDate = new Date(result.solar.getYear(), result.solar.getMonth() - 1, result.solar.getDay());
targetDate = new Date(result.solar.getYear(), result.solar.getMonth() - 1, result.solar.getDay(), originalHours, originalMinutes);
} else {
// 无法解析农历日期,使用原始公历日期作为后备
targetDate = new Date(originalYear, originalMonth, originalDay);
targetDate = new Date(originalYear, originalMonth, originalDay, originalHours, originalMinutes);
}
} else {
// 公历日期
targetDate = new Date(originalYear, originalMonth, originalDay);
targetDate = new Date(originalYear, originalMonth, originalDay, originalHours, originalMinutes);
}
// 先判断是否今天(日期部分相等)
const isToday = targetDate.toDateString() === today.toDateString();
// 计算下一个 occurrence
if (repeatType === 'yearly') {
// 年度重复:找到今年或明年的对应日期
targetDate = safeCreateDate(today.getFullYear(), targetDate.getMonth(), targetDate.getDate());
targetDate.setHours(originalHours, originalMinutes, 0, 0);
if (targetDate < today) {
targetDate = safeCreateDate(today.getFullYear() + 1, targetDate.getMonth(), targetDate.getDate());
targetDate.setHours(originalHours, originalMinutes, 0, 0);
}
} else if (repeatType === 'monthly') {
// 月度重复:找到本月或下月的对应日期
targetDate = safeCreateDate(today.getFullYear(), today.getMonth(), targetDate.getDate());
if (targetDate < today) {
targetDate = safeCreateDate(today.getFullYear(), today.getMonth() + 1, targetDate.getDate());
// 月度重复:找到本月或之后月份的对应日期
const originalDay = targetDate.getDate();
let currentMonth = today.getMonth(); // 从当前月份开始
targetDate = safeCreateDate(today.getFullYear(), currentMonth, originalDay);
targetDate.setHours(originalHours, originalMinutes, 0, 0);
// 持续递增月份直到找到未来日期
while (targetDate < today) {
currentMonth++;
targetDate = safeCreateDate(today.getFullYear(), currentMonth, originalDay);
targetDate.setHours(originalHours, originalMinutes, 0, 0);
}
} else if (repeatType === 'daily') {
// 每日重复:找到今天或明天的对应时间点
targetDate = new Date(today);
targetDate.setHours(originalHours, originalMinutes, 0, 0);
if (targetDate < now) {
targetDate = new Date(today);
targetDate.setDate(targetDate.getDate() + 1);
targetDate.setHours(originalHours, originalMinutes, 0, 0);
}
} else if (repeatType === 'weekly') {
// 每周重复:找到本周或下周对应星期几的时间点
const dayOfWeek = new Date(originalYear, originalMonth, originalDay).getDay();
targetDate = new Date(today);
const daysUntilTarget = (dayOfWeek - today.getDay() + 7) % 7;
targetDate.setDate(targetDate.getDate() + daysUntilTarget);
targetDate.setHours(originalHours, originalMinutes, 0, 0);
if (targetDate < now) {
targetDate.setDate(targetDate.getDate() + 7);
}
} else {
// 不重复
@ -106,17 +142,38 @@ export function calculateCountdown(
// 计算时间差
const diff = targetDate.getTime() - now.getTime();
const isToday = targetDate.toDateString() === today.toDateString();
// 如果是今天,即使是负数(已过),也显示为 0 而不是"已过"
// 只要 isToday 为 true就不算"已过",显示为"今天"
if (diff < 0 && !isPast) {
if (!isToday) {
isPast = true;
}
}
const absDiff = Math.abs(diff);
const days = Math.floor(absDiff / (1000 * 60 * 60 * 24));
const hours = Math.floor((absDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((absDiff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((absDiff % (1000 * 60)) / 1000);
// 如果是今天直接返回0天
let days: number;
let hours = 0;
let minutes = 0;
let seconds = 0;
if (isToday) {
days = 0;
hours = 0;
minutes = 0;
seconds = 0;
isPast = false; // 今天不算已过
} else {
// 使用 ceil 来计算天数,这样用户看到的是"还有X天"的直观感受
// 例如2/12 18:00 到 2/15 00:00 应该显示"还有3天"而不是"还有2天"
days = Math.ceil(absDiff / (1000 * 60 * 60 * 24));
// 重新计算剩余的小时、分钟、秒(使用 ceil 后剩余时间需要调整)
const remainingAfterDays = absDiff - (days - 1) * (1000 * 60 * 60 * 24);
hours = Math.floor((remainingAfterDays % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
minutes = Math.floor((remainingAfterDays % (1000 * 60 * 60)) / (1000 * 60));
seconds = Math.floor((remainingAfterDays % (1000 * 60)) / 1000);
}
return {
days: isPast ? -days : days,
@ -157,7 +214,7 @@ export function formatCountdown(countdown: CountdownResult): string {
*/
export function getFriendlyDateDescription(
dateStr: string,
repeatType: 'yearly' | 'monthly' | 'none',
repeatType: 'yearly' | 'monthly' | 'daily' | 'weekly' | 'none',
isLunar: boolean
): string {
const countdown = calculateCountdown(dateStr, repeatType, isLunar);