feat: 新增短链接工具,优化日历提醒功能
1. 新增完整短链接工具服务,包括前端页面、后端API与数据库支持 2. 新增移动端日历提醒工具与鉴权配置 3. 优化日历事件编辑与展示: - 添加事件循环重复设置 - 新增全天事件支持 - 时间选择器自动对齐15分钟间隔 - 保存状态加载动画 - 事件卡片显示重复规则 4. 更新API基础地址与路由配置 5. 新增SSL证书文件与相关工具静态资源
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { Event, ApiResponse } from './types';
|
||||
|
||||
const API_BASE_URL = 'http://47.101.56.101:8976/api/app_calendar';
|
||||
const API_BASE_URL = 'https://traesite.umersoft.com:8975/api/app_calendar';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function CalendarScreen() {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
@@ -139,10 +140,13 @@ export default function CalendarScreen() {
|
||||
|
||||
const handleToggleComplete = async (event: Event) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await eventApi.updateEvent(event.id, { completed: !event.completed });
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
showError('更新失败', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,6 +174,7 @@ export default function CalendarScreen() {
|
||||
|
||||
const handleSaveEvent = async (eventData: Partial<Event>) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
if (selectedEvent) {
|
||||
await eventApi.updateEvent(selectedEvent.id, eventData);
|
||||
} else {
|
||||
@@ -184,6 +189,8 @@ export default function CalendarScreen() {
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
showError('保存失败', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function TodayScreen() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
@@ -64,10 +65,13 @@ export default function TodayScreen() {
|
||||
|
||||
const handleToggleComplete = async (event: Event) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await eventApi.updateEvent(event.id, { completed: !event.completed });
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
showError('更新失败', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,6 +99,7 @@ export default function TodayScreen() {
|
||||
|
||||
const handleSaveEvent = async (eventData: Partial<Event>) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
if (selectedEvent) {
|
||||
await eventApi.updateEvent(selectedEvent.id, eventData);
|
||||
} else {
|
||||
@@ -109,6 +114,8 @@ export default function TodayScreen() {
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
showError('保存失败', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user