qia-client/src/pages/HomePage.tsx
ddshi 62aa5cd54c feat: 优化提醒时间选择器和 SW 宽限期
- 缩短 SW 宽限期从10分钟改为3分钟
- 新增 PopoverTimePicker 弹出式时间选择器
- 支持数字输入和30分钟间隔选择
- 替换原有的 WheelTimePicker

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 14:54:17 +08:00

725 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, useRef } from 'react';
import {
Container,
Title,
Button,
Group,
Text,
Modal,
TextInput,
Textarea,
Switch,
Select,
Stack,
Box,
ActionIcon,
} from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import { IconCalendar, IconClock, IconSettings, IconLogout, IconX } from '@tabler/icons-react';
import { useDisclosure } from '@mantine/hooks';
import { FixedCalendar } from '../components/common/FixedCalendar';
import { PopoverTimePicker } from '../components/common/PopoverTimePicker';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../stores';
import { AnniversaryList } from '../components/anniversary/AnniversaryList';
import { ReminderList } from '../components/reminder/ReminderList';
import { NoteEditor } from '../components/note/NoteEditor';
import { FloatingAIChat } from '../components/ai/FloatingAIChat';
import appIcon from '../assets/icon.png';
import type { Event, EventType, RepeatType, PriorityType } from '../types';
import { calculateNextReminderDate, getReminderOptions, getDefaultReminderValue, calculateReminderTimes, getReminderValueFromTimes, formatReminderTimeDisplay } from '../utils/repeatCalculator';
export function HomePage() {
const navigate = useNavigate();
const user = useAppStore((state) => state.user);
const logout = useAppStore((state) => state.logout);
const checkAuth = useAppStore((state) => state.checkAuth);
const events = useAppStore((state) => state.events);
const fetchEvents = useAppStore((state) => state.fetchEvents);
const createEvent = useAppStore((state) => state.createEvent);
const updateEventById = useAppStore((state) => state.updateEventById);
const deleteEventById = useAppStore((state) => state.deleteEventById);
const [opened, { open, close }] = useDisclosure(false);
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
const [isEdit, setIsEdit] = useState(false);
// Form state
const [formType, setFormType] = useState<EventType>('anniversary');
const [formTitle, setFormTitle] = useState('');
const [formContent, setFormContent] = useState('');
const [formDate, setFormDate] = useState<Date | null>(null);
const [formTime, setFormTime] = useState('');
const [formIsLunar, setFormIsLunar] = useState(false);
const [formRepeatType, setFormRepeatType] = useState<RepeatType>('none');
const [formIsHoliday, setFormIsHoliday] = useState(false);
const [formPriority, setFormPriority] = useState<PriorityType>('none');
const [formReminderValue, setFormReminderValue] = useState('0'); // 提醒选项值
// Initialize auth and data on mount
useEffect(() => {
checkAuth();
fetchEvents().catch(console.error);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleLogout = async () => {
await logout();
navigate('/landing');
};
const handleEventClick = (event: Event) => {
setSelectedEvent(event);
setIsEdit(true);
setFormType(event.type);
setFormTitle(event.title);
setFormContent(event.content || '');
setFormDate(new Date(event.date));
setFormIsLunar(event.is_lunar);
setFormRepeatType(event.repeat_type);
setFormIsHoliday(event.is_holiday || false);
setFormPriority(event.priority || 'none');
// 提取时间(如果日期中包含时间信息)
const eventDate = new Date(event.date);
const hours = eventDate.getHours();
const minutes = eventDate.getMinutes();
const hasTime = hours !== 0 || minutes !== 0;
setFormTime(hasTime ? `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}` : '');
// 从 reminder_times 反推用户选择的提醒选项值
setFormReminderValue(getReminderValueFromTimes(event.reminder_times, event.date, hasTime));
open();
};
const handleAddClick = (type: EventType) => {
setSelectedEvent(null);
setIsEdit(false);
setFormType(type);
setFormTitle('');
setFormContent('');
setFormDate(null);
setFormTime('');
setFormIsLunar(false);
setFormRepeatType('none');
setFormIsHoliday(type === 'anniversary');
setFormPriority('none');
setFormReminderValue('0');
open();
};
const handleSubmit = async () => {
if (!formTitle.trim() || !formDate) return;
// 确保 date 是 Date 对象
const dateObj = formDate instanceof Date ? formDate : new Date(formDate as unknown as string);
let dateStr: string;
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
if (formTime) {
// 有时间:构建 UTC 时间格式(用户选择的是本地时间,需要转换为 UTC 存储)
const hours = parseInt(formTime.split(':')[0]);
const minutes = parseInt(formTime.split(':')[1]);
// 先用本地时间创建日期对象,然后获取 UTC 时间
const localDate = new Date(year, dateObj.getMonth(), day, hours, minutes);
dateStr = localDate.toISOString();
} else {
// 无时间:使用 UTC 日期格式
dateStr = `${year}-${month}-${day}T00:00:00.000Z`;
}
// 构建事件数据,确保不包含 undefined 值
const eventData: Record<string, any> = {
type: formType,
title: formTitle,
date: dateStr,
is_lunar: formIsLunar,
repeat_type: formRepeatType,
// 计算下一次提醒日期
next_reminder_date: calculateNextReminderDate(dateStr, formRepeatType, undefined),
is_holiday: formIsHoliday ?? false,
priority: formPriority,
};
// 只有当 content 有值时才包含
if (formContent.trim()) {
eventData.content = formContent;
}
// 计算提醒时间点(仅对提醒事项)
if (formType === 'reminder') {
const hasTime = !!formTime;
eventData.reminder_times = calculateReminderTimes(dateStr, hasTime, formReminderValue);
}
if (isEdit && selectedEvent) {
await updateEventById(selectedEvent.id, eventData);
} else {
await createEvent(eventData);
}
close();
resetForm();
fetchEvents();
};
const handleDeleteFromModal = async () => {
if (!selectedEvent) return;
await deleteEventById(selectedEvent.id);
close();
resetForm();
};
const handleToggleComplete = async (event: Event) => {
if (event.type !== 'reminder') return;
// 使用当前期望的状态(取反)
const newCompleted = !event.is_completed;
const result = await updateEventById(event.id, {
is_completed: newCompleted,
});
if (result.error) {
console.error('更新失败:', result.error);
}
// 乐观更新已处理 UI 响应,无需 fetchEvents
};
const handleDelete = async (event: Event) => {
if (event.type !== 'reminder') return;
await deleteEventById(event.id);
fetchEvents();
};
const handlePostpone = async (event: Event) => {
if (event.type !== 'reminder') return;
// 将日期顺延到今天,保留原事件的时间
const today = new Date();
const originalDate = new Date(event.date);
// 提取原时间的小时和分钟
const hours = originalDate.getHours();
const minutes = originalDate.getMinutes();
// 构建新的本地时间字符串
const newDateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
// 构建更新数据
const updateData: Record<string, any> = {
date: newDateStr,
priority: event.priority || 'none',
};
// 如果是重复提醒,同步更新 next_reminder_date
if (event.repeat_type && event.repeat_type !== 'none') {
const nextReminderDate = calculateNextReminderDate(
newDateStr,
event.repeat_type as RepeatType,
event.repeat_interval || undefined
);
updateData.next_reminder_date = nextReminderDate;
}
const result = await updateEventById(event.id, updateData);
if (result.error) {
console.error('顺延失败:', result.error);
}
};
const handleDateChange = async (
event: Event,
date: string,
repeatType: string,
repeatInterval: number | null
) => {
if (event.type !== 'reminder') return;
// 保留原事件的时间信息
const originalDate = new Date(event.date);
const hours = originalDate.getHours();
const minutes = originalDate.getMinutes();
// 构建新的本地时间字符串
const newDateStr = `${date}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
// 构建更新数据
const updateData: Record<string, any> = {
date: newDateStr,
priority: event.priority || 'none',
};
// 如果是重复提醒,同步更新 next_reminder_date
if (repeatType && repeatType !== 'none') {
const nextReminderDate = calculateNextReminderDate(
newDateStr,
repeatType as RepeatType,
repeatInterval || undefined
);
updateData.next_reminder_date = nextReminderDate;
}
// 合并更新
const result = await updateEventById(event.id, updateData);
if (result.error) {
console.error('日期调整失败:', result.error);
}
fetchEvents(); // 刷新列表以更新UI
};
const handlePriorityChange = async (event: Event, priority: PriorityType) => {
if (event.type !== 'reminder') return;
// 合并更新 priority 和 date确保 updated_at 被更新
const result = await updateEventById(event.id, {
priority,
date: event.date,
});
if (result.error) {
console.error('优先级设置失败:', result.error);
}
fetchEvents(); // 刷新列表以更新UI
};
const resetForm = () => {
setFormType('anniversary');
setFormTitle('');
setFormContent('');
setFormDate(null);
setFormTime('');
setFormIsLunar(false);
setFormRepeatType('none');
setFormIsHoliday(false);
setFormPriority('none');
setFormReminderValue('0');
setSelectedEvent(null);
setIsEdit(false);
};
const handleAIEventCreated = () => {
fetchEvents();
};
return (
<div
style={{
minHeight: '100vh',
background: '#faf9f7',
overflow: 'hidden',
height: '100vh',
}}
>
<Container size="xl" py="md" h="100vh" style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Header */}
<Group justify="space-between" mb="md" style={{ flexShrink: 0 }}>
<Group gap="sm">
<img
src={appIcon}
alt="掐日子"
style={{
width: 28,
height: 28,
borderRadius: '50%',
}}
/>
<Title
order={2}
style={{
fontWeight: 300,
fontSize: '1.25rem',
letterSpacing: '0.15em',
color: '#1a1a1a',
}}
>
</Title>
</Group>
<Group>
{/* 设置入口 */}
<Button
variant="subtle"
color="gray"
size="xs"
leftSection={<IconSettings size={12} />}
onClick={() => navigate('/settings')}
style={{
letterSpacing: '0.1em',
borderRadius: 2,
}}
>
</Button>
<Text
size="xs"
c="#888"
style={{ letterSpacing: '0.05em' }}
>
{user?.nickname || user?.email}
</Text>
<Button
variant="subtle"
color="gray"
size="xs"
leftSection={<IconLogout size={12} />}
onClick={handleLogout}
style={{
letterSpacing: '0.1em',
borderRadius: 2,
}}
>
退
</Button>
</Group>
</Group>
{/* Main Content - 3 column horizontal layout */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 16, overflow: 'hidden' }}>
{/* Left column - Anniversary */}
<div style={{ flex: 1, minWidth: 0, height: '100%', overflow: 'hidden' }}>
<AnniversaryList
events={events}
onEventClick={handleEventClick}
onAddClick={() => handleAddClick('anniversary')}
/>
</div>
{/* Middle column - Reminder */}
<div style={{ flex: 1, minWidth: 0, height: '100%', overflow: 'hidden' }}>
<ReminderList
events={events}
onEventClick={handleEventClick}
onToggleComplete={handleToggleComplete}
onAddClick={() => handleAddClick('reminder')}
onDelete={handleDelete}
onPostpone={handlePostpone}
onDateChange={handleDateChange}
onPriorityChange={handlePriorityChange}
/>
</div>
{/* Right column - Note */}
<div style={{ flex: 1, minWidth: 0, height: '100%', overflow: 'hidden' }}>
<NoteEditor />
</div>
</div>
{/* AI Chat - Floating */}
<FloatingAIChat onEventCreated={handleAIEventCreated} />
{/* Add/Edit Event Modal */}
<Modal
opened={opened}
onClose={close}
title={
<Text
fw={400}
style={{
letterSpacing: '0.1em',
fontSize: '1rem',
}}
>
{isEdit ? '编辑事件' : '添加事件'}
</Text>
}
size="md"
styles={{
header: {
borderBottom: '1px solid rgba(0,0,0,0.06)',
},
body: {
paddingTop: 20,
},
}}
>
<Stack gap="md">
{/* Event type */}
<Select
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
</Text>
}
data={[
{ value: 'anniversary', label: '纪念日' },
{ value: 'reminder', label: '提醒' },
]}
value={formType}
onChange={(value) => value && setFormType(value as EventType)}
readOnly={isEdit}
rightSectionPointerEvents={isEdit ? 'none' : 'auto'}
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
pointerEvents: isEdit ? 'none' : 'auto',
},
}}
/>
{/* Title */}
<TextInput
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
<Box component="span" c="red">*</Box>
</Text>
}
placeholder="输入标题"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
},
}}
/>
{/* Content (only for reminders) */}
{formType === 'reminder' && (
<Box style={{ position: 'relative' }}>
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}>
</Text>
<Box
style={{
position: 'relative',
background: '#faf9f7',
borderRadius: 2,
border: '1px solid #ddd',
}}
>
<textarea
id="content-input"
placeholder="输入详细内容"
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
rows={3}
style={{
width: '100%',
border: 'none',
background: 'transparent',
padding: '8px 12px',
fontSize: '14px',
resize: 'vertical',
fontFamily: 'inherit',
outline: 'none',
minHeight: '72px',
boxSizing: 'border-box',
}}
/>
</Box>
</Box>
)}
{/* Date & Time - Combined selector */}
<Box>
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 8 }}>
</Text>
<Group gap={8} grow>
{/* Date selector - Fixed 6-row calendar */}
<Popover
position="bottom"
withArrow
shadow="md"
>
<Popover.Target>
<TextInput
placeholder="选择日期"
value={formDate ? `${formDate.getFullYear()}-${String(formDate.getMonth() + 1).padStart(2, '0')}-${String(formDate.getDate()).padStart(2, '0')}` : ''}
readOnly
leftSection={<IconCalendar size={14} />}
leftSectionPointerEvents="none"
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
borderColor: '#ddd',
color: '#666',
height: 32,
paddingLeft: 36,
paddingRight: 8,
cursor: 'pointer',
},
section: {
paddingLeft: 8,
},
}}
/>
</Popover.Target>
<Popover.Dropdown style={{ background: 'transparent', border: 'none', padding: 0, boxShadow: 'none' }}>
<FixedCalendar
value={formDate}
onChange={(date) => setFormDate(date)}
/>
</Popover.Dropdown>
</Popover>
{/* Time selector (only for reminders) - Popover picker */}
{formType === 'reminder' && (
<PopoverTimePicker
value={formTime}
onChange={(time) => setFormTime(time)}
size="sm"
/>
)}
</Group>
</Box>
{/* Lunar switch (only for anniversaries) */}
{formType === 'anniversary' && (
<Switch
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
</Text>
}
checked={formIsLunar}
onChange={(e) => setFormIsLunar(e.currentTarget.checked)}
/>
)}
{/* Repeat and Reminder on same row */}
<Group gap={12} grow>
{/* Repeat type */}
<Select
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
</Text>
}
data={[
{ value: 'none', label: '不重复' },
{ value: 'daily', label: '每天' },
{ value: 'weekly', label: '每周' },
{ value: 'monthly', label: '每月' },
{ value: 'yearly', label: '每年' },
]}
value={formRepeatType}
onChange={(value) => value && setFormRepeatType(value as RepeatType)}
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
},
}}
/>
{/* Reminder options (only for reminders) */}
{formType === 'reminder' && (
<Select
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
<Box component="span" size="xs" c="dimmed" ml={4}>
{formDate ? formatReminderTimeDisplay(
calculateReminderTimes(
`${formDate.getFullYear()}-${String(formDate.getMonth() + 1).padStart(2, '0')}-${String(formDate.getDate()).padStart(2, '0')}T${formTime ? formTime : '00:00'}:00`,
!!formTime,
formReminderValue
)
) : ''}
</Box>
</Text>
}
data={getReminderOptions(!!formTime).map(opt => ({
value: opt.value,
label: opt.label,
}))}
value={formReminderValue}
onChange={(value) => value && setFormReminderValue(value)}
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
},
}}
/>
)}
</Group>
{/* Color (only for reminders) */}
{formType === 'reminder' && (
<Box>
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 8 }}>
</Text>
<Group gap={8}>
{([
{ value: 'none' as const, color: 'rgba(0, 0, 0, 0.15)' },
{ value: 'red' as const, color: '#dc2626' },
{ value: 'green' as const, color: '#16a34a' },
{ value: 'yellow' as const, color: '#ca8a04' },
].map((item) => (
<Box
key={item.value}
onClick={() => setFormPriority(item.value)}
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: item.color,
border: formPriority === item.value ? '2px solid #1a1a1a' : '1px solid rgba(0, 0, 0, 0.1)',
cursor: 'pointer',
transition: 'all 0.2s ease',
transform: formPriority === item.value ? 'scale(1.1)' : 'scale(1)',
}}
/>
)))}
</Group>
</Box>
)}
{/* Holiday switch (only for anniversaries) */}
{formType === 'anniversary' && (
<Switch
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
</Text>
}
checked={formIsHoliday}
onChange={(e) => setFormIsHoliday(e.currentTarget.checked)}
/>
)}
{/* Actions */}
<Group justify="space-between" mt="md">
{isEdit && (
<Button
color="dark"
variant="light"
onClick={handleDeleteFromModal}
style={{
borderRadius: 2,
}}
>
</Button>
)}
<Group ml="auto">
<Button
variant="subtle"
onClick={close}
style={{
borderRadius: 2,
color: '#666',
}}
>
</Button>
<Button
onClick={handleSubmit}
disabled={!formTitle.trim() || !formDate}
style={{
background: '#1a1a1a',
border: '1px solid #1a1a1a',
borderRadius: 2,
}}
>
{isEdit ? '保存' : '添加'}
</Button>
</Group>
</Group>
</Stack>
</Modal>
</Container>
</div>
);
}