feat: 新增短链接工具,优化日历提醒功能

1. 新增完整短链接工具服务,包括前端页面、后端API与数据库支持
2. 新增移动端日历提醒工具与鉴权配置
3. 优化日历事件编辑与展示:
   - 添加事件循环重复设置
   - 新增全天事件支持
   - 时间选择器自动对齐15分钟间隔
   - 保存状态加载动画
   - 事件卡片显示重复规则
4. 更新API基础地址与路由配置
5. 新增SSL证书文件与相关工具静态资源
This commit is contained in:
yangxiangyuan
2026-07-28 16:23:37 +08:00
parent 212dbc4e1d
commit 154b79c8f1
18 changed files with 3508 additions and 23 deletions
@@ -12,6 +12,33 @@ interface EventCardProps {
onDelete: (event: Event) => void;
}
const WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
// 生成循环规则中文描述
const getRecurrenceLabel = (recurrence: string | null): string | null => {
if (!recurrence) return null;
const parts = recurrence.split(';');
const type = parts[0];
let interval = 1;
let by: number[] = [];
for (let i = 1; i < parts.length; i++) {
const [key, val] = parts[i].split('=');
if (key === 'interval') interval = parseInt(val) || 1;
if (key === 'by') by = val.split(',').map(Number);
}
if (type === 'DAILY') {
return interval === 1 ? '每天' : `每${interval}天`;
}
if (type === 'WEEKLY') {
const prefix = interval === 1 ? '每周' : `每${interval}周`;
if (by.length > 0) {
return `${prefix} ${by.map(d => WEEKDAY_NAMES[d] || '').join('、')}`;
}
return prefix;
}
return null;
};
export default function EventCard({
event,
onEdit,
@@ -26,6 +53,8 @@ export default function EventCard({
});
};
const recurrenceLabel = getRecurrenceLabel(event.recurrence);
return (
<TouchableOpacity
style={[styles.container, event.completed && styles.completedContainer]}
@@ -71,6 +100,17 @@ export default function EventCard({
color={theme.colors.textSecondary}
/>
<Text style={styles.timeText}>{formatTime(event.start)}</Text>
{recurrenceLabel && (
<>
<Ionicons
name="repeat-outline"
size={14}
color={theme.colors.primary}
style={{ marginLeft: 8 }}
/>
<Text style={styles.recurrenceText}>{recurrenceLabel}</Text>
</>
)}
</View>
</View>
@@ -133,6 +173,10 @@ const styles = StyleSheet.create({
...theme.typography.caption,
color: theme.colors.textSecondary,
},
recurrenceText: {
...theme.typography.caption,
color: theme.colors.primary,
},
deleteButton: {
padding: theme.spacing.sm,
},
@@ -8,6 +8,7 @@ import {
TouchableOpacity,
ScrollView,
Switch,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker';
@@ -22,6 +23,32 @@ interface EventModalProps {
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,
@@ -32,9 +59,13 @@ export default function EventModal({
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);
@@ -45,16 +76,14 @@ export default function EventModal({
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());
if (event.recurrence?.startsWith('DAILY')) {
setRecurrenceType('DAILY');
} else if (event.recurrence?.startsWith('WEEKLY')) {
setRecurrenceType('WEEKLY');
} else {
setRecurrenceType('NONE');
}
const parsed = parseRecurrence(event.recurrence);
setRecurrenceType(parsed.type);
setInterval_(parsed.interval);
setWeekdays(parsed.by);
} else {
resetForm();
}
@@ -63,11 +92,15 @@ export default function EventModal({
const resetForm = () => {
setTitle('');
setContent('');
setStartDate(new Date());
setStartDate(snapTo15(new Date()));
setEndDate(null);
setAllDay(false);
setEnableReminder(true);
setRemindDate(new Date());
setRemindDate(snapTo15(new Date()));
setRecurrenceType('NONE');
setInterval_(1);
setWeekdays([]);
setSaving(false);
};
const handleSave = () => {
@@ -75,20 +108,36 @@ export default function EventModal({
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=1';
recurrence = `DAILY;interval=${interval}`;
} else if (recurrenceType === 'WEEKLY') {
recurrence = 'WEEKLY;interval=1';
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: endDate ? endDate.toISOString() : null,
allDay: false,
end: finalEnd.toISOString(),
allDay,
remindAt: enableReminder ? remindDate.toISOString() : null,
recurrence: recurrence || null,
completed: event?.completed || false,
@@ -97,6 +146,12 @@ export default function EventModal({
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',
@@ -123,8 +178,12 @@ export default function EventModal({
<Text style={styles.headerTitle}>
{event ? '编辑事件' : '新建事件'}
</Text>
<TouchableOpacity onPress={handleSave}>
<Text style={styles.saveButton}>保存</Text>
<TouchableOpacity onPress={handleSave} disabled={saving}>
{saving ? (
<ActivityIndicator size="small" color={theme.colors.primary} />
) : (
<Text style={styles.saveButton}>保存</Text>
)}
</TouchableOpacity>
</View>
@@ -153,6 +212,16 @@ export default function EventModal({
/>
</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
@@ -164,7 +233,7 @@ export default function EventModal({
</View>
<View style={styles.field}>
<Text style={styles.label}>结束时间(可选)</Text>
<Text style={styles.label}>结束时间(可选,默认+1小时)</Text>
<TouchableOpacity
style={styles.dateButton}
onPress={() => setShowEndPicker(true)}
@@ -233,26 +302,78 @@ export default function EventModal({
))}
</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(date);
if (date) setStartDate(snapTo15(date));
}}
/>
)}
{showEndPicker && (
<DateTimePicker
value={endDate || new Date()}
value={endDate || new Date(startDate.getTime() + 3600000)}
mode="datetime"
minuteInterval={15}
onChange={(event, date) => {
setShowEndPicker(false);
if (date) setEndDate(date);
if (date) setEndDate(snapTo15(date));
}}
/>
)}
@@ -261,9 +382,10 @@ export default function EventModal({
<DateTimePicker
value={remindDate}
mode="datetime"
minuteInterval={15}
onChange={(event, date) => {
setShowRemindPicker(false);
if (date) setRemindDate(date);
if (date) setRemindDate(snapTo15(date));
}}
/>
)}
@@ -371,4 +493,50 @@ const styles = StyleSheet.create({
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',
},
});