From 154b79c8f1ae36eed7e25ca2ad3bbce9493ed2fb Mon Sep 17 00:00:00 2001 From: yangxiangyuan Date: Tue, 28 Jul 2026 16:23:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=9F=AD=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E5=B7=A5=E5=85=B7=EF=BC=8C=E4=BC=98=E5=8C=96=E6=97=A5?= =?UTF-8?q?=E5=8E=86=E6=8F=90=E9=86=92=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增完整短链接工具服务,包括前端页面、后端API与数据库支持 2. 新增移动端日历提醒工具与鉴权配置 3. 优化日历事件编辑与展示: - 添加事件循环重复设置 - 新增全天事件支持 - 时间选择器自动对齐15分钟间隔 - 保存状态加载动画 - 事件卡片显示重复规则 4. 更新API基础地址与路由配置 5. 新增SSL证书文件与相关工具静态资源 --- .../maise_pro.pfx | Bin 0 -> 5958 bytes .../maise.tech.final.pfx | Bin 0 -> 8613 bytes apps/app_calendar_reminder/api.ts | 2 +- .../components/EventCard.tsx | 44 + .../components/EventModal.tsx | 210 +++- .../screens/CalendarScreen.tsx | 7 + .../screens/TodayScreen.tsx | 7 + public/tools/short_link/auth_config.json | 15 + public/tools/short_link/index.html | 170 ++++ public/tools/short_link/short_link.css | 187 ++++ public/tools/short_link/short_link.js | 423 ++++++++ .../auth_config.json | 1 + .../css/styles.css | 906 +++++++++++++++++ .../web_mobile_calendar_reminder/index.html | 176 ++++ .../web_mobile_calendar_reminder/js/app.js | 936 ++++++++++++++++++ ....json => usa_pop_2026_top50_playlist.json} | 0 src/server/index.js | 147 ++- src/server/short_link.js | 300 ++++++ 18 files changed, 3508 insertions(+), 23 deletions(-) create mode 100644 USA_ubuntu_SSL/USA_ubuntu_SSL_for_maise.pro/maise_pro.pfx create mode 100644 USA_ubuntu_SSL/USA_ubuntu_SSL_for_maise.tech/maise.tech.final.pfx create mode 100644 public/tools/short_link/auth_config.json create mode 100644 public/tools/short_link/index.html create mode 100644 public/tools/short_link/short_link.css create mode 100644 public/tools/short_link/short_link.js create mode 100644 public/tools/web_mobile_calendar_reminder/auth_config.json create mode 100644 public/tools/web_mobile_calendar_reminder/css/styles.css create mode 100644 public/tools/web_mobile_calendar_reminder/index.html create mode 100644 public/tools/web_mobile_calendar_reminder/js/app.js rename public/tools/web_zen_box/{pop_2026_top50_playlist.json => usa_pop_2026_top50_playlist.json} (100%) create mode 100644 src/server/short_link.js diff --git a/USA_ubuntu_SSL/USA_ubuntu_SSL_for_maise.pro/maise_pro.pfx b/USA_ubuntu_SSL/USA_ubuntu_SSL_for_maise.pro/maise_pro.pfx new file mode 100644 index 0000000000000000000000000000000000000000..bca1156464413a5881564dcf8e25e0742890d768 GIT binary patch literal 5958 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 literal 0 HcmV?d00001 diff --git a/apps/app_calendar_reminder/api.ts b/apps/app_calendar_reminder/api.ts index ec554e1..eb0313f 100644 --- a/apps/app_calendar_reminder/api.ts +++ b/apps/app_calendar_reminder/api.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import { Event, ApiResponse } from './types'; -const API_BASE_URL = 'http://47.101.56.101:8976/api/app_calendar'; +const API_BASE_URL = 'https://traesite.umersoft.com:8975/api/app_calendar'; const api = axios.create({ baseURL: API_BASE_URL, diff --git a/apps/app_calendar_reminder/components/EventCard.tsx b/apps/app_calendar_reminder/components/EventCard.tsx index 032229a..5c1f1a6 100644 --- a/apps/app_calendar_reminder/components/EventCard.tsx +++ b/apps/app_calendar_reminder/components/EventCard.tsx @@ -12,6 +12,33 @@ interface EventCardProps { onDelete: (event: Event) => void; } +const WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; + +// 生成循环规则中文描述 +const getRecurrenceLabel = (recurrence: string | null): string | null => { + if (!recurrence) return null; + const parts = recurrence.split(';'); + const type = parts[0]; + let interval = 1; + let by: number[] = []; + for (let i = 1; i < parts.length; i++) { + const [key, val] = parts[i].split('='); + if (key === 'interval') interval = parseInt(val) || 1; + if (key === 'by') by = val.split(',').map(Number); + } + if (type === 'DAILY') { + return interval === 1 ? '每天' : `每${interval}天`; + } + if (type === 'WEEKLY') { + const prefix = interval === 1 ? '每周' : `每${interval}周`; + if (by.length > 0) { + return `${prefix} ${by.map(d => WEEKDAY_NAMES[d] || '').join('、')}`; + } + return prefix; + } + return null; +}; + export default function EventCard({ event, onEdit, @@ -26,6 +53,8 @@ export default function EventCard({ }); }; + const recurrenceLabel = getRecurrenceLabel(event.recurrence); + return ( {formatTime(event.start)} + {recurrenceLabel && ( + <> + + {recurrenceLabel} + + )} @@ -133,6 +173,10 @@ const styles = StyleSheet.create({ ...theme.typography.caption, color: theme.colors.textSecondary, }, + recurrenceText: { + ...theme.typography.caption, + color: theme.colors.primary, + }, deleteButton: { padding: theme.spacing.sm, }, diff --git a/apps/app_calendar_reminder/components/EventModal.tsx b/apps/app_calendar_reminder/components/EventModal.tsx index 16019a9..6ec07fb 100644 --- a/apps/app_calendar_reminder/components/EventModal.tsx +++ b/apps/app_calendar_reminder/components/EventModal.tsx @@ -8,6 +8,7 @@ import { TouchableOpacity, ScrollView, Switch, + ActivityIndicator, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import DateTimePicker from '@react-native-community/datetimepicker'; @@ -22,6 +23,32 @@ interface EventModalProps { onSave: (event: Partial) => void; } +const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日']; + +// 15 分钟对齐 +const snapTo15 = (date: Date): Date => { + const d = new Date(date); + const minutes = d.getMinutes(); + const snapped = Math.round(minutes / 15) * 15; + d.setMinutes(snapped, 0, 0); + return d; +}; + +// 解析循环规则字符串 +const parseRecurrence = (str: string | null): { type: 'NONE' | 'DAILY' | 'WEEKLY'; interval: number; by: number[] } => { + if (!str) return { type: 'NONE', interval: 1, by: [] }; + const parts = str.split(';'); + const type = (parts[0] === 'DAILY' || parts[0] === 'WEEKLY') ? parts[0] as 'DAILY' | 'WEEKLY' : 'NONE'; + let interval = 1; + let by: number[] = []; + for (let i = 1; i < parts.length; i++) { + const [key, val] = parts[i].split('='); + if (key === 'interval') interval = Math.max(1, parseInt(val) || 1); + if (key === 'by') by = val.split(',').map(Number).filter(n => n >= 0 && n <= 6); + } + return { type, interval, by }; +}; + export default function EventModal({ visible, event, @@ -32,9 +59,13 @@ export default function EventModal({ const [content, setContent] = useState(''); const [startDate, setStartDate] = useState(new Date()); const [endDate, setEndDate] = useState(null); + const [allDay, setAllDay] = useState(false); const [enableReminder, setEnableReminder] = useState(true); const [remindDate, setRemindDate] = useState(new Date()); const [recurrenceType, setRecurrenceType] = useState<'NONE' | 'DAILY' | 'WEEKLY'>('NONE'); + const [interval, setInterval_] = useState(1); + const [weekdays, setWeekdays] = useState([]); + const [saving, setSaving] = useState(false); const [showStartPicker, setShowStartPicker] = useState(false); const [showEndPicker, setShowEndPicker] = useState(false); const [showRemindPicker, setShowRemindPicker] = useState(false); @@ -45,16 +76,14 @@ export default function EventModal({ setContent(event.content || ''); setStartDate(safeParseDate(event.start)); setEndDate(event.end ? safeParseDate(event.end) : null); + setAllDay(!!event.allDay); setEnableReminder(!!event.remindAt); setRemindDate(event.remindAt ? safeParseDate(event.remindAt) : new Date()); - - if (event.recurrence?.startsWith('DAILY')) { - setRecurrenceType('DAILY'); - } else if (event.recurrence?.startsWith('WEEKLY')) { - setRecurrenceType('WEEKLY'); - } else { - setRecurrenceType('NONE'); - } + + const parsed = parseRecurrence(event.recurrence); + setRecurrenceType(parsed.type); + setInterval_(parsed.interval); + setWeekdays(parsed.by); } else { resetForm(); } @@ -63,11 +92,15 @@ export default function EventModal({ const resetForm = () => { setTitle(''); setContent(''); - setStartDate(new Date()); + setStartDate(snapTo15(new Date())); setEndDate(null); + setAllDay(false); setEnableReminder(true); - setRemindDate(new Date()); + setRemindDate(snapTo15(new Date())); setRecurrenceType('NONE'); + setInterval_(1); + setWeekdays([]); + setSaving(false); }; const handleSave = () => { @@ -75,20 +108,36 @@ export default function EventModal({ alert('请输入标题'); return; } + if (!startDate) { + alert('请设置开始时间'); + return; + } + setSaving(true); + + // 结束时间自动补全:未设置时 +1h(全天则 +24h) + let finalEnd = endDate; + if (!finalEnd) { + finalEnd = new Date(startDate.getTime() + (allDay ? 24 : 1) * 3600 * 1000); + } + + // 组装循环规则 let recurrence = ''; if (recurrenceType === 'DAILY') { - recurrence = 'DAILY;interval=1'; + recurrence = `DAILY;interval=${interval}`; } else if (recurrenceType === 'WEEKLY') { - recurrence = 'WEEKLY;interval=1'; + recurrence = `WEEKLY;interval=${interval}`; + if (weekdays.length > 0) { + recurrence += `;by=${weekdays.join(',')}`; + } } const eventData: Partial = { title: title.trim(), content: content.trim(), start: startDate.toISOString(), - end: endDate ? endDate.toISOString() : null, - allDay: false, + end: finalEnd.toISOString(), + allDay, remindAt: enableReminder ? remindDate.toISOString() : null, recurrence: recurrence || null, completed: event?.completed || false, @@ -97,6 +146,12 @@ export default function EventModal({ onSave(eventData); }; + const toggleWeekday = (day: number) => { + setWeekdays(prev => + prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day].sort() + ); + }; + const formatDate = (date: Date) => { return date.toLocaleDateString('zh-CN', { year: 'numeric', @@ -123,8 +178,12 @@ export default function EventModal({ {event ? '编辑事件' : '新建事件'} - - 保存 + + {saving ? ( + + ) : ( + 保存 + )} @@ -153,6 +212,16 @@ export default function EventModal({ /> + + 全天事件 + + + 开始时间 - 结束时间(可选) + 结束时间(可选,默认+1小时) setShowEndPicker(true)} @@ -233,26 +302,78 @@ export default function EventModal({ ))} + + {recurrenceType !== 'NONE' && ( + + + 每 {interval} {recurrenceType === 'DAILY' ? '天' : '周'} + + + setInterval_(Math.max(1, interval - 1))} + > + + + {interval} + setInterval_(interval + 1)} + > + + + + + )} + + {recurrenceType === 'WEEKLY' && ( + + 重复星期 + + {WEEKDAY_LABELS.map((label, idx) => ( + toggleWeekday(idx)} + > + + {label} + + + ))} + + + )} {showStartPicker && ( { setShowStartPicker(false); - if (date) setStartDate(date); + if (date) setStartDate(snapTo15(date)); }} /> )} {showEndPicker && ( { setShowEndPicker(false); - if (date) setEndDate(date); + if (date) setEndDate(snapTo15(date)); }} /> )} @@ -261,9 +382,10 @@ export default function EventModal({ { setShowRemindPicker(false); - if (date) setRemindDate(date); + if (date) setRemindDate(snapTo15(date)); }} /> )} @@ -371,4 +493,50 @@ const styles = StyleSheet.create({ color: theme.colors.primary, fontWeight: '600', }, + intervalRow: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.md, + }, + intervalBtn: { + width: 40, + height: 40, + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: theme.colors.border, + }, + intervalValue: { + ...theme.typography.h3, + color: theme.colors.text, + minWidth: 30, + textAlign: 'center', + }, + weekdayRow: { + flexDirection: 'row', + gap: theme.spacing.xs, + }, + weekdayBtn: { + flex: 1, + paddingVertical: theme.spacing.sm, + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface, + alignItems: 'center', + borderWidth: 1, + borderColor: theme.colors.border, + }, + weekdayBtnActive: { + borderColor: theme.colors.primary, + backgroundColor: theme.colors.surfaceVariant, + }, + weekdayBtnText: { + ...theme.typography.bodySmall, + color: theme.colors.textSecondary, + }, + weekdayBtnTextActive: { + color: theme.colors.primary, + fontWeight: '600', + }, }); diff --git a/apps/app_calendar_reminder/screens/CalendarScreen.tsx b/apps/app_calendar_reminder/screens/CalendarScreen.tsx index 8cb5cf0..f791b46 100644 --- a/apps/app_calendar_reminder/screens/CalendarScreen.tsx +++ b/apps/app_calendar_reminder/screens/CalendarScreen.tsx @@ -28,6 +28,7 @@ export default function CalendarScreen() { const [selectedDate, setSelectedDate] = useState(null); const [modalVisible, setModalVisible] = useState(false); const [selectedEvent, setSelectedEvent] = useState(null); + const [saving, setSaving] = useState(false); const loadEvents = useCallback(async () => { try { @@ -139,10 +140,13 @@ export default function CalendarScreen() { const handleToggleComplete = async (event: Event) => { try { + setSaving(true); await eventApi.updateEvent(event.id, { completed: !event.completed }); await loadEvents(); } catch (error) { showError('更新失败', error); + } finally { + setSaving(false); } }; @@ -170,6 +174,7 @@ export default function CalendarScreen() { const handleSaveEvent = async (eventData: Partial) => { try { + setSaving(true); if (selectedEvent) { await eventApi.updateEvent(selectedEvent.id, eventData); } else { @@ -184,6 +189,8 @@ export default function CalendarScreen() { await loadEvents(); } catch (error) { showError('保存失败', error); + } finally { + setSaving(false); } }; diff --git a/apps/app_calendar_reminder/screens/TodayScreen.tsx b/apps/app_calendar_reminder/screens/TodayScreen.tsx index 9ec9b4a..bd7129b 100644 --- a/apps/app_calendar_reminder/screens/TodayScreen.tsx +++ b/apps/app_calendar_reminder/screens/TodayScreen.tsx @@ -28,6 +28,7 @@ export default function TodayScreen() { const [loading, setLoading] = useState(true); const [modalVisible, setModalVisible] = useState(false); const [selectedEvent, setSelectedEvent] = useState(null); + const [saving, setSaving] = useState(false); const loadEvents = useCallback(async () => { try { @@ -64,10 +65,13 @@ export default function TodayScreen() { const handleToggleComplete = async (event: Event) => { try { + setSaving(true); await eventApi.updateEvent(event.id, { completed: !event.completed }); await loadEvents(); } catch (error) { showError('更新失败', error); + } finally { + setSaving(false); } }; @@ -95,6 +99,7 @@ export default function TodayScreen() { const handleSaveEvent = async (eventData: Partial) => { try { + setSaving(true); if (selectedEvent) { await eventApi.updateEvent(selectedEvent.id, eventData); } else { @@ -109,6 +114,8 @@ export default function TodayScreen() { await loadEvents(); } catch (error) { showError('保存失败', error); + } finally { + setSaving(false); } }; diff --git a/public/tools/short_link/auth_config.json b/public/tools/short_link/auth_config.json new file mode 100644 index 0000000..6ff4176 --- /dev/null +++ b/public/tools/short_link/auth_config.json @@ -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 名称,默认格式 _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": "" +} diff --git a/public/tools/short_link/index.html b/public/tools/short_link/index.html new file mode 100644 index 0000000..25611e7 --- /dev/null +++ b/public/tools/short_link/index.html @@ -0,0 +1,170 @@ + + + + + + Yang's Toolbox · 短链接 + + + + + +
+ +
+ debug +
+
+ +
+ +
+

创建短链接

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+

工具快捷入口

+

以下别名在 config/short_link.json 中配置,访问对应短链接可直接跳转到工具

+
+
+ + +
+
+

短链接列表

+
+ + + +
+
+
+ + + + + + + + + + + + + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + diff --git a/public/tools/short_link/short_link.css b/public/tools/short_link/short_link.css new file mode 100644 index 0000000..9407b7a --- /dev/null +++ b/public/tools/short_link/short_link.css @@ -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 } +} diff --git a/public/tools/short_link/short_link.js b/public/tools/short_link/short_link.js new file mode 100644 index 0000000..d114873 --- /dev/null +++ b/public/tools/short_link/short_link.js @@ -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, '"') + + 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 = '
暂无配置别名
' + return + } + wrap.innerHTML = entries.map(([code, target]) => ` +
+ ${escHtml(code)} + ${escHtml(target)} + +
+ `).join('') + } + + // ===== 渲染列表 ===== + const renderList = (rows, total, page) => { + const tbody = $('link-tbody') + if (!rows || rows.length === 0) { + tbody.innerHTML = '暂无短链接' + renderPagination(0, page) + return + } + tbody.innerHTML = rows.map(r => { + const shortUrl = fullShortUrl(r.code) + const now = fmtCNDateTime(new Date()) + let statusHtml = '启用' + if (!r.is_active) statusHtml = '停用' + else if (r.expires_at && r.expires_at < now) statusHtml = '已过期' + else if (r.max_clicks && r.click_count >= r.max_clicks) statusHtml = '已达上限' + + return ` + ${escHtml(shortUrl)} + ${escHtml(r.target_url)} + ${escHtml(r.title || '-')} + ${r.click_count}${r.max_clicks ? ' / ' + r.max_clicks : ''} + ${escHtml(r.created_at || '')} + ${statusHtml} + + + + + + + ` + }).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 = `` + for (let i = 1; i <= pages && i <= 10; i++) { + html += `` + } + html += `` + 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 = ` +
${link.click_count}
总点击
+
${clicks.length}
最近记录
+
${link.is_active ? '启用' : '停用'}
当前状态
+ ` + if (clicks.length === 0) { + $('stats-clicks').innerHTML = '
暂无点击记录
' + } else { + $('stats-clicks').innerHTML = clicks.map(c => ` +
+ ${escHtml(c.clicked_at)} + ${escHtml(c.ip || '-')} + ${escHtml(c.user_agent || '-')} +
+ `).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() + } +})() diff --git a/public/tools/web_mobile_calendar_reminder/auth_config.json b/public/tools/web_mobile_calendar_reminder/auth_config.json new file mode 100644 index 0000000..9b20540 --- /dev/null +++ b/public/tools/web_mobile_calendar_reminder/auth_config.json @@ -0,0 +1 @@ +{"_comment":"移动端日历提醒鉴权配置","enable_auth":false} \ No newline at end of file diff --git a/public/tools/web_mobile_calendar_reminder/css/styles.css b/public/tools/web_mobile_calendar_reminder/css/styles.css new file mode 100644 index 0000000..863628d --- /dev/null +++ b/public/tools/web_mobile_calendar_reminder/css/styles.css @@ -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; +} diff --git a/public/tools/web_mobile_calendar_reminder/index.html b/public/tools/web_mobile_calendar_reminder/index.html new file mode 100644 index 0000000..d7e3f74 --- /dev/null +++ b/public/tools/web_mobile_calendar_reminder/index.html @@ -0,0 +1,176 @@ + + + + + + + + + 日历提醒 + + + +
+ +
+
+
+
早上好 👋
+
+
+
+
+
+
0
+
待完成
+
+
+
0
+
已完成
+
+
+
0
+
共计
+
+
+
待完成
+ +
+ +
+ + +
+
+ +
+ +
+
+
日
+
一
+
二
+
三
+
四
+
五
+
六
+
+
+
点击日期查看事项
+ +
+ + + + + + +
+ + + + + +
+
+
确定要删除这个事件吗?
+ + +
+
+ + +
+ + + + diff --git a/public/tools/web_mobile_calendar_reminder/js/app.js b/public/tools/web_mobile_calendar_reminder/js/app.js new file mode 100644 index 0000000..1e872f9 --- /dev/null +++ b/public/tools/web_mobile_calendar_reminder/js/app.js @@ -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(); + } +})(); diff --git a/public/tools/web_zen_box/pop_2026_top50_playlist.json b/public/tools/web_zen_box/usa_pop_2026_top50_playlist.json similarity index 100% rename from public/tools/web_zen_box/pop_2026_top50_playlist.json rename to public/tools/web_zen_box/usa_pop_2026_top50_playlist.json diff --git a/src/server/index.js b/src/server/index.js index 8ebb36d..846e38d 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -78,6 +78,7 @@ const plantHome = require('./plant_home') const globalNews = require('./global_news') const boxBackup = require('./box_backup') const yuanzhupai = require('./yuanzhupai') +const shortLink = require('./short_link') const dataGateway = require('./data_gateway') const ossFileCabinet = require('./oss_file_cabinet') yuanzhupai.initDb() @@ -1579,7 +1580,9 @@ const mapSystemIdToUrl = (systemId) => { 'Tools-plant_home': '/tools/plant_home', 'Tools-apple_watch': '/tools/apple_watch', '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 (systemId.startsWith('Tools-')) { @@ -5721,6 +5724,133 @@ app.use('/tools/yuanzhupai', (req, res, next) => { 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 readBoxBackupAuthConfig = () => { try { @@ -6395,6 +6525,21 @@ globalNews.bindRoutes(app) dataGateway.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 currentPort = Number(process.env.PORT || '8976') || 8976 const marketsAutomationPort = Number(marketsCfgAtBoot.automation_port || 8977) || 8977 diff --git a/src/server/short_link.js b/src/server/short_link.js new file mode 100644 index 0000000..8cf54c2 --- /dev/null +++ b/src/server/short_link.js @@ -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 }