Signed-off-by: 吴文峰 <kevin@lmve.net>

This commit is contained in:
2026-04-30 21:44:22 +08:00
parent 2b3ee849f1
commit cb363c93a0
10 changed files with 1434 additions and 2 deletions
+1
View File
@@ -81,6 +81,7 @@ func main() {
routers.ApiWorkOrderInit()
routers.ApiWarehouseInit()
routers.ApiCustomerInit()
routers.ApiCalendarInit()
routers.BindsInit() //最后初始化绑定数据表
+1
View File
@@ -45,6 +45,7 @@ func ApiRoot(r *gin.RouterGroup) {
ApiWarehouse(r.Group("/warehouse"))
ApiSysAdmin(r.Group("/admin"))
ApiCustomer(r.Group("/customer"))
ApiCalendar(r.Group("/calendar"))
r.GET("/", func(ctx *gin.Context) {
ReturnJson(ctx, "apiOK", gin.H{
"isOpsApiRoot": true,
+434
View File
@@ -0,0 +1,434 @@
package routers
import (
"encoding/json"
"ops/models"
"time"
"github.com/gin-gonic/gin"
"github.com/mitchellh/mapstructure"
"gorm.io/gorm"
)
// TabCalendar 日历表
type TabCalendar struct {
ID uint `gorm:"primarykey"`
UserID uint `gorm:"not null;comment:创建人ID"`
Name string `gorm:"size:100;not null;comment:日历名称"`
Description string `gorm:"size:500;comment:日历描述"`
Color string `gorm:"size:50;default:#3788d9;comment:日历颜色"`
IsPublic bool `gorm:"default:false;comment:是否公开"`
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime;comment:创建时间"`
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime;comment:最后修改时间"`
DeletedAt gorm.DeletedAt `gorm:"index"`
}
// TabCalendarEvent 日历事件表
type TabCalendarEvent struct {
ID uint `gorm:"primarykey"`
CalendarID uint `gorm:"not null;index;comment:关联日历ID"`
UserID uint `gorm:"not null;comment:创建人ID"`
Title string `gorm:"size:200;not null;comment:事件标题"`
StartDate string `gorm:"size:10;not null;index;comment:开始日期 YYYY-MM-DD"`
EndDate string `gorm:"size:10;not null;index;comment:结束日期 YYYY-MM-DD"`
BgColor string `gorm:"size:50;default:#3788d9;comment:背景颜色"`
Remark string `gorm:"type:text;comment:备注"`
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime;comment:创建时间"`
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime;comment:最后修改时间"`
DeletedAt gorm.DeletedAt `gorm:"index"`
}
// TabCalendarLog 日历操作日志表
type TabCalendarLog struct {
ID uint `gorm:"primarykey"`
CalendarID uint `gorm:"not null;index;comment:关联日历ID"`
EventID uint `gorm:"not null;index;comment:关联事件ID(可选)"`
UserID uint `gorm:"not null;comment:操作人ID"`
ActionType string `gorm:"size:50;not null;comment:操作类型: create-创建 update-修改 delete-删除"`
OldContent string `gorm:"type:text;comment:修改前内容(JSON)"`
NewContent string `gorm:"type:text;comment:修改后内容(JSON)"`
IP string `gorm:"size:50;comment:操作IP"`
Remark string `gorm:"size:500;comment:备注/操作描述"`
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime;comment:操作时间"`
}
// 请求结构体
type fromCreateCalendar struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Color string `json:"color"`
IsPublic bool `json:"is_public"`
}
type fromUpdateCalendar struct {
ID uint `json:"id" binding:"required"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Color string `json:"color"`
IsPublic bool `json:"is_public"`
}
type fromDeleteCalendar struct {
ID uint `json:"id" binding:"required"`
}
type fromGetCalendarEvents struct {
CalendarID uint `json:"calendar_id" binding:"required"`
Start string `json:"start" binding:"required"`
End string `json:"end" binding:"required"`
}
type fromAddCalendarEvent struct {
CalendarID uint `json:"calendar_id" binding:"required"`
Title string `json:"title" binding:"required"`
Start string `json:"start" binding:"required"`
End string `json:"end" binding:"required"`
Color string `json:"color"`
Remark string `json:"remark"`
}
type fromUpdateCalendarEvent struct {
ID uint `json:"id" binding:"required"`
Title string `json:"title" binding:"required"`
Start string `json:"start" binding:"required"`
End string `json:"end" binding:"required"`
Color string `json:"color"`
Remark string `json:"remark"`
}
type fromDeleteCalendarEvent struct {
ID uint `json:"id" binding:"required"`
}
func ApiCalendarInit() {
// 初始化数据表
models.DB.AutoMigrate(&TabCalendar{})
models.DB.AutoMigrate(&TabCalendarEvent{})
models.DB.AutoMigrate(&TabCalendarLog{})
}
func ApiCalendar(r *gin.RouterGroup) {
// 创建日历
r.POST("/calendar/create", func(ctx *gin.Context) {
isAuth, user, data := AuthenticationAuthority(ctx)
if isAuth {
var from fromCreateCalendar
if err := mapstructure.Decode(data, &from); err == nil {
calendar := TabCalendar{
UserID: user.ID,
Name: from.Name,
Description: from.Description,
Color: from.Color,
IsPublic: from.IsPublic,
}
if calendar.Color == "" {
calendar.Color = "#3788d9"
}
if models.DB.Create(&calendar).Error == nil {
// 记录日志
newContent, _ := json.Marshal(calendar)
log := TabCalendarLog{
CalendarID: calendar.ID,
UserID: user.ID,
ActionType: "create",
NewContent: string(newContent),
IP: ctx.ClientIP(),
}
models.DB.Create(&log)
ReturnJson(ctx, "apiOK", gin.H{"id": calendar.ID})
} else {
ReturnJson(ctx, "apiErr", nil)
}
} else {
ReturnJson(ctx, "jsonErr", nil)
}
} else {
ReturnJson(ctx, "userCookieError", nil)
}
})
// 获取日历列表
r.POST("/calendar/list", func(ctx *gin.Context) {
isAuth, _, _ := AuthenticationAuthority(ctx)
if isAuth {
var calendars []TabCalendar
models.DB.Where("deleted_at IS NULL").Order("created_at DESC").Find(&calendars)
ReturnJson(ctx, "apiOK", gin.H{"list": calendars})
} else {
ReturnJson(ctx, "userCookieError", nil)
}
})
// 更新日历
r.POST("/calendar/update", func(ctx *gin.Context) {
isAuth, user, data := AuthenticationAuthority(ctx)
if isAuth {
var from fromUpdateCalendar
if err := mapstructure.Decode(data, &from); err == nil {
oldCalendar := TabCalendar{}
if models.DB.Where("id = ?", from.ID).First(&oldCalendar).Error == nil {
// 检查权限(只有创建人可以修改)
if oldCalendar.UserID != user.ID {
ReturnJson(ctx, "permission_denied", nil)
return
}
newCalendar := TabCalendar{
Name: from.Name,
Description: from.Description,
Color: from.Color,
IsPublic: from.IsPublic,
}
if newCalendar.Color == "" {
newCalendar.Color = "#3788d9"
}
if models.DB.Model(&oldCalendar).Updates(&newCalendar).Error == nil {
// 记录日志
newContent, _ := json.Marshal(newCalendar)
oldContent, _ := json.Marshal(oldCalendar)
log := TabCalendarLog{
CalendarID: oldCalendar.ID,
UserID: user.ID,
ActionType: "update",
OldContent: string(oldContent),
NewContent: string(newContent),
IP: ctx.ClientIP(),
}
models.DB.Create(&log)
ReturnJson(ctx, "apiOK", nil)
} else {
ReturnJson(ctx, "apiErr", nil)
}
} else {
ReturnJson(ctx, "calendar_not_find", nil)
}
} else {
ReturnJson(ctx, "jsonErr", nil)
}
} else {
ReturnJson(ctx, "userCookieError", nil)
}
})
// 删除日历
r.POST("/calendar/delete", func(ctx *gin.Context) {
isAuth, user, data := AuthenticationAuthority(ctx)
if isAuth {
var from fromDeleteCalendar
if err := mapstructure.Decode(data, &from); err == nil {
oldCalendar := TabCalendar{}
if models.DB.Where("id = ?", from.ID).First(&oldCalendar).Error == nil {
// 检查权限(只有创建人可以删除)
if oldCalendar.UserID != user.ID {
ReturnJson(ctx, "permission_denied", nil)
return
}
// 软删除日历
if models.DB.Delete(&oldCalendar).Error == nil {
// 记录日志
oldContent, _ := json.Marshal(oldCalendar)
log := TabCalendarLog{
CalendarID: oldCalendar.ID,
UserID: user.ID,
ActionType: "delete",
OldContent: string(oldContent),
IP: ctx.ClientIP(),
}
models.DB.Create(&log)
ReturnJson(ctx, "apiOK", nil)
} else {
ReturnJson(ctx, "apiErr", nil)
}
} else {
ReturnJson(ctx, "calendar_not_find", nil)
}
} else {
ReturnJson(ctx, "jsonErr", nil)
}
} else {
ReturnJson(ctx, "userCookieError", nil)
}
})
// 获取日历事件
r.POST("/calendar/events", func(ctx *gin.Context) {
isAuth, _, data := AuthenticationAuthority(ctx)
if isAuth {
var from fromGetCalendarEvents
if err := mapstructure.Decode(data, &from); err == nil {
var events []TabCalendarEvent
models.DB.Where("calendar_id = ? AND start_date <= ? AND end_date >= ? AND deleted_at IS NULL",
from.CalendarID, from.End, from.Start).Find(&events)
// 为事件添加编辑权限标识
var relist []map[string]interface{}
for _, event := range events {
data, _ := json.Marshal(event)
var temp map[string]interface{}
json.Unmarshal(data, &temp)
// 这里可以根据需要添加 edit 字段
relist = append(relist, temp)
}
ReturnJson(ctx, "apiOK", gin.H{"list": relist})
} else {
ReturnJson(ctx, "jsonErr", nil)
}
} else {
ReturnJson(ctx, "userCookieError", nil)
}
})
// 添加日历事件
r.POST("/calendar/addevent", func(ctx *gin.Context) {
isAuth, user, data := AuthenticationAuthority(ctx)
if isAuth {
var from fromAddCalendarEvent
if err := mapstructure.Decode(data, &from); err == nil {
// 检查日历是否存在
var calendar TabCalendar
if models.DB.Where("id = ? AND deleted_at IS NULL", from.CalendarID).First(&calendar).Error != nil {
ReturnJson(ctx, "calendar_not_find", nil)
return
}
event := TabCalendarEvent{
CalendarID: from.CalendarID,
UserID: user.ID,
Title: from.Title,
StartDate: from.Start,
EndDate: from.End,
BgColor: from.Color,
Remark: from.Remark,
}
if event.BgColor == "" {
event.BgColor = calendar.Color
}
if models.DB.Create(&event).Error == nil {
// 记录日志
newContent, _ := json.Marshal(event)
log := TabCalendarLog{
CalendarID: event.CalendarID,
EventID: event.ID,
UserID: user.ID,
ActionType: "create_event",
NewContent: string(newContent),
IP: ctx.ClientIP(),
}
models.DB.Create(&log)
ReturnJson(ctx, "apiOK", gin.H{"id": event.ID})
} else {
ReturnJson(ctx, "apiErr", nil)
}
} else {
ReturnJson(ctx, "jsonErr", nil)
}
} else {
ReturnJson(ctx, "userCookieError", nil)
}
})
// 更新日历事件
r.POST("/calendar/updateevent", func(ctx *gin.Context) {
isAuth, user, data := AuthenticationAuthority(ctx)
if isAuth {
var from fromUpdateCalendarEvent
if err := mapstructure.Decode(data, &from); err == nil {
oldEvent := TabCalendarEvent{}
if models.DB.Where("id = ?", from.ID).First(&oldEvent).Error == nil {
// 检查权限(只有创建人可以修改)
if oldEvent.UserID != user.ID {
ReturnJson(ctx, "permission_denied", nil)
return
}
newEvent := TabCalendarEvent{
Title: from.Title,
StartDate: from.Start,
EndDate: from.End,
BgColor: from.Color,
Remark: from.Remark,
}
if newEvent.BgColor == "" {
// 获取日历颜色
var calendar TabCalendar
models.DB.Where("id = ?", oldEvent.CalendarID).First(&calendar)
newEvent.BgColor = calendar.Color
}
if models.DB.Model(&oldEvent).Updates(&newEvent).Error == nil {
// 记录日志
newContent, _ := json.Marshal(newEvent)
oldContent, _ := json.Marshal(oldEvent)
log := TabCalendarLog{
CalendarID: oldEvent.CalendarID,
EventID: oldEvent.ID,
UserID: user.ID,
ActionType: "update_event",
OldContent: string(oldContent),
NewContent: string(newContent),
IP: ctx.ClientIP(),
}
models.DB.Create(&log)
ReturnJson(ctx, "apiOK", nil)
} else {
ReturnJson(ctx, "apiErr", nil)
}
} else {
ReturnJson(ctx, "event_not_find", nil)
}
} else {
ReturnJson(ctx, "jsonErr", nil)
}
} else {
ReturnJson(ctx, "userCookieError", nil)
}
})
// 删除日历事件
r.POST("/calendar/deleteevent", func(ctx *gin.Context) {
isAuth, user, data := AuthenticationAuthority(ctx)
if isAuth {
var from fromDeleteCalendarEvent
if err := mapstructure.Decode(data, &from); err == nil {
oldEvent := TabCalendarEvent{}
if models.DB.Where("id = ?", from.ID).First(&oldEvent).Error == nil {
// 检查权限(只有创建人可以删除)
if oldEvent.UserID != user.ID {
ReturnJson(ctx, "permission_denied", nil)
return
}
if models.DB.Delete(&oldEvent).Error == nil {
// 记录日志
oldContent, _ := json.Marshal(oldEvent)
log := TabCalendarLog{
CalendarID: oldEvent.CalendarID,
EventID: oldEvent.ID,
UserID: user.ID,
ActionType: "delete_event",
OldContent: string(oldContent),
IP: ctx.ClientIP(),
}
models.DB.Create(&log)
ReturnJson(ctx, "apiOK", nil)
} else {
ReturnJson(ctx, "apiErr", nil)
}
} else {
ReturnJson(ctx, "event_not_find", nil)
}
} else {
ReturnJson(ctx, "jsonErr", nil)
}
} else {
ReturnJson(ctx, "userCookieError", nil)
}
})
}
+43
View File
@@ -0,0 +1,43 @@
import { api } from './index'
export const calendarApi = {
// 创建日历
createCalendar(data) {
return api.post('/calendar/calendar/create', { data })
},
// 获取日历列表
getCalendars() {
return api.post('/calendar/calendar/list', { data: {} })
},
// 更新日历
updateCalendar(data) {
return api.post('/calendar/calendar/update', { data })
},
// 删除日历
deleteCalendar(id) {
return api.post('/calendar/calendar/delete', { data: { id } })
},
// 获取日历事件
getEvents(data) {
return api.post('/calendar/calendar/events', { data })
},
// 添加日历事件
addEvent(data) {
return api.post('/calendar/calendar/addevent', { data })
},
// 更新日历事件
updateEvent(data) {
return api.post('/calendar/calendar/updateevent', { data })
},
// 删除日历事件
deleteEvent(id) {
return api.post('/calendar/calendar/deleteevent', { data: { id } })
}
}
@@ -54,6 +54,7 @@ const normalClass = "rounded-md px-3 py-2 text-sm font-medium text-gray-600 tran
const navItems = computed(() => [
{ label: t("appname.home"), to: "/" },
{ label: t("appname.schedule"), to: "/schedule" },
{ label: t("appname.calendar"), to: "/calendars" },
{ label: t("appname.purchase"), to: "/purchase" },
{ label: t("appname.work_order"), to: "/work_order" },
{ label: t("appname.warehouse"), to: "/warehouse" },
+38 -1
View File
@@ -33,7 +33,8 @@
"purchase": "Purchase",
"warehouse": "Warehouse",
"warehouse_items": "Items Overview",
"work_order": "Work Order"
"work_order": "Work Order",
"calendar": "Calendar"
},
"tagadder": {
"not_fund_item": "No matching items found",
@@ -601,5 +602,41 @@
"created_by": "Created By",
"not_found": "Customer not found",
"related_customers": "Related Customers"
},
"calendar": {
"calendars": "Calendar List",
"calendar_detail": "Calendar Detail",
"create_calendar": "Create Calendar",
"edit_calendar": "Edit Calendar",
"delete_calendar": "Delete Calendar",
"calendar_not_found": "Calendar not found",
"no_calendars": "No calendars yet",
"name": "Name",
"name_required": "Calendar name is required",
"name_placeholder": "Enter calendar name",
"description": "Description",
"description_placeholder": "Enter calendar description",
"color": "Color",
"is_public": "Public Calendar",
"public": "Public",
"private": "Private",
"create_success": "Calendar created successfully",
"update_success": "Calendar updated successfully",
"delete_success": "Calendar deleted successfully",
"confirm_delete": "Are you sure you want to delete this calendar?",
"loading": "Loading...",
"add_event": "Add Event",
"edit_event": "Edit Event",
"delete_event": "Delete Event",
"event_title": "Event Title",
"event_title_required": "Event title is required",
"event_title_placeholder": "Enter event title",
"start_date": "Start Date",
"end_date": "End Date",
"remark": "Remark",
"remark_placeholder": "Enter remark",
"event_save_success": "Event saved successfully",
"event_delete_success": "Event deleted successfully",
"confirm_delete_event": "Are you sure you want to delete this event?"
}
}
+38 -1
View File
@@ -33,7 +33,8 @@
"purchase": "采购",
"warehouse": "仓库",
"warehouse_items": "物品总览",
"work_order": "工单"
"work_order": "工单",
"calendar": "日历"
},
"tagadder": {
"not_fund_item": "没有找到匹配项",
@@ -601,5 +602,41 @@
"created_by": "创建者",
"not_found": "客户不存在",
"related_customers": "关联客户"
},
"calendar": {
"calendars": "日历列表",
"calendar_detail": "日历详情",
"create_calendar": "新建日历",
"edit_calendar": "编辑日历",
"delete_calendar": "删除日历",
"calendar_not_found": "日历未找到",
"no_calendars": "暂无日历",
"name": "名称",
"name_required": "请输入日历名称",
"name_placeholder": "请输入日历名称",
"description": "描述",
"description_placeholder": "请输入日历描述",
"color": "颜色",
"is_public": "公开日历",
"public": "公开",
"private": "私有",
"create_success": "创建成功",
"update_success": "更新成功",
"delete_success": "删除成功",
"confirm_delete": "确定要删除此日历吗?",
"loading": "加载中...",
"add_event": "添加事件",
"edit_event": "编辑事件",
"delete_event": "删除事件",
"event_title": "事件标题",
"event_title_required": "请输入事件标题",
"event_title_placeholder": "请输入事件标题",
"start_date": "开始日期",
"end_date": "结束日期",
"remark": "备注",
"remark_placeholder": "请输入备注",
"event_save_success": "事件保存成功",
"event_delete_success": "事件删除成功",
"confirm_delete_event": "确定要删除此事件吗?"
}
}
+10
View File
@@ -129,6 +129,16 @@ const router = createRouter({
name: 'customer-edit',
component: () => import('@/views/customer/CustomerFormPage.vue'),
},
{
path: 'calendars',
name: 'calendars',
component: () => import('@/views/calendar/CalendarList.vue'),
},
{
path: 'calendar/:id',
name: 'calendar-detail',
component: () => import('@/views/calendar/CalendarDetail.vue'),
},
],
},
@@ -0,0 +1,492 @@
<script setup>
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from "vue"
import { useRoute, useRouter } from "vue-router"
import FullCalendar from "@fullcalendar/vue3"
import dayGridPlugin from "@fullcalendar/daygrid"
import timeGridPlugin from "@fullcalendar/timegrid"
import interactionPlugin from "@fullcalendar/interaction"
import listPlugin from "@fullcalendar/list"
import { useI18n } from "vue-i18n"
import { usePageTitle } from "@/composables/usePageTitle"
import { useToastStore } from "@/stores/toast"
import { useUserStore } from "@/stores/user"
import { calendarApi } from "@/api/calendar"
import { useDateUtils } from "@/composables/useDateUtils"
import { IconPlus, IconTrash, IconEdit, IconCalendar } from "@tabler/icons-vue"
const route = useRoute()
const router = useRouter()
const { t, locale } = useI18n()
const toast = useToastStore()
const userStore = useUserStore()
const DateUtils = useDateUtils()
const calendarId = ref(parseInt(route.params.id))
const calendarInfo = ref({})
const loading = ref(false)
usePageTitle('appname.calendar_detail')
const calendarRef = ref(null)
const calendarNowShow = ref()
const showModal = ref(false)
const eventData = ref({
id: 0,
title: "",
startDate: "",
endDate: "",
color: "#3788d9",
remark: "",
isEditing: false,
isEditable: false
})
const colorOptions = ref([
{ value: "#3788d9", label: t("schedule.work") },
{ value: "#06d6a0", label: t("schedule.duty") },
{ value: "#ff595e", label: t("schedule.exam") },
{ value: "#ffca3a", label: t("schedule.standby") },
])
const pageData = ref({
seleEventID: 0,
lastClickTime: 0,
lastClickTimeStr: "",
lastEventClickTime: 0,
lastEventClickID: 0,
submitChecked: false
})
function openEventModal(startDate, endDate) {
eventData.value = {
id: 0,
title: "",
startDate: startDate || "",
endDate: endDate || "",
color: calendarInfo.value.Color || "#3788d9",
remark: "",
isEditing: false,
isEditable: true
}
showModal.value = true
}
function editEvent(event) {
eventData.value = {
id: event.id,
title: event.title,
startDate: event.start,
endDate: event.end || event.start,
color: event.backgroundColor,
remark: event.extendedProps?.remark || "",
isEditing: true,
isEditable: true
}
showModal.value = true
}
async function saveEvent() {
if (!eventData.value.title.trim()) {
toast.error(t('calendar.event_title_required'))
return
}
pageData.value.submitChecked = true
try {
let result
if (eventData.value.isEditing) {
result = await calendarApi.updateEvent({
id: eventData.value.id,
title: eventData.value.title,
start: eventData.value.startDate,
end: eventData.value.endDate,
color: eventData.value.color,
remark: eventData.value.remark
})
} else {
result = await calendarApi.addEvent({
calendar_id: calendarId.value,
title: eventData.value.title,
start: eventData.value.startDate,
end: eventData.value.endDate,
color: eventData.value.color,
remark: eventData.value.remark
})
}
if (result.errCode === 0) {
toast.success(t('calendar.event_save_success'))
showModal.value = false
getEvents()
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
pageData.value.submitChecked = false
}
}
async function deleteEvent() {
if (!confirm(t('calendar.confirm_delete_event'))) {
return
}
try {
const result = await calendarApi.deleteEvent(eventData.value.id)
if (result.errCode === 0) {
toast.success(t('calendar.event_delete_success'))
showModal.value = false
getEvents()
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
}
}
async function getEvents() {
if (!calendarRef.value) return
const calendarApi2 = calendarRef.value.getApi()
const view = calendarApi2.view
const start = DateUtils.formatDate(view.activeStart)
const end = DateUtils.formatDate(view.activeEnd)
try {
const { errCode, data } = await calendarApi.getEvents({
calendar_id: calendarId.value,
start: start,
end: end
})
if (errCode === 0) {
const events = (data.list || []).map(event => ({
id: event.ID,
title: event.Title,
start: event.StartDate,
end: event.EndDate,
backgroundColor: event.BgColor,
borderColor: event.BgColor,
extendedProps: {
remark: event.Remark
}
}))
calendarOptions.value.events = events
}
} catch {
// 拦截器已处理
}
}
async function fetchCalendarInfo() {
loading.value = true
try {
const { errCode, data } = await calendarApi.getCalendars()
if (errCode === 0) {
const calendar = (data.list || []).find(c => c.ID === calendarId.value)
if (calendar) {
calendarInfo.value = calendar
} else {
toast.error(t('calendar.calendar_not_found'))
router.push('/calendars')
}
}
} catch {
// 拦截器已处理
} finally {
loading.value = false
}
}
function unseleEvent(eventID) {
const target = calendarOptions.value.events.find(item => item.id === eventID)
if (target) {
target.borderColor = target.backgroundColor
}
}
function unseleEventAll() {
unseleEvent(pageData.value.seleEventID)
pageData.value.seleEventID = 0
}
const calendarOptions = ref({
height: "100%",
contentHeight: "auto",
locale: locale.value,
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin],
nowIndicator: true,
weekends: true,
initialView: "dayGridMonth",
selectable: true,
editable: true,
dayMaxEvents: true,
firstDay: 1,
expandRows: true,
stickyHeaderDates: true,
headerToolbar: {
left: "prevYear,prev,today,next,nextYear",
center: "title",
right: ""
},
customButtons: {
prevYear: {
text: t("schedule.previous_year"),
click() {
calendarRef.value.getApi().prevYear()
getEvents()
}
},
nextYear: {
text: t("schedule.next_year"),
click() {
calendarRef.value.getApi().nextYear()
getEvents()
}
},
prev: {
text: t("schedule.previous_month"),
click() {
calendarRef.value.getApi().prev()
getEvents()
}
},
next: {
text: t("schedule.next_month"),
click() {
calendarRef.value.getApi().next()
getEvents()
}
},
today: {
text: t("schedule.today"),
click() {
calendarRef.value.getApi().today()
getEvents()
}
}
},
events: [],
datesSet(info) {
calendarNowShow.value = info
getEvents()
},
dateClick(info) {
const nowTime = new Date().getTime()
const timeDifference = nowTime - pageData.value.lastClickTime
unseleEventAll()
if (info.dateStr === pageData.value.lastClickTimeStr) {
if (timeDifference < 400 && timeDifference > 0) {
if (userStore.isLoggedIn) {
openEventModal(info.dateStr, info.dateStr)
} else {
toast.warning(t("message.login_to_your_account"))
router.replace("/login?redirect=/calendar/" + calendarId.value)
}
}
}
pageData.value.lastClickTimeStr = info.dateStr
pageData.value.lastClickTime = nowTime
},
select(info) {
if (info.end - info.start > 86400000) {
if (userStore.isLoggedIn) {
openEventModal(info.startStr, info.endStr)
} else {
toast.warning(t("message.login_to_your_account"))
}
}
},
eventClick(info) {
const nowTime = new Date().getTime()
const timeDifference = nowTime - pageData.value.lastEventClickTime
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()
const target = calendarOptions.value.events.find(item => String(item.id) === String(info.event.id))
if (target) {
target.borderColor = "#000000"
pageData.value.seleEventID = target.id
}
}
})
onMounted(() => {
fetchCalendarInfo()
})
</script>
<template>
<div class="flex flex-col gap-6 px-6 py-6">
<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
v-if="showModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="showModal = false"
>
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-dk-card">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
{{ eventData.isEditing ? t('calendar.edit_event') : t('calendar.add_event') }}
</h3>
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('calendar.event_title') }} *
</label>
<input
v-model="eventData.title"
type="text"
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.event_title_placeholder')"
/>
</div>
<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
v-if="eventData.isEditing"
@click="deleteEvent"
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" />
{{ t('delete') }}
</button>
<div v-else></div>
<div class="flex gap-2">
<button
@click="showModal = false"
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"
>
{{ t('cancel') }}
</button>
<button
@click="saveEvent"
:disabled="pageData.submitChecked"
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"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,376 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toast'
import { usePageTitle } from '@/composables/usePageTitle'
import { calendarApi } from '@/api/calendar'
import { useUserStore } from '@/stores/user'
import { IconPlus, IconCalendar, IconTrash, IconEdit } from '@tabler/icons-vue'
usePageTitle('appname.calendar')
const { t } = useI18n()
const router = useRouter()
const toast = useToastStore()
const userStore = useUserStore()
const calendars = ref([])
const loading = ref(false)
const showCreateModal = ref(false)
const showEditModal = ref(false)
const editingCalendar = ref(null)
const form = ref({
name: '',
description: '',
color: '#3788d9',
is_public: false
})
const colorOptions = [
'#3788d9',
'#06d6a0',
'#ff595e',
'#ffca3a',
'#8b5cf6',
'#ec4899'
]
async function fetchCalendars() {
loading.value = true
try {
const { errCode, data } = await calendarApi.getCalendars()
if (errCode === 0) {
calendars.value = data.list || []
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
loading.value = false
}
}
function openCreateModal() {
form.value = { name: '', description: '', color: '#3788d9', is_public: false }
showCreateModal.value = true
}
function openEditModal(calendar) {
editingCalendar.value = calendar
form.value = {
name: calendar.Name,
description: calendar.Description || '',
color: calendar.Color,
is_public: calendar.IsPublic
}
showEditModal.value = true
}
async function createCalendar() {
if (!form.value.name.trim()) {
toast.error(t('calendar.name_required'))
return
}
try {
const { errCode, data } = await calendarApi.createCalendar(form.value)
if (errCode === 0) {
toast.success(t('calendar.create_success'))
showCreateModal.value = false
fetchCalendars()
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
}
}
async function updateCalendar() {
if (!form.value.name.trim()) {
toast.error(t('calendar.name_required'))
return
}
try {
const { errCode } = await calendarApi.updateCalendar({
id: editingCalendar.value.ID,
...form.value
})
if (errCode === 0) {
toast.success(t('calendar.update_success'))
showEditModal.value = false
fetchCalendars()
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
}
}
async function deleteCalendar(calendar) {
if (!confirm(t('calendar.confirm_delete'))) {
return
}
try {
const { errCode } = await calendarApi.deleteCalendar(calendar.ID)
if (errCode === 0) {
toast.success(t('calendar.delete_success'))
fetchCalendars()
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
}
}
function goToCalendar(id) {
router.push(`/calendar/${id}`)
}
onMounted(fetchCalendars)
</script>
<template>
<div class="mx-auto max-w-6xl px-6 py-6">
<div class="flex flex-col gap-6 rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dk-muted">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('calendar.calendars') }}</h3>
<button
@click="openCreateModal"
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.create_calendar') }}
</button>
</div>
<!-- Calendar List -->
<div class="px-6 py-3">
<div v-if="loading" class="py-8 text-center text-gray-400">
<svg class="mx-auto mb-2 h-5 w-5 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading...
</div>
<div v-else-if="calendars.length === 0" class="py-8 text-center text-gray-400">
{{ t('calendar.no_calendars') }}
</div>
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="calendar in calendars"
:key="calendar.ID"
class="cursor-pointer rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:hover:bg-dk-base"
@click="goToCalendar(calendar.ID)"
>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div
class="h-4 w-4 rounded-full"
:style="{ backgroundColor: calendar.Color }"
></div>
<div>
<h4 class="font-medium text-gray-900 dark:text-white">{{ calendar.Name }}</h4>
<p v-if="calendar.Description" class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ calendar.Description }}
</p>
</div>
</div>
<div class="flex items-center gap-1" @click.stop>
<button
v-if="calendar.UserID === userStore.userInfo.ID"
@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"
>
<IconEdit :size="16" />
</button>
<button
v-if="calendar.UserID === userStore.userInfo.ID"
@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"
>
<IconTrash :size="16" />
</button>
</div>
</div>
<div class="mt-3 flex items-center gap-2 text-xs text-gray-400">
<IconCalendar :size="14" />
<span>{{ calendar.IsPublic ? t('calendar.public') : t('calendar.private') }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create Calendar Modal -->
<div
v-if="showCreateModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="showCreateModal = false"
>
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-dk-card">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('calendar.create_calendar') }}
</h3>
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('calendar.name') }} *
</label>
<input
v-model="form.name"
type="text"
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.name_placeholder')"
/>
</div>
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('calendar.description') }}
</label>
<textarea
v-model="form.description"
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.description_placeholder')"
></textarea>
</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"
@click="form.color = color"
class="h-8 w-8 rounded-full transition-transform hover:scale-110"
:class="{ 'ring-2 ring-blue-500 ring-offset-2': form.color === color }"
:style="{ backgroundColor: color }"
></button>
</div>
</div>
<div class="mb-6 flex items-center gap-2">
<input
id="is_public"
v-model="form.is_public"
type="checkbox"
class="rounded border-gray-300"
/>
<label for="is_public" class="text-sm text-gray-700 dark:text-gray-300">
{{ t('calendar.is_public') }}
</label>
</div>
<div class="flex justify-end gap-2">
<button
@click="showCreateModal = false"
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"
>
{{ t('cancel') }}
</button>
<button
@click="createCalendar"
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
{{ t('create') }}
</button>
</div>
</div>
</div>
<!-- Edit Calendar Modal -->
<div
v-if="showEditModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="showEditModal = false"
>
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-dk-card">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('calendar.edit_calendar') }}
</h3>
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('calendar.name') }} *
</label>
<input
v-model="form.name"
type="text"
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.name_placeholder')"
/>
</div>
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('calendar.description') }}
</label>
<textarea
v-model="form.description"
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.description_placeholder')"
></textarea>
</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"
@click="form.color = color"
class="h-8 w-8 rounded-full transition-transform hover:scale-110"
:class="{ 'ring-2 ring-blue-500 ring-offset-2': form.color === color }"
:style="{ backgroundColor: color }"
></button>
</div>
</div>
<div class="mb-6 flex items-center gap-2">
<input
id="edit_is_public"
v-model="form.is_public"
type="checkbox"
class="rounded border-gray-300"
/>
<label for="edit_is_public" class="text-sm text-gray-700 dark:text-gray-300">
{{ t('calendar.is_public') }}
</label>
</div>
<div class="flex justify-end gap-2">
<button
@click="showEditModal = false"
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"
>
{{ t('cancel') }}
</button>
<button
@click="updateCalendar"
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
</template>