Merge branch '新增日历模块' into 'main'
新增日历模块 See merge request kevin/ops2!1
This commit is contained in:
@@ -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}"
|
||||
@@ -81,6 +81,7 @@ func main() {
|
||||
routers.ApiWorkOrderInit()
|
||||
routers.ApiWarehouseInit()
|
||||
routers.ApiCustomerInit()
|
||||
routers.ApiCalendarInit()
|
||||
|
||||
routers.BindsInit() //最后初始化绑定数据表
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -81,17 +81,17 @@ func ScheduleUpdateAdminsCash() {
|
||||
|
||||
func ApiScheduleInit() {
|
||||
//先初始化数据表
|
||||
models.DB.AutoMigrate(&TabSchedule{})
|
||||
models.DB.AutoMigrate(&TabScheduleLog{})
|
||||
// models.DB.AutoMigrate(&TabSchedule{})
|
||||
// models.DB.AutoMigrate(&TabScheduleLog{})
|
||||
|
||||
//先检查用户组有没有这个key
|
||||
userGroup.Name = "schedule_admin"
|
||||
if models.DB.Where(&userGroup).First(&userGroup).Error == nil {
|
||||
ScheduleUpdateAdminsCash()
|
||||
} else {
|
||||
userGroup.Type = "usergroup"
|
||||
models.DB.Create(&userGroup)
|
||||
}
|
||||
// //先检查用户组有没有这个key
|
||||
// userGroup.Name = "schedule_admin"
|
||||
// if models.DB.Where(&userGroup).First(&userGroup).Error == nil {
|
||||
// ScheduleUpdateAdminsCash()
|
||||
// } else {
|
||||
// userGroup.Type = "usergroup"
|
||||
// models.DB.Create(&userGroup)
|
||||
// }
|
||||
}
|
||||
|
||||
func ApiSchedule(r *gin.RouterGroup) {
|
||||
|
||||
@@ -164,7 +164,7 @@ func ApiSysAdmin(r *gin.RouterGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
var params struct {
|
||||
var params struct {
|
||||
GroupID float64 `json:"group_id" mapstructure:"group_id"`
|
||||
Page float64 `json:"page" mapstructure:"page"`
|
||||
PageSize float64 `json:"page_size" mapstructure:"page_size"`
|
||||
@@ -407,6 +407,8 @@ func ApiSysAdmin(r *gin.RouterGroup) {
|
||||
WarehouseUpdateAdminsCash()
|
||||
case "customer_admin":
|
||||
CustomerUpdateAdminsCash()
|
||||
case "calendar_admin":
|
||||
CalendarUpdateAdminsCash()
|
||||
}
|
||||
|
||||
ReturnJson(ctx, "apiOK", nil)
|
||||
@@ -462,6 +464,8 @@ func ApiSysAdmin(r *gin.RouterGroup) {
|
||||
WarehouseUpdateAdminsCash()
|
||||
case "customer_admin":
|
||||
CustomerUpdateAdminsCash()
|
||||
case "calendar_admin":
|
||||
CalendarUpdateAdminsCash()
|
||||
}
|
||||
|
||||
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" {
|
||||
var logs []TabWarehouseLog
|
||||
query := models.DB.Model(&TabWarehouseLog{})
|
||||
|
||||
@@ -339,7 +339,7 @@ func AuthenticationAuthority(ctx *gin.Context) (bool, TabUser, map[string]interf
|
||||
}
|
||||
|
||||
} else {
|
||||
ReturnJson(ctx, "userCookieError", nil)
|
||||
//ReturnJson(ctx, "userCookieError", nil)
|
||||
return false, user, nil
|
||||
}
|
||||
|
||||
@@ -594,6 +594,23 @@ func ApiUser(r *gin.RouterGroup) {
|
||||
}
|
||||
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)
|
||||
|
||||
}
|
||||
|
||||
@@ -82,6 +82,15 @@ type TabWarehouseItemCustomerBind struct {
|
||||
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() {
|
||||
models.DB.AutoMigrate(
|
||||
&TabPurchaseFileBind{},
|
||||
@@ -93,6 +102,7 @@ func BindsInit() {
|
||||
&TabWorkOrderPurchaseOrderBind{},
|
||||
&TabWorkOrderCustomerBind{},
|
||||
&TabWarehouseItemCustomerBind{},
|
||||
&TabCalendarEventUserBind{},
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ops_vue_js",
|
||||
"version": "1.3.5",
|
||||
"version": "1.4.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -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(() => [
|
||||
{ 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.work_order"), to: "/work_order" },
|
||||
{ label: t("appname.warehouse"), to: "/warehouse" },
|
||||
@@ -143,6 +144,14 @@ const navItems = computed(() => [
|
||||
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"
|
||||
>
|
||||
<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
|
||||
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"
|
||||
@@ -218,6 +227,14 @@ const navItems = computed(() => [
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
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"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* 或者作为组件使用 v-model:
|
||||
* <ConfirmDialog v-model="show" @confirm="..." @cancel="..." />
|
||||
*/
|
||||
import { ref, watch } from "vue";
|
||||
import { ref, watch, onMounted, getCurrentInstance } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -47,7 +47,11 @@ const props = defineProps({
|
||||
|
||||
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() {
|
||||
emit("update:modelValue", false);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"common": {
|
||||
"actions": "Actions",
|
||||
"search": "Search",
|
||||
@@ -33,7 +35,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",
|
||||
@@ -311,6 +314,7 @@
|
||||
"standby": "Standby",
|
||||
"personal_holiday": "Personal Holiday",
|
||||
"public_holiday": "Public Holiday",
|
||||
"event_type": "Event Type",
|
||||
"to": "To",
|
||||
"close": "Close",
|
||||
"copy": "Copy",
|
||||
@@ -394,7 +398,8 @@
|
||||
"save_success": "Saved successfully",
|
||||
"submit": "Submit",
|
||||
"submitting": "Submitting...",
|
||||
"loading": "Loading..."
|
||||
"loading": "Loading...",
|
||||
"profile_information": "Profile Information"
|
||||
},
|
||||
"settings": {
|
||||
"cancel": "Cancel",
|
||||
@@ -434,7 +439,13 @@
|
||||
"placeholder_name": "Enter your name",
|
||||
"placeholder_remark": "Personal introduction or remark",
|
||||
"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": {
|
||||
"submit": "Submit",
|
||||
@@ -601,5 +612,58 @@
|
||||
"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?",
|
||||
"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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"common": {
|
||||
"actions": "操作",
|
||||
"search": "搜索",
|
||||
@@ -33,7 +35,8 @@
|
||||
"purchase": "采购",
|
||||
"warehouse": "仓库",
|
||||
"warehouse_items": "物品总览",
|
||||
"work_order": "工单"
|
||||
"work_order": "工单",
|
||||
"calendar": "日历"
|
||||
},
|
||||
"tagadder": {
|
||||
"not_fund_item": "没有找到匹配项",
|
||||
@@ -311,6 +314,7 @@
|
||||
"standby": "备用",
|
||||
"personal_holiday": "个人假期",
|
||||
"public_holiday": "公众假期",
|
||||
"event_type": "日程类型",
|
||||
"to": "至",
|
||||
"close": "关闭",
|
||||
"copy": "复制",
|
||||
@@ -394,7 +398,8 @@
|
||||
"save_success": "保存成功",
|
||||
"submit": "提交",
|
||||
"submitting": "提交中...",
|
||||
"loading": "加载中..."
|
||||
"loading": "加载中...",
|
||||
"profile_information": "个人信息"
|
||||
},
|
||||
"settings": {
|
||||
"cancel": "取消",
|
||||
@@ -434,7 +439,13 @@
|
||||
"placeholder_name": "请输入您的姓名",
|
||||
"placeholder_remark": "个人简介或备注",
|
||||
"name_hint": "仅用于显示,不影响登录名",
|
||||
"remark_hint": "仅备注"
|
||||
"remark_hint": "仅备注",
|
||||
"edit_profile": "编辑资料",
|
||||
"security": "安全设置",
|
||||
"security_description": "管理您的账户安全设置",
|
||||
"my_groups": "我的群组",
|
||||
"no_groups": "暂未加入任何群组",
|
||||
"admin_panel": "管理"
|
||||
},
|
||||
"button": {
|
||||
"submit": "提交",
|
||||
@@ -549,6 +560,7 @@
|
||||
"module_purchase": "采购",
|
||||
"module_schedule": "日程",
|
||||
"module_warehouse": "仓库",
|
||||
"module_calendar": "日历",
|
||||
"module_work_order": "工单",
|
||||
"action_create": "创建",
|
||||
"action_update": "更新",
|
||||
@@ -601,5 +613,58 @@
|
||||
"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": "确定要删除此日历吗?",
|
||||
"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} 创建"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,27 @@ const router = createRouter({
|
||||
name: 'customer-edit',
|
||||
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) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 不需要登录的页面
|
||||
const publicPages = ['/', '/login', '/register', '/forgot_password', '/schedule', '/404']
|
||||
if (publicPages.includes(to.path)) return true
|
||||
// 不需要登录的页面(精确匹配或前缀匹配)
|
||||
const publicPages = ['/', '/login', '/register', '/forgot_password', '/schedule', '/calendars', '/404']
|
||||
const publicPrefixes = ['/calendar/']
|
||||
if (publicPages.includes(to.path) || publicPrefixes.some(p => to.path.startsWith(p))) return true
|
||||
|
||||
// 未登录 → 跳转登录
|
||||
if (!userStore.isLoggedIn) {
|
||||
|
||||
@@ -41,6 +41,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
const userInfo = ref(null) // TabUserInfo_ 详情
|
||||
const userCookie = ref(null) // Cookie session
|
||||
const isLoggedIn = ref(false)
|
||||
const groups = ref([]) // 用户加入的群组列表
|
||||
// ── Getters ──
|
||||
const cookieValue = computed(() => userCookie.value?.Value ?? '')
|
||||
|
||||
@@ -63,6 +64,14 @@ export const useUserStore = defineStore('user', () => {
|
||||
// 是否系统管理员(后端直接返回)
|
||||
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 ──
|
||||
function login(cookie) {
|
||||
userCookie.value = cookie
|
||||
@@ -86,6 +95,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
user.value = null
|
||||
userInfo.value = null
|
||||
isSysAdmin.value = false
|
||||
groups.value = []
|
||||
isLoggedIn.value = false
|
||||
removeStorage(STORAGE_KEY_COOKIE)
|
||||
}
|
||||
@@ -98,6 +108,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
userInfo.value = data.userInfo ?? null
|
||||
// 存储系统管理员状态
|
||||
isSysAdmin.value = data.isSysAdmin === true
|
||||
// 存储用户群组列表
|
||||
groups.value = data.groups ?? []
|
||||
}
|
||||
} catch {
|
||||
// 拦截器已处理错误提示
|
||||
@@ -123,6 +135,9 @@ export const useUserStore = defineStore('user', () => {
|
||||
userCookie,
|
||||
isLoggedIn,
|
||||
isSysAdmin,
|
||||
isCalendarAdmin,
|
||||
groups,
|
||||
groupNames,
|
||||
cookieValue,
|
||||
avatarUrl,
|
||||
birthday,
|
||||
|
||||
@@ -131,19 +131,19 @@ onMounted(() => {
|
||||
<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-3 flex items-center justify-between">
|
||||
<!-- <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">
|
||||
<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>
|
||||
</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') }}
|
||||
</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">
|
||||
{{ t('home.today_schedule_count', { count: todayCount }) }}
|
||||
</div>
|
||||
@@ -173,13 +173,13 @@ onMounted(() => {
|
||||
</div>
|
||||
</li>
|
||||
</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') }}
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<!-- </div> -->
|
||||
|
||||
<!-- 功能入口卡片 -->
|
||||
<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 = [
|
||||
{ id: 'all', label: 'operation_logs.all' },
|
||||
{ id: 'calendar', label: 'calendar.calendars' },
|
||||
{ id: 'customer', label: 'customer.title' },
|
||||
{ id: 'purchase', label: 'purchase.title' },
|
||||
{ id: 'schedule', label: 'schedule.title' },
|
||||
// { id: 'schedule', label: 'schedule.title' },
|
||||
{ id: 'warehouse', label: 'warehouse.title' },
|
||||
{ id: 'work_order', label: 'work_order.title' },
|
||||
|
||||
]
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
}
|
||||
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: 'operation_logs', label: t('sysadmin.tab_operation_logs') },
|
||||
{ id: 'customer', label: t('customer.title'), to: '/customer' },
|
||||
{ id: 'calendar', label: t('calendar.admin_title'), to: '/calendars/admin' },
|
||||
]
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user