diff --git a/.workbuddy/memory/2026-05-06.md b/.workbuddy/memory/2026-05-06.md index 2cde560..f09fce0 100644 --- a/.workbuddy/memory/2026-05-06.md +++ b/.workbuddy/memory/2026-05-06.md @@ -84,3 +84,32 @@ **i18n 新增**: - `zh-CN.json`: `calendar.admin_title = "日历管理"`, `calendar.event_count = "日程数量"` - `en.json`: `calendar.admin_title = "Calendar Admin"`, `calendar.event_count = "Event Count"` + +## 修复:CalendarAdminList eventCounts 未定义 + +**问题**:模板访问 `eventCounts[calendar.ID]` 但 `eventCounts` 从未声明,且 `fetchEventCounts` 未被调用。 + +**修复**:后端已直接返回 `event_count` 字段,改为模板直接用 `calendar.event_count ?? 0`,删除多余的 `eventCounts` ref 和 `fetchEventCounts` 函数。 + +## 新增:后端 TabCalendarEventUserBind 绑定表 + +**文件**:`routers/binds.go` +- 新增 `TabCalendarEventUserBind` 结构体(EventID/UserID/CreatorID/CreatedAt) +- 在 `BindsInit` AutoMigrate 中注册 + +## 新增:日历右键菜单(复制/粘贴日程) + +**文件**:`CalendarDetail.vue` + +**功能**: +- 右键日程事件弹出上下文菜单,显示「复制日程」「粘贴日程」 +- 复制:将日程数据存入 `clipboard` ref +- 粘贴:以 clipboard 数据调用 `addEvent` API 创建新日程 +- 点击任意位置关闭菜单 + +**实现**: +- `contextMenu` ref 管理菜单显示/位置/事件数据 +- `clipboard` ref 存储复制的日程 +- `eventDidMount` 中为每个事件绑定 `contextmenu` 事件 +- `onMounted` 注册全局 click 关闭菜单,`onBeforeUnmount` 移除 +- i18n 新增 `calendar.copy_event/paste_event/copy_success/paste_success/no_event_to_paste` diff --git a/frontend/ops_vue_js/src/i18n/en.json b/frontend/ops_vue_js/src/i18n/en.json index 45d52f2..ba71fb3 100644 --- a/frontend/ops_vue_js/src/i18n/en.json +++ b/frontend/ops_vue_js/src/i18n/en.json @@ -658,6 +658,11 @@ "deleted": "Deleted", "confirm_restore": "Are you sure you want to restore this calendar?", "confirm_restore_message": "Are you sure you want to restore calendar '{name}'?", - "admin_panel": "Admin" + "admin_panel": "Admin", + "copy_event": "Copy Event", + "paste_event": "Paste Event", + "copy_success": "Event copied", + "paste_success": "Event pasted", + "no_event_to_paste": "No event to paste" } } diff --git a/frontend/ops_vue_js/src/i18n/zh-CN.json b/frontend/ops_vue_js/src/i18n/zh-CN.json index 35ec349..c71ac91 100644 --- a/frontend/ops_vue_js/src/i18n/zh-CN.json +++ b/frontend/ops_vue_js/src/i18n/zh-CN.json @@ -659,6 +659,11 @@ "deleted": "已删除", "confirm_restore": "确定要恢复此日历吗?", "confirm_restore_message": "确定要恢复日历「{name}」吗?", - "admin_panel": "管理" + "admin_panel": "管理", + "copy_event": "复制日程", + "paste_event": "粘贴日程", + "copy_success": "日程已复制", + "paste_success": "日程已粘贴", + "no_event_to_paste": "没有可粘贴的日程" } } diff --git a/frontend/ops_vue_js/src/views/calendar/CalendarDetail.vue b/frontend/ops_vue_js/src/views/calendar/CalendarDetail.vue index db78d8e..be81448 100644 --- a/frontend/ops_vue_js/src/views/calendar/CalendarDetail.vue +++ b/frontend/ops_vue_js/src/views/calendar/CalendarDetail.vue @@ -65,6 +65,113 @@ const pageData = ref({ const showDeleteModal = ref(false) +// 右键菜单 +const contextMenu = ref({ + visible: false, + x: 0, + y: 0, + eventInfo: null, +}) + +// 剪贴板:存储复制的日程数据 +const clipboard = ref(null) + +// 右键菜单处理 +function handleEventContextMenu(info) { + info.jsEvent.preventDefault() + const canEdit = info.event.extendedProps?.canEdit ?? false + contextMenu.value = { + visible: true, + x: info.jsEvent.clientX, + y: info.jsEvent.clientY, + eventInfo: { + id: parseInt(info.event.id), + title: info.event.title, + start: info.event.startStr, + end: info.event.end ? info.event.endStr : info.event.startStr, + color: info.event.backgroundColor, + scheduleType: info.event.extendedProps?.scheduleType || 'work', + isPublic: info.event.extendedProps?.isPublic || false, + canEdit: canEdit, + }, + targetDate: info.event.startStr.split('T')[0], + } +} + +function closeContextMenu() { + contextMenu.value.visible = false +} + +function copyEvent() { + if (!contextMenu.value.eventInfo) return + clipboard.value = { ...contextMenu.value.eventInfo } + toast.success(t('calendar.copy_success')) + closeContextMenu() +} + +async function pasteEvent() { + if (!clipboard.value) { + toast.warning(t('calendar.no_event_to_paste')) + closeContextMenu() + return + } + + // 确定粘贴目标日期:优先右键菜单记录的日期,其次选中日程的 start + let targetStart = contextMenu.value.targetDate + if (!targetStart) { + const selectedEvent = calendarOptions.value.events.find( + item => item.id === pageData.value.seleEventID + ) + if (selectedEvent) { + targetStart = selectedEvent.start.split('T')[0] + } + } + if (!targetStart) { + toast.warning(t('calendar.no_event_to_paste')) + closeContextMenu() + return + } + + // 计算原始日程的时长(天数) + const origStart = clipboard.value.start.split('T')[0] + const origEnd = clipboard.value.end.split('T')[0] + const origStartMs = new Date(origStart).getTime() + const origEndMs = new Date(origEnd).getTime() + const durationMs = origEndMs - origStartMs + const isSameDay = durationMs === 0 || origStart === origEnd + + // 粘贴的结束日期 = 目标起始日期 + 原始时长 + let targetEnd = targetStart + if (!isSameDay) { + const targetEndMs = new Date(targetStart).getTime() + durationMs + targetEnd = new Date(targetEndMs).toISOString().split('T')[0] + } + + try { + const result = await calendarApi.addEvent({ + calendar_id: calendarId.value, + title: clipboard.value.title, + start: toDatetime(targetStart), + end: toDatetime( + isSameDay + ? targetEnd + : DateUtils.toRealEnd(targetEnd), + ), + schedule_type: clipboard.value.scheduleType, + is_public: clipboard.value.isPublic, + }) + if (result.errCode === 0) { + toast.success(t('calendar.paste_success')) + getEvents() + } else { + toast.error(t('message.server_error')) + } + } catch { + // 拦截器已处理 + } + closeContextMenu() +} + // 选中/取消选中事件 function unseleEvent(eventID) { const target = calendarOptions.value.events.find(item => item.id === eventID) @@ -323,6 +430,21 @@ const calendarOptions = ref({ info.el.style.backgroundColor = "#f5f5f5" } info.el.style.border = "1px solid #e5e7eb" + // 空白格子右键菜单 + info.el.addEventListener("contextmenu", (e) => { + e.preventDefault() + const y = info.date.getFullYear() + const m = String(info.date.getMonth() + 1).padStart(2, '0') + const d = String(info.date.getDate()).padStart(2, '0') + const dateStr = `${y}-${m}-${d}` + contextMenu.value = { + visible: true, + x: e.clientX, + y: e.clientY, + eventInfo: null, + targetDate: dateStr, + } + }) }, headerToolbar: { @@ -373,6 +495,11 @@ const calendarOptions = ref({ if (titleEl) { requestAnimationFrame(() => applyScrollToTitle(titleEl)) } + // 绑定右键菜单 + info.el.addEventListener("contextmenu", (e) => { + e.stopPropagation() // 阻止冒泡到 dayCell + handleEventContextMenu({ event: info.event, jsEvent: e }) + }) }, datesSet(info) { @@ -484,6 +611,8 @@ let refreshTimer = null onMounted(() => { fetchCalendarInfo() + // 点击任意位置关闭右键菜单 + document.addEventListener('click', closeContextMenu) // 每 5 秒刷新一次数据 refreshTimer = setInterval(() => { getEvents() @@ -506,6 +635,7 @@ onMounted(() => { resizeObserver.disconnect() resizeObserver = null } + document.removeEventListener('click', closeContextMenu) clearTimeout(resizeTimer) }) }) @@ -696,6 +826,31 @@ onMounted(() => { + +