feat: 优化 AI 对话框交互体验
- 重构 AI 输入框为底部悬浮式,聚焦展开 - 添加预览卡片支持编辑重复、颜色等选项 - 优化时区显示和日期格式化 - 添加 loading 状态和 Toast 提示 - 支持确认后自动关闭并显示成功通知 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bc627544d8
commit
5f1c6208df
@ -9,23 +9,46 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Box,
|
Box,
|
||||||
Transition,
|
Transition,
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
Divider,
|
||||||
} from '@mantine/core';
|
} 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 { api } from '../../services/api';
|
||||||
import { useAppStore } from '../../stores';
|
import { useAppStore } from '../../stores';
|
||||||
import type { AIConversation } from '../../types';
|
import type { AIConversation, AIParsedEvent, RepeatType, PriorityType } from '../../types';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
|
||||||
interface FloatingAIChatProps {
|
interface FloatingAIChatProps {
|
||||||
onEventCreated?: () => void;
|
onEventCreated?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
|
export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [history, setHistory] = useState<AIConversation[]>([]);
|
const [history, setHistory] = useState<AIConversation[]>([]);
|
||||||
|
const [parsedEvent, setParsedEvent] = useState<AIParsedEvent | null>(null);
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const addConversation = useAppStore((state) => state.addConversation);
|
const addConversation = useAppStore((state) => state.addConversation);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
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 = () => {
|
const scrollToBottom = () => {
|
||||||
scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
|
scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
@ -33,7 +56,19 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [history]);
|
}, [history, showPreview, loading]);
|
||||||
|
|
||||||
|
// 聚焦时展开
|
||||||
|
const handleFocus = () => {
|
||||||
|
setIsFocused(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 点击遮罩层关闭
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsFocused(false);
|
||||||
|
setShowPreview(false);
|
||||||
|
setParsedEvent(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!message.trim() || loading) return;
|
if (!message.trim() || loading) return;
|
||||||
@ -41,15 +76,28 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
|
|||||||
const userMessage = message.trim();
|
const userMessage = message.trim();
|
||||||
setMessage('');
|
setMessage('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setShowPreview(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.ai.parse(userMessage);
|
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 = {
|
const newConversation: AIConversation = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
user_id: '',
|
user_id: '',
|
||||||
message: userMessage,
|
message: userMessage,
|
||||||
response: result.response,
|
response: responseText,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -61,202 +109,451 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('AI parse error:', error);
|
console.error('AI parse error:', error);
|
||||||
|
notifications.show({
|
||||||
|
title: '解析失败',
|
||||||
|
message: 'AI 解析出错,请重试',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Floating button */}
|
{/* 遮罩层 */}
|
||||||
<ActionIcon
|
<Transition mounted={isFocused} transition="fade" duration={200}>
|
||||||
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}>
|
|
||||||
{(styles) => (
|
{(styles) => (
|
||||||
<Paper
|
<Box
|
||||||
shadow="lg"
|
|
||||||
radius={4}
|
|
||||||
style={{
|
style={{
|
||||||
...styles,
|
...styles,
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: 90,
|
inset: 0,
|
||||||
right: 24,
|
background: 'rgba(0, 0, 0, 0.2)',
|
||||||
width: 360,
|
zIndex: 1999,
|
||||||
maxHeight: 'calc(100vh - 150px)',
|
}}
|
||||||
zIndex: 1000,
|
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',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
background: '#faf9f7',
|
overflow: 'hidden',
|
||||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
boxShadow: '0 -4px 24px rgba(0, 0, 0, 0.1)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Group
|
<Box
|
||||||
p="md"
|
p="sm"
|
||||||
style={{
|
style={{
|
||||||
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
|
borderBottom: '1px solid var(--mantine-color-gray-1)',
|
||||||
background: '#1a1a1a',
|
background: 'var(--mantine-color-gray-0)',
|
||||||
borderRadius: '4px 4px 0 0',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={8}>
|
<Group justify="space-between" align="center">
|
||||||
<IconSparkles size={16} color="#faf9f7" />
|
<Group gap={6}>
|
||||||
<Text
|
<IconSparkles size={16} color="var(--mantine-color-blue-6)" />
|
||||||
fw={400}
|
<Text fw={600} size="sm" c="dark.7">
|
||||||
size="sm"
|
|
||||||
c="#faf9f7"
|
|
||||||
style={{ letterSpacing: '0.1em' }}
|
|
||||||
>
|
|
||||||
AI 助手
|
AI 助手
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</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>
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Chat history */}
|
{/* Chat area */}
|
||||||
<Box
|
<Box
|
||||||
p="md"
|
p="sm"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '12px',
|
gap: '8px',
|
||||||
minHeight: 280,
|
minHeight: 160,
|
||||||
maxHeight: 380,
|
maxHeight: 320,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{history.length === 0 ? (
|
{history.length === 0 && !showPreview && !loading ? (
|
||||||
<Stack align="center" justify="center" h="100%" gap="sm">
|
<Stack align="center" justify="center" h="100%" gap="xs">
|
||||||
<IconSparkles size={32} color="rgba(0, 0, 0, 0.2)" />
|
<IconSparkles size={24} color="var(--mantine-color-gray-4)" />
|
||||||
<Text size="xs" c="#888" ta="center" style={{ letterSpacing: '0.05em' }}>
|
<Text size="xs" c="dimmed" ta="center">
|
||||||
告诉我你想记住的事情
|
告诉 AI 帮你添加纪念日或提醒
|
||||||
<br />
|
|
||||||
<Text span size="xs" c="#aaa">
|
|
||||||
例如:下周三见客户、每月15号还房贷
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text size="xxs" c="var(--mantine-color-gray-3)" ta="center">
|
||||||
|
例如:「下周三见客户」「每月15号还房贷」「明年5月20日结婚纪念日」
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
history.map((conv) => (
|
<>
|
||||||
<Box key={conv.id}>
|
{history.map((conv) => (
|
||||||
<Group gap={8} mb={6}>
|
<Box key={conv.id} style={{ marginBottom: 4 }}>
|
||||||
<Text size="xs" c="#666" fw={400} style={{ letterSpacing: '0.05em' }}>
|
<Group gap={6} mb={4}>
|
||||||
|
<Text size="xs" c="dimmed" fw={500}>
|
||||||
你
|
你
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Paper
|
<Paper
|
||||||
withBorder
|
p="xs"
|
||||||
p="sm"
|
radius={8}
|
||||||
radius={2}
|
mb="xs"
|
||||||
mb="sm"
|
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(0, 0, 0, 0.02)',
|
background: 'var(--mantine-color-blue-0)',
|
||||||
borderColor: 'rgba(0, 0, 0, 0.08)',
|
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}
|
{conv.message}
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Group gap={8} mb={6}>
|
<Group gap={6} mb={4}>
|
||||||
<IconSparkles size={10} color="#1a1a1a" />
|
<IconSparkles size={10} color="var(--mantine-color-blue-5)" />
|
||||||
<Text size="xs" c="#1a1a1a" fw={400} style={{ letterSpacing: '0.05em' }}>
|
<Text size="xs" c="blue.6" fw={600}>
|
||||||
AI
|
AI
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Paper
|
<Paper
|
||||||
withBorder
|
p="xs"
|
||||||
p="sm"
|
radius={8}
|
||||||
radius={2}
|
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(0, 0, 0, 0.03)',
|
background: 'var(--mantine-color-gray-0)',
|
||||||
borderColor: 'rgba(0, 0, 0, 0.08)',
|
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}
|
{conv.response}
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<Group gap={8}>
|
<Group gap={6}>
|
||||||
<Loader size="xs" color="#1a1a1a" />
|
<Loader size="sm" color="blue" />
|
||||||
<Text size="xs" c="#888" style={{ letterSpacing: '0.05em' }}>
|
<Text size="xs" c="dimmed">
|
||||||
AI 正在思考...
|
AI 正在思考...
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={scrollRef} />
|
{showPreview && parsedEvent && (
|
||||||
</Box>
|
<Box style={{ marginTop: 4 }}>
|
||||||
|
<Paper
|
||||||
{/* Input */}
|
p="sm"
|
||||||
<Group
|
radius={12}
|
||||||
p="md"
|
|
||||||
style={{
|
style={{
|
||||||
borderTop: '1px solid rgba(0, 0, 0, 0.06)',
|
background: 'var(--mantine-color-body)',
|
||||||
borderRadius: '0 0 4px 4px',
|
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
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
onKeyPress={handleKeyPress}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="输入你的提醒事项..."
|
onFocus={handleFocus}
|
||||||
|
placeholder="输入你想记住的事情..."
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
borderRadius: 2,
|
borderRadius: 12,
|
||||||
borderColor: 'rgba(0, 0, 0, 0.1)',
|
borderColor: 'var(--mantine-color-gray-2)',
|
||||||
background: '#faf9f7',
|
background: isFocused ? 'rgba(255, 255, 255, 0.95)' : 'rgba(255, 255, 255, 0.6)',
|
||||||
|
paddingLeft: 12,
|
||||||
|
paddingRight: 12,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="lg"
|
size="lg"
|
||||||
radius={2}
|
radius={10}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!message.trim() || loading}
|
disabled={!message.trim() || loading}
|
||||||
style={{
|
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>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
</Box>
|
||||||
</Transition>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 type { Event } from '../../types';
|
||||||
import { calculateCountdown, formatCountdown } from '../../utils/countdown';
|
import { calculateCountdown } from '../../utils/countdown';
|
||||||
import { getHolidayById } from '../../constants/holidays';
|
import { getHolidayById } from '../../constants/holidays';
|
||||||
|
|
||||||
interface AnniversaryCardProps {
|
interface AnniversaryCardProps {
|
||||||
@ -12,9 +13,32 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
|
|||||||
const isLunar = event.is_lunar;
|
const isLunar = event.is_lunar;
|
||||||
const repeatType = event.repeat_type;
|
const repeatType = event.repeat_type;
|
||||||
const countdown = calculateCountdown(event.date, repeatType, isLunar);
|
const countdown = calculateCountdown(event.date, repeatType, isLunar);
|
||||||
const formattedCountdown = formatCountdown(countdown);
|
|
||||||
const holiday = event.is_holiday ? getHolidayById(event.title) || event.is_holiday : false;
|
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 (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
p="sm"
|
p="sm"
|
||||||
@ -30,46 +54,61 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
|
|||||||
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
|
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
{/* 左侧:纪念日名称、循环icon、下一次日期 */}
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
<Group gap={6}>
|
<Group gap={4}>
|
||||||
<Text size="xs" c="#c41c1c">{event.title}</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap="xs">
|
|
||||||
{/* Countdown */}
|
|
||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
fw={400}
|
fw={500}
|
||||||
|
style={{ color: getNameColor() }}
|
||||||
|
>
|
||||||
|
{event.title}
|
||||||
|
</Text>
|
||||||
|
{/* 节假日标签 - 浅浅显示 */}
|
||||||
|
{(event.is_holiday || holiday) && (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
style={{
|
style={{
|
||||||
letterSpacing: '0.05em',
|
height: 16,
|
||||||
color: countdown.isToday ? '#c41c1c' : '#666',
|
padding: '0 4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 400,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formattedCountdown}
|
节假日
|
||||||
</Text>
|
</Badge>
|
||||||
|
)}
|
||||||
{/* Date display */}
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
{/* 循环icon */}
|
||||||
|
{repeatType !== 'none' && (
|
||||||
|
<IconRepeat
|
||||||
|
size={10}
|
||||||
|
color={getRepeatIconColor()}
|
||||||
|
style={{ marginTop: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Text size="xs" c="#999">
|
<Text size="xs" c="#999">
|
||||||
{countdown.isPast
|
{getNextDateText()}
|
||||||
? '已过'
|
|
||||||
: `${countdown.nextDate.getMonth() + 1}月${countdown.nextDate.getDate()}日`}
|
|
||||||
{isLunar && ' (农历)'}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group gap={6}>
|
{/* 右侧:倒数剩余天数 */}
|
||||||
{repeatType !== 'none' && (
|
<Text
|
||||||
<Text size="xs" c="#888">
|
size="xs"
|
||||||
{repeatType === 'yearly' ? '每年' : '每月'}
|
fw={400}
|
||||||
|
style={{
|
||||||
|
color: countdown.isToday ? '#c41c1c' : countdown.isPast ? '#999' : '#666',
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
minWidth: '3.5em',
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{countdown.isPast ? '已过' : `${countdown.days}天`}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
|
||||||
{(event.is_holiday || holiday) && (
|
|
||||||
<Text size="xs" c="#888">
|
|
||||||
节假日
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -391,6 +391,8 @@ export function HomePage() {
|
|||||||
onDateChange={handleDateChange}
|
onDateChange={handleDateChange}
|
||||||
onPriorityChange={handlePriorityChange}
|
onPriorityChange={handlePriorityChange}
|
||||||
/>
|
/>
|
||||||
|
{/* 底部空白区域 - 避免被 AI 输入框遮挡 */}
|
||||||
|
<Box style={{ height: 120 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column - Note */}
|
{/* Right column - Note */}
|
||||||
@ -441,14 +443,25 @@ export function HomePage() {
|
|||||||
]}
|
]}
|
||||||
value={formType}
|
value={formType}
|
||||||
onChange={(value) => value && setFormType(value as EventType)}
|
onChange={(value) => value && setFormType(value as EventType)}
|
||||||
|
disabled={isEdit}
|
||||||
readOnly={isEdit}
|
readOnly={isEdit}
|
||||||
|
rightSection={isEdit ? undefined : undefined}
|
||||||
rightSectionPointerEvents={isEdit ? 'none' : 'auto'}
|
rightSectionPointerEvents={isEdit ? 'none' : 'auto'}
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
background: '#faf9f7',
|
background: isEdit ? '#eee' : '#faf9f7',
|
||||||
|
color: isEdit ? '#999' : undefined,
|
||||||
pointerEvents: isEdit ? 'none' : 'auto',
|
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>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Lunar switch (only for anniversaries) */}
|
{/* Lunar switch (only for anniversaries, disabled in edit mode) */}
|
||||||
{formType === 'anniversary' && (
|
{formType === 'anniversary' && (
|
||||||
<Switch
|
<Switch
|
||||||
label={
|
label={
|
||||||
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
|
<Text size="xs" c={isEdit ? '#ccc' : '#666'} style={{ letterSpacing: '0.05em' }}>
|
||||||
农历日期
|
农历日期
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
checked={formIsLunar}
|
checked={formIsLunar}
|
||||||
onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
|
onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
|
||||||
|
disabled={isEdit}
|
||||||
|
styles={{
|
||||||
|
track: {
|
||||||
|
opacity: isEdit ? 0.5 : 1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -45,14 +45,18 @@ export interface Note {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI Parsing types
|
// AI Parsing types - 扩展支持所有字段
|
||||||
export interface AIParsedEvent {
|
export interface AIParsedEvent {
|
||||||
title: string;
|
title: string;
|
||||||
date: string;
|
date: string;
|
||||||
|
timezone: string; // 时区信息
|
||||||
is_lunar: boolean;
|
is_lunar: boolean;
|
||||||
repeat_type: RepeatType;
|
repeat_type: RepeatType;
|
||||||
reminder_time?: string;
|
|
||||||
type: EventType;
|
type: EventType;
|
||||||
|
content?: string; // 详细内容(仅提醒)
|
||||||
|
is_holiday?: boolean; // 节假日(仅纪念日)
|
||||||
|
priority?: PriorityType; // 颜色(仅提醒)
|
||||||
|
reminder_times?: string[]; // 提前提醒时间点
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AIConversation {
|
export interface AIConversation {
|
||||||
|
|||||||
@ -55,7 +55,7 @@ function safeCreateLunarDate(year: number, month: number, day: number): { lunar:
|
|||||||
*/
|
*/
|
||||||
export function calculateCountdown(
|
export function calculateCountdown(
|
||||||
dateStr: string,
|
dateStr: string,
|
||||||
repeatType: 'yearly' | 'monthly' | 'none',
|
repeatType: 'yearly' | 'monthly' | 'daily' | 'weekly' | 'none',
|
||||||
isLunar: boolean
|
isLunar: boolean
|
||||||
): CountdownResult {
|
): CountdownResult {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -70,32 +70,68 @@ export function calculateCountdown(
|
|||||||
const originalMonth = dateParts[1] - 1; // JavaScript月份从0开始
|
const originalMonth = dateParts[1] - 1; // JavaScript月份从0开始
|
||||||
const originalDay = dateParts[2];
|
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) {
|
if (isLunar) {
|
||||||
// 农历日期:使用安全方法创建,处理月末边界
|
// 农历日期:使用安全方法创建,处理月末边界
|
||||||
const result = safeCreateLunarDate(originalYear, originalMonth + 1, originalDay);
|
const result = safeCreateLunarDate(originalYear, originalMonth + 1, originalDay);
|
||||||
if (result) {
|
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 {
|
} else {
|
||||||
// 无法解析农历日期,使用原始公历日期作为后备
|
// 无法解析农历日期,使用原始公历日期作为后备
|
||||||
targetDate = new Date(originalYear, originalMonth, originalDay);
|
targetDate = new Date(originalYear, originalMonth, originalDay, originalHours, originalMinutes);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 公历日期
|
// 公历日期
|
||||||
targetDate = new Date(originalYear, originalMonth, originalDay);
|
targetDate = new Date(originalYear, originalMonth, originalDay, originalHours, originalMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 先判断是否今天(日期部分相等)
|
||||||
|
const isToday = targetDate.toDateString() === today.toDateString();
|
||||||
|
|
||||||
// 计算下一个 occurrence
|
// 计算下一个 occurrence
|
||||||
if (repeatType === 'yearly') {
|
if (repeatType === 'yearly') {
|
||||||
// 年度重复:找到今年或明年的对应日期
|
// 年度重复:找到今年或明年的对应日期
|
||||||
targetDate = safeCreateDate(today.getFullYear(), targetDate.getMonth(), targetDate.getDate());
|
targetDate = safeCreateDate(today.getFullYear(), targetDate.getMonth(), targetDate.getDate());
|
||||||
|
targetDate.setHours(originalHours, originalMinutes, 0, 0);
|
||||||
if (targetDate < today) {
|
if (targetDate < today) {
|
||||||
targetDate = safeCreateDate(today.getFullYear() + 1, targetDate.getMonth(), targetDate.getDate());
|
targetDate = safeCreateDate(today.getFullYear() + 1, targetDate.getMonth(), targetDate.getDate());
|
||||||
|
targetDate.setHours(originalHours, originalMinutes, 0, 0);
|
||||||
}
|
}
|
||||||
} else if (repeatType === 'monthly') {
|
} else if (repeatType === 'monthly') {
|
||||||
// 月度重复:找到本月或下月的对应日期
|
// 月度重复:找到本月或之后月份的对应日期
|
||||||
targetDate = safeCreateDate(today.getFullYear(), today.getMonth(), targetDate.getDate());
|
const originalDay = targetDate.getDate();
|
||||||
if (targetDate < today) {
|
let currentMonth = today.getMonth(); // 从当前月份开始
|
||||||
targetDate = safeCreateDate(today.getFullYear(), today.getMonth() + 1, targetDate.getDate());
|
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 {
|
} else {
|
||||||
// 不重复
|
// 不重复
|
||||||
@ -106,17 +142,38 @@ export function calculateCountdown(
|
|||||||
|
|
||||||
// 计算时间差
|
// 计算时间差
|
||||||
const diff = targetDate.getTime() - now.getTime();
|
const diff = targetDate.getTime() - now.getTime();
|
||||||
const isToday = targetDate.toDateString() === today.toDateString();
|
|
||||||
|
|
||||||
|
// 如果是今天,即使是负数(已过),也显示为 0 而不是"已过"
|
||||||
|
// 只要 isToday 为 true,就不算"已过",显示为"今天"
|
||||||
if (diff < 0 && !isPast) {
|
if (diff < 0 && !isPast) {
|
||||||
|
if (!isToday) {
|
||||||
isPast = true;
|
isPast = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const absDiff = Math.abs(diff);
|
const absDiff = Math.abs(diff);
|
||||||
const days = Math.floor(absDiff / (1000 * 60 * 60 * 24));
|
// 如果是今天,直接返回0天
|
||||||
const hours = Math.floor((absDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
let days: number;
|
||||||
const minutes = Math.floor((absDiff % (1000 * 60 * 60)) / (1000 * 60));
|
let hours = 0;
|
||||||
const seconds = Math.floor((absDiff % (1000 * 60)) / 1000);
|
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 {
|
return {
|
||||||
days: isPast ? -days : days,
|
days: isPast ? -days : days,
|
||||||
@ -157,7 +214,7 @@ export function formatCountdown(countdown: CountdownResult): string {
|
|||||||
*/
|
*/
|
||||||
export function getFriendlyDateDescription(
|
export function getFriendlyDateDescription(
|
||||||
dateStr: string,
|
dateStr: string,
|
||||||
repeatType: 'yearly' | 'monthly' | 'none',
|
repeatType: 'yearly' | 'monthly' | 'daily' | 'weekly' | 'none',
|
||||||
isLunar: boolean
|
isLunar: boolean
|
||||||
): string {
|
): string {
|
||||||
const countdown = calculateCountdown(dateStr, repeatType, isLunar);
|
const countdown = calculateCountdown(dateStr, repeatType, isLunar);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user