This commit is contained in:
2026-05-06 15:47:26 +08:00
parent cb363c93a0
commit 49785004aa
7 changed files with 438 additions and 286 deletions
+38 -39
View File
@@ -26,14 +26,16 @@ type TabCalendar struct {
// TabCalendarEvent 日历事件表 // TabCalendarEvent 日历事件表
type TabCalendarEvent struct { type TabCalendarEvent struct {
ID uint `gorm:"primarykey"` ID uint `gorm:"primarykey"`
CalendarID uint `gorm:"not null;index;comment:关联日历ID"` CalendarID uint `gorm:"not null;index;comment:关联日历ID"`
UserID uint `gorm:"not null;comment:创建人ID"` UserID uint `gorm:"not null;comment:创建人ID"`
Title string `gorm:"size:200;not null;comment:事件标题"` UsersID []uint `gorm:"type:json; null;comment:其他关联用户ID"`
StartDate string `gorm:"size:10;not null;index;comment:开始日期 YYYY-MM-DD"` Title string `gorm:"size:200;not null;comment:事件标题"`
EndDate string `gorm:"size:10;not null;index;comment:结束日期 YYYY-MM-DD"` StartDate *time.Time `gorm:"size:10;not null;index;comment:开始日期 YYYY-MM-DD"`
BgColor string `gorm:"size:50;default:#3788d9;comment:背景颜色"` EndDate *time.Time `gorm:"size:10;not null;index;comment:结束日期 YYYY-MM-DD"`
Remark string `gorm:"type:text;comment:备注"` IsAllDay bool `gorm:"default:true;comment:是否全日事件"`
BgColor string `gorm:"size:50;default:#3788d9;comment:背景颜色"`
Remark string `gorm:"type:text;comment:备注"`
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime;comment:创建时间"` CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime;comment:创建时间"`
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime;comment:最后修改时间"` UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime;comment:最后修改时间"`
@@ -60,7 +62,7 @@ type fromCreateCalendar struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Description string `json:"description"` Description string `json:"description"`
Color string `json:"color"` Color string `json:"color"`
IsPublic bool `json:"is_public"` Is_public bool `json:"is_public"`
} }
type fromUpdateCalendar struct { type fromUpdateCalendar struct {
@@ -68,7 +70,7 @@ type fromUpdateCalendar struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Description string `json:"description"` Description string `json:"description"`
Color string `json:"color"` Color string `json:"color"`
IsPublic bool `json:"is_public"` Is_public bool `json:"is_public"`
} }
type fromDeleteCalendar struct { type fromDeleteCalendar struct {
@@ -76,27 +78,27 @@ type fromDeleteCalendar struct {
} }
type fromGetCalendarEvents struct { type fromGetCalendarEvents struct {
CalendarID uint `json:"calendar_id" binding:"required"` CalendarID uint `json:"calendar_id" binding:"required"`
Start string `json:"start" binding:"required"` Start *time.Time `json:"start" binding:"required"`
End string `json:"end" binding:"required"` End *time.Time `json:"end" binding:"required"`
} }
type fromAddCalendarEvent struct { type fromAddCalendarEvent struct {
CalendarID uint `json:"calendar_id" binding:"required"` CalendarID uint `json:"calendar_id" binding:"required"`
Title string `json:"title" binding:"required"` Title string `json:"title" binding:"required"`
Start string `json:"start" binding:"required"` Start *time.Time `json:"start" binding:"required"`
End string `json:"end" binding:"required"` End *time.Time `json:"end" binding:"required"`
Color string `json:"color"` Color string `json:"color"`
Remark string `json:"remark"` Remark string `json:"remark"`
} }
type fromUpdateCalendarEvent struct { type fromUpdateCalendarEvent struct {
ID uint `json:"id" binding:"required"` ID uint `json:"id" binding:"required"`
Title string `json:"title" binding:"required"` Title string `json:"title" binding:"required"`
Start string `json:"start" binding:"required"` Start *time.Time `json:"start" binding:"required"`
End string `json:"end" binding:"required"` End *time.Time `json:"end" binding:"required"`
Color string `json:"color"` Color string `json:"color"`
Remark string `json:"remark"` Remark string `json:"remark"`
} }
type fromDeleteCalendarEvent struct { type fromDeleteCalendarEvent struct {
@@ -122,7 +124,7 @@ func ApiCalendar(r *gin.RouterGroup) {
Name: from.Name, Name: from.Name,
Description: from.Description, Description: from.Description,
Color: from.Color, Color: from.Color,
IsPublic: from.IsPublic, IsPublic: from.Is_public,
} }
if calendar.Color == "" { if calendar.Color == "" {
calendar.Color = "#3788d9" calendar.Color = "#3788d9"
@@ -152,14 +154,11 @@ func ApiCalendar(r *gin.RouterGroup) {
// 获取日历列表 // 获取日历列表
r.POST("/calendar/list", func(ctx *gin.Context) { r.POST("/calendar/list", func(ctx *gin.Context) {
isAuth, _, _ := AuthenticationAuthority(ctx)
if isAuth { var calendars []TabCalendar
var calendars []TabCalendar models.DB.Where("deleted_at IS NULL").Order("created_at DESC").Find(&calendars)
models.DB.Where("deleted_at IS NULL").Order("created_at DESC").Find(&calendars) ReturnJson(ctx, "apiOK", gin.H{"list": calendars})
ReturnJson(ctx, "apiOK", gin.H{"list": calendars})
} else {
ReturnJson(ctx, "userCookieError", nil)
}
}) })
// 更新日历 // 更新日历
@@ -180,7 +179,7 @@ func ApiCalendar(r *gin.RouterGroup) {
Name: from.Name, Name: from.Name,
Description: from.Description, Description: from.Description,
Color: from.Color, Color: from.Color,
IsPublic: from.IsPublic, IsPublic: from.Is_public,
} }
if newCalendar.Color == "" { if newCalendar.Color == "" {
newCalendar.Color = "#3788d9" newCalendar.Color = "#3788d9"
@@ -349,11 +348,11 @@ func ApiCalendar(r *gin.RouterGroup) {
} }
newEvent := TabCalendarEvent{ newEvent := TabCalendarEvent{
Title: from.Title, Title: from.Title,
StartDate: from.Start, StartDate: from.Start,
EndDate: from.End, EndDate: from.End,
BgColor: from.Color, BgColor: from.Color,
Remark: from.Remark, Remark: from.Remark,
} }
if newEvent.BgColor == "" { if newEvent.BgColor == "" {
// 获取日历颜色 // 获取日历颜色
+8 -8
View File
@@ -3,41 +3,41 @@ import { api } from './index'
export const calendarApi = { export const calendarApi = {
// 创建日历 // 创建日历
createCalendar(data) { createCalendar(data) {
return api.post('/calendar/calendar/create', { data }) return api.post('/calendar/calendar/create', data)
}, },
// 获取日历列表 // 获取日历列表
getCalendars() { getCalendars() {
return api.post('/calendar/calendar/list', { data: {} }) return api.post('/calendar/calendar/list', {})
}, },
// 更新日历 // 更新日历
updateCalendar(data) { updateCalendar(data) {
return api.post('/calendar/calendar/update', { data }) return api.post('/calendar/calendar/update', data)
}, },
// 删除日历 // 删除日历
deleteCalendar(id) { deleteCalendar(id) {
return api.post('/calendar/calendar/delete', { data: { id } }) return api.post('/calendar/calendar/delete', { id })
}, },
// 获取日历事件 // 获取日历事件
getEvents(data) { getEvents(data) {
return api.post('/calendar/calendar/events', { data }) return api.post('/calendar/calendar/events', data)
}, },
// 添加日历事件 // 添加日历事件
addEvent(data) { addEvent(data) {
return api.post('/calendar/calendar/addevent', { data }) return api.post('/calendar/calendar/addevent', data)
}, },
// 更新日历事件 // 更新日历事件
updateEvent(data) { updateEvent(data) {
return api.post('/calendar/calendar/updateevent', { data }) return api.post('/calendar/calendar/updateevent', data)
}, },
// 删除日历事件 // 删除日历事件
deleteEvent(id) { deleteEvent(id) {
return api.post('/calendar/calendar/deleteevent', { data: { id } }) return api.post('/calendar/calendar/deleteevent', { id })
} }
} }
+1
View File
@@ -627,6 +627,7 @@
"loading": "Loading...", "loading": "Loading...",
"add_event": "Add Event", "add_event": "Add Event",
"edit_event": "Edit Event", "edit_event": "Edit Event",
"view_event": "View Event",
"delete_event": "Delete Event", "delete_event": "Delete Event",
"event_title": "Event Title", "event_title": "Event Title",
"event_title_required": "Event title is required", "event_title_required": "Event title is required",
+1
View File
@@ -627,6 +627,7 @@
"loading": "加载中...", "loading": "加载中...",
"add_event": "添加事件", "add_event": "添加事件",
"edit_event": "编辑事件", "edit_event": "编辑事件",
"view_event": "查看事件",
"delete_event": "删除事件", "delete_event": "删除事件",
"event_title": "事件标题", "event_title": "事件标题",
"event_title_required": "请输入事件标题", "event_title_required": "请输入事件标题",
+4 -3
View File
@@ -194,9 +194,10 @@ const router = createRouter({
router.beforeEach((to) => { router.beforeEach((to) => {
const userStore = useUserStore() const userStore = useUserStore()
// 不需要登录的页面 // 不需要登录的页面(精确匹配或前缀匹配)
const publicPages = ['/', '/login', '/register', '/forgot_password', '/schedule', '/404'] const publicPages = ['/', '/login', '/register', '/forgot_password', '/schedule', '/calendars', '/404']
if (publicPages.includes(to.path)) return true const publicPrefixes = ['/calendar/']
if (publicPages.includes(to.path) || publicPrefixes.some(p => to.path.startsWith(p))) return true
// 未登录 → 跳转登录 // 未登录 → 跳转登录
if (!userStore.isLoggedIn) { if (!userStore.isLoggedIn) {
@@ -12,7 +12,7 @@ import { useToastStore } from "@/stores/toast"
import { useUserStore } from "@/stores/user" import { useUserStore } from "@/stores/user"
import { calendarApi } from "@/api/calendar" import { calendarApi } from "@/api/calendar"
import { useDateUtils } from "@/composables/useDateUtils" import { useDateUtils } from "@/composables/useDateUtils"
import { IconPlus, IconTrash, IconEdit, IconCalendar } from "@tabler/icons-vue" import DatatimePickerForFullCalendar from "@/components/datatimePickerForFullCalendar.vue"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -37,16 +37,17 @@ const eventData = ref({
startDate: "", startDate: "",
endDate: "", endDate: "",
color: "#3788d9", color: "#3788d9",
remark: "",
isEditing: false, isEditing: false,
isEditable: false isEditable: false,
}) })
const colorOptions = ref([ const colorOptions = ref([
{ value: "#3788d9", label: t("schedule.work") }, { value: "#3788d9", label: t("schedule.work"), name: t("schedule.work"), type: "work" },
{ value: "#06d6a0", label: t("schedule.duty") }, { value: "#06d6a0", label: t("schedule.duty"), name: t("schedule.duty"), type: "duty" },
{ value: "#ff595e", label: t("schedule.exam") }, { value: "#ff595e", label: t("schedule.exam"), name: t("schedule.exam"), type: "exam" },
{ value: "#ffca3a", label: t("schedule.standby") }, { value: "#ffca3a", label: t("schedule.standby"), name: t("schedule.standby"), type: "standby" },
{ value: "#D16C13", label: t("schedule.personal_holiday"), name: t("schedule.personal_holiday"), type: "personal_holiday" },
{ value: "#D10D21", label: t("schedule.public_holiday"), name: t("schedule.public_holiday"), type: "public_holiday" },
]) ])
const pageData = ref({ const pageData = ref({
@@ -55,91 +56,127 @@ const pageData = ref({
lastClickTimeStr: "", lastClickTimeStr: "",
lastEventClickTime: 0, lastEventClickTime: 0,
lastEventClickID: 0, lastEventClickID: 0,
submitChecked: false submitChecked: false,
lastEventsSnapshot: null,
}) })
function openEventModal(startDate, endDate) { // 选中/取消选中事件
function unseleEvent(eventID) {
const target = calendarOptions.value.events.find(item => item.id === eventID)
if (target) {
target.borderColor = "#F7F7F7"
}
}
function unseleEventAll() {
unseleEvent(pageData.value.seleEventID)
pageData.value.seleEventID = 0
}
function closeEventModal() {
showModal.value = false
}
function openEventModal(dateStr, dataEnd, id = 0, title = "", color = "#3788d9", isEditing = false, isEditable = true) {
eventData.value = { eventData.value = {
id: 0, id: id,
title: "", title: title,
startDate: startDate || "", startDate: dateStr,
endDate: endDate || "", endDate: dataEnd,
color: calendarInfo.value.Color || "#3788d9", color: color,
remark: "", isEditing: isEditing,
isEditing: false, isEditable: isEditable,
isEditable: true
} }
showModal.value = true showModal.value = true
} }
function editEvent(event) { function editEvent(info) {
eventData.value = { openEventModal(
id: event.id, info.event.startStr,
title: event.title, info.event.end ? info.event.endStr : info.event.startStr,
startDate: event.start, parseInt(info.event.id),
endDate: event.end || event.start, info.event.title,
color: event.backgroundColor, info.event.backgroundColor,
remark: event.extendedProps?.remark || "", true,
isEditing: true, info.event.durationEditable,
isEditable: true )
}
function selectColor(colorValue) {
if (eventData.value.isEditable) {
eventData.value.color = colorValue
} }
showModal.value = true }
// 日期转后端格式:YYYY-MM-DD 00:00:00
function toDatetime(dateStr) {
return dateStr ? dateStr + " 00:00:00" : ""
} }
async function saveEvent() { async function saveEvent() {
if (!eventData.value.title.trim()) { if (!eventData.value.title.trim()) {
toast.error(t('calendar.event_title_required')) pageData.value.submitChecked = true
toast.warning(t('calendar.event_title_required'))
return
}
pageData.value.submitChecked = false
if (!eventData.value.startDate || !eventData.value.endDate) {
toast.warning(t('schedule.date_required'))
return return
} }
pageData.value.submitChecked = true const selectedColor = colorOptions.value.find(c => c.value === eventData.value.color)
const scheduleType = selectedColor ? selectedColor.type : "work"
try { try {
let result let result
if (eventData.value.isEditing) { if (eventData.value.isEditing) {
result = await calendarApi.updateEvent({ result = await calendarApi.updateEvent({
id: eventData.value.id, id: eventData.value.id,
title: eventData.value.title, title: eventData.value.title.trim(),
start: eventData.value.startDate, start: toDatetime(eventData.value.startDate),
end: eventData.value.endDate, end: toDatetime(
color: eventData.value.color, eventData.value.startDate === eventData.value.endDate
remark: eventData.value.remark ? eventData.value.endDate
: DateUtils.toRealEnd(eventData.value.endDate),
),
schedule_type: scheduleType,
}) })
} else { } else {
result = await calendarApi.addEvent({ result = await calendarApi.addEvent({
calendar_id: calendarId.value, calendar_id: calendarId.value,
title: eventData.value.title, title: eventData.value.title.trim(),
start: eventData.value.startDate, start: toDatetime(eventData.value.startDate),
end: eventData.value.endDate, end: toDatetime(
color: eventData.value.color, eventData.value.startDate === eventData.value.endDate
remark: eventData.value.remark ? eventData.value.endDate
: DateUtils.toRealEnd(eventData.value.endDate),
),
schedule_type: scheduleType,
}) })
} }
if (result.errCode === 0) { if (result.errCode === 0) {
toast.success(t('calendar.event_save_success')) toast.success(t('calendar.event_save_success'))
showModal.value = false closeEventModal()
getEvents() getEvents()
} else { } else {
toast.error(t('message.server_error')) toast.error(t('message.server_error'))
} }
} catch { } catch {
// 拦截器已处理 // 拦截器已处理
} finally {
pageData.value.submitChecked = false
} }
} }
async function deleteEvent() { async function deleteEvent() {
if (!confirm(t('calendar.confirm_delete_event'))) { if (!confirm(t('calendar.confirm_delete_event'))) return
return
}
try { try {
const result = await calendarApi.deleteEvent(eventData.value.id) const result = await calendarApi.deleteEvent(eventData.value.id)
if (result.errCode === 0) { if (result.errCode === 0) {
toast.success(t('calendar.event_delete_success')) toast.success(t('calendar.event_delete_success'))
showModal.value = false closeEventModal()
getEvents() getEvents()
} else { } else {
toast.error(t('message.server_error')) toast.error(t('message.server_error'))
@@ -152,31 +189,32 @@ async function deleteEvent() {
async function getEvents() { async function getEvents() {
if (!calendarRef.value) return if (!calendarRef.value) return
const calendarApi2 = calendarRef.value.getApi() const calApi = calendarRef.value.getApi()
const view = calendarApi2.view const start = DateUtils.dateToStr(calendarNowShow.value.start)
const start = DateUtils.formatDate(view.activeStart) const end = DateUtils.toRealEnd(calendarNowShow.value.end)
const end = DateUtils.formatDate(view.activeEnd)
try { try {
const { errCode, data } = await calendarApi.getEvents({ const { errCode, data } = await calendarApi.getEvents({
calendar_id: calendarId.value, calendar_id: calendarId.value,
start: start, start: start,
end: end end: end,
}) })
if (errCode === 0) { if (errCode === 0) {
const events = (data.list || []).map(event => ({ calendarOptions.value.events = []
id: event.ID, ;(data.list || []).forEach(item => {
title: event.Title, calendarOptions.value.events.push({
start: event.StartDate, id: item.ID,
end: event.EndDate, title: item.Title,
backgroundColor: event.BgColor, start: item.StartDate,
borderColor: event.BgColor, end: item.StartDate === item.EndDate
extendedProps: { ? item.EndDate
remark: event.Remark : DateUtils.toCalendarEnd(item.EndDate),
} backgroundColor: item.BgColor,
})) borderColor: item.ID === pageData.value.seleEventID ? "#000000" : "#F7F7F7",
calendarOptions.value.events = events allDay: true,
})
})
} }
} catch { } catch {
// 拦截器已处理 // 拦截器已处理
@@ -203,18 +241,29 @@ async function fetchCalendarInfo() {
} }
} }
function unseleEvent(eventID) { // ─── 滚动标题工具函数 ───────────────────────────────────────────────────────
const target = calendarOptions.value.events.find(item => item.id === eventID) function applyScrollToTitle(titleEl) {
if (target) { titleEl.removeAttribute("data-truncated")
target.borderColor = target.backgroundColor titleEl.style.removeProperty("--scroll-distance")
const overflow = titleEl.scrollWidth - titleEl.clientWidth
if (overflow > 0) {
titleEl.style.setProperty("--scroll-distance", `-${overflow}px`)
titleEl.setAttribute("data-truncated", "true")
} }
} }
function unseleEventAll() { function recalcScrollTitles() {
unseleEvent(pageData.value.seleEventID) nextTick(() => {
pageData.value.seleEventID = 0 requestAnimationFrame(() => {
const calendarEl = calendarRef.value?.$el
if (!calendarEl) return
calendarEl.querySelectorAll(".fc-event-title").forEach(applyScrollToTitle)
})
})
} }
// ─────────────────────────────────────────────────────────────────────────────
const calendarOptions = ref({ const calendarOptions = ref({
height: "100%", height: "100%",
contentHeight: "auto", contentHeight: "auto",
@@ -230,52 +279,55 @@ const calendarOptions = ref({
expandRows: true, expandRows: true,
stickyHeaderDates: true, stickyHeaderDates: true,
dayCellDidMount(info) {
if (info.date.getDay() === 0 || info.date.getDay() === 6) {
info.el.style.backgroundColor = "#f5f5f5"
}
info.el.style.border = "1px solid #e5e7eb"
},
headerToolbar: { headerToolbar: {
left: "prevYear,prev,today,next,nextYear", left: "prevYear,prev,today,next,nextYear",
center: "title", center: "title",
right: "" right: "",
}, },
customButtons: { customButtons: {
prevYear: { prevYear: {
text: t("schedule.previous_year"), text: t("schedule.previous_year"),
click() { click() { calendarRef.value.getApi().prevYear(); getEvents() },
calendarRef.value.getApi().prevYear()
getEvents()
}
}, },
nextYear: { nextYear: {
text: t("schedule.next_year"), text: t("schedule.next_year"),
click() { click() { calendarRef.value.getApi().nextYear(); getEvents() },
calendarRef.value.getApi().nextYear()
getEvents()
}
}, },
prev: { prev: {
text: t("schedule.previous_month"), text: t("schedule.previous_month"),
click() { click() { calendarRef.value.getApi().prev(); getEvents() },
calendarRef.value.getApi().prev()
getEvents()
}
}, },
next: { next: {
text: t("schedule.next_month"), text: t("schedule.next_month"),
click() { click() { calendarRef.value.getApi().next(); getEvents() },
calendarRef.value.getApi().next()
getEvents()
}
}, },
today: { today: {
text: t("schedule.today"), text: t("schedule.today"),
click() { click() { calendarRef.value.getApi().today(); getEvents() },
calendarRef.value.getApi().today() },
getEvents() week: {
} text: t("schedule.week"),
} click() { calendarRef.value.getApi().changeView("timeGridWeek") },
},
}, },
events: [], events: [],
eventDidMount(info) {
const titleEl = info.el.querySelector(".fc-event-title")
if (titleEl) {
requestAnimationFrame(() => applyScrollToTitle(titleEl))
}
},
datesSet(info) { datesSet(info) {
calendarNowShow.value = info calendarNowShow.value = info
getEvents() getEvents()
@@ -284,7 +336,6 @@ const calendarOptions = ref({
dateClick(info) { dateClick(info) {
const nowTime = new Date().getTime() const nowTime = new Date().getTime()
const timeDifference = nowTime - pageData.value.lastClickTime const timeDifference = nowTime - pageData.value.lastClickTime
unseleEventAll() unseleEventAll()
if (info.dateStr === pageData.value.lastClickTimeStr) { if (info.dateStr === pageData.value.lastClickTimeStr) {
@@ -314,179 +365,278 @@ const calendarOptions = ref({
eventClick(info) { eventClick(info) {
const nowTime = new Date().getTime() const nowTime = new Date().getTime()
const timeDifference = nowTime - pageData.value.lastEventClickTime const timeDifference = nowTime - pageData.value.lastEventClickTime
const eventid = parseInt(info.event.id)
if (info.event.id === pageData.value.lastEventClickID) {
if (timeDifference < 400 && timeDifference > 0) {
editEvent({
id: info.event.id,
title: info.event.title,
start: info.event.startStr?.split('T')[0] || info.event.start?.toISOString().split('T')[0],
end: info.event.endStr?.split('T')[0] || info.event.end?.toISOString().split('T')[0],
backgroundColor: info.event.backgroundColor
})
}
}
pageData.value.lastEventClickID = info.event.id
pageData.value.lastEventClickTime = nowTime
// 选中效果
unseleEventAll() unseleEventAll()
const target = calendarOptions.value.events.find(item => String(item.id) === String(info.event.id)) const target = calendarOptions.value.events.find(item => String(item.id) === String(info.event.id))
if (target) { if (target) {
target.borderColor = "#000000" target.borderColor = "#000000"
pageData.value.seleEventID = target.id pageData.value.seleEventID = target.id
} }
}
if (eventid === pageData.value.lastEventClickID) {
if (timeDifference < 400 && timeDifference > 0) {
editEvent(info)
unseleEventAll()
}
}
pageData.value.lastEventClickID = eventid
pageData.value.lastEventClickTime = nowTime
},
eventDrop(info) {
// 拖拽后直接更新
const selectedColor = colorOptions.value.find(c => c.value === info.event.backgroundColor)
const scheduleType = selectedColor ? selectedColor.type : "work"
const startStr = info.event.startStr
const endStr = info.event.end ? info.event.endStr : startStr
calendarApi.updateEvent({
id: parseInt(info.event.id),
title: info.event.title,
start: toDatetime(startStr),
end: toDatetime(startStr === endStr ? endStr : DateUtils.toRealEnd(endStr)),
schedule_type: scheduleType,
}).then(r => {
if (r.errCode !== 0) toast.error(t('message.server_error'))
else getEvents()
})
},
}) })
// 监听语言变化
watch(locale, () => {
calendarOptions.value.locale = locale.value
calendarOptions.value.customButtons.prevYear.text = t("schedule.previous_year")
calendarOptions.value.customButtons.nextYear.text = t("schedule.next_year")
calendarOptions.value.customButtons.prev.text = t("schedule.previous_month")
calendarOptions.value.customButtons.next.text = t("schedule.next_month")
calendarOptions.value.customButtons.today.text = t("schedule.today")
calendarOptions.value.customButtons.week.text = t("schedule.week")
colorOptions.value = [
{ value: "#3788d9", label: t("schedule.work"), name: t("schedule.work"), type: "work" },
{ value: "#06d6a0", label: t("schedule.duty"), name: t("schedule.duty"), type: "duty" },
{ value: "#ff595e", label: t("schedule.exam"), name: t("schedule.exam"), type: "exam" },
{ value: "#ffca3a", label: t("schedule.standby"), name: t("schedule.standby"), type: "standby" },
{ value: "#D16C13", label: t("schedule.personal_holiday"), name: t("schedule.personal_holiday"), type: "personal_holiday" },
{ value: "#D10D21", label: t("schedule.public_holiday"), name: t("schedule.public_holiday"), type: "public_holiday" },
]
})
let resizeObserver = null
onMounted(() => { onMounted(() => {
fetchCalendarInfo() fetchCalendarInfo()
// 监听日历容器宽度变化
let resizeTimer = null
resizeObserver = new ResizeObserver(() => {
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => recalcScrollTitles(), 150)
})
if (calendarRef.value?.$el) {
resizeObserver.observe(calendarRef.value.$el)
}
onBeforeUnmount(() => {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
clearTimeout(resizeTimer)
})
}) })
</script> </script>
<template> <template>
<div class="flex flex-col gap-6 px-6 py-6"> <div class="flex w-full flex-col relative">
<div class="flex items-center justify-between"> <!-- 事件编辑模态框 -->
<div class="flex items-center gap-3">
<button
@click="router.push('/calendars')"
class="rounded-lg p-2 text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dk-muted"
>
</button>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ calendarInfo.Name || t('calendar.loading') }}
</h3>
<p v-if="calendarInfo.Description" class="text-sm text-gray-500 dark:text-gray-400">
{{ calendarInfo.Description }}
</p>
</div>
</div>
<div
v-if="userStore.isLoggedIn"
class="flex items-center gap-2"
>
<button
@click="openEventModal()"
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
<IconPlus :size="16" />
{{ t('calendar.add_event') }}
</button>
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
<FullCalendar
ref="calendarRef"
:options="calendarOptions"
/>
</div>
<!-- Event Modal -->
<div <div
v-if="showModal" v-if="showModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" class="fixed inset-0 z-50 flex items-center justify-center bg-gray-800/20"
@click.self="showModal = false"
> >
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-dk-card"> <div
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white"> class="modal-content bg-white rounded-lg shadow-lg w-full max-w-2xl max-h-[95vh] flex flex-col"
{{ eventData.isEditing ? t('calendar.edit_event') : t('calendar.add_event') }} >
</h3> <!-- 模态框头部 -->
<div
<div class="mb-4"> class="modal-header border-b p-4 flex justify-between items-center flex-shrink-0"
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"> >
{{ t('calendar.event_title') }} * <h5 class="modal-title text-lg font-semibold">
</label> {{
<input userStore.isLoggedIn
v-model="eventData.title" ? eventData.isEditing
type="text" ? t("calendar.edit_event")
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white" : t("calendar.add_event")
:placeholder="t('calendar.event_title_placeholder')" : t("calendar.view_event")
/> }}
</div> </h5>
<div class="mb-4 grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('calendar.start_date') }} *
</label>
<input
v-model="eventData.startDate"
type="date"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('calendar.end_date') }} *
</label>
<input
v-model="eventData.endDate"
type="date"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white"
/>
</div>
</div>
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('calendar.color') }}
</label>
<div class="flex gap-2">
<button
v-for="color in colorOptions"
:key="color.value"
@click="eventData.color = color.value"
class="h-8 w-8 rounded-full transition-transform hover:scale-110"
:class="{ 'ring-2 ring-blue-500 ring-offset-2': eventData.color === color.value }"
:style="{ backgroundColor: color.value }"
:title="color.label"
></button>
</div>
</div>
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('calendar.remark') }}
</label>
<textarea
v-model="eventData.remark"
rows="3"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition-colors focus:border-blue-500 dark:border-dk-muted dark:bg-dk-base dark:text-white"
:placeholder="t('calendar.remark_placeholder')"
></textarea>
</div>
<div class="flex justify-between">
<button <button
v-if="eventData.isEditing" @click="closeEventModal"
@click="deleteEvent" class="btn-close text-gray-500 hover:text-gray-700"
class="inline-flex items-center gap-1 rounded-lg bg-red-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700"
> >
<IconTrash :size="16" /> <svg
{{ t('delete') }} xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-x"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button> </button>
<div v-else></div> </div>
<!-- 主体区域 -->
<div class="modal-body p-4 flex-1 overflow-y-auto">
<!-- 日期选择区域 -->
<DatatimePickerForFullCalendar
v-model:startDate="eventData.startDate"
v-model:endDate="eventData.endDate"
:color="eventData.color"
:title="eventData.title"
:isEditable="eventData.isEditable"
/>
<!-- 内容输入区域 -->
<div class="mb-4">
<div class="uni-easyinput input relative">
<div
class="uni-easyinput__content is-input-border border border-gray-300 rounded-md bg-white relative"
:class="{
'border-gray-300': eventData.title || !pageData.submitChecked,
'border-red-500': !eventData.title && pageData.submitChecked,
}"
>
<input
v-model="eventData.title"
type="text"
maxlength="140"
class="uni-easyinput__content-input w-full px-3 py-2 outline-none"
:placeholder="t('calendar.event_title_placeholder')"
@keyup.enter="saveEvent"
:disabled="!eventData.isEditable"
/>
</div>
</div>
</div>
<!-- 颜色选择区域 -->
<div class="mb-4">
<div class="color_box grid grid-cols-3 gap-2">
<div
v-for="color in colorOptions"
:key="color.value"
class="color_box_item"
>
<label
class="uni-label-pointer form-colorinput flex items-center gap-2 cursor-pointer"
@click="selectColor(color.value)"
>
<div class="uni-radio-wrapper">
<div
class="uni-radio-input flex items-center justify-center w-6 h-6 rounded-full transition-all"
:style="{
backgroundColor: color.value,
borderColor: color.value,
}"
>
<svg
v-if="eventData.color === color.value"
width="18"
height="18"
viewBox="0 0 32 32"
>
<path
d="M1.952 18.080q-0.32-0.352-0.416-0.88t0.128-0.976l0.16-0.352q0.224-0.416 0.64-0.528t0.8 0.176l6.496 4.704q0.384 0.288 0.912 0.272t0.88-0.336l17.312-14.272q0.352-0.288 0.848-0.256t0.848 0.352l-0.416-0.416q0.32 0.352 0.32 0.816t-0.32 0.816l-18.656 18.912q-0.32 0.352-0.8 0.352t-0.8-0.32l-7.936-8.064z"
fill="#ffffff"
></path>
</svg>
</div>
</div>
<span class="text-gray-700">{{ color.label }}</span>
</label>
</div>
</div>
</div>
</div>
<!-- 底部固定 -->
<div
v-if="userStore.isLoggedIn"
class="modal-footer border-t p-4 flex justify-end items-center flex-shrink-0"
>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
@click="showModal = false" v-if="eventData.isEditing"
class="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:text-gray-300 dark:hover:bg-dk-muted" @click="deleteEvent"
class="btn px-4 py-2 text-white bg-red-500 hover:bg-red-600 rounded-md disabled:bg-gray-400 disabled:cursor-not-allowed"
:disabled="!eventData.isEditable"
> >
{{ t('cancel') }} {{ t('delete') }}
</button> </button>
<button <button
v-if="!eventData.isEditing"
@click="saveEvent" @click="saveEvent"
:disabled="pageData.submitChecked" class="btn btn-primary px-4 py-2 bg-cyan-600 text-white hover:bg-cyan-700 rounded-md disabled:bg-gray-400 disabled:cursor-not-allowed"
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50" :disabled="!eventData.isEditable"
> >
{{ t('save') }} {{ t('calendar.add_event') }}
</button>
<button
v-if="eventData.isEditing"
@click="saveEvent"
class="btn btn-primary px-4 py-2 bg-teal-600 text-white hover:bg-teal-700 rounded-md disabled:bg-gray-400 disabled:cursor-not-allowed"
:disabled="!eventData.isEditable"
>
{{ t('calendar.edit_event') }}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 日历主体区域 -->
<div
class="flex-1 rounded-lg border border-gray-200 bg-white p-0.5 shadow dark:border-dk-muted dark:bg-dk-card"
>
<div class="h-full w-full overflow-hidden rounded-md">
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div>
</div>
</div> </div>
</template> </template>
<style scoped>
/* 父容器作为裁剪视口 */
:deep(.fc-daygrid-event .fc-event-title-container) {
overflow: hidden !important;
}
/* 默认状态:单行截断省略 */
:deep(.fc-daygrid-event .fc-event-title) {
white-space: nowrap !important;
overflow: visible !important;
text-overflow: ellipsis !important;
display: block !important;
will-change: transform;
}
/* 需要滚动的标题 */
:deep(.fc-daygrid-event .fc-event-title[data-truncated="true"]) {
text-overflow: clip !important;
display: inline-block !important;
animation: marquee-bounce 6s ease-in-out infinite !important;
}
@keyframes marquee-bounce {
0%, 20% { transform: translateX(0); }
60% { transform: translateX(var(--scroll-distance, 0px)); }
80% { transform: translateX(var(--scroll-distance, 0px)); }
100% { transform: translateX(0); }
}
</style>
@@ -188,14 +188,14 @@ onMounted(fetchCalendars)
<div class="flex items-center gap-1" @click.stop> <div class="flex items-center gap-1" @click.stop>
<button <button
v-if="calendar.UserID === userStore.userInfo.ID" v-if="calendar.UserID === userStore.userInfo?.ID"
@click="openEditModal(calendar)" @click="openEditModal(calendar)"
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dk-muted" class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dk-muted"
> >
<IconEdit :size="16" /> <IconEdit :size="16" />
</button> </button>
<button <button
v-if="calendar.UserID === userStore.userInfo.ID" v-if="calendar.UserID === userStore.userInfo?.ID"
@click="deleteCalendar(calendar)" @click="deleteCalendar(calendar)"
class="rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-dk-muted" class="rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-dk-muted"
> >