up
This commit is contained in:
@@ -84,3 +84,32 @@
|
|||||||
**i18n 新增**:
|
**i18n 新增**:
|
||||||
- `zh-CN.json`: `calendar.admin_title = "日历管理"`, `calendar.event_count = "日程数量"`
|
- `zh-CN.json`: `calendar.admin_title = "日历管理"`, `calendar.event_count = "日程数量"`
|
||||||
- `en.json`: `calendar.admin_title = "Calendar Admin"`, `calendar.event_count = "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`
|
||||||
|
|||||||
@@ -658,6 +658,11 @@
|
|||||||
"deleted": "Deleted",
|
"deleted": "Deleted",
|
||||||
"confirm_restore": "Are you sure you want to restore this calendar?",
|
"confirm_restore": "Are you sure you want to restore this calendar?",
|
||||||
"confirm_restore_message": "Are you sure you want to restore calendar '{name}'?",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -659,6 +659,11 @@
|
|||||||
"deleted": "已删除",
|
"deleted": "已删除",
|
||||||
"confirm_restore": "确定要恢复此日历吗?",
|
"confirm_restore": "确定要恢复此日历吗?",
|
||||||
"confirm_restore_message": "确定要恢复日历「{name}」吗?",
|
"confirm_restore_message": "确定要恢复日历「{name}」吗?",
|
||||||
"admin_panel": "管理"
|
"admin_panel": "管理",
|
||||||
|
"copy_event": "复制日程",
|
||||||
|
"paste_event": "粘贴日程",
|
||||||
|
"copy_success": "日程已复制",
|
||||||
|
"paste_success": "日程已粘贴",
|
||||||
|
"no_event_to_paste": "没有可粘贴的日程"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,113 @@ const pageData = ref({
|
|||||||
|
|
||||||
const showDeleteModal = ref(false)
|
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) {
|
function unseleEvent(eventID) {
|
||||||
const target = calendarOptions.value.events.find(item => item.id === 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.backgroundColor = "#f5f5f5"
|
||||||
}
|
}
|
||||||
info.el.style.border = "1px solid #e5e7eb"
|
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: {
|
headerToolbar: {
|
||||||
@@ -373,6 +495,11 @@ const calendarOptions = ref({
|
|||||||
if (titleEl) {
|
if (titleEl) {
|
||||||
requestAnimationFrame(() => applyScrollToTitle(titleEl))
|
requestAnimationFrame(() => applyScrollToTitle(titleEl))
|
||||||
}
|
}
|
||||||
|
// 绑定右键菜单
|
||||||
|
info.el.addEventListener("contextmenu", (e) => {
|
||||||
|
e.stopPropagation() // 阻止冒泡到 dayCell
|
||||||
|
handleEventContextMenu({ event: info.event, jsEvent: e })
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
datesSet(info) {
|
datesSet(info) {
|
||||||
@@ -484,6 +611,8 @@ let refreshTimer = null
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchCalendarInfo()
|
fetchCalendarInfo()
|
||||||
|
// 点击任意位置关闭右键菜单
|
||||||
|
document.addEventListener('click', closeContextMenu)
|
||||||
// 每 5 秒刷新一次数据
|
// 每 5 秒刷新一次数据
|
||||||
refreshTimer = setInterval(() => {
|
refreshTimer = setInterval(() => {
|
||||||
getEvents()
|
getEvents()
|
||||||
@@ -506,6 +635,7 @@ onMounted(() => {
|
|||||||
resizeObserver.disconnect()
|
resizeObserver.disconnect()
|
||||||
resizeObserver = null
|
resizeObserver = null
|
||||||
}
|
}
|
||||||
|
document.removeEventListener('click', closeContextMenu)
|
||||||
clearTimeout(resizeTimer)
|
clearTimeout(resizeTimer)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -696,6 +826,31 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 右键菜单 -->
|
||||||
|
<div
|
||||||
|
v-if="contextMenu.visible"
|
||||||
|
class="fixed z-[60] min-w-[140px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dk-muted dark:bg-dk-card"
|
||||||
|
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="contextMenu.eventInfo"
|
||||||
|
@click="copyEvent"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-dk-text dark:hover:bg-dk-base"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
||||||
|
{{ t('calendar.copy_event') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="pasteEvent"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-dk-text dark:hover:bg-dk-base"
|
||||||
|
:class="{ 'opacity-40 cursor-not-allowed': !clipboard }"
|
||||||
|
:disabled="!clipboard"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>
|
||||||
|
{{ t('calendar.paste_event') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Delete Confirm Dialog -->
|
<!-- Delete Confirm Dialog -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
v-model="showDeleteModal"
|
v-model="showDeleteModal"
|
||||||
|
|||||||
Reference in New Issue
Block a user