Merge branch '新增日历模块' into 'main'

新增日历模块

See merge request kevin/ops2!1
This commit is contained in:
2026-05-06 15:05:12 +00:00
23 changed files with 3127 additions and 36 deletions
+147
View File
@@ -0,0 +1,147 @@
# 2026-05-06 日志
## 日历事件日程类型功能
### 修改内容
- **后端** `routers/apiCalendar.go`:
- `TabCalendarEvent` 结构体新增 `ScheduleType string` 字段(默认值 work
- `addevent`/`updateevent` 接口解析并保存 `schedule_type` 参数
- **前端** `CalendarDetail.vue`:
- `eventData` 新增 `scheduleType` 字段
- `openEventModal`/`editEvent` 传递 `scheduleType`
- `selectColor` 函数联动更新 `scheduleType`
- `saveEvent`/`eventDrop` 提交 `schedule_type`
- `getEvents` 返回数据附加 `extendedProps.scheduleType`
- 模态框显示日程类型标签
- **i18n**:
- `zh-CN.json`: 新增 `event_type: "日程类型"`
- `en.json`: 新增 `event_type: "Event Type"`
### 日程类型选项
- work - 工作
- duty - 值班
- exam - 考试
- standby - 备用
- personal_holiday - 个人假期
- public_holiday - 公众假期
### 注意事项
- GORM AutoMigrate 会自动添加新字段
- 前端颜色选择与日程类型联动
## 修复:calendar/events jsonErr
**问题**`fromGetCalendarEvents``start/end``*time.Time` 类型,无法直接解析字符串格式的日期。
**修复**:改为直接用类型断言解析字符串,用 `time.Parse("2006-01-02", ...)` 解析。
## 优化:BgColor 弃用,前端根据 ScheduleType 渲染颜色
**前端**:添加 `getColorByScheduleType()` 函数,`getEvents` 中使用 scheduleType 映射颜色。
**后端**:只存储 ScheduleType,不处理颜色逻辑。颜色完全由前端 `colorOptions` 控制。
## CalendarDetail 滚动标题快照对比
**功能**:每次 getEvents 存快照,数据变化或宽度变化时重新计算标题滚动。
**实现**
- `pageData.lastEventsSnapshot`:存储上一次的 JSON 快照
- `getEvents()` 末尾对比快照,变化则 `setTimeout(recalcScrollTitles, 150)`
- `ResizeObserver` 监听日历容器宽度变化,防抖 150ms 后重算
- `applyScrollToTitle()`:清除旧状态 → 测量 overflow → 设置 `--scroll-distance``data-truncated` 属性
## CalendarList 编辑/删除按钮改用 canEdit
**改动**
- `CalendarList.vue`:编辑/删除按钮 `v-if` 条件从 `calendar.UserID === userStore.userInfo?.ID` 改为 `calendar.canEdit`
- 移除废弃的 `useUserStore` 导入
## CalendarList 删除改用 ConfirmDialog 组件
**改动**
- `CalendarList.vue` 导入并使用 `ConfirmDialog` 组件
- 新增 `showDeleteModal` + `deletingCalendar` 状态
- `deleteCalendar()` 改为打开确认弹窗,`confirmDelete()` 执行实际删除 API
- i18n 新增 `calendar.confirm_delete_message`:zh-CN「确定要删除日历「{name}」吗?此操作不可撤销。」,en 英文版
## 新增日历管理页面 /calendars/admin
**功能**:系统管理员查看所有日历列表,包含日程数量、创建者、创建时间。
**新增文件**
- `src/views/calendar/CalendarAdminList.vue` - 日历管理列表组件
**路由修改** `src/router/index.js`
- 新增 `/calendars/admin` 路由,指向 `CalendarAdminList.vue`
- 设置 `meta: { requireSysAdmin: true }` 要求管理员权限
**SysAdminView.vue**
- `tabs` 数组新增 `{ id: 'calendar', label: t('calendar.admin_title'), to: '/calendars/admin' }`
**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`
## 修复:粘贴多天日程时结束日期少1天
**问题**:Ctrl+V 粘贴大于1天的日程时,目标结束日期少1天。
**原因**`pasteToDate` 中用 `new Date(targetEndMs).toISOString().split('T')[0]` 计算目标结束日期,`toISOString()` 输出 UTC 时间,在 UTC+8 时区下日期会往前偏移1天。
**修复**`CalendarDetail.vue` `pasteToDate` 函数):
- 改用天数差 `Math.round((origEndDate - origStartDate) / 86400000)` 替代毫秒差
- 用本地日期方法 `targetEndDate.setDate(targetEndDate.getDate() + diffDays)` + 手动拼接 `YYYY-MM-DD`,替代 `toISOString().split('T')[0]`
## 修复:Ctrl+C 无法复制大于1天的日程
**问题**:Ctrl+C 复制多天日程后粘贴无效果。
**根因**`calendarOptions.value.events` 中多天事件的 `end``DateUtils.toCalendarEnd()` 返回的 **Date 对象**(非字符串)。Ctrl+C 直接 `selectedEvent.end || selectedEvent.start` 存入 clipboard,导致 `pasteToDate` 调用 `clipboard.value.end.split('T')[0]` 时 Date 对象没有 `split` 方法而报错。单天事件因走 `isSameDay` 分支不解析 `end`,所以不受影响。
**修复**`CalendarDetail.vue` `handleKeyDown` Ctrl+C 分支):
- 检测 `end` 是否为 Date 实例,如果是则用本地日期方法转为 `YYYY-MM-DD` 字符串再存入 clipboard
## 新增:编辑事件窗口显示创建者
**功能**:编辑事件模态框标题居中显示「xxx 创建」
**实现**(参考 ScheduleView.vue):
- 导入 `useUsersStore`,添加 `usersStore`
- `pageData.eventBindUserID`:数组,每项 `{ eventID, userID }`
- `getUserIdFromEventID(eventID)`:通过事件ID查创建者用户ID
- `getUsernameFromUserID(userID)`:调用 `usersStore.getUsernameFromUserID(userID)`(有缓存)
- `getEvents` 中遍历事件时 `pageData.value.eventBindUserID.push({ eventID: item.ID, userID: item.UserID })`
- 模态框标题新增 `<h5 v-if="eventData.isEditing && getUserIdFromEventID(eventData.id)">` 居中显示
- i18n`calendar.created_by` = "{name} 创建" / "Created by {name}"
+1
View File
@@ -81,6 +81,7 @@ func main() {
routers.ApiWorkOrderInit() routers.ApiWorkOrderInit()
routers.ApiWarehouseInit() routers.ApiWarehouseInit()
routers.ApiCustomerInit() routers.ApiCustomerInit()
routers.ApiCalendarInit()
routers.BindsInit() //最后初始化绑定数据表 routers.BindsInit() //最后初始化绑定数据表
+1
View File
@@ -45,6 +45,7 @@ func ApiRoot(r *gin.RouterGroup) {
ApiWarehouse(r.Group("/warehouse")) ApiWarehouse(r.Group("/warehouse"))
ApiSysAdmin(r.Group("/admin")) ApiSysAdmin(r.Group("/admin"))
ApiCustomer(r.Group("/customer")) ApiCustomer(r.Group("/customer"))
ApiCalendar(r.Group("/calendar"))
r.GET("/", func(ctx *gin.Context) { r.GET("/", func(ctx *gin.Context) {
ReturnJson(ctx, "apiOK", gin.H{ ReturnJson(ctx, "apiOK", gin.H{
"isOpsApiRoot": true, "isOpsApiRoot": true,
+677
View File
@@ -0,0 +1,677 @@
package routers
import (
"encoding/json"
"ops/models"
"slices"
"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 *time.Time `gorm:"size:10;not null;index;comment:开始日期 YYYY-MM-DD"`
EndDate *time.Time `gorm:"size:10;not null;index;comment:结束日期 YYYY-MM-DD"`
IsAllDay bool `gorm:"default:true;comment:是否全日事件"`
ScheduleType string `gorm:"size:50;default:work;comment:日程类型: work-工作 duty-值班 exam-考试 standby-待命 personal_holiday-调休 personal_holiday-公假"`
BgColor string `gorm:"size:50;default:#3788d9;comment:背景颜色"`
IsPublic bool `gorm:"default:false;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"`
Is_public 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"`
Is_public bool `json:"is_public"`
}
type fromDeleteCalendar struct {
ID uint `json:"id" binding:"required"`
}
type fromGetCalendarEvents struct {
CalendarID uint `json:"calendar_id" binding:"required"`
Start *time.Time `json:"start" binding:"required"`
End *time.Time `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"`
ScheduleType string `json:"schedule_type"`
Is_public bool `json:"is_public"`
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"`
ScheduleType string `json:"schedule_type"`
Is_public bool `json:"is_public"`
Remark string `json:"remark"`
}
type fromDeleteCalendarEvent struct {
ID uint `json:"id" binding:"required"`
}
type fromRestoreCalendar struct {
ID uint `json:"id" binding:"required"`
}
var (
calendarUserGroup TabUserGroups
calendarAdmins []uint
)
// CalendarUpdateAdminsCash
func CalendarUpdateAdminsCash() {
calendarAdmins = nil
calendarAdmins = append(calendarAdmins, 1) // id=1 系统管理员默认拥有所有权限
var binds []TabUserGroupBinds
models.DB.Where("group_id = ?", calendarUserGroup.ID).Find(&binds)
for _, item := range binds {
if !slices.Contains(calendarAdmins, item.UserID) {
calendarAdmins = append(calendarAdmins, item.UserID)
}
}
}
// canModifyCustomer 判断是否有权限修改/删除客户(创建者或管理员)
func canModifyCalendar(userID, creatorUserID uint) bool {
if slices.Contains(calendarAdmins, userID) {
return true
}
return userID == creatorUserID
}
func ApiCalendarInit() {
// 初始化数据表
models.DB.AutoMigrate(&TabCalendar{})
models.DB.AutoMigrate(&TabCalendarEvent{})
models.DB.AutoMigrate(&TabCalendarLog{})
// 自动创建 calendar_admin 用户组
models.DB.Where("name = ?", "calendar_admin").FirstOrCreate(&calendarUserGroup, TabUserGroups{
Name: "calendar_admin",
Type: "usergroup",
})
CalendarUpdateAdminsCash()
}
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.Is_public,
}
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, user, _ := AuthenticationAuthority(ctx)
var calendars []TabCalendar
models.DB.Where("deleted_at IS NULL").Order("created_at DESC").Find(&calendars)
type CalendarWithEdit struct {
TabCalendar
CanEdit bool `json:"canEdit"`
}
var result []CalendarWithEdit
for _, cal := range calendars {
// 私有日历:只有创建者可见
if !cal.IsPublic {
if !isAuth || cal.UserID != user.ID {
continue
}
result = append(result, CalendarWithEdit{
TabCalendar: cal,
CanEdit: true,
})
continue
}
// 公开日历
canEdit := false
if isAuth {
canEdit = canModifyCalendar(user.ID, cal.UserID)
}
result = append(result, CalendarWithEdit{
TabCalendar: cal,
CanEdit: canEdit,
})
}
ReturnJson(ctx, "apiOK", gin.H{"list": result})
})
// 更新日历
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.Is_public,
}
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/list_all", func(ctx *gin.Context) {
isAuth, user, _ := AuthenticationAuthority(ctx)
if !isAuth {
ReturnJson(ctx, "userCookieError", nil)
return
}
// 限制只有日历管理员可访问
if !slices.Contains(calendarAdmins, user.ID) {
ReturnJson(ctx, "permission_denied", nil)
return
}
// 使用 Unscoped 查询所有日历(包括软删除的)
var calendars []TabCalendar
models.DB.Unscoped().Order("created_at DESC").Find(&calendars)
// 一次性查询所有日历的事件数量(仅统计未删除的事件)
type calendarEventCount struct {
CalendarID uint `gorm:"column:calendar_id"`
Cnt int `gorm:"column:cnt"`
}
var rows []calendarEventCount
models.DB.Model(&TabCalendarEvent{}).
Select("calendar_id, COUNT(*) as cnt").
Where("deleted_at IS NULL").
Group("calendar_id").
Scan(&rows)
eventCountMap := make(map[uint]int)
for _, row := range rows {
eventCountMap[row.CalendarID] = row.Cnt
}
type CalendarWithEdit struct {
TabCalendar
CanEdit bool `json:"canEdit"`
EventCount int `json:"event_count"`
}
var result []CalendarWithEdit
for _, cal := range calendars {
result = append(result, CalendarWithEdit{
TabCalendar: cal,
CanEdit: true,
EventCount: eventCountMap[cal.ID],
})
}
ReturnJson(ctx, "apiOK", gin.H{"list": result})
})
// 恢复已删除的日历
r.POST("/calendar/restore", func(ctx *gin.Context) {
isAuth, user, data := AuthenticationAuthority(ctx)
if !isAuth {
ReturnJson(ctx, "userCookieError", nil)
return
}
// 限制只有日历管理员可操作
if !slices.Contains(calendarAdmins, user.ID) {
ReturnJson(ctx, "permission_denied", nil)
return
}
var from fromRestoreCalendar
if err := mapstructure.Decode(data, &from); err != nil {
ReturnJson(ctx, "jsonErr", nil)
return
}
// 使用 Unscoped 查询(包括软删除的)
var calendar TabCalendar
if models.DB.Unscoped().Where("id = ?", from.ID).First(&calendar).Error != nil {
ReturnJson(ctx, "calendar_not_find", nil)
return
}
// 恢复软删除(将 deleted_at 设为 NULL
if models.DB.Unscoped().Model(&calendar).Update("deleted_at", nil).Error != nil {
ReturnJson(ctx, "apiErr", nil)
return
}
// 记录日志
newContent, _ := json.Marshal(calendar)
log := TabCalendarLog{
CalendarID: calendar.ID,
UserID: user.ID,
ActionType: "restore",
NewContent: string(newContent),
IP: ctx.ClientIP(),
}
models.DB.Create(&log)
ReturnJson(ctx, "apiOK", 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) {
data, cookieval := SeparateData(ctx)
if data == nil {
ReturnJson(ctx, "jsonErr", nil)
return
}
calendarIDRaw, ok := data["calendar_id"].(float64)
if !ok || calendarIDRaw == 0 {
ReturnJson(ctx, "jsonErr", nil)
return
}
calendarID := uint(calendarIDRaw)
startStr, _ := data["start"].(string)
endStr, _ := data["end"].(string)
startDate, _ := time.Parse("2006-01-02", startStr)
endDate, _ := time.Parse("2006-01-02", endStr)
// 查询:当前日历的事件 + 所有公共日程
var events []TabCalendarEvent
models.DB.Where(
"(calendar_id = ? OR is_public = ?) AND start_date <= ? AND end_date >= ? AND deleted_at IS NULL",
calendarID, true, &endDate, &startDate,
).Find(&events)
// 判断是否已登录
var currentUserID uint
isLogin := false
if cookieval != "" {
user, err := AuthenticationAuthorityFromCookie(cookieval)
if err == nil {
isLogin = true
currentUserID = user.ID
}
}
// 查询日历创建者(用于判断权限)
var calendarCreatorID uint
var calendar TabCalendar
if models.DB.Where("id = ?", calendarID).First(&calendar).Error == nil {
calendarCreatorID = calendar.UserID
}
var relist []map[string]interface{}
for _, event := range events {
eventMap, _ := json.Marshal(event)
var item map[string]interface{}
json.Unmarshal(eventMap, &item)
// 可编辑条件:事件创建者 或 日历创建者 或 日历管理员
canEdit := false
if isLogin {
if event.UserID == currentUserID || calendarCreatorID == currentUserID || slices.Contains(calendarAdmins, currentUserID) {
canEdit = true
}
}
item["canEdit"] = canEdit
relist = append(relist, item)
}
//fmt.Println(calendarAdmins)
//fmt.Println(calendarUserGroup)
ReturnJson(ctx, "apiOK", gin.H{"list": relist})
})
// 添加日历事件
r.POST("/calendar/addevent", func(ctx *gin.Context) {
isAuth, user, data := AuthenticationAuthority(ctx)
if isAuth {
// 先检查必需字段
calendarIDRaw, ok := data["calendar_id"].(float64)
if !ok || calendarIDRaw == 0 {
ReturnJson(ctx, "jsonErr", nil)
return
}
calendarID := uint(calendarIDRaw)
// 检查日历是否存在
var calendar TabCalendar
if models.DB.Where("id = ? AND deleted_at IS NULL", calendarID).First(&calendar).Error != nil {
ReturnJson(ctx, "calendar_not_find", nil)
return
}
// 解析日期
startStr, _ := data["start"].(string)
endStr, _ := data["end"].(string)
title, _ := data["title"].(string)
remark, _ := data["remark"].(string)
isPublic, _ := data["is_public"].(bool)
scheduleType, _ := data["schedule_type"].(string)
if scheduleType == "" {
scheduleType = "work"
}
startDate, _ := time.Parse("2006-01-02 15:04:05", startStr)
endDate, _ := time.Parse("2006-01-02 15:04:05", endStr)
event := TabCalendarEvent{
CalendarID: calendarID,
UserID: user.ID,
Title: title,
StartDate: &startDate,
EndDate: &endDate,
ScheduleType: scheduleType,
IsPublic: isPublic,
Remark: remark,
}
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, "userCookieError", nil)
}
})
// 更新日历事件
r.POST("/calendar/updateevent", func(ctx *gin.Context) {
isAuth, user, data := AuthenticationAuthority(ctx)
if isAuth {
// 先检查必需字段
idRaw, ok := data["id"].(float64)
if !ok || idRaw == 0 {
ReturnJson(ctx, "jsonErr", nil)
return
}
eventID := uint(idRaw)
oldEvent := TabCalendarEvent{}
if models.DB.Where("id = ?", eventID).First(&oldEvent).Error == nil {
// 检查权限(事件创建人、日历创建人或管理员可修改)
var calendarCreatorID uint
var calendar TabCalendar
if models.DB.Where("id = ?", oldEvent.CalendarID).First(&calendar).Error == nil {
calendarCreatorID = calendar.UserID
}
if !canModifyCalendar(user.ID, oldEvent.UserID) && calendarCreatorID != user.ID {
ReturnJson(ctx, "permission_denied", nil)
return
}
// 解析字段
startStr, _ := data["start"].(string)
endStr, _ := data["end"].(string)
title, _ := data["title"].(string)
remark, _ := data["remark"].(string)
isPublic, _ := data["is_public"].(bool)
scheduleType, _ := data["schedule_type"].(string)
if scheduleType == "" {
scheduleType = "work"
}
startDate, _ := time.Parse("2006-01-02 15:04:05", startStr)
endDate, _ := time.Parse("2006-01-02 15:04:05", endStr)
newEvent := TabCalendarEvent{
Title: title,
StartDate: &startDate,
EndDate: &endDate,
ScheduleType: scheduleType,
IsPublic: isPublic,
Remark: remark,
}
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, "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 {
// 检查权限(事件创建人、日历创建人或管理员可删除)
var calendarCreatorID uint
var calendar TabCalendar
if models.DB.Where("id = ?", oldEvent.CalendarID).First(&calendar).Error == nil {
calendarCreatorID = calendar.UserID
}
if !canModifyCalendar(user.ID, oldEvent.UserID) && calendarCreatorID != 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)
}
})
}
+10 -10
View File
@@ -81,17 +81,17 @@ func ScheduleUpdateAdminsCash() {
func ApiScheduleInit() { func ApiScheduleInit() {
//先初始化数据表 //先初始化数据表
models.DB.AutoMigrate(&TabSchedule{}) // models.DB.AutoMigrate(&TabSchedule{})
models.DB.AutoMigrate(&TabScheduleLog{}) // models.DB.AutoMigrate(&TabScheduleLog{})
//先检查用户组有没有这个key // //先检查用户组有没有这个key
userGroup.Name = "schedule_admin" // userGroup.Name = "schedule_admin"
if models.DB.Where(&userGroup).First(&userGroup).Error == nil { // if models.DB.Where(&userGroup).First(&userGroup).Error == nil {
ScheduleUpdateAdminsCash() // ScheduleUpdateAdminsCash()
} else { // } else {
userGroup.Type = "usergroup" // userGroup.Type = "usergroup"
models.DB.Create(&userGroup) // models.DB.Create(&userGroup)
} // }
} }
func ApiSchedule(r *gin.RouterGroup) { func ApiSchedule(r *gin.RouterGroup) {
+26
View File
@@ -407,6 +407,8 @@ func ApiSysAdmin(r *gin.RouterGroup) {
WarehouseUpdateAdminsCash() WarehouseUpdateAdminsCash()
case "customer_admin": case "customer_admin":
CustomerUpdateAdminsCash() CustomerUpdateAdminsCash()
case "calendar_admin":
CalendarUpdateAdminsCash()
} }
ReturnJson(ctx, "apiOK", nil) ReturnJson(ctx, "apiOK", nil)
@@ -462,6 +464,8 @@ func ApiSysAdmin(r *gin.RouterGroup) {
WarehouseUpdateAdminsCash() WarehouseUpdateAdminsCash()
case "customer_admin": case "customer_admin":
CustomerUpdateAdminsCash() CustomerUpdateAdminsCash()
case "calendar_admin":
CalendarUpdateAdminsCash()
} }
ReturnJson(ctx, "apiOK", nil) ReturnJson(ctx, "apiOK", nil)
@@ -579,6 +583,28 @@ func ApiSysAdmin(r *gin.RouterGroup) {
} }
} }
if params.Module == "all" || params.Module == "calendar" {
var logs []TabCalendarLog
query := models.DB.Model(&TabCalendarLog{})
if params.Module == "calendar" {
query.Order("created_at DESC").Find(&logs)
} else {
query.Order("created_at DESC").Limit(1000).Find(&logs)
}
for _, log := range logs {
allLogs = append(allLogs, LogEntry{
ID: log.ID,
Module: "calendar",
EntityID: log.CalendarID,
UserID: log.UserID,
ActionType: log.ActionType,
IP: log.IP,
Remark: log.Remark,
CreatedAt: log.CreatedAt,
})
}
}
if params.Module == "all" || params.Module == "warehouse" { if params.Module == "all" || params.Module == "warehouse" {
var logs []TabWarehouseLog var logs []TabWarehouseLog
query := models.DB.Model(&TabWarehouseLog{}) query := models.DB.Model(&TabWarehouseLog{})
+18 -1
View File
@@ -339,7 +339,7 @@ func AuthenticationAuthority(ctx *gin.Context) (bool, TabUser, map[string]interf
} }
} else { } else {
ReturnJson(ctx, "userCookieError", nil) //ReturnJson(ctx, "userCookieError", nil)
return false, user, nil return false, user, nil
} }
@@ -594,6 +594,23 @@ func ApiUser(r *gin.RouterGroup) {
} }
redata["isSysAdmin"] = isSysAdmin redata["isSysAdmin"] = isSysAdmin
// 获取用户加入的群组列表
var binds []TabUserGroupBinds
models.DB.Where("user_id = ?", user.ID).Find(&binds)
var groups []map[string]interface{}
for _, bind := range binds {
var group TabUserGroups
if models.DB.Where("id = ?", bind.GroupID).First(&group).Error == nil {
groups = append(groups, map[string]interface{}{
"id": group.ID,
"name": group.Name,
"type": group.Type,
})
}
}
redata["groups"] = groups
ReturnJson(ctx, "apiOK", redata) ReturnJson(ctx, "apiOK", redata)
} }
+10
View File
@@ -82,6 +82,15 @@ type TabWarehouseItemCustomerBind struct {
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"` CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
} }
// TabCalendarEventUserBind 日程与用户关联表(日程分配给的用户)
type TabCalendarEventUserBind struct {
ID uint `gorm:"primarykey"`
EventID uint `gorm:"not null;index;comment:关联日程ID"`
UserID uint `gorm:"not null;index;comment:关联用户ID"`
CreatorID uint `gorm:"not null;comment:分配人id"`
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
}
func BindsInit() { func BindsInit() {
models.DB.AutoMigrate( models.DB.AutoMigrate(
&TabPurchaseFileBind{}, &TabPurchaseFileBind{},
@@ -93,6 +102,7 @@ func BindsInit() {
&TabWorkOrderPurchaseOrderBind{}, &TabWorkOrderPurchaseOrderBind{},
&TabWorkOrderCustomerBind{}, &TabWorkOrderCustomerBind{},
&TabWarehouseItemCustomerBind{}, &TabWarehouseItemCustomerBind{},
&TabCalendarEventUserBind{},
) )
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ops_vue_js", "name": "ops_vue_js",
"version": "1.3.5", "version": "1.4.7",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+53
View File
@@ -0,0 +1,53 @@
import { api } from './index'
export const calendarApi = {
// 创建日历
createCalendar(data) {
return api.post('/calendar/calendar/create', data)
},
// 获取日历列表
getCalendars() {
return api.post('/calendar/calendar/list', {})
},
// 获取所有日历(包括已删除的,系统管理员专用)
getAllCalendars() {
return api.post('/calendar/calendar/list_all', {})
},
// 更新日历
updateCalendar(data) {
return api.post('/calendar/calendar/update', data)
},
// 删除日历
deleteCalendar(id) {
return api.post('/calendar/calendar/delete', { id })
},
// 恢复已删除的日历
restoreCalendar(id) {
return api.post('/calendar/calendar/restore', { 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', { id })
}
}
@@ -53,7 +53,8 @@ const normalClass = "rounded-md px-3 py-2 text-sm font-medium text-gray-600 tran
const navItems = computed(() => [ const navItems = computed(() => [
{ label: t("appname.home"), to: "/" }, { label: t("appname.home"), to: "/" },
{ label: t("appname.schedule"), to: "/schedule" }, // { label: t("appname.schedule"), to: "/schedule" },
{ label: t("appname.calendar"), to: "/calendars" },
{ label: t("appname.purchase"), to: "/purchase" }, { label: t("appname.purchase"), to: "/purchase" },
{ label: t("appname.work_order"), to: "/work_order" }, { label: t("appname.work_order"), to: "/work_order" },
{ label: t("appname.warehouse"), to: "/warehouse" }, { label: t("appname.warehouse"), to: "/warehouse" },
@@ -143,6 +144,14 @@ const navItems = computed(() => [
v-if="userDropdownOpen" v-if="userDropdownOpen"
class="absolute right-0 z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dk-muted dark:bg-dk-card" class="absolute right-0 z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dk-muted dark:bg-dk-card"
> >
<RouterLink
to="/user/my"
class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-dk-subtle dark:hover:bg-dk-muted"
@click="userDropdownOpen = false"
>
<IconUser :size="16" />
{{ t("message.profile_information") }}
</RouterLink>
<RouterLink <RouterLink
to="/settings/account" to="/settings/account"
class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-dk-subtle dark:hover:bg-dk-muted" class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-dk-subtle dark:hover:bg-dk-muted"
@@ -218,6 +227,14 @@ const navItems = computed(() => [
</div> </div>
</div> </div>
<hr class="my-1 border-gray-200 dark:border-dk-muted" /> <hr class="my-1 border-gray-200 dark:border-dk-muted" />
<RouterLink
to="/user/my"
class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-dk-subtle dark:hover:bg-dk-muted"
@click="userDropdownOpen = false"
>
<IconUser :size="16" />
{{ t("message.profile_information") }}
</RouterLink>
<RouterLink <RouterLink
to="/settings/account" to="/settings/account"
class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-dk-subtle dark:hover:bg-dk-muted" class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-dk-subtle dark:hover:bg-dk-muted"
@@ -15,7 +15,7 @@
* 或者作为组件使用 v-model: * 或者作为组件使用 v-model:
* <ConfirmDialog v-model="show" @confirm="..." @cancel="..." /> * <ConfirmDialog v-model="show" @confirm="..." @cancel="..." />
*/ */
import { ref, watch } from "vue"; import { ref, watch, onMounted, getCurrentInstance } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
const props = defineProps({ const props = defineProps({
@@ -47,7 +47,11 @@ const props = defineProps({
const emit = defineEmits(["update:modelValue", "confirm", "cancel"]); const emit = defineEmits(["update:modelValue", "confirm", "cancel"]);
const { t } = useI18n(); // 优先用组件内 i18n,Teleport 场景下可能为空,从全局补足
const instance = getCurrentInstance();
const { t: componentT } = useI18n();
const globalT = instance?.appContext?.config?.globalProperties?.$i18n?.t;
const t = (key, ...args) => componentT(key, ...args) || globalT?.(key, ...args) || key;
function close() { function close() {
emit("update:modelValue", false); emit("update:modelValue", false);
+67 -3
View File
@@ -1,4 +1,6 @@
{ {
"delete": "Delete",
"cancel": "Cancel",
"common": { "common": {
"actions": "Actions", "actions": "Actions",
"search": "Search", "search": "Search",
@@ -33,7 +35,8 @@
"purchase": "Purchase", "purchase": "Purchase",
"warehouse": "Warehouse", "warehouse": "Warehouse",
"warehouse_items": "Items Overview", "warehouse_items": "Items Overview",
"work_order": "Work Order" "work_order": "Work Order",
"calendar": "Calendar"
}, },
"tagadder": { "tagadder": {
"not_fund_item": "No matching items found", "not_fund_item": "No matching items found",
@@ -311,6 +314,7 @@
"standby": "Standby", "standby": "Standby",
"personal_holiday": "Personal Holiday", "personal_holiday": "Personal Holiday",
"public_holiday": "Public Holiday", "public_holiday": "Public Holiday",
"event_type": "Event Type",
"to": "To", "to": "To",
"close": "Close", "close": "Close",
"copy": "Copy", "copy": "Copy",
@@ -394,7 +398,8 @@
"save_success": "Saved successfully", "save_success": "Saved successfully",
"submit": "Submit", "submit": "Submit",
"submitting": "Submitting...", "submitting": "Submitting...",
"loading": "Loading..." "loading": "Loading...",
"profile_information": "Profile Information"
}, },
"settings": { "settings": {
"cancel": "Cancel", "cancel": "Cancel",
@@ -434,7 +439,13 @@
"placeholder_name": "Enter your name", "placeholder_name": "Enter your name",
"placeholder_remark": "Personal introduction or remark", "placeholder_remark": "Personal introduction or remark",
"name_hint": "Display only, login name unchanged.", "name_hint": "Display only, login name unchanged.",
"remark_hint": "Remark only" "remark_hint": "Remark only",
"edit_profile": "Edit Profile",
"security": "Security",
"security_description": "Manage your account security settings",
"my_groups": "My Groups",
"no_groups": "Not joined any groups yet",
"admin_panel": "Admin"
}, },
"button": { "button": {
"submit": "Submit", "submit": "Submit",
@@ -601,5 +612,58 @@
"created_by": "Created By", "created_by": "Created By",
"not_found": "Customer not found", "not_found": "Customer not found",
"related_customers": "Related Customers" "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?",
"confirm_delete_message": "Are you sure you want to delete calendar '{name}'? This action cannot be undone.",
"loading": "Loading...",
"add_event": "Add Event",
"edit_event": "Edit Event",
"view_event": "View 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?",
"is_public_event": "Public Event",
"admin_title": "Calendar Admin",
"event_count": "Event Count",
"restore": "Restore",
"restore_success": "Restored successfully",
"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",
"copy_event": "Copy Event",
"paste_event": "Paste Event",
"copy_success": "Event copied",
"paste_success": "Event pasted",
"no_event_to_paste": "No event to paste",
"created_by": "Created by {name}"
} }
} }
+68 -3
View File
@@ -1,4 +1,6 @@
{ {
"delete": "删除",
"cancel": "取消",
"common": { "common": {
"actions": "操作", "actions": "操作",
"search": "搜索", "search": "搜索",
@@ -33,7 +35,8 @@
"purchase": "采购", "purchase": "采购",
"warehouse": "仓库", "warehouse": "仓库",
"warehouse_items": "物品总览", "warehouse_items": "物品总览",
"work_order": "工单" "work_order": "工单",
"calendar": "日历"
}, },
"tagadder": { "tagadder": {
"not_fund_item": "没有找到匹配项", "not_fund_item": "没有找到匹配项",
@@ -311,6 +314,7 @@
"standby": "备用", "standby": "备用",
"personal_holiday": "个人假期", "personal_holiday": "个人假期",
"public_holiday": "公众假期", "public_holiday": "公众假期",
"event_type": "日程类型",
"to": "至", "to": "至",
"close": "关闭", "close": "关闭",
"copy": "复制", "copy": "复制",
@@ -394,7 +398,8 @@
"save_success": "保存成功", "save_success": "保存成功",
"submit": "提交", "submit": "提交",
"submitting": "提交中...", "submitting": "提交中...",
"loading": "加载中..." "loading": "加载中...",
"profile_information": "个人信息"
}, },
"settings": { "settings": {
"cancel": "取消", "cancel": "取消",
@@ -434,7 +439,13 @@
"placeholder_name": "请输入您的姓名", "placeholder_name": "请输入您的姓名",
"placeholder_remark": "个人简介或备注", "placeholder_remark": "个人简介或备注",
"name_hint": "仅用于显示,不影响登录名", "name_hint": "仅用于显示,不影响登录名",
"remark_hint": "仅备注" "remark_hint": "仅备注",
"edit_profile": "编辑资料",
"security": "安全设置",
"security_description": "管理您的账户安全设置",
"my_groups": "我的群组",
"no_groups": "暂未加入任何群组",
"admin_panel": "管理"
}, },
"button": { "button": {
"submit": "提交", "submit": "提交",
@@ -549,6 +560,7 @@
"module_purchase": "采购", "module_purchase": "采购",
"module_schedule": "日程", "module_schedule": "日程",
"module_warehouse": "仓库", "module_warehouse": "仓库",
"module_calendar": "日历",
"module_work_order": "工单", "module_work_order": "工单",
"action_create": "创建", "action_create": "创建",
"action_update": "更新", "action_update": "更新",
@@ -601,5 +613,58 @@
"created_by": "创建者", "created_by": "创建者",
"not_found": "客户不存在", "not_found": "客户不存在",
"related_customers": "关联客户" "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": "确定要删除此日历吗?",
"confirm_delete_message": "确定要删除日历「{name}」吗?此操作不可撤销。",
"loading": "加载中...",
"add_event": "添加事件",
"edit_event": "编辑事件",
"view_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": "确定要删除此事件吗?",
"is_public_event": "公共日程",
"admin_title": "日历管理",
"event_count": "日程数量",
"restore": "恢复",
"restore_success": "恢复成功",
"deleted": "已删除",
"confirm_restore": "确定要恢复此日历吗?",
"confirm_restore_message": "确定要恢复日历「{name}」吗?",
"admin_panel": "管理",
"copy_event": "复制日程",
"paste_event": "粘贴日程",
"copy_success": "日程已复制",
"paste_success": "日程已粘贴",
"no_event_to_paste": "没有可粘贴的日程",
"created_by": "{name} 创建"
} }
} }
+25 -3
View File
@@ -129,6 +129,27 @@ const router = createRouter({
name: 'customer-edit', name: 'customer-edit',
component: () => import('@/views/customer/CustomerFormPage.vue'), component: () => import('@/views/customer/CustomerFormPage.vue'),
}, },
{
path: 'calendars/admin',
name: 'calendars-admin',
component: () => import('@/views/calendar/CalendarAdminList.vue'),
meta: { requireSysAdmin: true },
},
{
path: 'calendars',
name: 'calendars',
component: () => import('@/views/calendar/CalendarList.vue'),
},
{
path: 'calendar/:id',
name: 'calendar-detail',
component: () => import('@/views/calendar/CalendarDetail.vue'),
},
{
path: 'user/my',
name: 'user-my',
component: () => import('@/views/user/MyProfile.vue'),
},
], ],
}, },
@@ -184,9 +205,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) {
+15
View File
@@ -41,6 +41,7 @@ export const useUserStore = defineStore('user', () => {
const userInfo = ref(null) // TabUserInfo_ 详情 const userInfo = ref(null) // TabUserInfo_ 详情
const userCookie = ref(null) // Cookie session const userCookie = ref(null) // Cookie session
const isLoggedIn = ref(false) const isLoggedIn = ref(false)
const groups = ref([]) // 用户加入的群组列表
// ── Getters ── // ── Getters ──
const cookieValue = computed(() => userCookie.value?.Value ?? '') const cookieValue = computed(() => userCookie.value?.Value ?? '')
@@ -63,6 +64,14 @@ export const useUserStore = defineStore('user', () => {
// 是否系统管理员(后端直接返回) // 是否系统管理员(后端直接返回)
const isSysAdmin = ref(false) const isSysAdmin = ref(false)
// 是否为日历管理员(在 calendar_admin 群组中)
const isCalendarAdmin = computed(() =>
groups.value.some(g => g.name === 'calendar_admin')
)
// 用户加入的群组名称列表(计算属性)
const groupNames = computed(() => groups.value.map(g => g.name))
// ── Actions ── // ── Actions ──
function login(cookie) { function login(cookie) {
userCookie.value = cookie userCookie.value = cookie
@@ -86,6 +95,7 @@ export const useUserStore = defineStore('user', () => {
user.value = null user.value = null
userInfo.value = null userInfo.value = null
isSysAdmin.value = false isSysAdmin.value = false
groups.value = []
isLoggedIn.value = false isLoggedIn.value = false
removeStorage(STORAGE_KEY_COOKIE) removeStorage(STORAGE_KEY_COOKIE)
} }
@@ -98,6 +108,8 @@ export const useUserStore = defineStore('user', () => {
userInfo.value = data.userInfo ?? null userInfo.value = data.userInfo ?? null
// 存储系统管理员状态 // 存储系统管理员状态
isSysAdmin.value = data.isSysAdmin === true isSysAdmin.value = data.isSysAdmin === true
// 存储用户群组列表
groups.value = data.groups ?? []
} }
} catch { } catch {
// 拦截器已处理错误提示 // 拦截器已处理错误提示
@@ -123,6 +135,9 @@ export const useUserStore = defineStore('user', () => {
userCookie, userCookie,
isLoggedIn, isLoggedIn,
isSysAdmin, isSysAdmin,
isCalendarAdmin,
groups,
groupNames,
cookieValue, cookieValue,
avatarUrl, avatarUrl,
birthday, birthday,
+10 -10
View File
@@ -131,19 +131,19 @@ onMounted(() => {
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">{{ t('message.welcome') }}</h2> <h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">{{ t('message.welcome') }}</h2>
<!-- 日程卡片 --> <!-- 日程卡片 -->
<div class="mb-6 rounded-xl border border-gray-200 bg-white px-5 py-4 dark:border-dk-muted dark:bg-dk-card"> <!-- <div class="mb-6 rounded-xl border border-gray-200 bg-white px-5 py-4 dark:border-dk-muted dark:bg-dk-card"> -->
<div class="mb-3 flex items-center justify-between"> <!-- <div class="mb-3 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('appname.schedule') }}</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('appname.schedule') }}</h3>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ t('home.today', { date: todayDisplay }) }}</span> <span class="text-sm text-gray-500 dark:text-gray-400">{{ t('home.today', { date: todayDisplay }) }}</span>
</div> </div> -->
<!-- 加载状态 --> <!-- 加载状态 -->
<div v-if="loadingSchedules" class="py-4 text-center text-gray-500"> <!-- <div v-if="loadingSchedules" class="py-4 text-center text-gray-500">
{{ t('home.loading') }} {{ t('home.loading') }}
</div> </div> -->
<!-- 日程列表 --> <!-- 日程列表 -->
<div v-else-if="todaySchedules.length > 0"> <!-- <div v-else-if="todaySchedules.length > 0">
<div class="mb-2 text-sm text-gray-600 dark:text-gray-400"> <div class="mb-2 text-sm text-gray-600 dark:text-gray-400">
{{ t('home.today_schedule_count', { count: todayCount }) }} {{ t('home.today_schedule_count', { count: todayCount }) }}
</div> </div>
@@ -173,13 +173,13 @@ onMounted(() => {
</div> </div>
</li> </li>
</ul> </ul>
</div> </div> -->
<!-- 无日程 --> <!-- 无日程 -->
<div v-else class="py-4 text-center text-gray-500 dark:text-gray-400"> <!-- <div v-else class="py-4 text-center text-gray-500 dark:text-gray-400">
{{ t('home.today_no_schedule') }} {{ t('home.today_no_schedule') }}
</div> </div> -->
</div> <!-- </div> -->
<!-- 功能入口卡片 --> <!-- 功能入口卡片 -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -0,0 +1,432 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePageTitle } from '@/composables/usePageTitle'
import { useToastStore } from '@/stores/toast'
import { calendarApi } from '@/api/calendar'
import { useUsersStore } from '@/stores/users'
import { IconCalendar, IconClock, IconEdit, IconRestore } from '@tabler/icons-vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
usePageTitle('calendar.admin_title')
const { t } = useI18n()
const toast = useToastStore()
const usersStore = useUsersStore()
const calendars = ref([])
const loading = ref(false)
// 编辑相关
const showEditModal = ref(false)
const editingCalendar = ref(null)
const showDeleteModal = ref(false)
const deletingCalendar = ref(null)
const showRestoreModal = ref(false)
const restoringCalendar = 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 {
// 使用 getAllCalendars 获取所有日历(包括已删除的)
const { errCode, data } = await calendarApi.getAllCalendars()
if (errCode === 0) {
calendars.value = data.list || []
// 预加载创建者信息
calendars.value.forEach(cal => {
if (cal.UserID) {
usersStore.fetchUser(cal.UserID)
}
})
}
} catch {
// 拦截器已处理
} finally {
loading.value = false
}
}
function getCreatorName(userID) {
return usersStore.getUsernameFromUserID(userID) || '...'
}
function getCreatorAvatar(userID) {
return usersStore.getAvatarUrlFromUserID(userID)
}
function formatDateTime(dateStr) {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString()
}
// 判断日历是否已删除(deleted_at 不为 NULL
function isDeleted(calendar) {
return calendar.DeletedAt !== null && calendar.DeletedAt !== undefined
}
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 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 {
// 拦截器已处理
}
}
function openDeleteModal(calendar) {
deletingCalendar.value = calendar
showDeleteModal.value = true
}
async function confirmDelete() {
if (!deletingCalendar.value) return
try {
const { errCode } = await calendarApi.deleteCalendar(deletingCalendar.value.ID)
if (errCode === 0) {
toast.success(t('calendar.delete_success'))
fetchCalendars()
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
showDeleteModal.value = false
deletingCalendar.value = null
}
}
function openRestoreModal(calendar) {
restoringCalendar.value = calendar
showRestoreModal.value = true
}
async function confirmRestore() {
if (!restoringCalendar.value) return
try {
const { errCode } = await calendarApi.restoreCalendar(restoringCalendar.value.ID)
if (errCode === 0) {
toast.success(t('calendar.restore_success'))
fetchCalendars()
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
showRestoreModal.value = false
restoringCalendar.value = null
}
}
onMounted(fetchCalendars)
</script>
<template>
<div class="min-h-screen bg-gray-50 p-6 dark:bg-dk-base">
<div class="mx-auto max-w-6xl">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-dk-text">{{ t('calendar.admin_title') }}</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-dk-subtle">
{{ t('calendar.calendars') }}
</p>
</div>
<button
@click="fetchCalendars"
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
<IconClock :size="16" />
{{ t('sysadmin.refresh') }}
</button>
</div>
<!-- Calendar List Table -->
<div class="rounded-lg border border-gray-200 bg-white dark:border-dk-muted dark:bg-dk-card">
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-12">
<svg class="h-6 w-6 animate-spin text-blue-500" 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>
<span class="ml-2 text-gray-500 dark:text-dk-subtle">{{ t('calendar.loading') }}</span>
</div>
<!-- Empty -->
<div v-else-if="calendars.length === 0" class="py-12 text-center">
<IconCalendar :size="48" class="mx-auto mb-3 text-gray-300 dark:text-dk-muted" />
<p class="text-gray-500 dark:text-dk-subtle">{{ t('calendar.no_calendars') }}</p>
</div>
<!-- Table -->
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dk-muted">
<thead class="bg-gray-50 dark:bg-dk-base">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">
{{ t('calendar.name') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">
{{ t('schedule.event_type') }}
</th>
<th class="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">
{{ t('calendar.event_count') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">
{{ t('customer.created_by') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">
{{ t('customer.created_at') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">
{{ t('common.actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-dk-muted">
<tr
v-for="calendar in calendars"
:key="calendar.ID"
class="hover:bg-gray-50 dark:hover:bg-dk-base"
:class="{ 'opacity-50': isDeleted(calendar) }"
>
<!-- Name + Color -->
<td class="whitespace-nowrap px-6 py-4">
<div class="flex items-center gap-3">
<div
class="h-4 w-4 rounded-full flex-shrink-0"
:style="{ backgroundColor: calendar.Color || '#3788d9' }"
></div>
<div>
<div class="flex items-center gap-2 font-medium text-gray-900 dark:text-dk-text">
{{ calendar.Name }}
<span
v-if="isDeleted(calendar)"
class="inline-flex rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400"
>
{{ t('calendar.deleted') }}
</span>
</div>
<div v-if="calendar.Description" class="text-sm text-gray-500 dark:text-dk-subtle">
{{ calendar.Description }}
</div>
</div>
</div>
</td>
<!-- Public/Private -->
<td class="whitespace-nowrap px-6 py-4">
<span
class="inline-flex rounded-full px-2 py-1 text-xs font-medium"
:class="calendar.IsPublic
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'"
>
{{ calendar.IsPublic ? t('calendar.public') : t('calendar.private') }}
</span>
</td>
<!-- Event Count -->
<td class="whitespace-nowrap px-6 py-4 text-center">
<div class="inline-flex items-center gap-1 text-gray-900 dark:text-dk-text">
<IconCalendar :size="16" class="text-gray-400" />
<span class="font-medium">{{ calendar.event_count ?? 0 }}</span>
</div>
</td>
<!-- Creator -->
<td class="whitespace-nowrap px-6 py-4">
<div class="flex items-center gap-2">
<img
:src="getCreatorAvatar(calendar.UserID)"
class="h-6 w-6 rounded-full"
alt="avatar"
/>
<span class="text-sm text-gray-900 dark:text-dk-text">
{{ getCreatorName(calendar.UserID) }}
</span>
</div>
</td>
<!-- Created At -->
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500 dark:text-dk-subtle">
{{ formatDateTime(calendar.CreatedAt) }}
</td>
<!-- Actions -->
<td class="whitespace-nowrap px-6 py-4">
<div class="flex items-center gap-1">
<button
v-if="isDeleted(calendar)"
@click="openRestoreModal(calendar)"
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-dk-muted"
:title="t('calendar.restore')"
>
<IconRestore :size="16" />
</button>
<button
v-else
@click="openEditModal(calendar)"
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-dk-muted"
:title="t('common.edit')"
>
<IconEdit :size="16" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Summary -->
<div v-if="calendars.length > 0" class="mt-4 text-sm text-gray-500 dark:text-dk-subtle">
{{ t('calendar.calendars') }}: {{ calendars.length }}
</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>
<!-- Delete Confirm Dialog -->
<ConfirmDialog
v-model="showDeleteModal"
:title="t('calendar.confirm_delete')"
:message="t('calendar.confirm_delete_message', { name: deletingCalendar?.Name || '' })"
:confirm-text="t('delete')"
:cancel-text="t('cancel')"
danger
@confirm="confirmDelete"
/>
<!-- Restore Confirm Dialog -->
<ConfirmDialog
v-model="showRestoreModal"
:title="t('calendar.confirm_restore')"
:message="t('calendar.confirm_restore_message', { name: restoringCalendar?.Name || '' })"
:confirm-text="t('calendar.restore')"
:cancel-text="t('cancel')"
@confirm="confirmRestore"
/>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,406 @@
<script setup>
import { ref, computed, 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 { IconPlus, IconCalendar, IconTrash, IconEdit, IconSettings } from '@tabler/icons-vue'
import { useUserStore } from '@/stores/user'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
usePageTitle('appname.calendar')
const { t } = useI18n()
const router = useRouter()
const toast = useToastStore()
const userStore = useUserStore()
const isCalendarAdmin = computed(() => userStore.isCalendarAdmin)
const calendars = ref([])
const loading = ref(false)
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteModal = ref(false)
const editingCalendar = ref(null)
const deletingCalendar = 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) {
deletingCalendar.value = calendar
showDeleteModal.value = true
}
async function confirmDelete() {
if (!deletingCalendar.value) return
try {
const { errCode } = await calendarApi.deleteCalendar(deletingCalendar.value.ID)
if (errCode === 0) {
toast.success(t('calendar.delete_success'))
fetchCalendars()
} else {
toast.error(t('message.server_error'))
}
} catch {
// 拦截器已处理
} finally {
showDeleteModal.value = false
deletingCalendar.value = null
}
}
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>
<div class="flex items-center gap-2">
<RouterLink
v-if="isCalendarAdmin"
to="/calendars/admin"
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:text-gray-300 dark:hover:bg-dk-muted"
>
<IconSettings :size="16" />
{{ t('calendar.admin_panel') }}
</RouterLink>
<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>
</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.canEdit"
@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.canEdit"
@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>
<!-- Delete Confirm Dialog -->
<ConfirmDialog
v-model="showDeleteModal"
:title="t('calendar.confirm_delete')"
:message="t('calendar.confirm_delete_message', { name: deletingCalendar?.Name || '' })"
:confirm-text="t('delete')"
:cancel-text="t('cancel')"
danger
@confirm="confirmDelete"
/>
</template>
@@ -19,11 +19,13 @@ const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
// 模块列表 // 模块列表
const modules = [ const modules = [
{ id: 'all', label: 'operation_logs.all' }, { id: 'all', label: 'operation_logs.all' },
{ id: 'calendar', label: 'calendar.calendars' },
{ id: 'customer', label: 'customer.title' }, { id: 'customer', label: 'customer.title' },
{ id: 'purchase', label: 'purchase.title' }, { id: 'purchase', label: 'purchase.title' },
{ id: 'schedule', label: 'schedule.title' }, // { id: 'schedule', label: 'schedule.title' },
{ id: 'warehouse', label: 'warehouse.title' }, { id: 'warehouse', label: 'warehouse.title' },
{ id: 'work_order', label: 'work_order.title' }, { id: 'work_order', label: 'work_order.title' },
] ]
const activeModule = ref('all') const activeModule = ref('all')
@@ -83,6 +85,7 @@ function getModuleClass(module) {
purchase: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', purchase: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
schedule: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', schedule: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
warehouse: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400', warehouse: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
calendar: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
work_order: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400', work_order: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400',
} }
return map[module] || 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400' return map[module] || 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400'
@@ -31,6 +31,7 @@ const tabs = [
{ id: 'logs', label: t('sysadmin.tab_logs') }, { id: 'logs', label: t('sysadmin.tab_logs') },
{ id: 'operation_logs', label: t('sysadmin.tab_operation_logs') }, { id: 'operation_logs', label: t('sysadmin.tab_operation_logs') },
{ id: 'customer', label: t('customer.title'), to: '/customer' }, { id: 'customer', label: t('customer.title'), to: '/customer' },
{ id: 'calendar', label: t('calendar.admin_title'), to: '/calendars/admin' },
] ]
async function fetchSysAdmins() { async function fetchSysAdmins() {
@@ -0,0 +1,122 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePageTitle } from '@/composables/usePageTitle'
import { useUserStore } from '@/stores/user'
usePageTitle('message.profile_information')
const { t } = useI18n()
const userStore = useUserStore()
onMounted(() => {
userStore.fetchUserInfo()
})
</script>
<template>
<div class="min-h-screen bg-gray-50 p-6 dark:bg-dk-base">
<div class="mx-auto max-w-4xl">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-dk-text">
{{ t('message.profile_information') }}
</h1>
</div>
<!-- Profile Card -->
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-dk-muted dark:bg-dk-card">
<div class="flex flex-col items-center gap-6 md:flex-row md:items-start">
<!-- Avatar -->
<div class="flex-shrink-0">
<img
:src="userStore.avatarUrl"
class="h-24 w-24 rounded-full border-4 border-white shadow-lg dark:border-dk-muted"
alt="avatar"
/>
</div>
<!-- User Info -->
<div class="flex-1 text-center md:text-left">
<h2 class="text-xl font-semibold text-gray-900 dark:text-dk-text">
{{ userStore.user?.Name || '-' }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dk-subtle">
{{ userStore.userInfo?.Username || '-' }}
</p>
<!-- Additional Info -->
<div class="mt-4 grid grid-cols-1 gap-3 text-sm md:grid-cols-2">
<div class="rounded-lg bg-gray-50 p-3 dark:bg-dk-base">
<div class="text-xs text-gray-500 dark:text-dk-subtle">{{ t('settings.remark') }}</div>
<div class="mt-1 font-medium text-gray-900 dark:text-dk-text">
{{ userStore.userInfo?.FirstName || '-' }}
</div>
</div>
<div class="rounded-lg bg-gray-50 p-3 dark:bg-dk-base">
<div class="text-xs text-gray-500 dark:text-dk-subtle">{{ t('settings.birthday') }}</div>
<div class="mt-1 font-medium text-gray-900 dark:text-dk-text">
{{ userStore.birthday || '-' }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="mt-6 grid gap-4 md:grid-cols-2">
<RouterLink
to="/settings/account"
class="flex items-center gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-dk-muted dark:bg-dk-card"
>
<div class="rounded-full bg-blue-100 p-3 dark:bg-blue-900/30">
<svg class="h-6 w-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<div>
<div class="font-medium text-gray-900 dark:text-dk-text">{{ t('message.user_settings') }}</div>
<div class="text-sm text-gray-500 dark:text-dk-subtle">{{ t('settings.edit_profile') }}</div>
</div>
</RouterLink>
<RouterLink
to="/settings/security"
class="flex items-center gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-dk-muted dark:bg-dk-card"
>
<div class="rounded-full bg-green-100 p-3 dark:bg-green-900/30">
<svg class="h-6 w-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<div class="font-medium text-gray-900 dark:text-dk-text">{{ t('settings.security') }}</div>
<div class="text-sm text-gray-500 dark:text-dk-subtle">{{ t('settings.security_description') }}</div>
</div>
</RouterLink>
</div>
<!-- My Groups -->
<div class="mt-6">
<h2 class="mb-3 text-lg font-semibold text-gray-900 dark:text-dk-text">
{{ t('settings.my_groups') }}
</h2>
<div v-if="userStore.groups.length > 0" class="flex flex-wrap gap-2">
<span
v-for="group in userStore.groups"
:key="group.id"
class="inline-flex items-center rounded-full bg-purple-100 px-3 py-1 text-sm font-medium text-purple-800 dark:bg-purple-900/30 dark:text-purple-300"
>
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{{ group.name }}
</span>
</div>
<div v-else class="rounded-lg border border-gray-200 bg-white p-4 text-center text-sm text-gray-500 dark:border-dk-muted dark:bg-dk-card dark:text-dk-subtle">
{{ t('settings.no_groups') }}
</div>
</div>
</div>
</div>
</template>