Files
Toolbox/apps/app_calendar_reminder/components/EventModal.tsx
T
yangxiangyuan 154b79c8f1 feat: 新增短链接工具,优化日历提醒功能
1. 新增完整短链接工具服务,包括前端页面、后端API与数据库支持
2. 新增移动端日历提醒工具与鉴权配置
3. 优化日历事件编辑与展示:
   - 添加事件循环重复设置
   - 新增全天事件支持
   - 时间选择器自动对齐15分钟间隔
   - 保存状态加载动画
   - 事件卡片显示重复规则
4. 更新API基础地址与路由配置
5. 新增SSL证书文件与相关工具静态资源
2026-07-28 16:23:37 +08:00

543 lines
16 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 React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
TextInput,
TouchableOpacity,
ScrollView,
Switch,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker';
import { theme } from '../theme';
import { Event } from '../types';
import { safeParseDate } from '../utils/recurrence';
interface EventModalProps {
visible: boolean;
event: Event | null;
onClose: () => void;
onSave: (event: Partial<Event>) => void;
}
const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
// 15 分钟对齐
const snapTo15 = (date: Date): Date => {
const d = new Date(date);
const minutes = d.getMinutes();
const snapped = Math.round(minutes / 15) * 15;
d.setMinutes(snapped, 0, 0);
return d;
};
// 解析循环规则字符串
const parseRecurrence = (str: string | null): { type: 'NONE' | 'DAILY' | 'WEEKLY'; interval: number; by: number[] } => {
if (!str) return { type: 'NONE', interval: 1, by: [] };
const parts = str.split(';');
const type = (parts[0] === 'DAILY' || parts[0] === 'WEEKLY') ? parts[0] as 'DAILY' | 'WEEKLY' : 'NONE';
let interval = 1;
let by: number[] = [];
for (let i = 1; i < parts.length; i++) {
const [key, val] = parts[i].split('=');
if (key === 'interval') interval = Math.max(1, parseInt(val) || 1);
if (key === 'by') by = val.split(',').map(Number).filter(n => n >= 0 && n <= 6);
}
return { type, interval, by };
};
export default function EventModal({
visible,
event,
onClose,
onSave,
}: EventModalProps) {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [startDate, setStartDate] = useState(new Date());
const [endDate, setEndDate] = useState<Date | null>(null);
const [allDay, setAllDay] = useState(false);
const [enableReminder, setEnableReminder] = useState(true);
const [remindDate, setRemindDate] = useState(new Date());
const [recurrenceType, setRecurrenceType] = useState<'NONE' | 'DAILY' | 'WEEKLY'>('NONE');
const [interval, setInterval_] = useState(1);
const [weekdays, setWeekdays] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const [showStartPicker, setShowStartPicker] = useState(false);
const [showEndPicker, setShowEndPicker] = useState(false);
const [showRemindPicker, setShowRemindPicker] = useState(false);
useEffect(() => {
if (event) {
setTitle(event.title);
setContent(event.content || '');
setStartDate(safeParseDate(event.start));
setEndDate(event.end ? safeParseDate(event.end) : null);
setAllDay(!!event.allDay);
setEnableReminder(!!event.remindAt);
setRemindDate(event.remindAt ? safeParseDate(event.remindAt) : new Date());
const parsed = parseRecurrence(event.recurrence);
setRecurrenceType(parsed.type);
setInterval_(parsed.interval);
setWeekdays(parsed.by);
} else {
resetForm();
}
}, [event, visible]);
const resetForm = () => {
setTitle('');
setContent('');
setStartDate(snapTo15(new Date()));
setEndDate(null);
setAllDay(false);
setEnableReminder(true);
setRemindDate(snapTo15(new Date()));
setRecurrenceType('NONE');
setInterval_(1);
setWeekdays([]);
setSaving(false);
};
const handleSave = () => {
if (!title.trim()) {
alert('请输入标题');
return;
}
if (!startDate) {
alert('请设置开始时间');
return;
}
setSaving(true);
// 结束时间自动补全:未设置时 +1h(全天则 +24h)
let finalEnd = endDate;
if (!finalEnd) {
finalEnd = new Date(startDate.getTime() + (allDay ? 24 : 1) * 3600 * 1000);
}
// 组装循环规则
let recurrence = '';
if (recurrenceType === 'DAILY') {
recurrence = `DAILY;interval=${interval}`;
} else if (recurrenceType === 'WEEKLY') {
recurrence = `WEEKLY;interval=${interval}`;
if (weekdays.length > 0) {
recurrence += `;by=${weekdays.join(',')}`;
}
}
const eventData: Partial<Event> = {
title: title.trim(),
content: content.trim(),
start: startDate.toISOString(),
end: finalEnd.toISOString(),
allDay,
remindAt: enableReminder ? remindDate.toISOString() : null,
recurrence: recurrence || null,
completed: event?.completed || false,
};
onSave(eventData);
};
const toggleWeekday = (day: number) => {
setWeekdays(prev =>
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day].sort()
);
};
const formatDate = (date: Date) => {
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={onClose}
>
<View style={styles.overlay}>
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={onClose}>
<Ionicons name="close" size={28} color={theme.colors.text} />
</TouchableOpacity>
<Text style={styles.headerTitle}>
{event ? '编辑事件' : '新建事件'}
</Text>
<TouchableOpacity onPress={handleSave} disabled={saving}>
{saving ? (
<ActivityIndicator size="small" color={theme.colors.primary} />
) : (
<Text style={styles.saveButton}>保存</Text>
)}
</TouchableOpacity>
</View>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.field}>
<Text style={styles.label}>标题</Text>
<TextInput
style={styles.input}
value={title}
onChangeText={setTitle}
placeholder="输入事件标题"
placeholderTextColor={theme.colors.textSecondary}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>内容</Text>
<TextInput
style={[styles.input, styles.textArea]}
value={content}
onChangeText={setContent}
placeholder="输入事件内容(可选)"
placeholderTextColor={theme.colors.textSecondary}
multiline
numberOfLines={4}
/>
</View>
<View style={styles.switchField}>
<Text style={styles.label}>全天事件</Text>
<Switch
value={allDay}
onValueChange={setAllDay}
trackColor={{ false: theme.colors.border, true: theme.colors.primary }}
thumbColor={theme.colors.text}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>开始时间</Text>
<TouchableOpacity
style={styles.dateButton}
onPress={() => setShowStartPicker(true)}
>
<Text style={styles.dateButtonText}>{formatDate(startDate)}</Text>
</TouchableOpacity>
</View>
<View style={styles.field}>
<Text style={styles.label}>结束时间(可选,默认+1小时)</Text>
<TouchableOpacity
style={styles.dateButton}
onPress={() => setShowEndPicker(true)}
>
<Text style={styles.dateButtonText}>
{endDate ? formatDate(endDate) : '未设置'}
</Text>
{endDate && (
<TouchableOpacity
onPress={() => setEndDate(null)}
style={styles.clearButton}
>
<Ionicons name="close-circle" size={20} color={theme.colors.textSecondary} />
</TouchableOpacity>
)}
</TouchableOpacity>
</View>
<View style={styles.switchField}>
<Text style={styles.label}>启用提醒</Text>
<Switch
value={enableReminder}
onValueChange={setEnableReminder}
trackColor={{ false: theme.colors.border, true: theme.colors.primary }}
thumbColor={theme.colors.text}
/>
</View>
{enableReminder && (
<View style={styles.field}>
<Text style={styles.label}>提醒时间</Text>
<TouchableOpacity
style={styles.dateButton}
onPress={() => setShowRemindPicker(true)}
>
<Text style={styles.dateButtonText}>{formatDate(remindDate)}</Text>
</TouchableOpacity>
</View>
)}
<View style={styles.field}>
<Text style={styles.label}>重复</Text>
<View style={styles.recurrenceOptions}>
{[
{ label: '不重复', value: 'NONE' },
{ label: '每天', value: 'DAILY' },
{ label: '每周', value: 'WEEKLY' },
].map((option) => (
<TouchableOpacity
key={option.value}
style={[
styles.recurrenceButton,
recurrenceType === option.value && styles.recurrenceButtonActive,
]}
onPress={() => setRecurrenceType(option.value as any)}
>
<Text
style={[
styles.recurrenceButtonText,
recurrenceType === option.value && styles.recurrenceButtonTextActive,
]}
>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{recurrenceType !== 'NONE' && (
<View style={styles.field}>
<Text style={styles.label}>
每 {interval} {recurrenceType === 'DAILY' ? '天' : '周'}
</Text>
<View style={styles.intervalRow}>
<TouchableOpacity
style={styles.intervalBtn}
onPress={() => setInterval_(Math.max(1, interval - 1))}
>
<Ionicons name="remove" size={20} color={theme.colors.text} />
</TouchableOpacity>
<Text style={styles.intervalValue}>{interval}</Text>
<TouchableOpacity
style={styles.intervalBtn}
onPress={() => setInterval_(interval + 1)}
>
<Ionicons name="add" size={20} color={theme.colors.text} />
</TouchableOpacity>
</View>
</View>
)}
{recurrenceType === 'WEEKLY' && (
<View style={styles.field}>
<Text style={styles.label}>重复星期</Text>
<View style={styles.weekdayRow}>
{WEEKDAY_LABELS.map((label, idx) => (
<TouchableOpacity
key={idx}
style={[
styles.weekdayBtn,
weekdays.includes(idx) && styles.weekdayBtnActive,
]}
onPress={() => toggleWeekday(idx)}
>
<Text
style={[
styles.weekdayBtnText,
weekdays.includes(idx) && styles.weekdayBtnTextActive,
]}
>
{label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
</ScrollView>
{showStartPicker && (
<DateTimePicker
value={startDate}
mode="datetime"
minuteInterval={15}
onChange={(event, date) => {
setShowStartPicker(false);
if (date) setStartDate(snapTo15(date));
}}
/>
)}
{showEndPicker && (
<DateTimePicker
value={endDate || new Date(startDate.getTime() + 3600000)}
mode="datetime"
minuteInterval={15}
onChange={(event, date) => {
setShowEndPicker(false);
if (date) setEndDate(snapTo15(date));
}}
/>
)}
{showRemindPicker && (
<DateTimePicker
value={remindDate}
mode="datetime"
minuteInterval={15}
onChange={(event, date) => {
setShowRemindPicker(false);
if (date) setRemindDate(snapTo15(date));
}}
/>
)}
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
container: {
backgroundColor: theme.colors.background,
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
maxHeight: '90%',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: theme.spacing.md,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
headerTitle: {
...theme.typography.h3,
color: theme.colors.text,
},
saveButton: {
...theme.typography.body,
color: theme.colors.primary,
fontWeight: '600',
},
content: {
padding: theme.spacing.md,
},
field: {
marginBottom: theme.spacing.lg,
},
label: {
...theme.typography.body,
color: theme.colors.text,
marginBottom: theme.spacing.sm,
},
input: {
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.md,
padding: theme.spacing.md,
color: theme.colors.text,
fontSize: 16,
},
textArea: {
height: 100,
textAlignVertical: 'top',
},
dateButton: {
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.md,
padding: theme.spacing.md,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
dateButtonText: {
...theme.typography.body,
color: theme.colors.text,
},
clearButton: {
marginLeft: theme.spacing.sm,
},
switchField: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: theme.spacing.lg,
},
recurrenceOptions: {
flexDirection: 'row',
gap: theme.spacing.sm,
},
recurrenceButton: {
flex: 1,
paddingVertical: theme.spacing.sm,
paddingHorizontal: theme.spacing.md,
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.md,
alignItems: 'center',
borderWidth: 1,
borderColor: theme.colors.border,
},
recurrenceButtonActive: {
borderColor: theme.colors.primary,
backgroundColor: theme.colors.surfaceVariant,
},
recurrenceButtonText: {
...theme.typography.bodySmall,
color: theme.colors.text,
},
recurrenceButtonTextActive: {
color: theme.colors.primary,
fontWeight: '600',
},
intervalRow: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing.md,
},
intervalBtn: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: theme.colors.border,
},
intervalValue: {
...theme.typography.h3,
color: theme.colors.text,
minWidth: 30,
textAlign: 'center',
},
weekdayRow: {
flexDirection: 'row',
gap: theme.spacing.xs,
},
weekdayBtn: {
flex: 1,
paddingVertical: theme.spacing.sm,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface,
alignItems: 'center',
borderWidth: 1,
borderColor: theme.colors.border,
},
weekdayBtnActive: {
borderColor: theme.colors.primary,
backgroundColor: theme.colors.surfaceVariant,
},
weekdayBtnText: {
...theme.typography.bodySmall,
color: theme.colors.textSecondary,
},
weekdayBtnTextActive: {
color: theme.colors.primary,
fontWeight: '600',
},
});