feat: 新增短链接工具,优化日历提醒功能
1. 新增完整短链接工具服务,包括前端页面、后端API与数据库支持 2. 新增移动端日历提醒工具与鉴权配置 3. 优化日历事件编辑与展示: - 添加事件循环重复设置 - 新增全天事件支持 - 时间选择器自动对齐15分钟间隔 - 保存状态加载动画 - 事件卡片显示重复规则 4. 更新API基础地址与路由配置 5. 新增SSL证书文件与相关工具静态资源
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Event, ApiResponse } from './types';
|
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({
|
const api = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
|
|||||||
@@ -12,6 +12,33 @@ interface EventCardProps {
|
|||||||
onDelete: (event: Event) => void;
|
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({
|
export default function EventCard({
|
||||||
event,
|
event,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -26,6 +53,8 @@ export default function EventCard({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const recurrenceLabel = getRecurrenceLabel(event.recurrence);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.container, event.completed && styles.completedContainer]}
|
style={[styles.container, event.completed && styles.completedContainer]}
|
||||||
@@ -71,6 +100,17 @@ export default function EventCard({
|
|||||||
color={theme.colors.textSecondary}
|
color={theme.colors.textSecondary}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.timeText}>{formatTime(event.start)}</Text>
|
<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>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -133,6 +173,10 @@ const styles = StyleSheet.create({
|
|||||||
...theme.typography.caption,
|
...theme.typography.caption,
|
||||||
color: theme.colors.textSecondary,
|
color: theme.colors.textSecondary,
|
||||||
},
|
},
|
||||||
|
recurrenceText: {
|
||||||
|
...theme.typography.caption,
|
||||||
|
color: theme.colors.primary,
|
||||||
|
},
|
||||||
deleteButton: {
|
deleteButton: {
|
||||||
padding: theme.spacing.sm,
|
padding: theme.spacing.sm,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Switch,
|
Switch,
|
||||||
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
@@ -22,6 +23,32 @@ interface EventModalProps {
|
|||||||
onSave: (event: Partial<Event>) => 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({
|
export default function EventModal({
|
||||||
visible,
|
visible,
|
||||||
event,
|
event,
|
||||||
@@ -32,9 +59,13 @@ export default function EventModal({
|
|||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [startDate, setStartDate] = useState(new Date());
|
const [startDate, setStartDate] = useState(new Date());
|
||||||
const [endDate, setEndDate] = useState<Date | null>(null);
|
const [endDate, setEndDate] = useState<Date | null>(null);
|
||||||
|
const [allDay, setAllDay] = useState(false);
|
||||||
const [enableReminder, setEnableReminder] = useState(true);
|
const [enableReminder, setEnableReminder] = useState(true);
|
||||||
const [remindDate, setRemindDate] = useState(new Date());
|
const [remindDate, setRemindDate] = useState(new Date());
|
||||||
const [recurrenceType, setRecurrenceType] = useState<'NONE' | 'DAILY' | 'WEEKLY'>('NONE');
|
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 [showStartPicker, setShowStartPicker] = useState(false);
|
||||||
const [showEndPicker, setShowEndPicker] = useState(false);
|
const [showEndPicker, setShowEndPicker] = useState(false);
|
||||||
const [showRemindPicker, setShowRemindPicker] = useState(false);
|
const [showRemindPicker, setShowRemindPicker] = useState(false);
|
||||||
@@ -45,16 +76,14 @@ export default function EventModal({
|
|||||||
setContent(event.content || '');
|
setContent(event.content || '');
|
||||||
setStartDate(safeParseDate(event.start));
|
setStartDate(safeParseDate(event.start));
|
||||||
setEndDate(event.end ? safeParseDate(event.end) : null);
|
setEndDate(event.end ? safeParseDate(event.end) : null);
|
||||||
|
setAllDay(!!event.allDay);
|
||||||
setEnableReminder(!!event.remindAt);
|
setEnableReminder(!!event.remindAt);
|
||||||
setRemindDate(event.remindAt ? safeParseDate(event.remindAt) : new Date());
|
setRemindDate(event.remindAt ? safeParseDate(event.remindAt) : new Date());
|
||||||
|
|
||||||
if (event.recurrence?.startsWith('DAILY')) {
|
const parsed = parseRecurrence(event.recurrence);
|
||||||
setRecurrenceType('DAILY');
|
setRecurrenceType(parsed.type);
|
||||||
} else if (event.recurrence?.startsWith('WEEKLY')) {
|
setInterval_(parsed.interval);
|
||||||
setRecurrenceType('WEEKLY');
|
setWeekdays(parsed.by);
|
||||||
} else {
|
|
||||||
setRecurrenceType('NONE');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
resetForm();
|
resetForm();
|
||||||
}
|
}
|
||||||
@@ -63,11 +92,15 @@ export default function EventModal({
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setContent('');
|
setContent('');
|
||||||
setStartDate(new Date());
|
setStartDate(snapTo15(new Date()));
|
||||||
setEndDate(null);
|
setEndDate(null);
|
||||||
|
setAllDay(false);
|
||||||
setEnableReminder(true);
|
setEnableReminder(true);
|
||||||
setRemindDate(new Date());
|
setRemindDate(snapTo15(new Date()));
|
||||||
setRecurrenceType('NONE');
|
setRecurrenceType('NONE');
|
||||||
|
setInterval_(1);
|
||||||
|
setWeekdays([]);
|
||||||
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
@@ -75,20 +108,36 @@ export default function EventModal({
|
|||||||
alert('请输入标题');
|
alert('请输入标题');
|
||||||
return;
|
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 = '';
|
let recurrence = '';
|
||||||
if (recurrenceType === 'DAILY') {
|
if (recurrenceType === 'DAILY') {
|
||||||
recurrence = 'DAILY;interval=1';
|
recurrence = `DAILY;interval=${interval}`;
|
||||||
} else if (recurrenceType === 'WEEKLY') {
|
} else if (recurrenceType === 'WEEKLY') {
|
||||||
recurrence = 'WEEKLY;interval=1';
|
recurrence = `WEEKLY;interval=${interval}`;
|
||||||
|
if (weekdays.length > 0) {
|
||||||
|
recurrence += `;by=${weekdays.join(',')}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventData: Partial<Event> = {
|
const eventData: Partial<Event> = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
start: startDate.toISOString(),
|
start: startDate.toISOString(),
|
||||||
end: endDate ? endDate.toISOString() : null,
|
end: finalEnd.toISOString(),
|
||||||
allDay: false,
|
allDay,
|
||||||
remindAt: enableReminder ? remindDate.toISOString() : null,
|
remindAt: enableReminder ? remindDate.toISOString() : null,
|
||||||
recurrence: recurrence || null,
|
recurrence: recurrence || null,
|
||||||
completed: event?.completed || false,
|
completed: event?.completed || false,
|
||||||
@@ -97,6 +146,12 @@ export default function EventModal({
|
|||||||
onSave(eventData);
|
onSave(eventData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleWeekday = (day: number) => {
|
||||||
|
setWeekdays(prev =>
|
||||||
|
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day].sort()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
return date.toLocaleDateString('zh-CN', {
|
return date.toLocaleDateString('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -123,8 +178,12 @@ export default function EventModal({
|
|||||||
<Text style={styles.headerTitle}>
|
<Text style={styles.headerTitle}>
|
||||||
{event ? '编辑事件' : '新建事件'}
|
{event ? '编辑事件' : '新建事件'}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity onPress={handleSave}>
|
<TouchableOpacity onPress={handleSave} disabled={saving}>
|
||||||
<Text style={styles.saveButton}>保存</Text>
|
{saving ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.saveButton}>保存</Text>
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -153,6 +212,16 @@ export default function EventModal({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</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}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>开始时间</Text>
|
<Text style={styles.label}>开始时间</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -164,7 +233,7 @@ export default function EventModal({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>结束时间(可选)</Text>
|
<Text style={styles.label}>结束时间(可选,默认+1小时)</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.dateButton}
|
style={styles.dateButton}
|
||||||
onPress={() => setShowEndPicker(true)}
|
onPress={() => setShowEndPicker(true)}
|
||||||
@@ -233,26 +302,78 @@ export default function EventModal({
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</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>
|
</ScrollView>
|
||||||
|
|
||||||
{showStartPicker && (
|
{showStartPicker && (
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={startDate}
|
value={startDate}
|
||||||
mode="datetime"
|
mode="datetime"
|
||||||
|
minuteInterval={15}
|
||||||
onChange={(event, date) => {
|
onChange={(event, date) => {
|
||||||
setShowStartPicker(false);
|
setShowStartPicker(false);
|
||||||
if (date) setStartDate(date);
|
if (date) setStartDate(snapTo15(date));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showEndPicker && (
|
{showEndPicker && (
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={endDate || new Date()}
|
value={endDate || new Date(startDate.getTime() + 3600000)}
|
||||||
mode="datetime"
|
mode="datetime"
|
||||||
|
minuteInterval={15}
|
||||||
onChange={(event, date) => {
|
onChange={(event, date) => {
|
||||||
setShowEndPicker(false);
|
setShowEndPicker(false);
|
||||||
if (date) setEndDate(date);
|
if (date) setEndDate(snapTo15(date));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -261,9 +382,10 @@ export default function EventModal({
|
|||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={remindDate}
|
value={remindDate}
|
||||||
mode="datetime"
|
mode="datetime"
|
||||||
|
minuteInterval={15}
|
||||||
onChange={(event, date) => {
|
onChange={(event, date) => {
|
||||||
setShowRemindPicker(false);
|
setShowRemindPicker(false);
|
||||||
if (date) setRemindDate(date);
|
if (date) setRemindDate(snapTo15(date));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -371,4 +493,50 @@ const styles = StyleSheet.create({
|
|||||||
color: theme.colors.primary,
|
color: theme.colors.primary,
|
||||||
fontWeight: '600',
|
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 [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const loadEvents = useCallback(async () => {
|
const loadEvents = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -139,10 +140,13 @@ export default function CalendarScreen() {
|
|||||||
|
|
||||||
const handleToggleComplete = async (event: Event) => {
|
const handleToggleComplete = async (event: Event) => {
|
||||||
try {
|
try {
|
||||||
|
setSaving(true);
|
||||||
await eventApi.updateEvent(event.id, { completed: !event.completed });
|
await eventApi.updateEvent(event.id, { completed: !event.completed });
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('更新失败', error);
|
showError('更新失败', error);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -170,6 +174,7 @@ export default function CalendarScreen() {
|
|||||||
|
|
||||||
const handleSaveEvent = async (eventData: Partial<Event>) => {
|
const handleSaveEvent = async (eventData: Partial<Event>) => {
|
||||||
try {
|
try {
|
||||||
|
setSaving(true);
|
||||||
if (selectedEvent) {
|
if (selectedEvent) {
|
||||||
await eventApi.updateEvent(selectedEvent.id, eventData);
|
await eventApi.updateEvent(selectedEvent.id, eventData);
|
||||||
} else {
|
} else {
|
||||||
@@ -184,6 +189,8 @@ export default function CalendarScreen() {
|
|||||||
await loadEvents();
|
await loadEvents();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('保存失败', error);
|
showError('保存失败', error);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default function TodayScreen() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const loadEvents = useCallback(async () => {
|
const loadEvents = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -64,10 +65,13 @@ export default function TodayScreen() {
|
|||||||
|
|
||||||
const handleToggleComplete = async (event: Event) => {
|
const handleToggleComplete = async (event: Event) => {
|
||||||
try {
|
try {
|
||||||
|
setSaving(true);
|
||||||
await eventApi.updateEvent(event.id, { completed: !event.completed });
|
await eventApi.updateEvent(event.id, { completed: !event.completed });
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('更新失败', error);
|
showError('更新失败', error);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,6 +99,7 @@ export default function TodayScreen() {
|
|||||||
|
|
||||||
const handleSaveEvent = async (eventData: Partial<Event>) => {
|
const handleSaveEvent = async (eventData: Partial<Event>) => {
|
||||||
try {
|
try {
|
||||||
|
setSaving(true);
|
||||||
if (selectedEvent) {
|
if (selectedEvent) {
|
||||||
await eventApi.updateEvent(selectedEvent.id, eventData);
|
await eventApi.updateEvent(selectedEvent.id, eventData);
|
||||||
} else {
|
} else {
|
||||||
@@ -109,6 +114,8 @@ export default function TodayScreen() {
|
|||||||
await loadEvents();
|
await loadEvents();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('保存失败', error);
|
showError('保存失败', error);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"_comment": "短链接工具的鉴权配置",
|
||||||
|
"enable_auth_comment": "true:启用 tool 外层鉴权;false:完全放开访问",
|
||||||
|
"enable_auth": true,
|
||||||
|
"max_age_days_comment": "鉴权 cookie 最大有效天数(1~365)",
|
||||||
|
"max_age_days": 30,
|
||||||
|
"cookieName_comment": "鉴权 Cookie 名称,默认格式 <tool>_gate",
|
||||||
|
"cookieName": "short_link_gate",
|
||||||
|
"requireGuid_comment": "true:强制 GUID 参数才能访问;false:允许仅凭 token 或已有会话访问",
|
||||||
|
"requireGuid": false,
|
||||||
|
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
|
||||||
|
"guidParam": "guid",
|
||||||
|
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换(留空表示不启用 GUID)",
|
||||||
|
"guidValue": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Yang's Toolbox · 短链接</title>
|
||||||
|
<link rel="icon" href="/assets/picker.svg" type="image/svg+xml" />
|
||||||
|
<link rel="stylesheet" href="/common/common.css" />
|
||||||
|
<link rel="stylesheet" href="/tools/short_link/short_link.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="tabs">
|
||||||
|
<a class="tab" href="/">首页</a>
|
||||||
|
<a class="tab active" href="/tools/short_link/index.html">短链接</a>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<span id="mode-badge" class="mode-badge">debug</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- 创建区域 -->
|
||||||
|
<section class="card create-card">
|
||||||
|
<h2>创建短链接</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-row full">
|
||||||
|
<label for="inp-url">目标链接 <span class="req">*</span></label>
|
||||||
|
<input id="inp-url" type="url" placeholder="https://example.com/very/long/path?with=params" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="inp-title">备注标题</label>
|
||||||
|
<input id="inp-title" type="text" placeholder="可选,方便识别" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="inp-code">自定义短码</label>
|
||||||
|
<input id="inp-code" type="text" placeholder="留空则自动生成" maxlength="20" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="inp-expires">过期时间</label>
|
||||||
|
<input id="inp-expires" type="datetime-local" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="inp-maxclicks">最大点击数</label>
|
||||||
|
<input id="inp-maxclicks" type="number" min="0" placeholder="不限" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="btn-create" class="btn btn-primary">生成短链接</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 工具别名区域 -->
|
||||||
|
<section class="card alias-card">
|
||||||
|
<h2>工具快捷入口</h2>
|
||||||
|
<p class="hint">以下别名在 config/short_link.json 中配置,访问对应短链接可直接跳转到工具</p>
|
||||||
|
<div id="alias-list" class="alias-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 列表区域 -->
|
||||||
|
<section class="card list-card">
|
||||||
|
<div class="list-header">
|
||||||
|
<h2>短链接列表</h2>
|
||||||
|
<div class="list-toolbar">
|
||||||
|
<input id="inp-search" type="text" placeholder="搜索短码/链接/标题…" />
|
||||||
|
<button id="btn-search" class="btn btn-sm">搜索</button>
|
||||||
|
<button id="btn-refresh" class="btn btn-sm">刷新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="link-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>短链接</th>
|
||||||
|
<th>目标</th>
|
||||||
|
<th>备注</th>
|
||||||
|
<th>点击</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="link-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="pagination" class="pagination"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 交互详情弹窗 -->
|
||||||
|
<div class="modal hidden" id="interaction-modal">
|
||||||
|
<div class="modal-body interaction-modal-body">
|
||||||
|
<h3 id="interaction-title">交互详情</h3>
|
||||||
|
<div id="interaction-content" class="interaction-content"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="btn-interaction-copy" class="btn btn-sm">复制日志</button>
|
||||||
|
<button id="btn-interaction-close" class="btn btn-sm">关闭</button>
|
||||||
|
<span id="interaction-countdown" class="countdown"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 确认弹窗 -->
|
||||||
|
<div class="modal hidden" id="confirm-modal">
|
||||||
|
<div class="modal-body confirm-modal-body">
|
||||||
|
<h3 id="confirm-title">确认操作</h3>
|
||||||
|
<p id="confirm-message"></p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="btn-confirm-yes" class="btn btn-danger btn-sm">确认</button>
|
||||||
|
<button id="btn-confirm-no" class="btn btn-sm">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑弹窗 -->
|
||||||
|
<div class="modal hidden" id="edit-modal">
|
||||||
|
<div class="modal-body edit-modal-body">
|
||||||
|
<h3>编辑短链接</h3>
|
||||||
|
<input type="hidden" id="edit-code" />
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-row full">
|
||||||
|
<label for="edit-url">目标链接</label>
|
||||||
|
<input id="edit-url" type="url" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="edit-title">备注标题</label>
|
||||||
|
<input id="edit-title" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="edit-expires">过期时间</label>
|
||||||
|
<input id="edit-expires" type="datetime-local" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="edit-maxclicks">最大点击数</label>
|
||||||
|
<input id="edit-maxclicks" type="number" min="0" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="edit-active">状态</label>
|
||||||
|
<select id="edit-active">
|
||||||
|
<option value="1">启用</option>
|
||||||
|
<option value="0">停用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="btn-edit-save" class="btn btn-primary btn-sm">保存</button>
|
||||||
|
<button id="btn-edit-cancel" class="btn btn-sm">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 统计弹窗 -->
|
||||||
|
<div class="modal hidden" id="stats-modal">
|
||||||
|
<div class="modal-body stats-modal-body">
|
||||||
|
<h3 id="stats-title">点击统计</h3>
|
||||||
|
<div id="stats-summary" class="stats-summary"></div>
|
||||||
|
<div id="stats-clicks" class="stats-clicks"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="btn-stats-close" class="btn btn-sm">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div id="toast" class="toast hidden"></div>
|
||||||
|
|
||||||
|
<script src="/common/common.js"></script>
|
||||||
|
<script src="/tools/short_link/short_link.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/* ===== 短链接工具样式 ===== */
|
||||||
|
:root {
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-hover: #1d4ed8;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-hover: #b91c1c;
|
||||||
|
--success: #16a34a;
|
||||||
|
--bg: #f8fafc;
|
||||||
|
--card-bg: #fff;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--text: #1e293b;
|
||||||
|
--text-secondary: #64748b;
|
||||||
|
--radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { background: var(--bg); color: var(--text); min-height: 100vh }
|
||||||
|
main { max-width: 960px; margin: 0 auto; padding: 16px 12px 40px }
|
||||||
|
|
||||||
|
/* 卡片 */
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.card h2 { margin: 0 0 12px; font-size: 16px; font-weight: 600 }
|
||||||
|
.hint { font-size: 12px; color: var(--text-secondary); margin: 0 0 12px }
|
||||||
|
|
||||||
|
/* 模式徽章 */
|
||||||
|
.mode-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mode-badge.normal { background: #d1fae5; color: #065f46 }
|
||||||
|
|
||||||
|
/* 表单 */
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px }
|
||||||
|
.form-row { display: flex; flex-direction: column; gap: 4px }
|
||||||
|
.form-row.full { grid-column: 1 / -1 }
|
||||||
|
.form-row label { font-size: 13px; font-weight: 500; color: var(--text-secondary) }
|
||||||
|
.form-row .req { color: var(--danger) }
|
||||||
|
.form-row input, .form-row select {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.form-row input:focus, .form-row select:focus { border-color: var(--primary) }
|
||||||
|
.form-actions { margin-top: 14px; display: flex; gap: 8px }
|
||||||
|
|
||||||
|
/* 按钮 */
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.btn:hover { background: #f1f5f9 }
|
||||||
|
.btn:disabled { opacity: .5; cursor: not-allowed }
|
||||||
|
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary) }
|
||||||
|
.btn-primary:hover { background: var(--primary-hover) }
|
||||||
|
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger) }
|
||||||
|
.btn-danger:hover { background: var(--danger-hover) }
|
||||||
|
.btn-sm { padding: 4px 10px; font-size: 12px }
|
||||||
|
.btn-link {
|
||||||
|
border: none; background: none; color: var(--primary);
|
||||||
|
cursor: pointer; font-size: 12px; padding: 2px 4px; text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 别名网格 */
|
||||||
|
.alias-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px }
|
||||||
|
.alias-item {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.alias-item code {
|
||||||
|
background: #e0e7ff;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.alias-item .alias-target { color: var(--text-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||||
|
|
||||||
|
/* 列表 */
|
||||||
|
.list-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 12px }
|
||||||
|
.list-header h2 { margin: 0 }
|
||||||
|
.list-toolbar { display: flex; gap: 6px; align-items: center }
|
||||||
|
.list-toolbar input {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 200px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.list-toolbar input:focus { border-color: var(--primary) }
|
||||||
|
|
||||||
|
.table-wrap { overflow-x: auto }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px }
|
||||||
|
th { text-align: left; padding: 8px 10px; border-bottom: 2px solid var(--border); color: var(--text-secondary); font-weight: 600; white-space: nowrap }
|
||||||
|
td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: middle }
|
||||||
|
tr:hover td { background: #f8fafc }
|
||||||
|
|
||||||
|
.short-url { font-weight: 600; color: var(--primary); cursor: pointer; white-space: nowrap }
|
||||||
|
.short-url:hover { text-decoration: underline }
|
||||||
|
.target-url { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); font-size: 12px }
|
||||||
|
.status-active { color: var(--success); font-weight: 600 }
|
||||||
|
.status-inactive { color: var(--danger); font-weight: 600 }
|
||||||
|
.status-expired { color: #d97706; font-weight: 600 }
|
||||||
|
.actions { display: flex; gap: 4px; flex-wrap: nowrap }
|
||||||
|
|
||||||
|
/* 分页 */
|
||||||
|
.pagination { display: flex; justify-content: center; gap: 6px; margin-top: 12px }
|
||||||
|
.pagination button {
|
||||||
|
padding: 4px 10px; border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
background: #fff; cursor: pointer; font-size: 12px;
|
||||||
|
}
|
||||||
|
.pagination button.active { background: var(--primary); color: #fff; border-color: var(--primary) }
|
||||||
|
.pagination button:disabled { opacity: .4; cursor: not-allowed }
|
||||||
|
|
||||||
|
/* 弹窗 */
|
||||||
|
.interaction-modal-body { max-height: 80vh; overflow-y: auto; width: 600px }
|
||||||
|
.interaction-content {
|
||||||
|
background: #1e293b; color: #e2e8f0; border-radius: 6px;
|
||||||
|
padding: 12px; font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all;
|
||||||
|
max-height: 50vh; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.countdown { font-size: 12px; color: var(--text-secondary); margin-left: auto }
|
||||||
|
|
||||||
|
.confirm-modal-body { width: 400px }
|
||||||
|
.edit-modal-body { width: 520px }
|
||||||
|
.stats-modal-body { width: 600px; max-height: 80vh; overflow-y: auto }
|
||||||
|
|
||||||
|
.stats-summary {
|
||||||
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.stat-box {
|
||||||
|
background: #f1f5f9; border-radius: 6px; padding: 10px; text-align: center;
|
||||||
|
}
|
||||||
|
.stat-box .stat-num { font-size: 22px; font-weight: 700; color: var(--primary) }
|
||||||
|
.stat-box .stat-label { font-size: 11px; color: var(--text-secondary); margin-top: 2px }
|
||||||
|
|
||||||
|
.stats-clicks { max-height: 40vh; overflow-y: auto }
|
||||||
|
.click-row {
|
||||||
|
display: flex; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 12px; color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.click-row .click-time { white-space: nowrap; color: var(--text) }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||||
|
padding: 10px 20px; border-radius: 8px; font-size: 13px; color: #fff;
|
||||||
|
z-index: 2000; transition: opacity .3s; box-shadow: 0 4px 12px rgba(0,0,0,.15);
|
||||||
|
}
|
||||||
|
.toast.info { background: var(--primary) }
|
||||||
|
.toast.success { background: var(--success) }
|
||||||
|
.toast.error { background: var(--danger) }
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state { text-align: center; padding: 32px; color: var(--text-secondary); font-size: 14px }
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.form-grid { grid-template-columns: 1fr }
|
||||||
|
.alias-grid { grid-template-columns: 1fr }
|
||||||
|
.list-header { flex-direction: column; align-items: flex-start }
|
||||||
|
.list-toolbar { width: 100% }
|
||||||
|
.list-toolbar input { flex: 1; width: auto }
|
||||||
|
.stats-summary { grid-template-columns: 1fr }
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
/* ===== 短链接工具前端逻辑 ===== */
|
||||||
|
;(function () {
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
// ===== 全局状态 =====
|
||||||
|
let UI_MODE = 'debug'
|
||||||
|
let DOMAIN = ''
|
||||||
|
let TOOL_ALIASES = {}
|
||||||
|
let currentPage = 1
|
||||||
|
const pageSize = 20
|
||||||
|
let interactionTimer = null
|
||||||
|
|
||||||
|
// ===== DOM 引用 =====
|
||||||
|
const $ = id => document.getElementById(id)
|
||||||
|
|
||||||
|
// ===== 工具函数 =====
|
||||||
|
const escHtml = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
|
|
||||||
|
const fullShortUrl = code => {
|
||||||
|
const base = DOMAIN || (location.origin)
|
||||||
|
return `${base}/l/${code}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyText = async (text) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = text
|
||||||
|
ta.style.cssText = 'position:fixed;opacity:0'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Toast(normal 模式轻提示)=====
|
||||||
|
const showToast = (msg, type = 'info') => {
|
||||||
|
const t = $('toast')
|
||||||
|
t.textContent = msg
|
||||||
|
t.className = `toast ${type}`
|
||||||
|
clearTimeout(t._timer)
|
||||||
|
t._timer = setTimeout(() => { t.className = 'toast hidden' }, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 交互详情弹窗(双模式)=====
|
||||||
|
const showInteraction = (title, detail, mode = 'view') => {
|
||||||
|
if (UI_MODE === 'normal' && mode === 'view') {
|
||||||
|
showToast(detail.ok ? '操作成功' : (detail.error || '操作失败'), detail.ok ? 'success' : 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lines = []
|
||||||
|
lines.push(`动作: ${title}`)
|
||||||
|
lines.push(`时间: ${fmtCNDateTime(new Date())}`)
|
||||||
|
if (detail.method) lines.push(`方法: ${detail.method}`)
|
||||||
|
if (detail.url) lines.push(`接口: ${detail.url}`)
|
||||||
|
if (detail.params) lines.push(`请求参数: ${JSON.stringify(detail.params, null, 2)}`)
|
||||||
|
if (detail.status !== undefined) lines.push(`HTTP 状态: ${detail.status}`)
|
||||||
|
if (detail.response) lines.push(`返回结果: ${JSON.stringify(detail.response, null, 2)}`)
|
||||||
|
if (detail.error) lines.push(`错误信息: ${detail.error}`)
|
||||||
|
if (detail.duration !== undefined) lines.push(`耗时: ${detail.duration}ms`)
|
||||||
|
lines.push(`当前状态: ${detail.ok ? '成功' : '失败'}`)
|
||||||
|
|
||||||
|
$('interaction-title').textContent = title
|
||||||
|
$('interaction-content').textContent = lines.join('\n')
|
||||||
|
$('interaction-modal').classList.remove('hidden')
|
||||||
|
|
||||||
|
// 自动关闭倒计时
|
||||||
|
clearInterval(interactionTimer)
|
||||||
|
const cd = $('interaction-countdown')
|
||||||
|
if (detail.ok) {
|
||||||
|
const secs = UI_MODE === 'debug' ? 3 : 2
|
||||||
|
let remain = secs
|
||||||
|
cd.textContent = `${remain}s 后关闭`
|
||||||
|
interactionTimer = setInterval(() => {
|
||||||
|
remain--
|
||||||
|
if (remain <= 0) {
|
||||||
|
clearInterval(interactionTimer)
|
||||||
|
closeInteraction()
|
||||||
|
} else {
|
||||||
|
cd.textContent = `${remain}s 后关闭`
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
cd.textContent = '异常停留,请排查'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeInteraction = () => {
|
||||||
|
clearInterval(interactionTimer)
|
||||||
|
$('interaction-modal').classList.add('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 确认弹窗 =====
|
||||||
|
let confirmResolve = null
|
||||||
|
const openConfirm = (title, message) => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
confirmResolve = resolve
|
||||||
|
$('confirm-title').textContent = title
|
||||||
|
$('confirm-message').textContent = message
|
||||||
|
$('confirm-modal').classList.remove('hidden')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const closeConfirm = (result) => {
|
||||||
|
$('confirm-modal').classList.add('hidden')
|
||||||
|
if (confirmResolve) { confirmResolve(result); confirmResolve = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 按钮 Loading =====
|
||||||
|
const setLoading = (btn, loading, text) => {
|
||||||
|
if (loading) {
|
||||||
|
btn._origText = btn.textContent
|
||||||
|
btn.disabled = true
|
||||||
|
btn.textContent = text || '处理中…'
|
||||||
|
} else {
|
||||||
|
btn.disabled = false
|
||||||
|
btn.textContent = btn._origText || btn.textContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== API 请求封装 =====
|
||||||
|
const api = async (method, path, body) => {
|
||||||
|
const url = `/api/short_link${path}`
|
||||||
|
const t0 = performance.now()
|
||||||
|
try {
|
||||||
|
const opts = { method, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
if (body && method !== 'GET') opts.body = JSON.stringify(body)
|
||||||
|
const resp = await fetch(url, opts)
|
||||||
|
const data = await resp.json()
|
||||||
|
const duration = Math.round(performance.now() - t0)
|
||||||
|
return { ok: data.ok !== false, status: resp.status, data, duration, method, url, params: body || null }
|
||||||
|
} catch (e) {
|
||||||
|
const duration = Math.round(performance.now() - t0)
|
||||||
|
return { ok: false, status: 0, data: null, error: String(e.message || e), duration, method, url, params: body || null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 加载配置 =====
|
||||||
|
const loadConfig = async () => {
|
||||||
|
const r = await api('GET', '/config')
|
||||||
|
if (r.ok && r.data) {
|
||||||
|
DOMAIN = r.data.domain || ''
|
||||||
|
TOOL_ALIASES = r.data.tool_aliases || {}
|
||||||
|
UI_MODE = r.data.ui_mode || 'debug'
|
||||||
|
const badge = $('mode-badge')
|
||||||
|
badge.textContent = UI_MODE
|
||||||
|
badge.className = `mode-badge ${UI_MODE === 'normal' ? 'normal' : ''}`
|
||||||
|
renderAliases()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 渲染工具别名 =====
|
||||||
|
const renderAliases = () => {
|
||||||
|
const wrap = $('alias-list')
|
||||||
|
const entries = Object.entries(TOOL_ALIASES)
|
||||||
|
if (entries.length === 0) {
|
||||||
|
wrap.innerHTML = '<div class="empty-state">暂无配置别名</div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wrap.innerHTML = entries.map(([code, target]) => `
|
||||||
|
<div class="alias-item">
|
||||||
|
<code>${escHtml(code)}</code>
|
||||||
|
<span class="alias-target" title="${escHtml(target)}">${escHtml(target)}</span>
|
||||||
|
<button class="btn-link" data-copy="${escHtml(fullShortUrl(code))}">复制</button>
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 渲染列表 =====
|
||||||
|
const renderList = (rows, total, page) => {
|
||||||
|
const tbody = $('link-tbody')
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">暂无短链接</td></tr>'
|
||||||
|
renderPagination(0, page)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tbody.innerHTML = rows.map(r => {
|
||||||
|
const shortUrl = fullShortUrl(r.code)
|
||||||
|
const now = fmtCNDateTime(new Date())
|
||||||
|
let statusHtml = '<span class="status-active">启用</span>'
|
||||||
|
if (!r.is_active) statusHtml = '<span class="status-inactive">停用</span>'
|
||||||
|
else if (r.expires_at && r.expires_at < now) statusHtml = '<span class="status-expired">已过期</span>'
|
||||||
|
else if (r.max_clicks && r.click_count >= r.max_clicks) statusHtml = '<span class="status-expired">已达上限</span>'
|
||||||
|
|
||||||
|
return `<tr>
|
||||||
|
<td><span class="short-url" data-copy="${escHtml(shortUrl)}" title="点击复制">${escHtml(shortUrl)}</span></td>
|
||||||
|
<td><span class="target-url" title="${escHtml(r.target_url)}">${escHtml(r.target_url)}</span></td>
|
||||||
|
<td>${escHtml(r.title || '-')}</td>
|
||||||
|
<td>${r.click_count}${r.max_clicks ? ' / ' + r.max_clicks : ''}</td>
|
||||||
|
<td style="white-space:nowrap;font-size:12px">${escHtml(r.created_at || '')}</td>
|
||||||
|
<td>${statusHtml}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn-link" data-action="copy" data-url="${escHtml(shortUrl)}">复制</button>
|
||||||
|
<button class="btn-link" data-action="edit" data-code="${escHtml(r.code)}">编辑</button>
|
||||||
|
<button class="btn-link" data-action="stats" data-code="${escHtml(r.code)}">统计</button>
|
||||||
|
<button class="btn-link" style="color:var(--danger)" data-action="delete" data-code="${escHtml(r.code)}">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>`
|
||||||
|
}).join('')
|
||||||
|
renderPagination(total, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPagination = (total, page) => {
|
||||||
|
const wrap = $('pagination')
|
||||||
|
const pages = Math.ceil(total / pageSize)
|
||||||
|
if (pages <= 1) { wrap.innerHTML = ''; return }
|
||||||
|
let html = `<button ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}">上一页</button>`
|
||||||
|
for (let i = 1; i <= pages && i <= 10; i++) {
|
||||||
|
html += `<button class="${i === page ? 'active' : ''}" data-page="${i}">${i}</button>`
|
||||||
|
}
|
||||||
|
html += `<button ${page >= pages ? 'disabled' : ''} data-page="${page + 1}">下一页</button>`
|
||||||
|
wrap.innerHTML = html
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 加载列表 =====
|
||||||
|
const loadList = async (page = 1, keyword = '') => {
|
||||||
|
currentPage = page
|
||||||
|
const qs = `?page=${page}&pageSize=${pageSize}${keyword ? '&keyword=' + encodeURIComponent(keyword) : ''}`
|
||||||
|
const r = await api('GET', `/list${qs}`)
|
||||||
|
if (r.ok && r.data) {
|
||||||
|
renderList(r.data.rows, r.data.total, r.data.page)
|
||||||
|
} else {
|
||||||
|
showInteraction('加载列表', r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 创建短链接 =====
|
||||||
|
const doCreate = async () => {
|
||||||
|
const btn = $('btn-create')
|
||||||
|
const targetUrl = $('inp-url').value.trim()
|
||||||
|
if (!targetUrl) { showToast('请输入目标链接', 'error'); return }
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
target_url: targetUrl,
|
||||||
|
title: $('inp-title').value.trim(),
|
||||||
|
code: $('inp-code').value.trim() || undefined,
|
||||||
|
expires_at: $('inp-expires').value ? $('inp-expires').value.replace('T', ' ') + ':00' : null,
|
||||||
|
max_clicks: $('inp-maxclicks').value ? Number($('inp-maxclicks').value) : null,
|
||||||
|
source_type: 'manual'
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(btn, true, '生成中…')
|
||||||
|
const r = await api('POST', '/create', params)
|
||||||
|
setLoading(btn, false)
|
||||||
|
|
||||||
|
if (r.ok && r.data && r.data.data) {
|
||||||
|
const link = r.data.data
|
||||||
|
const shortUrl = fullShortUrl(link.code)
|
||||||
|
showInteraction('创建短链接', { ...r, response: r.data })
|
||||||
|
// 清空表单
|
||||||
|
$('inp-url').value = ''
|
||||||
|
$('inp-title').value = ''
|
||||||
|
$('inp-code').value = ''
|
||||||
|
$('inp-expires').value = ''
|
||||||
|
$('inp-maxclicks').value = ''
|
||||||
|
// 刷新列表
|
||||||
|
loadList(1, $('inp-search').value.trim())
|
||||||
|
// 自动复制
|
||||||
|
copyText(shortUrl)
|
||||||
|
if (UI_MODE === 'normal') showToast(`已创建并复制: ${shortUrl}`, 'success')
|
||||||
|
} else {
|
||||||
|
showInteraction('创建短链接', r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 编辑 =====
|
||||||
|
const openEdit = async (code) => {
|
||||||
|
const r = await api('GET', `/list?keyword=${encodeURIComponent(code)}&pageSize=1`)
|
||||||
|
if (!r.ok || !r.data || !r.data.rows || !r.data.rows.length) {
|
||||||
|
showToast('未找到该短链接', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const link = r.data.rows[0]
|
||||||
|
$('edit-code').value = link.code
|
||||||
|
$('edit-url').value = link.target_url
|
||||||
|
$('edit-title').value = link.title || ''
|
||||||
|
$('edit-expires').value = link.expires_at ? link.expires_at.replace(' ', 'T').slice(0, 16) : ''
|
||||||
|
$('edit-maxclicks').value = link.max_clicks || ''
|
||||||
|
$('edit-active').value = link.is_active ? '1' : '0'
|
||||||
|
$('edit-modal').classList.remove('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
const doEditSave = async () => {
|
||||||
|
const btn = $('btn-edit-save')
|
||||||
|
const code = $('edit-code').value
|
||||||
|
const params = {
|
||||||
|
target_url: $('edit-url').value.trim(),
|
||||||
|
title: $('edit-title').value.trim(),
|
||||||
|
expires_at: $('edit-expires').value ? $('edit-expires').value.replace('T', ' ') + ':00' : null,
|
||||||
|
max_clicks: $('edit-maxclicks').value ? Number($('edit-maxclicks').value) : null,
|
||||||
|
is_active: $('edit-active').value === '1'
|
||||||
|
}
|
||||||
|
setLoading(btn, true, '保存中…')
|
||||||
|
const r = await api('PUT', `/${encodeURIComponent(code)}`, params)
|
||||||
|
setLoading(btn, false)
|
||||||
|
$('edit-modal').classList.add('hidden')
|
||||||
|
showInteraction('编辑短链接', { ...r, response: r.data })
|
||||||
|
if (r.ok) loadList(currentPage, $('inp-search').value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 删除 =====
|
||||||
|
const doDelete = async (code) => {
|
||||||
|
const yes = await openConfirm('删除短链接', `确定要删除短码 "${code}" 吗?删除后该短链接将立即失效,此操作不可恢复。`)
|
||||||
|
if (!yes) return
|
||||||
|
const r = await api('DELETE', `/${encodeURIComponent(code)}`)
|
||||||
|
showInteraction('删除短链接', { ...r, response: r.data })
|
||||||
|
if (r.ok) loadList(currentPage, $('inp-search').value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 统计 =====
|
||||||
|
const openStats = async (code) => {
|
||||||
|
const r = await api('GET', `/stats/${encodeURIComponent(code)}?limit=200`)
|
||||||
|
if (!r.ok || !r.data) {
|
||||||
|
showToast('获取统计失败', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { link, clicks } = r.data
|
||||||
|
$('stats-title').textContent = `点击统计 - ${code}`
|
||||||
|
$('stats-summary').innerHTML = `
|
||||||
|
<div class="stat-box"><div class="stat-num">${link.click_count}</div><div class="stat-label">总点击</div></div>
|
||||||
|
<div class="stat-box"><div class="stat-num">${clicks.length}</div><div class="stat-label">最近记录</div></div>
|
||||||
|
<div class="stat-box"><div class="stat-num">${link.is_active ? '启用' : '停用'}</div><div class="stat-label">当前状态</div></div>
|
||||||
|
`
|
||||||
|
if (clicks.length === 0) {
|
||||||
|
$('stats-clicks').innerHTML = '<div class="empty-state">暂无点击记录</div>'
|
||||||
|
} else {
|
||||||
|
$('stats-clicks').innerHTML = clicks.map(c => `
|
||||||
|
<div class="click-row">
|
||||||
|
<span class="click-time">${escHtml(c.clicked_at)}</span>
|
||||||
|
<span>${escHtml(c.ip || '-')}</span>
|
||||||
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escHtml(c.user_agent || '')}">${escHtml(c.user_agent || '-')}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
}
|
||||||
|
$('stats-modal').classList.remove('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 事件绑定 =====
|
||||||
|
const bindEvents = () => {
|
||||||
|
// 创建
|
||||||
|
$('btn-create').addEventListener('click', doCreate)
|
||||||
|
$('inp-url').addEventListener('keydown', e => { if (e.key === 'Enter') doCreate() })
|
||||||
|
|
||||||
|
// 搜索 & 刷新
|
||||||
|
$('btn-search').addEventListener('click', () => loadList(1, $('inp-search').value.trim()))
|
||||||
|
$('inp-search').addEventListener('keydown', e => { if (e.key === 'Enter') loadList(1, $('inp-search').value.trim()) })
|
||||||
|
$('btn-refresh').addEventListener('click', () => loadList(currentPage, $('inp-search').value.trim()))
|
||||||
|
|
||||||
|
// 交互弹窗
|
||||||
|
$('btn-interaction-close').addEventListener('click', closeInteraction)
|
||||||
|
$('btn-interaction-copy').addEventListener('click', () => {
|
||||||
|
copyText($('interaction-content').textContent)
|
||||||
|
showToast('已复制交互日志', 'success')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 确认弹窗
|
||||||
|
$('btn-confirm-yes').addEventListener('click', () => closeConfirm(true))
|
||||||
|
$('btn-confirm-no').addEventListener('click', () => closeConfirm(false))
|
||||||
|
|
||||||
|
// 编辑弹窗
|
||||||
|
$('btn-edit-save').addEventListener('click', doEditSave)
|
||||||
|
$('btn-edit-cancel').addEventListener('click', () => $('edit-modal').classList.add('hidden'))
|
||||||
|
|
||||||
|
// 统计弹窗
|
||||||
|
$('btn-stats-close').addEventListener('click', () => $('stats-modal').classList.add('hidden'))
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
$('pagination').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('button[data-page]')
|
||||||
|
if (btn && !btn.disabled) loadList(Number(btn.dataset.page), $('inp-search').value.trim())
|
||||||
|
})
|
||||||
|
|
||||||
|
// 列表操作(事件委托)
|
||||||
|
$('link-tbody').addEventListener('click', e => {
|
||||||
|
const el = e.target.closest('[data-action]')
|
||||||
|
if (!el) return
|
||||||
|
const action = el.dataset.action
|
||||||
|
if (action === 'copy') { copyText(el.dataset.url); showToast('已复制短链接', 'success') }
|
||||||
|
else if (action === 'edit') openEdit(el.dataset.code)
|
||||||
|
else if (action === 'stats') openStats(el.dataset.code)
|
||||||
|
else if (action === 'delete') doDelete(el.dataset.code)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 短链接点击复制
|
||||||
|
$('link-tbody').addEventListener('click', e => {
|
||||||
|
const el = e.target.closest('.short-url[data-copy]')
|
||||||
|
if (el) { copyText(el.dataset.copy); showToast('已复制短链接', 'success') }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 别名复制
|
||||||
|
$('alias-list').addEventListener('click', e => {
|
||||||
|
const el = e.target.closest('[data-copy]')
|
||||||
|
if (el) { copyText(el.dataset.copy); showToast('已复制短链接', 'success') }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 弹窗背景点击关闭
|
||||||
|
document.querySelectorAll('.modal').forEach(m => {
|
||||||
|
m.addEventListener('click', e => {
|
||||||
|
if (e.target === m) {
|
||||||
|
if (m.id === 'interaction-modal') closeInteraction()
|
||||||
|
else if (m.id === 'confirm-modal') closeConfirm(false)
|
||||||
|
else m.classList.add('hidden')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 初始化 =====
|
||||||
|
const init = async () => {
|
||||||
|
bindEvents()
|
||||||
|
await loadConfig()
|
||||||
|
await loadList(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init)
|
||||||
|
} else {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_comment":"移动端日历提醒鉴权配置","enable_auth":false}
|
||||||
@@ -0,0 +1,906 @@
|
|||||||
|
/* ============================================
|
||||||
|
移动端日历提醒 - 暗色主题样式
|
||||||
|
对标 RN App (app_calendar_reminder)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a14;
|
||||||
|
--surface: #1a1a2e;
|
||||||
|
--surface-variant: #252540;
|
||||||
|
--primary: #e8b86d;
|
||||||
|
--primary-dim: rgba(232, 184, 109, 0.15);
|
||||||
|
--text: #f0f0f0;
|
||||||
|
--text-secondary: #8888aa;
|
||||||
|
--error: #ff6b6b;
|
||||||
|
--success: #4ecdc4;
|
||||||
|
--info: #5b9bd5;
|
||||||
|
--border: #2a2a44;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-xl: 20px;
|
||||||
|
--tab-height: 60px;
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主容器:max-width 480px 居中 */
|
||||||
|
.app-container {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
视图容器
|
||||||
|
============================================ */
|
||||||
|
.view {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 16px;
|
||||||
|
padding-bottom: calc(var(--tab-height) + var(--safe-bottom) + 16px);
|
||||||
|
display: none;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
今日视图
|
||||||
|
============================================ */
|
||||||
|
.today-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-date {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统计卡片 */
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 14px 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
事件卡片
|
||||||
|
============================================ */
|
||||||
|
.event-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--surface-variant);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
transition: transform 0.1s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card.completed {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-checkbox {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--text-secondary);
|
||||||
|
margin-right: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-checkbox:active {
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-checkbox.checked {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-checkbox.checked::after {
|
||||||
|
content: '✓';
|
||||||
|
color: var(--bg);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card.completed .event-title,
|
||||||
|
.event-card.completed .event-content {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-meta .recurrence-label {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-delete {
|
||||||
|
padding: 8px;
|
||||||
|
margin-left: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 18px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-delete:active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
空状态
|
||||||
|
============================================ */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载指示器 */
|
||||||
|
.loading-indicator {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
日历视图
|
||||||
|
============================================ */
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface);
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav-btn:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
background: var(--surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-month-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 星期标题行 */
|
||||||
|
.weekday-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-cell {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 日历网格 */
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.other-month {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.today .day-number {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.today {
|
||||||
|
background: var(--primary-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.selected {
|
||||||
|
border: 2px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-number {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中日期事件区 */
|
||||||
|
.selected-date-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-date-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-date-add {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
color: var(--bg);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-date-add:active {
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-date-hint {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
FAB 浮动按钮
|
||||||
|
============================================ */
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(var(--tab-height) + var(--safe-bottom) + 20px);
|
||||||
|
right: calc(50% - 240px + 20px);
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
color: var(--bg);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 16px rgba(232, 184, 109, 0.35);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:active {
|
||||||
|
transform: scale(0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 小屏幕时 FAB 靠右 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.fab {
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
底部 Tab 导航
|
||||||
|
============================================ */
|
||||||
|
.tab-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
height: calc(var(--tab-height) + var(--safe-bottom));
|
||||||
|
padding-bottom: var(--safe-bottom);
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: color 0.2s;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
底部滑出弹窗(事件编辑)
|
||||||
|
============================================ */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-sheet {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 88vh;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.visible .modal-sheet {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-save-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-save-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-save-btn:active:not(:disabled) {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单字段 */
|
||||||
|
.form-field {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-input {
|
||||||
|
min-height: 80px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 开关行 */
|
||||||
|
.switch-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-label {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iOS 风格开关 */
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-track {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text);
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-track::after {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 重复选项按钮组 */
|
||||||
|
.recurrence-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recurrence-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recurrence-btn.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-dim);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recurrence-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 循环间隔 */
|
||||||
|
.interval-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-btn:active {
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interval-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 星期勾选按钮组 */
|
||||||
|
.weekday-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-btn.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--bg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-btn:active {
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 时间输入 */
|
||||||
|
input[type="datetime-local"].form-input {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 清除按钮 */
|
||||||
|
.clear-time-btn {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-time-btn:active {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
删除确认 ActionSheet
|
||||||
|
============================================ */
|
||||||
|
.action-sheet-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 300;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-sheet-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-sheet {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
padding: 12px 16px calc(16px + var(--safe-bottom));
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.25s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-sheet-overlay.visible .action-sheet {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-sheet-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-sheet-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
transition: transform 0.1s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-sheet-btn:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-sheet-btn.danger {
|
||||||
|
background: var(--error);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-sheet-btn.cancel {
|
||||||
|
background: var(--surface-variant);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Toast 提示
|
||||||
|
============================================ */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 60px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(-20px);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fff;
|
||||||
|
z-index: 999;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.25s, transform 0.25s;
|
||||||
|
max-width: 90%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error {
|
||||||
|
background: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.info {
|
||||||
|
background: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
隐藏字段(全天时隐藏时间选择等)
|
||||||
|
============================================ */
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#0a0a14">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<title>日历提醒</title>
|
||||||
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- ======== 今日视图 ======== -->
|
||||||
|
<div id="view-today" class="view active">
|
||||||
|
<div class="today-header">
|
||||||
|
<div>
|
||||||
|
<div id="greeting" class="greeting">早上好 👋</div>
|
||||||
|
<div id="today-date" class="today-date"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div id="stat-pending" class="stat-number">0</div>
|
||||||
|
<div class="stat-label">待完成</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div id="stat-completed" class="stat-number">0</div>
|
||||||
|
<div class="stat-label">已完成</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div id="stat-total" class="stat-number">0</div>
|
||||||
|
<div class="stat-label">共计</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-title">待完成</div>
|
||||||
|
<div id="today-loading" class="loading-indicator hidden">加载中</div>
|
||||||
|
<div id="today-list"></div>
|
||||||
|
<div id="today-empty" class="empty-state hidden">今天没有待办事项 🎉</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== 日历视图 ======== -->
|
||||||
|
<div id="view-calendar" class="view">
|
||||||
|
<div class="calendar-header">
|
||||||
|
<button id="cal-prev" class="calendar-nav-btn">‹</button>
|
||||||
|
<div id="cal-month-title" class="calendar-month-title"></div>
|
||||||
|
<button id="cal-next" class="calendar-nav-btn">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="weekday-row">
|
||||||
|
<div class="weekday-cell">日</div>
|
||||||
|
<div class="weekday-cell">一</div>
|
||||||
|
<div class="weekday-cell">二</div>
|
||||||
|
<div class="weekday-cell">三</div>
|
||||||
|
<div class="weekday-cell">四</div>
|
||||||
|
<div class="weekday-cell">五</div>
|
||||||
|
<div class="weekday-cell">六</div>
|
||||||
|
</div>
|
||||||
|
<div id="calendar-grid" class="calendar-grid"></div>
|
||||||
|
<div id="cal-no-date" class="no-date-hint">点击日期查看事项</div>
|
||||||
|
<div id="cal-events-section" class="hidden">
|
||||||
|
<div class="selected-date-header">
|
||||||
|
<div id="cal-selected-title" class="selected-date-title"></div>
|
||||||
|
<button id="cal-add-btn" class="selected-date-add">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="cal-events-list"></div>
|
||||||
|
<div id="cal-events-empty" class="empty-state hidden">这一天没有事件</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== FAB 浮动新增按钮 ======== -->
|
||||||
|
<button id="fab-add" class="fab">+</button>
|
||||||
|
|
||||||
|
<!-- ======== 底部 Tab 导航 ======== -->
|
||||||
|
<nav class="tab-bar">
|
||||||
|
<button class="tab-item active" data-tab="today">
|
||||||
|
<span class="tab-icon">☀️</span>
|
||||||
|
<span class="tab-label">今日</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab-item" data-tab="calendar">
|
||||||
|
<span class="tab-icon">📅</span>
|
||||||
|
<span class="tab-label">日历</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== 事件编辑弹窗 ======== -->
|
||||||
|
<div id="modal-overlay" class="modal-overlay">
|
||||||
|
<div class="modal-sheet">
|
||||||
|
<div class="modal-header">
|
||||||
|
<button id="modal-close" class="modal-close">✕</button>
|
||||||
|
<div id="modal-title" class="modal-title">新建事件</div>
|
||||||
|
<button id="modal-save" class="modal-save-btn">保存</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label">标题</label>
|
||||||
|
<input id="f-title" class="form-input" type="text" placeholder="输入事件标题">
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label">内容</label>
|
||||||
|
<textarea id="f-content" class="form-input" placeholder="输入事件内容(可选)"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="switch-row">
|
||||||
|
<span class="switch-label">全天事件</span>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input id="f-allday" type="checkbox">
|
||||||
|
<span class="toggle-track"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="field-start" class="form-field">
|
||||||
|
<label class="form-label">开始时间</label>
|
||||||
|
<input id="f-start" class="form-input" type="datetime-local" step="900">
|
||||||
|
</div>
|
||||||
|
<div id="field-end" class="form-field">
|
||||||
|
<label class="form-label">结束时间(可选,默认+1小时)</label>
|
||||||
|
<input id="f-end" class="form-input" type="datetime-local" step="900">
|
||||||
|
<span id="f-end-clear" class="clear-time-btn hidden">清除</span>
|
||||||
|
</div>
|
||||||
|
<div class="switch-row">
|
||||||
|
<span class="switch-label">启用提醒</span>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input id="f-remind-enable" type="checkbox" checked>
|
||||||
|
<span class="toggle-track"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="field-remind" class="form-field">
|
||||||
|
<label class="form-label">提醒时间</label>
|
||||||
|
<input id="f-remind" class="form-input" type="datetime-local" step="900">
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label class="form-label">重复</label>
|
||||||
|
<div class="recurrence-options">
|
||||||
|
<button class="recurrence-btn active" data-type="NONE">不重复</button>
|
||||||
|
<button class="recurrence-btn" data-type="DAILY">每天</button>
|
||||||
|
<button class="recurrence-btn" data-type="WEEKLY">每周</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="field-interval" class="form-field hidden">
|
||||||
|
<label id="interval-label" class="form-label">每 1 天</label>
|
||||||
|
<div class="interval-row">
|
||||||
|
<button id="interval-minus" class="interval-btn">−</button>
|
||||||
|
<span id="interval-value" class="interval-value">1</span>
|
||||||
|
<button id="interval-plus" class="interval-btn">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="field-weekdays" class="form-field hidden">
|
||||||
|
<label class="form-label">重复星期</label>
|
||||||
|
<div class="weekday-options">
|
||||||
|
<button class="weekday-btn" data-day="1">一</button>
|
||||||
|
<button class="weekday-btn" data-day="2">二</button>
|
||||||
|
<button class="weekday-btn" data-day="3">三</button>
|
||||||
|
<button class="weekday-btn" data-day="4">四</button>
|
||||||
|
<button class="weekday-btn" data-day="5">五</button>
|
||||||
|
<button class="weekday-btn" data-day="6">六</button>
|
||||||
|
<button class="weekday-btn" data-day="7">日</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== 删除确认 ActionSheet ======== -->
|
||||||
|
<div id="delete-sheet-overlay" class="action-sheet-overlay">
|
||||||
|
<div class="action-sheet">
|
||||||
|
<div class="action-sheet-title">确定要删除这个事件吗?</div>
|
||||||
|
<button id="delete-confirm" class="action-sheet-btn danger">确认删除</button>
|
||||||
|
<button id="delete-cancel" class="action-sheet-btn cancel">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== Toast ======== -->
|
||||||
|
<div id="toast" class="toast"></div>
|
||||||
|
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,936 @@
|
|||||||
|
/* ============================================
|
||||||
|
移动端日历提醒 - 核心逻辑
|
||||||
|
对标 RN App (apps/app_calendar_reminder)
|
||||||
|
纯 JS + IIFE 模块化,所有时间按北京时间处理
|
||||||
|
============================================ */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
常量与全局状态
|
||||||
|
------------------------------------------ */
|
||||||
|
const API_BASE = '/api/app_calendar/events'; // 同域部署,相对路径
|
||||||
|
const WINDOW_MS = 2 * 60 * 1000; // 2 分钟补偿窗口
|
||||||
|
const REQUEST_TIMEOUT = 10000; // 请求超时 10s
|
||||||
|
const WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
events: [], // 已加载事件(去重、过滤 deleted 后)
|
||||||
|
currentTab: 'today', // 当前 Tab:today / calendar
|
||||||
|
calYear: 0, // 日历当前年
|
||||||
|
calMonth: 0, // 日历当前月(0-based)
|
||||||
|
selectedDate: null, // 日历选中日期
|
||||||
|
editingEvent: null, // 正在编辑的事件(null 表示新建)
|
||||||
|
deletingEvent: null, // 待删除事件
|
||||||
|
notifiedKeys: new Set(), // 已提醒防重复
|
||||||
|
scheduledTimers: [], // 预调度 setTimeout 句柄
|
||||||
|
heartbeatTimer: null, // 心跳巡检句柄
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑表单的循环规则状态
|
||||||
|
const form = {
|
||||||
|
recurrenceType: 'NONE', // NONE / DAILY / WEEKLY
|
||||||
|
interval: 1,
|
||||||
|
weekdays: [], // 1~7(1=周一,7=周日)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简易选择器
|
||||||
|
function $(sel) { return document.querySelector(sel); }
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
工具函数:日期解析与格式化
|
||||||
|
------------------------------------------ */
|
||||||
|
|
||||||
|
// 安全日期解析(兼容 ISO、yyyy-MM-dd HH:mm、yyyy-MM-ddTHH:mm)
|
||||||
|
function safeParseDate(dateStr) {
|
||||||
|
if (!dateStr) return new Date();
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (!isNaN(d.getTime())) return d;
|
||||||
|
} catch (e) { /* 忽略 */ }
|
||||||
|
// yyyy-MM-dd HH:mm → 替换空格为 T
|
||||||
|
if (String(dateStr).includes(' ')) {
|
||||||
|
try {
|
||||||
|
const d = new Date(String(dateStr).replace(' ', 'T'));
|
||||||
|
if (!isNaN(d.getTime())) return d;
|
||||||
|
} catch (e) { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
// 兜底:仅解析日期部分
|
||||||
|
const match = String(dateStr).match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
if (match) {
|
||||||
|
return new Date(parseInt(match[1], 10), parseInt(match[2], 10) - 1, parseInt(match[3], 10));
|
||||||
|
}
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地日期字符串 yyyy-MM-dd(北京时间)
|
||||||
|
function formatDateStr(date) {
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date → datetime-local 输入值(yyyy-MM-ddTHH:mm,本地时间)
|
||||||
|
function toDatetimeLocalValue(date) {
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15 分钟粒度对齐
|
||||||
|
function snapTo15(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setMinutes(Math.round(d.getMinutes() / 15) * 15, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否同一天
|
||||||
|
function isSameDay(a, b) {
|
||||||
|
return a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件时间展示(HH:mm)
|
||||||
|
function formatTime(dateStr) {
|
||||||
|
return safeParseDate(dateStr).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
循环规则解析与判断(与 RN recurrence.ts 一致)
|
||||||
|
------------------------------------------ */
|
||||||
|
|
||||||
|
// 解析循环规则字符串:DAILY;interval=N / WEEKLY;interval=N;by=1,3,5
|
||||||
|
function parseRecurrenceStr(str) {
|
||||||
|
const r = (str || '').trim();
|
||||||
|
if (!r) return { type: 'NONE', interval: 0, by: [] };
|
||||||
|
const type = r.startsWith('DAILY') ? 'DAILY' : (r.startsWith('WEEKLY') ? 'WEEKLY' : 'NONE');
|
||||||
|
const mInt = /interval=(\d+)/.exec(r);
|
||||||
|
const interval = mInt ? Math.max(1, parseInt(mInt[1], 10)) : 1;
|
||||||
|
const mBy = /by=([0-9,]+)/.exec(r);
|
||||||
|
const by = mBy ? mBy[1].split(',').map((s) => parseInt(s, 10)).filter((n) => n >= 1 && n <= 7) : [];
|
||||||
|
return { type, interval, by };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 循环规则中文描述(如「每2周 周一、周三」)
|
||||||
|
function getRecurrenceLabel(recurrence) {
|
||||||
|
if (!recurrence) return null;
|
||||||
|
const rec = parseRecurrenceStr(recurrence);
|
||||||
|
if (rec.type === 'DAILY') {
|
||||||
|
return rec.interval === 1 ? '每天' : `每${rec.interval}天`;
|
||||||
|
}
|
||||||
|
if (rec.type === 'WEEKLY') {
|
||||||
|
const prefix = rec.interval === 1 ? '每周' : `每${rec.interval}周`;
|
||||||
|
if (rec.by.length > 0) {
|
||||||
|
return `${prefix} ${rec.by.map((d) => WEEKDAY_NAMES[d - 1] || '').join('、')}`;
|
||||||
|
}
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断事件在目标日期是否展示(与 RN shouldShowEventOnDate 逻辑一致)
|
||||||
|
function shouldShowEventOnDate(event, targetDate) {
|
||||||
|
const evStart = safeParseDate(event.start);
|
||||||
|
const eventDateStr = formatDateStr(evStart);
|
||||||
|
const targetDateStr = formatDateStr(targetDate);
|
||||||
|
|
||||||
|
// 1. 普通事件:开始日期就是当天
|
||||||
|
if (eventDateStr === targetDateStr) return true;
|
||||||
|
|
||||||
|
const targetDayStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate());
|
||||||
|
const eventStartDay = new Date(evStart.getFullYear(), evStart.getMonth(), evStart.getDate());
|
||||||
|
|
||||||
|
// 2. 持续多天事件:目标日期在 [start, end] 之间
|
||||||
|
if (event.end) {
|
||||||
|
const evEnd = safeParseDate(event.end);
|
||||||
|
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||||
|
if (targetDayStart >= eventStartDay && targetDayStart <= eventEndDay) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 循环事件
|
||||||
|
const rec = parseRecurrenceStr(event.recurrence);
|
||||||
|
if (rec.type === 'NONE') return false;
|
||||||
|
|
||||||
|
// 目标日期早于开始日期,不展示
|
||||||
|
if (targetDayStart < eventStartDay) return false;
|
||||||
|
|
||||||
|
// 有结束日期且目标日期晚于结束日期,不展示
|
||||||
|
if (event.end) {
|
||||||
|
const evEnd = safeParseDate(event.end);
|
||||||
|
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||||
|
if (targetDayStart > eventEndDay) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rec.type === 'DAILY') {
|
||||||
|
const diffDays = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (24 * 3600 * 1000));
|
||||||
|
return diffDays % rec.interval === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rec.type === 'WEEKLY') {
|
||||||
|
const weeksSinceStart = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (7 * 24 * 3600 * 1000));
|
||||||
|
const weekday = targetDayStart.getDay() === 0 ? 7 : targetDayStart.getDay();
|
||||||
|
const by = rec.by.length ? rec.by : [(evStart.getDay() || 7)];
|
||||||
|
return by.includes(weekday) && (weeksSinceStart % rec.interval === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 timeSourceIso 的时分套用到 dateObj 上,返回 ISO 字符串
|
||||||
|
function setTimeFromIso(dateObj, timeSourceIso) {
|
||||||
|
const d = new Date(dateObj.getTime());
|
||||||
|
const t = timeSourceIso ? safeParseDate(timeSourceIso) : null;
|
||||||
|
d.setHours(t ? t.getHours() : 0, t ? t.getMinutes() : 0, 0, 0);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算事件在 [sinceMs, untilMs] 时间窗内的提醒时刻(与 RN computeUpcomingReminders 一致)
|
||||||
|
function computeUpcomingReminders(e, sinceMs, untilMs) {
|
||||||
|
const rec = parseRecurrenceStr(e.recurrence || null);
|
||||||
|
const evStart = safeParseDate(e.start);
|
||||||
|
const reminders = [];
|
||||||
|
|
||||||
|
// 非循环事件:直接判断 remindAt 或 start
|
||||||
|
if (rec.type === 'NONE') {
|
||||||
|
const iso = e.remindAt || e.start;
|
||||||
|
if (!iso) return reminders;
|
||||||
|
const t = safeParseDate(iso).getTime();
|
||||||
|
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||||
|
return reminders;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDayMs = new Date(evStart.getFullYear(), evStart.getMonth(), evStart.getDate()).getTime();
|
||||||
|
const ws = new Date(sinceMs);
|
||||||
|
const we = new Date(untilMs);
|
||||||
|
const windowStartDayMs = new Date(ws.getFullYear(), ws.getMonth(), ws.getDate()).getTime();
|
||||||
|
const windowEndDayMs = new Date(we.getFullYear(), we.getMonth(), we.getDate()).getTime();
|
||||||
|
|
||||||
|
let endDayMs;
|
||||||
|
if (e.end) {
|
||||||
|
const evEnd = safeParseDate(e.end);
|
||||||
|
endDayMs = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate()).getTime();
|
||||||
|
} else {
|
||||||
|
endDayMs = windowEndDayMs; // 无结束日期时用窗口边界,避免提前终止
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromDay = Math.max(startDayMs, windowStartDayMs);
|
||||||
|
const toDay = Math.min(endDayMs, windowEndDayMs);
|
||||||
|
const timeSourceIso = e.remindAt || e.start;
|
||||||
|
|
||||||
|
// 安全截断:最多遍历 400 天,防死循环
|
||||||
|
let guard = 0;
|
||||||
|
for (let d = fromDay; d <= toDay && guard < 400; d += 24 * 3600 * 1000, guard++) {
|
||||||
|
const cursor = new Date(d);
|
||||||
|
if (rec.type === 'DAILY') {
|
||||||
|
const diffDays = Math.floor((d - startDayMs) / (24 * 3600 * 1000));
|
||||||
|
if (diffDays % rec.interval === 0) {
|
||||||
|
const iso = setTimeFromIso(cursor, timeSourceIso);
|
||||||
|
const t = safeParseDate(iso).getTime();
|
||||||
|
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||||
|
}
|
||||||
|
} else if (rec.type === 'WEEKLY') {
|
||||||
|
const weekday = cursor.getDay() === 0 ? 7 : cursor.getDay();
|
||||||
|
const weeksSinceStart = Math.floor((d - startDayMs) / (7 * 24 * 3600 * 1000));
|
||||||
|
const by = rec.by.length ? rec.by : [(safeParseDate(e.start).getDay() || 7)];
|
||||||
|
if (by.includes(weekday) && (weeksSinceStart % rec.interval === 0)) {
|
||||||
|
const iso = setTimeFromIso(cursor, timeSourceIso);
|
||||||
|
const t = safeParseDate(iso).getTime();
|
||||||
|
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reminders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
API 层
|
||||||
|
------------------------------------------ */
|
||||||
|
|
||||||
|
// 统一请求封装(含超时与错误处理)
|
||||||
|
async function apiRequest(url, options) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, Object.assign({
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}, options));
|
||||||
|
if (!resp.ok) throw new Error(`服务器响应 ${resp.status}`);
|
||||||
|
const json = await resp.json();
|
||||||
|
if (json && json.ok === false) throw new Error(json.error || '请求失败');
|
||||||
|
return json;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') throw new Error('请求超时,请稍后重试');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按月查询
|
||||||
|
async function apiGetEventsMonth(year, month) {
|
||||||
|
const json = await apiRequest(`${API_BASE}?year=${year}&month=${month}`);
|
||||||
|
return (json && json.data) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载前 6 月 + 当月 + 后 1 月数据,按 id 去重,过滤已删除
|
||||||
|
async function loadAllEvents() {
|
||||||
|
const now = new Date();
|
||||||
|
const all = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (let i = -6; i <= 1; i++) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = d.getMonth() + 1;
|
||||||
|
try {
|
||||||
|
const monthEvents = await apiGetEventsMonth(year, month);
|
||||||
|
for (const ev of monthEvents) {
|
||||||
|
if (!seen.has(ev.id)) {
|
||||||
|
seen.add(ev.id);
|
||||||
|
all.push(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 单月失败不阻断整体加载
|
||||||
|
console.error('加载月份数据失败', year, month, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.events = all.filter((e) => !e.deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiCreateEvent(data) {
|
||||||
|
await apiRequest(API_BASE, { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiUpdateEvent(id, data) {
|
||||||
|
await apiRequest(`${API_BASE}?id=${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(Object.assign({}, data, { id })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiDeleteEvent(id) {
|
||||||
|
await apiRequest(`${API_BASE}?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
Toast 提示
|
||||||
|
------------------------------------------ */
|
||||||
|
let toastTimer = null;
|
||||||
|
function showToast(msg, type) {
|
||||||
|
const toast = $('#toast');
|
||||||
|
toast.textContent = msg;
|
||||||
|
toast.className = 'toast ' + (type || 'info');
|
||||||
|
void toast.offsetWidth; // 强制 reflow 重启动画
|
||||||
|
toast.classList.add('visible');
|
||||||
|
if (toastTimer) clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => toast.classList.remove('visible'), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
事件卡片
|
||||||
|
------------------------------------------ */
|
||||||
|
function createEventCard(ev) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'event-card' + (ev.completed ? ' completed' : '');
|
||||||
|
|
||||||
|
// 圆形 checkbox
|
||||||
|
const checkbox = document.createElement('div');
|
||||||
|
checkbox.className = 'event-checkbox' + (ev.completed ? ' checked' : '');
|
||||||
|
checkbox.addEventListener('click', (e) => { e.stopPropagation(); toggleComplete(ev); });
|
||||||
|
|
||||||
|
// 主体(点击编辑)
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'event-body';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'event-title';
|
||||||
|
title.textContent = ev.title;
|
||||||
|
body.appendChild(title);
|
||||||
|
|
||||||
|
if (ev.content) {
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'event-content';
|
||||||
|
content.textContent = ev.content;
|
||||||
|
body.appendChild(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'event-meta';
|
||||||
|
const timeSpan = document.createElement('span');
|
||||||
|
timeSpan.textContent = '🕐 ' + formatTime(ev.start);
|
||||||
|
meta.appendChild(timeSpan);
|
||||||
|
const recLabel = getRecurrenceLabel(ev.recurrence);
|
||||||
|
if (recLabel) {
|
||||||
|
const recSpan = document.createElement('span');
|
||||||
|
recSpan.className = 'recurrence-label';
|
||||||
|
recSpan.textContent = '🔁 ' + recLabel;
|
||||||
|
meta.appendChild(recSpan);
|
||||||
|
}
|
||||||
|
body.appendChild(meta);
|
||||||
|
body.addEventListener('click', () => openModal(ev));
|
||||||
|
|
||||||
|
// 删除按钮
|
||||||
|
const del = document.createElement('div');
|
||||||
|
del.className = 'event-delete';
|
||||||
|
del.textContent = '🗑';
|
||||||
|
del.addEventListener('click', (e) => { e.stopPropagation(); openDeleteSheet(ev); });
|
||||||
|
|
||||||
|
card.appendChild(checkbox);
|
||||||
|
card.appendChild(body);
|
||||||
|
card.appendChild(del);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
今日视图
|
||||||
|
------------------------------------------ */
|
||||||
|
function renderTodayView() {
|
||||||
|
const today = new Date();
|
||||||
|
const hour = today.getHours();
|
||||||
|
const greeting = hour < 12 ? '早上好' : (hour < 18 ? '下午好' : '晚上好');
|
||||||
|
$('#greeting').textContent = greeting + ' 👋';
|
||||||
|
$('#today-date').textContent = today.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric', month: 'long', day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const todayEvents = state.events
|
||||||
|
.filter((ev) => shouldShowEventOnDate(ev, today))
|
||||||
|
.sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
||||||
|
|
||||||
|
const pending = todayEvents.filter((e) => !e.completed);
|
||||||
|
const completed = todayEvents.filter((e) => e.completed);
|
||||||
|
|
||||||
|
$('#stat-pending').textContent = pending.length;
|
||||||
|
$('#stat-completed').textContent = completed.length;
|
||||||
|
$('#stat-total').textContent = todayEvents.length;
|
||||||
|
|
||||||
|
const list = $('#today-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
pending.forEach((ev) => list.appendChild(createEventCard(ev)));
|
||||||
|
$('#today-empty').classList.toggle('hidden', pending.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
日历视图
|
||||||
|
------------------------------------------ */
|
||||||
|
|
||||||
|
// 生成 6x7=42 格月历数据
|
||||||
|
function getDaysInMonth(year, month) {
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
const days = [];
|
||||||
|
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
||||||
|
for (let i = firstDay.getDay() - 1; i >= 0; i--) {
|
||||||
|
days.push({ date: new Date(year, month - 1, prevMonthLastDay - i), isCurrentMonth: false });
|
||||||
|
}
|
||||||
|
for (let i = 1; i <= lastDay.getDate(); i++) {
|
||||||
|
days.push({ date: new Date(year, month, i), isCurrentMonth: true });
|
||||||
|
}
|
||||||
|
const remaining = 42 - days.length;
|
||||||
|
for (let i = 1; i <= remaining; i++) {
|
||||||
|
days.push({ date: new Date(year, month + 1, i), isCurrentMonth: false });
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEventOnDate(date) {
|
||||||
|
return state.events.some((ev) => shouldShowEventOnDate(ev, date));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar() {
|
||||||
|
$('#cal-month-title').textContent = `${state.calYear}年${state.calMonth + 1}月`;
|
||||||
|
|
||||||
|
const grid = $('#calendar-grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
const days = getDaysInMonth(state.calYear, state.calMonth);
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
days.forEach(({ date, isCurrentMonth }) => {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'day-cell';
|
||||||
|
if (!isCurrentMonth) cell.classList.add('other-month');
|
||||||
|
if (isSameDay(date, today)) cell.classList.add('today');
|
||||||
|
if (state.selectedDate && isSameDay(date, state.selectedDate)) cell.classList.add('selected');
|
||||||
|
|
||||||
|
const num = document.createElement('div');
|
||||||
|
num.className = 'day-number';
|
||||||
|
num.textContent = date.getDate();
|
||||||
|
cell.appendChild(num);
|
||||||
|
|
||||||
|
if (hasEventOnDate(date)) {
|
||||||
|
const dot = document.createElement('div');
|
||||||
|
dot.className = 'event-dot';
|
||||||
|
cell.appendChild(dot);
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.addEventListener('click', () => {
|
||||||
|
state.selectedDate = date;
|
||||||
|
renderCalendar();
|
||||||
|
renderCalendarEvents();
|
||||||
|
});
|
||||||
|
grid.appendChild(cell);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.selectedDate) {
|
||||||
|
$('#cal-no-date').classList.add('hidden');
|
||||||
|
$('#cal-events-section').classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
$('#cal-no-date').classList.remove('hidden');
|
||||||
|
$('#cal-events-section').classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendarEvents() {
|
||||||
|
if (!state.selectedDate) return;
|
||||||
|
const d = state.selectedDate;
|
||||||
|
$('#cal-selected-title').textContent = d.toLocaleDateString('zh-CN', {
|
||||||
|
month: 'long', day: 'numeric', weekday: 'long',
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = state.events
|
||||||
|
.filter((ev) => shouldShowEventOnDate(ev, d))
|
||||||
|
.sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
||||||
|
|
||||||
|
const list = $('#cal-events-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
events.forEach((ev) => list.appendChild(createEventCard(ev)));
|
||||||
|
$('#cal-events-empty').classList.toggle('hidden', events.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurrentView() {
|
||||||
|
if (state.currentTab === 'today') {
|
||||||
|
renderTodayView();
|
||||||
|
} else {
|
||||||
|
renderCalendar();
|
||||||
|
renderCalendarEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
完成状态切换
|
||||||
|
------------------------------------------ */
|
||||||
|
async function toggleComplete(ev) {
|
||||||
|
try {
|
||||||
|
await apiUpdateEvent(ev.id, { completed: !ev.completed });
|
||||||
|
await loadAllEvents();
|
||||||
|
renderCurrentView();
|
||||||
|
scheduleReminders();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || '更新失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
事件编辑弹窗
|
||||||
|
------------------------------------------ */
|
||||||
|
function updateRecurrenceUI() {
|
||||||
|
document.querySelectorAll('.recurrence-btn').forEach((btn) => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.type === form.recurrenceType);
|
||||||
|
});
|
||||||
|
const isNone = form.recurrenceType === 'NONE';
|
||||||
|
$('#field-interval').classList.toggle('hidden', isNone);
|
||||||
|
$('#field-weekdays').classList.toggle('hidden', form.recurrenceType !== 'WEEKLY');
|
||||||
|
$('#interval-value').textContent = form.interval;
|
||||||
|
$('#interval-label').textContent = `每 ${form.interval} ${form.recurrenceType === 'DAILY' ? '天' : '周'}`;
|
||||||
|
document.querySelectorAll('.weekday-btn').forEach((btn) => {
|
||||||
|
btn.classList.toggle('active', form.weekdays.includes(parseInt(btn.dataset.day, 10)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRemindField() {
|
||||||
|
$('#field-remind').classList.toggle('hidden', !$('#f-remind-enable').checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回填 / 重置表单
|
||||||
|
function fillForm(ev) {
|
||||||
|
if (ev) {
|
||||||
|
$('#f-title').value = ev.title || '';
|
||||||
|
$('#f-content').value = ev.content || '';
|
||||||
|
$('#f-allday').checked = !!ev.allDay;
|
||||||
|
$('#f-start').value = toDatetimeLocalValue(safeParseDate(ev.start));
|
||||||
|
if (ev.end) {
|
||||||
|
$('#f-end').value = toDatetimeLocalValue(safeParseDate(ev.end));
|
||||||
|
$('#f-end-clear').classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
$('#f-end').value = '';
|
||||||
|
$('#f-end-clear').classList.add('hidden');
|
||||||
|
}
|
||||||
|
const hasRemind = !!ev.remindAt;
|
||||||
|
$('#f-remind-enable').checked = hasRemind;
|
||||||
|
$('#f-remind').value = toDatetimeLocalValue(hasRemind ? safeParseDate(ev.remindAt) : snapTo15(new Date()));
|
||||||
|
|
||||||
|
const rec = parseRecurrenceStr(ev.recurrence);
|
||||||
|
form.recurrenceType = rec.type === 'NONE' ? 'NONE' : rec.type;
|
||||||
|
form.interval = rec.interval || 1;
|
||||||
|
form.weekdays = rec.by.slice();
|
||||||
|
} else {
|
||||||
|
$('#f-title').value = '';
|
||||||
|
$('#f-content').value = '';
|
||||||
|
$('#f-allday').checked = false;
|
||||||
|
$('#f-start').value = toDatetimeLocalValue(snapTo15(new Date()));
|
||||||
|
$('#f-end').value = '';
|
||||||
|
$('#f-end-clear').classList.add('hidden');
|
||||||
|
$('#f-remind-enable').checked = true;
|
||||||
|
$('#f-remind').value = toDatetimeLocalValue(snapTo15(new Date()));
|
||||||
|
form.recurrenceType = 'NONE';
|
||||||
|
form.interval = 1;
|
||||||
|
form.weekdays = [];
|
||||||
|
}
|
||||||
|
toggleRemindField();
|
||||||
|
updateRecurrenceUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(ev) {
|
||||||
|
state.editingEvent = ev || null;
|
||||||
|
$('#modal-title').textContent = ev ? '编辑事件' : '新建事件';
|
||||||
|
fillForm(ev);
|
||||||
|
$('#modal-overlay').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
$('#modal-overlay').classList.remove('visible');
|
||||||
|
state.editingEvent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集表单并保存
|
||||||
|
async function handleSave() {
|
||||||
|
const title = $('#f-title').value.trim();
|
||||||
|
if (!title) { showToast('请输入标题', 'error'); return; }
|
||||||
|
|
||||||
|
const allDay = $('#f-allday').checked;
|
||||||
|
const startVal = $('#f-start').value;
|
||||||
|
if (!startVal) { showToast('请设置开始时间', 'error'); return; }
|
||||||
|
|
||||||
|
const startDate = snapTo15(new Date(startVal));
|
||||||
|
|
||||||
|
// 结束时间自动补全:未设置时 +1h(全天则 +24h)
|
||||||
|
let endDate;
|
||||||
|
const endVal = $('#f-end').value;
|
||||||
|
if (endVal) {
|
||||||
|
endDate = snapTo15(new Date(endVal));
|
||||||
|
} else {
|
||||||
|
endDate = new Date(startDate.getTime() + (allDay ? 24 : 1) * 3600 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提醒
|
||||||
|
let remindAt = null;
|
||||||
|
if ($('#f-remind-enable').checked) {
|
||||||
|
const remindVal = $('#f-remind').value;
|
||||||
|
remindAt = remindVal ? snapTo15(new Date(remindVal)).toISOString() : startDate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组装循环规则
|
||||||
|
let recurrence = null;
|
||||||
|
if (form.recurrenceType === 'DAILY') {
|
||||||
|
recurrence = `DAILY;interval=${form.interval}`;
|
||||||
|
} else if (form.recurrenceType === 'WEEKLY') {
|
||||||
|
recurrence = `WEEKLY;interval=${form.interval}`;
|
||||||
|
if (form.weekdays.length > 0) {
|
||||||
|
recurrence += `;by=${form.weekdays.slice().sort((a, b) => a - b).join(',')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventData = {
|
||||||
|
title,
|
||||||
|
content: $('#f-content').value.trim(),
|
||||||
|
start: startDate.toISOString(),
|
||||||
|
end: endDate.toISOString(),
|
||||||
|
allDay,
|
||||||
|
remindAt,
|
||||||
|
recurrence,
|
||||||
|
completed: state.editingEvent ? !!state.editingEvent.completed : false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveBtn = $('#modal-save');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = '保存中…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (state.editingEvent) {
|
||||||
|
await apiUpdateEvent(state.editingEvent.id, eventData);
|
||||||
|
showToast('事件已更新', 'success');
|
||||||
|
} else {
|
||||||
|
eventData.createdAt = new Date().toISOString();
|
||||||
|
eventData.updatedAt = new Date().toISOString();
|
||||||
|
eventData.deleted = false;
|
||||||
|
await apiCreateEvent(eventData);
|
||||||
|
showToast('事件已创建', 'success');
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
await loadAllEvents();
|
||||||
|
renderCurrentView();
|
||||||
|
scheduleReminders();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || '保存失败', 'error');
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = '保存';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
删除确认 ActionSheet
|
||||||
|
------------------------------------------ */
|
||||||
|
function openDeleteSheet(ev) {
|
||||||
|
state.deletingEvent = ev;
|
||||||
|
$('#delete-sheet-overlay').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteSheet() {
|
||||||
|
$('#delete-sheet-overlay').classList.remove('visible');
|
||||||
|
state.deletingEvent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
const ev = state.deletingEvent;
|
||||||
|
if (!ev) return;
|
||||||
|
const btn = $('#delete-confirm');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '删除中…';
|
||||||
|
try {
|
||||||
|
await apiDeleteEvent(ev.id);
|
||||||
|
showToast('事件已删除', 'success');
|
||||||
|
closeDeleteSheet();
|
||||||
|
await loadAllEvents();
|
||||||
|
renderCurrentView();
|
||||||
|
scheduleReminders();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || '删除失败', 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '确认删除';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
提醒通知
|
||||||
|
------------------------------------------ */
|
||||||
|
function requestNotificationPermission() {
|
||||||
|
if (!('Notification' in window)) return;
|
||||||
|
if (Notification.permission === 'default') {
|
||||||
|
Notification.requestPermission();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerNotify(ev) {
|
||||||
|
// 浏览器系统通知
|
||||||
|
if ('Notification' in window && Notification.permission === 'granted') {
|
||||||
|
try {
|
||||||
|
new Notification('提醒', { body: ev.title });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('通知发送失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 前台 Toast 兜底
|
||||||
|
showToast('提醒:' + ev.title, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 补偿过去 2 分钟内到期的提醒
|
||||||
|
function catchUpNotifications(now) {
|
||||||
|
const since = now - WINDOW_MS;
|
||||||
|
state.events.forEach((e) => {
|
||||||
|
if (e.completed || e.deleted) return;
|
||||||
|
const windowReminders = computeUpcomingReminders(e, since, now);
|
||||||
|
windowReminders.forEach((remindIso) => {
|
||||||
|
const key = `${e.id}|${remindIso}`;
|
||||||
|
if (!state.notifiedKeys.has(key)) {
|
||||||
|
triggerNotify(e);
|
||||||
|
state.notifiedKeys.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预调度未来 24h 提醒 + 补偿窗口
|
||||||
|
function scheduleReminders() {
|
||||||
|
state.scheduledTimers.forEach((t) => clearTimeout(t));
|
||||||
|
state.scheduledTimers = [];
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const next24h = now + 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
catchUpNotifications(now);
|
||||||
|
|
||||||
|
state.events.forEach((e) => {
|
||||||
|
if (e.completed || e.deleted) return;
|
||||||
|
const upcoming = computeUpcomingReminders(e, now, next24h);
|
||||||
|
upcoming.forEach((remindIso) => {
|
||||||
|
const ts = safeParseDate(remindIso).getTime();
|
||||||
|
const delay = ts - now;
|
||||||
|
if (delay <= 0) return;
|
||||||
|
const key = `${e.id}|${remindIso}`;
|
||||||
|
if (state.notifiedKeys.has(key)) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!state.notifiedKeys.has(key)) {
|
||||||
|
triggerNotify(e);
|
||||||
|
state.notifiedKeys.add(key);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
state.scheduledTimers.push(timer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 60 秒心跳巡检,捕捉休眠/时间漂移导致的遗漏
|
||||||
|
function startHeartbeat() {
|
||||||
|
if (state.heartbeatTimer) clearInterval(state.heartbeatTimer);
|
||||||
|
state.heartbeatTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const since = now - WINDOW_MS;
|
||||||
|
state.events.forEach((e) => {
|
||||||
|
if (e.completed || e.deleted) return;
|
||||||
|
const windowReminders = computeUpcomingReminders(e, since, now);
|
||||||
|
windowReminders.forEach((remindIso) => {
|
||||||
|
const key = `${e.id}|${remindIso}`;
|
||||||
|
if (!state.notifiedKeys.has(key)) {
|
||||||
|
triggerNotify(e);
|
||||||
|
state.notifiedKeys.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
Tab 切换
|
||||||
|
------------------------------------------ */
|
||||||
|
function switchTab(tab) {
|
||||||
|
state.currentTab = tab;
|
||||||
|
document.querySelectorAll('.tab-item').forEach((t) => {
|
||||||
|
t.classList.toggle('active', t.dataset.tab === tab);
|
||||||
|
});
|
||||||
|
$('#view-today').classList.toggle('active', tab === 'today');
|
||||||
|
$('#view-calendar').classList.toggle('active', tab === 'calendar');
|
||||||
|
renderCurrentView();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
事件绑定
|
||||||
|
------------------------------------------ */
|
||||||
|
function bindEvents() {
|
||||||
|
// Tab 导航
|
||||||
|
document.querySelectorAll('.tab-item').forEach((t) => {
|
||||||
|
t.addEventListener('click', () => switchTab(t.dataset.tab));
|
||||||
|
});
|
||||||
|
|
||||||
|
// FAB 新增
|
||||||
|
$('#fab-add').addEventListener('click', () => openModal(null));
|
||||||
|
|
||||||
|
// 日历月份切换
|
||||||
|
$('#cal-prev').addEventListener('click', () => {
|
||||||
|
const d = new Date(state.calYear, state.calMonth - 1, 1);
|
||||||
|
state.calYear = d.getFullYear();
|
||||||
|
state.calMonth = d.getMonth();
|
||||||
|
state.selectedDate = null;
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
$('#cal-next').addEventListener('click', () => {
|
||||||
|
const d = new Date(state.calYear, state.calMonth + 1, 1);
|
||||||
|
state.calYear = d.getFullYear();
|
||||||
|
state.calMonth = d.getMonth();
|
||||||
|
state.selectedDate = null;
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 选中日期旁的新增按钮
|
||||||
|
$('#cal-add-btn').addEventListener('click', () => openModal(null));
|
||||||
|
|
||||||
|
// 弹窗关闭 / 保存
|
||||||
|
$('#modal-close').addEventListener('click', closeModal);
|
||||||
|
$('#modal-save').addEventListener('click', handleSave);
|
||||||
|
$('#modal-overlay').addEventListener('click', (e) => {
|
||||||
|
if (e.target === $('#modal-overlay')) closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 结束时间清除
|
||||||
|
$('#f-end').addEventListener('input', () => {
|
||||||
|
$('#f-end-clear').classList.toggle('hidden', !$('#f-end').value);
|
||||||
|
});
|
||||||
|
$('#f-end-clear').addEventListener('click', () => {
|
||||||
|
$('#f-end').value = '';
|
||||||
|
$('#f-end-clear').classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 提醒开关
|
||||||
|
$('#f-remind-enable').addEventListener('change', toggleRemindField);
|
||||||
|
|
||||||
|
// 重复类型按钮组
|
||||||
|
document.querySelectorAll('.recurrence-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
form.recurrenceType = btn.dataset.type;
|
||||||
|
updateRecurrenceUI();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 循环间隔 +/-
|
||||||
|
$('#interval-minus').addEventListener('click', () => {
|
||||||
|
form.interval = Math.max(1, form.interval - 1);
|
||||||
|
updateRecurrenceUI();
|
||||||
|
});
|
||||||
|
$('#interval-plus').addEventListener('click', () => {
|
||||||
|
form.interval = form.interval + 1;
|
||||||
|
updateRecurrenceUI();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 每周星期勾选
|
||||||
|
document.querySelectorAll('.weekday-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const day = parseInt(btn.dataset.day, 10);
|
||||||
|
if (form.weekdays.includes(day)) {
|
||||||
|
form.weekdays = form.weekdays.filter((d) => d !== day);
|
||||||
|
} else {
|
||||||
|
form.weekdays = form.weekdays.concat(day).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
updateRecurrenceUI();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除确认
|
||||||
|
$('#delete-confirm').addEventListener('click', handleDelete);
|
||||||
|
$('#delete-cancel').addEventListener('click', closeDeleteSheet);
|
||||||
|
$('#delete-sheet-overlay').addEventListener('click', (e) => {
|
||||||
|
if (e.target === $('#delete-sheet-overlay')) closeDeleteSheet();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------
|
||||||
|
初始化
|
||||||
|
------------------------------------------ */
|
||||||
|
function init() {
|
||||||
|
const now = new Date();
|
||||||
|
state.calYear = now.getFullYear();
|
||||||
|
state.calMonth = now.getMonth();
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
|
requestNotificationPermission();
|
||||||
|
|
||||||
|
// 初始加载数据
|
||||||
|
$('#today-loading').classList.remove('hidden');
|
||||||
|
loadAllEvents().then(() => {
|
||||||
|
$('#today-loading').classList.add('hidden');
|
||||||
|
renderCurrentView();
|
||||||
|
scheduleReminders();
|
||||||
|
startHeartbeat();
|
||||||
|
}).catch((err) => {
|
||||||
|
$('#today-loading').classList.add('hidden');
|
||||||
|
showToast(err.message || '加载失败', 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 回前台重新加载并调度(对应 RN 的 AppState active)
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
loadAllEvents().then(() => {
|
||||||
|
renderCurrentView();
|
||||||
|
scheduleReminders();
|
||||||
|
}).catch(() => { /* 静默失败 */ });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
+146
-1
@@ -78,6 +78,7 @@ const plantHome = require('./plant_home')
|
|||||||
const globalNews = require('./global_news')
|
const globalNews = require('./global_news')
|
||||||
const boxBackup = require('./box_backup')
|
const boxBackup = require('./box_backup')
|
||||||
const yuanzhupai = require('./yuanzhupai')
|
const yuanzhupai = require('./yuanzhupai')
|
||||||
|
const shortLink = require('./short_link')
|
||||||
const dataGateway = require('./data_gateway')
|
const dataGateway = require('./data_gateway')
|
||||||
const ossFileCabinet = require('./oss_file_cabinet')
|
const ossFileCabinet = require('./oss_file_cabinet')
|
||||||
yuanzhupai.initDb()
|
yuanzhupai.initDb()
|
||||||
@@ -1579,7 +1580,9 @@ const mapSystemIdToUrl = (systemId) => {
|
|||||||
'Tools-plant_home': '/tools/plant_home',
|
'Tools-plant_home': '/tools/plant_home',
|
||||||
'Tools-apple_watch': '/tools/apple_watch',
|
'Tools-apple_watch': '/tools/apple_watch',
|
||||||
'Tools-private_clipboard': '/tools/private_clipboard',
|
'Tools-private_clipboard': '/tools/private_clipboard',
|
||||||
'Tools-style_check': '/tools/style_check/index.html'
|
'Tools-style_check': '/tools/style_check/index.html',
|
||||||
|
'Tools-short_link': '/tools/short_link',
|
||||||
|
'Tools-web_mobile_calendar_reminder': '/tools/web_mobile_calendar_reminder'
|
||||||
}
|
}
|
||||||
if (m[systemId]) return m[systemId]
|
if (m[systemId]) return m[systemId]
|
||||||
if (systemId.startsWith('Tools-')) {
|
if (systemId.startsWith('Tools-')) {
|
||||||
@@ -5721,6 +5724,133 @@ app.use('/tools/yuanzhupai', (req, res, next) => {
|
|||||||
return res.status(401).send('未授权')
|
return res.status(401).send('未授权')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Short Link: 鉴权基础函数 + 路由守卫(必须在 express.static 之前)
|
||||||
|
const SHORT_LINK_AUTH_COOKIE = 'short_link_gate'
|
||||||
|
const readShortLinkAuthConfig = () => {
|
||||||
|
try {
|
||||||
|
const p = path.join(process.cwd(), 'public', 'tools', 'short_link', 'auth_config.json')
|
||||||
|
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||||
|
} catch {}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const readShortLinkJwk = () => {
|
||||||
|
try {
|
||||||
|
const p = path.join(process.cwd(), 'config', 'short_link.jwk.json')
|
||||||
|
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||||
|
} catch {}
|
||||||
|
return { kty: 'RSA', n: '', e: '' }
|
||||||
|
}
|
||||||
|
const getShortLinkCookieName = () => {
|
||||||
|
try { const n = String(readShortLinkAuthConfig().cookieName || ''); if (n) return n } catch {}
|
||||||
|
return SHORT_LINK_AUTH_COOKIE
|
||||||
|
}
|
||||||
|
const getShortLinkMaxAge = () => {
|
||||||
|
try { const days = Number(readShortLinkAuthConfig().max_age_days || 0); if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000 } catch {}
|
||||||
|
return 30 * 24 * 3600 * 1000
|
||||||
|
}
|
||||||
|
const getShortLinkGuidConfig = () => {
|
||||||
|
try {
|
||||||
|
const cfg = readShortLinkAuthConfig()
|
||||||
|
const keyRaw = String(cfg.guidParam || cfg.guid_param || 'guid').trim()
|
||||||
|
const valueRaw = String(cfg.guidValue || cfg.guid_value || '').trim()
|
||||||
|
return { key: keyRaw || 'guid', value: valueRaw }
|
||||||
|
} catch { return { key: 'guid', value: '' } }
|
||||||
|
}
|
||||||
|
const hasValidShortLinkGuid = req => {
|
||||||
|
try {
|
||||||
|
const cfg = readShortLinkAuthConfig()
|
||||||
|
if (cfg.enable_auth === false) return false
|
||||||
|
const { key, value } = getShortLinkGuidConfig()
|
||||||
|
if (!value) return false
|
||||||
|
const q = req && req.query ? req.query : {}
|
||||||
|
const raw = q[key]
|
||||||
|
if (raw === undefined || raw === null) return false
|
||||||
|
const incoming = Array.isArray(raw) ? String(raw[0] || '') : String(raw)
|
||||||
|
return incoming === value
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
|
const isShortLinkForceGuid = () => {
|
||||||
|
try {
|
||||||
|
const cfg = readShortLinkAuthConfig()
|
||||||
|
if (cfg.enable_auth === false) return false
|
||||||
|
if (Object.prototype.hasOwnProperty.call(cfg, 'requireGuid')) return !!cfg.requireGuid
|
||||||
|
if (Object.prototype.hasOwnProperty.call(cfg, 'require_guid')) return !!cfg.require_guid
|
||||||
|
return false
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
|
const hasShortLinkAuth = req => {
|
||||||
|
try {
|
||||||
|
const config = readShortLinkAuthConfig()
|
||||||
|
if (config.enable_auth === false) return true
|
||||||
|
const cookies = parseCookie(req.headers.cookie || '')
|
||||||
|
return cookies[getShortLinkCookieName()] === '1'
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第1层:精确入口守卫
|
||||||
|
app.get('/tools/short_link', (req, res, next) => {
|
||||||
|
setNoCache(res)
|
||||||
|
try {
|
||||||
|
const forceGuid = isShortLinkForceGuid()
|
||||||
|
if (hasShortLinkAuth(req)) return next()
|
||||||
|
const guidKey = getShortLinkGuidConfig().key
|
||||||
|
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||||||
|
if (hasValidShortLinkGuid(req)) {
|
||||||
|
const maxAge = getShortLinkMaxAge()
|
||||||
|
const cookieName = getShortLinkCookieName()
|
||||||
|
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||||
|
return res.redirect('/tools/short_link/index.html')
|
||||||
|
}
|
||||||
|
if (hasGuidInput) return res.status(401).send('未授权')
|
||||||
|
const token = String(req.query.token || '')
|
||||||
|
if (!token) {
|
||||||
|
if (forceGuid) return res.status(401).send('未授权')
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
const payload = verifyJwtWithKeys(token, jwkKeys(readShortLinkJwk()))
|
||||||
|
if (!payload) return res.status(401).send('未授权')
|
||||||
|
{
|
||||||
|
const iss = String(payload.iss || '')
|
||||||
|
const navIss = getNavIssFromFlags()
|
||||||
|
if (iss === navIss && !audMatch(payload, 'Tools-short_link')) return res.status(401).send('未授权')
|
||||||
|
}
|
||||||
|
const maxAge = getShortLinkMaxAge()
|
||||||
|
const cookieName = getShortLinkCookieName()
|
||||||
|
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||||
|
return res.redirect('/tools/short_link/index.html')
|
||||||
|
} catch (e) { return res.status(401).send('未授权') }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 第2层:子路径守卫(拦截静态资源绕过)
|
||||||
|
app.use('/tools/short_link', (req, res, next) => {
|
||||||
|
const forceGuid = isShortLinkForceGuid()
|
||||||
|
if (hasShortLinkAuth(req)) return next()
|
||||||
|
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
|
||||||
|
const guidKey = getShortLinkGuidConfig().key
|
||||||
|
const hasGuidInput = isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
|
||||||
|
if (isHandshake && hasValidShortLinkGuid(req)) {
|
||||||
|
const maxAge = getShortLinkMaxAge()
|
||||||
|
const cookieName = getShortLinkCookieName()
|
||||||
|
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||||
|
return res.redirect('/tools/short_link/index.html')
|
||||||
|
}
|
||||||
|
if (hasGuidInput) return res.status(401).send('未授权')
|
||||||
|
if (isHandshake && String(req.query.token || '')) {
|
||||||
|
const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readShortLinkJwk()))
|
||||||
|
if (payload) {
|
||||||
|
const iss = String(payload.iss || '')
|
||||||
|
const navIss = getNavIssFromFlags()
|
||||||
|
if (iss === navIss && !audMatch(payload, 'Tools-short_link')) return res.status(401).send('未授权')
|
||||||
|
const maxAge = getShortLinkMaxAge()
|
||||||
|
const cookieName = getShortLinkCookieName()
|
||||||
|
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
|
||||||
|
return res.redirect('/tools/short_link/index.html')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isHandshake && forceGuid) return res.status(401).send('未授权')
|
||||||
|
return res.status(401).send('未授权')
|
||||||
|
})
|
||||||
|
|
||||||
const BOX_BACKUP_AUTH_COOKIE = 'box_disaster_sentinel_gate'
|
const BOX_BACKUP_AUTH_COOKIE = 'box_disaster_sentinel_gate'
|
||||||
const readBoxBackupAuthConfig = () => {
|
const readBoxBackupAuthConfig = () => {
|
||||||
try {
|
try {
|
||||||
@@ -6395,6 +6525,21 @@ globalNews.bindRoutes(app)
|
|||||||
dataGateway.bindRoutes(app)
|
dataGateway.bindRoutes(app)
|
||||||
ossFileCabinet.bindRoutes(app)
|
ossFileCabinet.bindRoutes(app)
|
||||||
|
|
||||||
|
// 第3层:Short Link API 鉴权(必须在 bindRoutes 之前注册)
|
||||||
|
app.use('/api/short_link', (req, res, next) => {
|
||||||
|
if (req.path === '/auth/hint') return next()
|
||||||
|
if (hasShortLinkAuth(req)) return next()
|
||||||
|
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/short_link/auth/hint', (req, res) => {
|
||||||
|
setNoCache(res)
|
||||||
|
res.json({ ok: true, message: '请通过首页九宫格点击「短链接」进入,或使用正确的访问链接' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Short Link 业务路由(/s/:code 公开跳转 + /api/short_link/* 管理接口)
|
||||||
|
shortLink.bindRoutes(app)
|
||||||
|
|
||||||
const marketsCfgAtBoot = getMarketsCfg()
|
const marketsCfgAtBoot = getMarketsCfg()
|
||||||
const currentPort = Number(process.env.PORT || '8976') || 8976
|
const currentPort = Number(process.env.PORT || '8976') || 8976
|
||||||
const marketsAutomationPort = Number(marketsCfgAtBoot.automation_port || 8977) || 8977
|
const marketsAutomationPort = Number(marketsCfgAtBoot.automation_port || 8977) || 8977
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
const Database = require('better-sqlite3')
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
// 数据目录
|
||||||
|
const dataDir = path.join(process.cwd(), 'data')
|
||||||
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
|
||||||
|
|
||||||
|
const db = new Database(path.join(dataDir, 'short_link.db'))
|
||||||
|
db.pragma('journal_mode = WAL')
|
||||||
|
|
||||||
|
// 建表
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS short_links (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
code TEXT NOT NULL UNIQUE,
|
||||||
|
target_url TEXT NOT NULL,
|
||||||
|
title TEXT DEFAULT '',
|
||||||
|
source_type TEXT DEFAULT 'manual',
|
||||||
|
source_ref TEXT DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT,
|
||||||
|
max_clicks INTEGER,
|
||||||
|
click_count INTEGER DEFAULT 0,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
created_by TEXT DEFAULT 'admin'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS short_link_clicks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
clicked_at TEXT NOT NULL,
|
||||||
|
ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
referer TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sl_clicks_code ON short_link_clicks(code);
|
||||||
|
`)
|
||||||
|
|
||||||
|
// 配置读取
|
||||||
|
const readConfig = () => {
|
||||||
|
try {
|
||||||
|
const p = path.join(process.cwd(), 'config', 'short_link.json')
|
||||||
|
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||||
|
} catch {}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短码生成(去除易混淆字符 0O1lI)
|
||||||
|
const SAFE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'
|
||||||
|
const generateCode = (len = 6) => {
|
||||||
|
const bytes = crypto.randomBytes(len)
|
||||||
|
let code = ''
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
code += SAFE_CHARS[bytes[i] % SAFE_CHARS.length]
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateUniqueCode = (len = 6) => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const code = generateCode(len)
|
||||||
|
const exists = db.prepare('SELECT 1 FROM short_links WHERE code = ?').get(code)
|
||||||
|
if (!exists) return code
|
||||||
|
}
|
||||||
|
return generateCode(len + 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 北京时间
|
||||||
|
const nowBJ = () => {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
|
||||||
|
}).format(new Date()).replace(/\//g, '-').replace(/\u200E/g, '')
|
||||||
|
} catch {
|
||||||
|
return new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析短码 → 目标 URL
|
||||||
|
const resolveCode = (code) => {
|
||||||
|
// 1. 先查 tool 别名(配置级)
|
||||||
|
const cfg = readConfig()
|
||||||
|
const aliases = cfg.tool_aliases || {}
|
||||||
|
if (aliases[code]) {
|
||||||
|
return { target: aliases[code], type: 'tool_alias', title: code }
|
||||||
|
}
|
||||||
|
// 2. 查数据库
|
||||||
|
const row = db.prepare('SELECT * FROM short_links WHERE code = ? AND is_active = 1').get(code)
|
||||||
|
if (!row) return null
|
||||||
|
if (row.expires_at && row.expires_at < nowBJ()) return { expired: true }
|
||||||
|
if (row.max_clicks && row.click_count >= row.max_clicks) return { exhausted: true }
|
||||||
|
return { target: row.target_url, type: row.source_type, title: row.title, row }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录点击
|
||||||
|
const recordClick = (code, ip, userAgent, referer) => {
|
||||||
|
try {
|
||||||
|
db.prepare('INSERT INTO short_link_clicks (code, clicked_at, ip, user_agent, referer) VALUES (?, ?, ?, ?, ?)')
|
||||||
|
.run(code, nowBJ(), ip || '', userAgent || '', referer || '')
|
||||||
|
db.prepare('UPDATE short_links SET click_count = click_count + 1 WHERE code = ?').run(code)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRUD
|
||||||
|
const createLink = ({ target_url, title, code, source_type, source_ref, expires_at, max_clicks }) => {
|
||||||
|
const finalCode = code || generateUniqueCode()
|
||||||
|
const exists = db.prepare('SELECT 1 FROM short_links WHERE code = ?').get(finalCode)
|
||||||
|
if (exists) throw new Error(`短码 "${finalCode}" 已存在`)
|
||||||
|
const cfg = readConfig()
|
||||||
|
const aliases = cfg.tool_aliases || {}
|
||||||
|
if (aliases[finalCode]) throw new Error(`短码 "${finalCode}" 与工具别名冲突`)
|
||||||
|
db.prepare(`INSERT INTO short_links (code, target_url, title, source_type, source_ref, created_at, expires_at, max_clicks) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||||
|
.run(finalCode, target_url, title || '', source_type || 'manual', source_ref || '', nowBJ(), expires_at || null, max_clicks || null)
|
||||||
|
return db.prepare('SELECT * FROM short_links WHERE code = ?').get(finalCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const listLinks = (page = 1, pageSize = 50, keyword = '') => {
|
||||||
|
const offset = (page - 1) * pageSize
|
||||||
|
let where = 'WHERE 1=1'
|
||||||
|
const params = []
|
||||||
|
if (keyword) {
|
||||||
|
where += ' AND (code LIKE ? OR target_url LIKE ? OR title LIKE ?)'
|
||||||
|
const kw = `%${keyword}%`
|
||||||
|
params.push(kw, kw, kw)
|
||||||
|
}
|
||||||
|
const total = db.prepare(`SELECT COUNT(*) as cnt FROM short_links ${where}`).get(...params).cnt
|
||||||
|
const rows = db.prepare(`SELECT * FROM short_links ${where} ORDER BY id DESC LIMIT ? OFFSET ?`).all(...params, pageSize, offset)
|
||||||
|
return { total, rows, page, pageSize }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateLink = (code, { target_url, title, expires_at, max_clicks, is_active }) => {
|
||||||
|
const existing = db.prepare('SELECT * FROM short_links WHERE code = ?').get(code)
|
||||||
|
if (!existing) throw new Error('短链接不存在')
|
||||||
|
const fields = []
|
||||||
|
const params = []
|
||||||
|
if (target_url !== undefined) { fields.push('target_url = ?'); params.push(target_url) }
|
||||||
|
if (title !== undefined) { fields.push('title = ?'); params.push(title) }
|
||||||
|
if (expires_at !== undefined) { fields.push('expires_at = ?'); params.push(expires_at || null) }
|
||||||
|
if (max_clicks !== undefined) { fields.push('max_clicks = ?'); params.push(max_clicks || null) }
|
||||||
|
if (is_active !== undefined) { fields.push('is_active = ?'); params.push(is_active ? 1 : 0) }
|
||||||
|
if (fields.length === 0) return existing
|
||||||
|
params.push(code)
|
||||||
|
db.prepare(`UPDATE short_links SET ${fields.join(', ')} WHERE code = ?`).run(...params)
|
||||||
|
return db.prepare('SELECT * FROM short_links WHERE code = ?').get(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteLink = (code) => {
|
||||||
|
const existing = db.prepare('SELECT * FROM short_links WHERE code = ?').get(code)
|
||||||
|
if (!existing) throw new Error('短链接不存在')
|
||||||
|
db.prepare('DELETE FROM short_link_clicks WHERE code = ?').run(code)
|
||||||
|
db.prepare('DELETE FROM short_links WHERE code = ?').run(code)
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStats = (code, limit = 100) => {
|
||||||
|
const link = db.prepare('SELECT * FROM short_links WHERE code = ?').get(code)
|
||||||
|
if (!link) throw new Error('短链接不存在')
|
||||||
|
const clicks = db.prepare('SELECT * FROM short_link_clicks WHERE code = ? ORDER BY id DESC LIMIT ?').all(code, limit)
|
||||||
|
return { link, clicks }
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchCreate = (items) => {
|
||||||
|
const results = []
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
results.push({ ok: true, data: createLink(item) })
|
||||||
|
} catch (e) {
|
||||||
|
results.push({ ok: false, error: String(e.message || e), target_url: item.target_url })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindRoutes = (app) => {
|
||||||
|
// ===== 公开跳转路由(无需鉴权)=====
|
||||||
|
app.get('/l/:code', (req, res) => {
|
||||||
|
try {
|
||||||
|
const code = String(req.params.code || '').trim()
|
||||||
|
if (!code) return res.status(400).send('Bad Request')
|
||||||
|
const result = resolveCode(code)
|
||||||
|
if (!result) return res.status(404).send('链接不存在或已失效')
|
||||||
|
if (result.expired) return res.status(410).send('链接已过期')
|
||||||
|
if (result.exhausted) return res.status(410).send('链接已达最大访问次数')
|
||||||
|
if (result.type !== 'tool_alias') {
|
||||||
|
recordClick(code, req.ip, req.headers['user-agent'], req.headers['referer'])
|
||||||
|
}
|
||||||
|
const target = result.target
|
||||||
|
if (target.startsWith('/')) {
|
||||||
|
const proto = req.headers['x-forwarded-proto'] || req.protocol
|
||||||
|
const host = req.headers['host'] || 'localhost:8976'
|
||||||
|
return res.redirect(302, `${proto}://${host}${target}`)
|
||||||
|
}
|
||||||
|
return res.redirect(302, target)
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send('Server Error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 管理 API(鉴权由 index.js 的 app.use('/api/short_link') 处理)=====
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
app.get('/api/short_link/config', (req, res) => {
|
||||||
|
try {
|
||||||
|
const cfg = readConfig()
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
domain: cfg.domain || '',
|
||||||
|
tool_aliases: cfg.tool_aliases || {},
|
||||||
|
ui_mode: cfg.ui_mode || 'debug'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
app.get('/api/short_link/list', (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = Math.max(1, Number(req.query.page) || 1)
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number(req.query.pageSize) || 50))
|
||||||
|
const keyword = String(req.query.keyword || '').trim()
|
||||||
|
const data = listLinks(page, pageSize, keyword)
|
||||||
|
res.json({ ok: true, ...data })
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建
|
||||||
|
app.post('/api/short_link/create', (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = req.body || {}
|
||||||
|
const target_url = String(body.target_url || '').trim()
|
||||||
|
if (!target_url) return res.status(400).json({ ok: false, error: '目标链接不能为空' })
|
||||||
|
const data = createLink({
|
||||||
|
target_url,
|
||||||
|
title: String(body.title || '').trim(),
|
||||||
|
code: String(body.code || '').trim() || undefined,
|
||||||
|
source_type: String(body.source_type || 'manual').trim(),
|
||||||
|
source_ref: String(body.source_ref || '').trim(),
|
||||||
|
expires_at: body.expires_at || null,
|
||||||
|
max_clicks: body.max_clicks ? Number(body.max_clicks) : null
|
||||||
|
})
|
||||||
|
res.json({ ok: true, data })
|
||||||
|
} catch (e) {
|
||||||
|
res.status(400).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
app.put('/api/short_link/:code', (req, res) => {
|
||||||
|
try {
|
||||||
|
const code = String(req.params.code || '').trim()
|
||||||
|
const data = updateLink(code, req.body || {})
|
||||||
|
res.json({ ok: true, data })
|
||||||
|
} catch (e) {
|
||||||
|
res.status(400).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
app.delete('/api/short_link/:code', (req, res) => {
|
||||||
|
try {
|
||||||
|
const code = String(req.params.code || '').trim()
|
||||||
|
const data = deleteLink(code)
|
||||||
|
res.json({ ok: true, data })
|
||||||
|
} catch (e) {
|
||||||
|
res.status(400).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
app.get('/api/short_link/stats/:code', (req, res) => {
|
||||||
|
try {
|
||||||
|
const code = String(req.params.code || '').trim()
|
||||||
|
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100))
|
||||||
|
const data = getStats(code, limit)
|
||||||
|
res.json({ ok: true, ...data })
|
||||||
|
} catch (e) {
|
||||||
|
res.status(400).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 批量创建
|
||||||
|
app.post('/api/short_link/batch', (req, res) => {
|
||||||
|
try {
|
||||||
|
const items = req.body && req.body.items
|
||||||
|
if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ ok: false, error: '请提供 items 数组' })
|
||||||
|
if (items.length > 100) return res.status(400).json({ ok: false, error: '单次最多 100 条' })
|
||||||
|
const results = batchCreate(items)
|
||||||
|
res.json({ ok: true, results })
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { bindRoutes, readConfig }
|
||||||
Reference in New Issue
Block a user