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 0000000..bca1156 Binary files /dev/null and b/USA_ubuntu_SSL/USA_ubuntu_SSL_for_maise.pro/maise_pro.pfx differ diff --git a/USA_ubuntu_SSL/USA_ubuntu_SSL_for_maise.tech/maise.tech.final.pfx b/USA_ubuntu_SSL/USA_ubuntu_SSL_for_maise.tech/maise.tech.final.pfx new file mode 100644 index 0000000..37f6f96 Binary files /dev/null and b/USA_ubuntu_SSL/USA_ubuntu_SSL_for_maise.tech/maise.tech.final.pfx differ 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 }