154b79c8f1
1. 新增完整短链接工具服务,包括前端页面、后端API与数据库支持 2. 新增移动端日历提醒工具与鉴权配置 3. 优化日历事件编辑与展示: - 添加事件循环重复设置 - 新增全天事件支持 - 时间选择器自动对齐15分钟间隔 - 保存状态加载动画 - 事件卡片显示重复规则 4. 更新API基础地址与路由配置 5. 新增SSL证书文件与相关工具静态资源
543 lines
16 KiB
TypeScript
543 lines
16 KiB
TypeScript
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',
|
||
},
|
||
});
|