up实现查看日志功能
This commit is contained in:
@@ -1,56 +1,23 @@
|
|||||||
# 2026-04-29 工作日志
|
# 2026-04-29 工作日志
|
||||||
|
|
||||||
- 修复 SysAdminView.vue 第 849 行 `<img ... />` 占位符 bug(上一轮 i18n 修改误删内容),恢复为 `<img :src="usersStore.getAvatarUrlFromUserID(adminId)" class="w-5 h-5 rounded-full" alt="avatar" />`
|
## 完成的工作
|
||||||
- 在 `en.json` 的 `message` 节点补充缺失的 `"sysadmin": "System Admin"` 翻译
|
|
||||||
- 全面重新分析代码结构,更新并精简 MEMORY.md(整合后端 main.go 启动流程、apiSysAdmin 完整路由、前端路由守卫逻辑、stores/user.js isSysAdmin 机制等)
|
### 1. 客户详情页添加编辑按钮
|
||||||
- 将 SysAdminView.vue 拆分为三个子组件:`src/views/sysadmin/UsersTab.vue`(用户管理+详情弹窗)、`GroupsTab.vue`(用户组+添加/移除成员)、`LogsTab.vue`(登录失败日志);父组件改用 v-show 保持子组件挂载,UsersTab 自身 onMounted 加载数据,Groups/Logs 由父组件 watch(activeTab) 懒加载
|
- 文件: `frontend/ops_vue_js/src/views/customer/CustomerDetail.vue`
|
||||||
- 将 SysAdminView.vue 整体移入 `src/views/sysadmin/` 目录,并更新 router/index.js 中的引用路径为 `@/views/sysadmin/SysAdminView.vue`
|
- 功能: 根据后端返回的 `canModify` 字段显示/隐藏编辑按钮
|
||||||
- 客户模块权限改造:移除 `/getinfo` 返回的 `isCustomerAdmin`,改为按记录返回 `edit` 权限标记
|
- 权限逻辑: 创建者或客户管理员可编辑
|
||||||
- 后端 `apiCustomer.go`:新增 `canModifyCustomer()` 函数(创建者或管理员可编辑),`/list` 每条记录附加 `edit` 字段,`/get` 返回 `canModify`,`/update` 和 `/delete` 改用 `canModifyCustomer` 校验权限
|
|
||||||
- 后端 `apiUsers.go`:移除 `isCustomerAdmin` 返回
|
### 2. 操作日志功能
|
||||||
- 前端 `stores/user.js`:移除 `isCustomerAdmin` 状态
|
- 后端 API: `backend/my_work/routers/apiSysAdmin.go`
|
||||||
- 前端 `CustomerList.vue`:改用 `customer.edit` 控制编辑/删除按钮显示,新增按钮始终显示(后端会校验权限)
|
- 新增 `/operation_logs` 接口,聚合所有模块的操作日志
|
||||||
- 客户称呼选项调整:移除"夫人"/"博士",新增"单位"作为默认选项
|
- 支持按模块筛选: all/customer/purchase/schedule/warehouse/work_order
|
||||||
- `CustomerFormModal.vue`:`titleOptions` 改为 `['Unit', 'Mr', 'Ms']`,默认值改为 `'Unit'`
|
- 支持分页,最新日志在前
|
||||||
- `zh-CN.json`:新增 `salutation_unit: "单位"`,移除 `salutation_mrs`/`salutation_dr`
|
|
||||||
- `en.json`:新增 `salutation_unit: "Unit"`,移除 `salutation_mrs`/`salutation_dr`
|
- 前端组件: `frontend/ops_vue_js/src/views/sysadmin/OperationLogsTab.vue`
|
||||||
- 后端 `apiCustomer.go`:`Title` 字段注释改为 `称呼:Unit/Mr/Ms`
|
- 左侧模块选择器,右侧日志表格
|
||||||
- 客户表单字段顺序调整:姓在前(必填),名在后(非必填)
|
- 分页显示,最新日志在前
|
||||||
- `CustomerFormModal.vue`:表单 grid 改为 姓 → 名,移除 `first_name` 必填验证
|
|
||||||
- `CustomerList.vue`:列表显示改为 `last_name + first_name` 顺序
|
- 系统管理页面: `frontend/ops_vue_js/src/views/sysadmin/SysAdminView.vue`
|
||||||
- 创建客户详情页 `CustomerDetail.vue`
|
- 新增"操作日志"标签页
|
||||||
- 显示客户基本信息(姓名、称呼、创建时间、创建者)
|
|
||||||
- 显示电话列表(含主号码标记)
|
- 翻译文件: 添加了 `operation_logs` 相关翻译键
|
||||||
- 显示邮箱列表(含主邮箱标记)
|
|
||||||
- 显示单位列表(含主单位标记)
|
|
||||||
- 编辑按钮(仅 `canModify` 为 true 时显示)
|
|
||||||
- 返回按钮
|
|
||||||
- 创建客户编辑页 `CustomerEdit.vue`(包装 `CustomerFormModal`)
|
|
||||||
- 添加路由:`/customer/detail/:id` 和 `/customer/edit/:id`
|
|
||||||
- `CustomerList.vue`:姓名改为可点击链接,跳转到详情页
|
|
||||||
- i18n:添加 `common.back`、`customer.detail_title`、`customer.basic_info`、`customer.created_by`、`customer.not_found` 等翻译
|
|
||||||
- 工单-客户关联功能
|
|
||||||
- `binds.go`: 新增 `TabWorkOrderCustomerBind` 关联表,在 `BindsInit()` 中注册
|
|
||||||
- `apiWorkOrder.go`: `/get` API 返回 `linkedCustomers`;新增 `/link_customer` 和 `/unlink_customer` API
|
|
||||||
- `ShowWorkOrder.vue`: 详情页显示关联客户,支持搜索/关联/解除关联客户
|
|
||||||
- `work_order.js`: 添加 `linkCustomer` 和 `unlinkCustomer` API 方法
|
|
||||||
- `AddEditWorkOrder.vue`: 新增工单时支持搜索并关联客户
|
|
||||||
- i18n: 添加 `linked_customer`、`linked_customers`、`link_customer_placeholder` 等翻译
|
|
||||||
- 工单编辑页支持关联物品和客户(多选)
|
|
||||||
- `AddEditWorkOrder.vue`: 移除 `v-if="!isEdit"` 限制,编辑模式也显示关联物品/客户搜索框
|
|
||||||
- `AddEditWorkOrder.vue`: 编辑模式加载时回填 `selectedItems` 和 `selectedCustomers`
|
|
||||||
- `AddEditWorkOrder.vue`: 编辑提交时发送 `item_ids` 和 `customer_ids`
|
|
||||||
- `apiWorkOrder.go`: `/update` 接口新增 `ItemIDs` 和 `CustomerIDs` 字段,重建物品/客户关联绑定
|
|
||||||
- 仓库物品添加页支持关联客户
|
|
||||||
- `binds.go`: 新增 `TabWarehouseItemCustomerBind` 关联表(物品-客户多对多)
|
|
||||||
- `apiWarehouse.go`: `/add_item` 接口新增 `CustomerIDs` 字段,仅新建物品时创建客户关联绑定
|
|
||||||
- `WarehouseAddItem.vue`: 添加客户搜索选择组件(多选),提交时发送 `customer_ids`
|
|
||||||
- 使用已有的 `customerApi.list()` 搜索客户
|
|
||||||
- 仓库物品详情页显示关联客户
|
|
||||||
- `apiWarehouse.go`: `/get_item` 接口返回 `customers` 列表(包含客户 ID、姓名、称呼)
|
|
||||||
- `WarehouseItemDetail.vue`: 新增"关联客户" Tab,显示关联客户列表(头像、姓名、称呼),点击可跳转到客户详情页
|
|
||||||
- i18n: 添加 `warehouse.customers` 和 `warehouse.no_customers` 翻译
|
|
||||||
- 仓库物品编辑页支持关联客户
|
|
||||||
- `apiWarehouse.go`: `/update_item` 接口新增 `CustomerIDs` 字段,重建客户关联绑定
|
|
||||||
- `WarehouseItemEdit.vue`: 添加客户搜索选择组件(多选),加载时回填已关联客户,提交时发送 `customer_ids`
|
|
||||||
- i18n: 添加 `warehouse.linked_customers`、`warehouse.linked_customer_placeholder`、`warehouse.linked_customer_not_found` 等翻译到 `warehouse` 节点
|
|
||||||
|
|||||||
@@ -494,11 +494,11 @@ func ApiCustomer(r *gin.RouterGroup) {
|
|||||||
models.DB.Where("customer_id = ?", req.ID).Find(&companies)
|
models.DB.Where("customer_id = ?", req.ID).Find(&companies)
|
||||||
|
|
||||||
// 写查询日志
|
// 写查询日志
|
||||||
models.DB.Create(&TabCustomerLog{
|
// models.DB.Create(&TabCustomerLog{
|
||||||
CustomerID: req.ID,
|
// CustomerID: req.ID,
|
||||||
ActionType: "query",
|
// ActionType: "query",
|
||||||
IP: ctx.ClientIP(),
|
// IP: ctx.ClientIP(),
|
||||||
})
|
// })
|
||||||
|
|
||||||
ReturnJson(ctx, "apiOK", gin.H{
|
ReturnJson(ctx, "apiOK", gin.H{
|
||||||
"customer": customer,
|
"customer": customer,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package routers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"ops/models"
|
"ops/models"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mitchellh/mapstructure"
|
"github.com/mitchellh/mapstructure"
|
||||||
@@ -150,12 +152,18 @@ func ApiSysAdmin(r *gin.RouterGroup) {
|
|||||||
|
|
||||||
// 获取用户组成员列表(仅系统管理员可访问)
|
// 获取用户组成员列表(仅系统管理员可访问)
|
||||||
r.POST("/group_members", func(ctx *gin.Context) {
|
r.POST("/group_members", func(ctx *gin.Context) {
|
||||||
isAuth, _, data := AuthenticationAuthority(ctx)
|
isAuth, authUser, data := AuthenticationAuthority(ctx)
|
||||||
if !isAuth {
|
if !isAuth {
|
||||||
ReturnJson(ctx, "userNoLogin", nil)
|
ReturnJson(ctx, "userNoLogin", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否为系统管理员
|
||||||
|
if !SysAdminCheck(authUser.ID) {
|
||||||
|
ReturnJson(ctx, "permission_denied", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var params struct {
|
var params struct {
|
||||||
GroupID float64 `json:"group_id" mapstructure:"group_id"`
|
GroupID float64 `json:"group_id" mapstructure:"group_id"`
|
||||||
Page float64 `json:"page" mapstructure:"page"`
|
Page float64 `json:"page" mapstructure:"page"`
|
||||||
@@ -229,12 +237,18 @@ func ApiSysAdmin(r *gin.RouterGroup) {
|
|||||||
|
|
||||||
// 获取用户详细信息(仅系统管理员可访问)
|
// 获取用户详细信息(仅系统管理员可访问)
|
||||||
r.POST("/user_detail", func(ctx *gin.Context) {
|
r.POST("/user_detail", func(ctx *gin.Context) {
|
||||||
isAuth, _, data := AuthenticationAuthority(ctx)
|
isAuth, authUser, data := AuthenticationAuthority(ctx)
|
||||||
if !isAuth {
|
if !isAuth {
|
||||||
ReturnJson(ctx, "userNoLogin", nil)
|
ReturnJson(ctx, "userNoLogin", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否为系统管理员
|
||||||
|
if !SysAdminCheck(authUser.ID) {
|
||||||
|
ReturnJson(ctx, "permission_denied", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var params struct {
|
var params struct {
|
||||||
UserID uint `json:"user_id"`
|
UserID uint `json:"user_id"`
|
||||||
}
|
}
|
||||||
@@ -453,14 +467,207 @@ func ApiSysAdmin(r *gin.RouterGroup) {
|
|||||||
ReturnJson(ctx, "apiOK", nil)
|
ReturnJson(ctx, "apiOK", nil)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取登录失败日志(仅系统管理员可访问)
|
// 获取操作日志(仅系统管理员可访问)
|
||||||
r.POST("/login_fail_logs", func(ctx *gin.Context) {
|
r.POST("/operation_logs", func(ctx *gin.Context) {
|
||||||
isAuth, _, data := AuthenticationAuthority(ctx)
|
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||||
if !isAuth {
|
if !isAuth {
|
||||||
ReturnJson(ctx, "userNoLogin", nil)
|
ReturnJson(ctx, "userNoLogin", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否为系统管理员
|
||||||
|
if !SysAdminCheck(user.ID) {
|
||||||
|
ReturnJson(ctx, "permission_denied", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析参数
|
||||||
|
var params struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
Module string `json:"module"` // 模块: all/customer/purchase/schedule/warehouse/work_order
|
||||||
|
}
|
||||||
|
if err := mapstructure.Decode(data, ¶ms); err != nil {
|
||||||
|
params.Page = 1
|
||||||
|
params.PageSize = 20
|
||||||
|
params.Module = "all"
|
||||||
|
}
|
||||||
|
if params.Page < 1 {
|
||||||
|
params.Page = 1
|
||||||
|
}
|
||||||
|
if params.PageSize < 1 || params.PageSize > 100 {
|
||||||
|
params.PageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogEntry struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Module string `json:"module"` // 模块名称
|
||||||
|
EntityID uint `json:"entity_id"` // 关联实体ID
|
||||||
|
UserID uint `json:"user_id"` // 操作人ID
|
||||||
|
ActionType string `json:"action_type"` // 操作类型
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
CreatedAt *time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var allLogs []LogEntry
|
||||||
|
|
||||||
|
// 根据模块筛选查询
|
||||||
|
if params.Module == "all" || params.Module == "customer" {
|
||||||
|
var logs []TabCustomerLog
|
||||||
|
query := models.DB.Model(&TabCustomerLog{})
|
||||||
|
if params.Module == "customer" {
|
||||||
|
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: "customer",
|
||||||
|
EntityID: log.CustomerID,
|
||||||
|
UserID: log.UserID,
|
||||||
|
ActionType: log.ActionType,
|
||||||
|
IP: log.IP,
|
||||||
|
Remark: log.Remark,
|
||||||
|
CreatedAt: log.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Module == "all" || params.Module == "purchase" {
|
||||||
|
var logs []TabPurchaseLog
|
||||||
|
query := models.DB.Model(&TabPurchaseLog{})
|
||||||
|
if params.Module == "purchase" {
|
||||||
|
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: "purchase",
|
||||||
|
EntityID: log.OrderID,
|
||||||
|
UserID: log.UserID,
|
||||||
|
ActionType: log.ActionType,
|
||||||
|
IP: log.IP,
|
||||||
|
Remark: log.Remark,
|
||||||
|
CreatedAt: log.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Module == "all" || params.Module == "schedule" {
|
||||||
|
var logs []TabScheduleLog
|
||||||
|
query := models.DB.Model(&TabScheduleLog{})
|
||||||
|
if params.Module == "schedule" {
|
||||||
|
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: "schedule",
|
||||||
|
EntityID: log.ScheduleID,
|
||||||
|
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{})
|
||||||
|
if params.Module == "warehouse" {
|
||||||
|
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: "warehouse",
|
||||||
|
EntityID: log.EntityID,
|
||||||
|
UserID: log.UserID,
|
||||||
|
ActionType: log.ActionType,
|
||||||
|
IP: log.IP,
|
||||||
|
Remark: log.Remark,
|
||||||
|
CreatedAt: &log.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Module == "all" || params.Module == "work_order" {
|
||||||
|
var logs []TabWorkOrderLog
|
||||||
|
query := models.DB.Model(&TabWorkOrderLog{})
|
||||||
|
if params.Module == "work_order" {
|
||||||
|
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: "work_order",
|
||||||
|
EntityID: log.WorkOrderID,
|
||||||
|
UserID: log.UserID,
|
||||||
|
ActionType: log.ActionType,
|
||||||
|
IP: log.IP,
|
||||||
|
Remark: log.Remark,
|
||||||
|
CreatedAt: log.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按时间倒序排序
|
||||||
|
sort.Slice(allLogs, func(i, j int) bool {
|
||||||
|
if allLogs[i].CreatedAt == nil || allLogs[j].CreatedAt == nil {
|
||||||
|
return allLogs[i].ID > allLogs[j].ID
|
||||||
|
}
|
||||||
|
return allLogs[i].CreatedAt.After(*allLogs[j].CreatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
total := len(allLogs)
|
||||||
|
offset := (params.Page - 1) * params.PageSize
|
||||||
|
end := offset + params.PageSize
|
||||||
|
if offset > total {
|
||||||
|
offset = total
|
||||||
|
}
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
|
||||||
|
var pagedLogs []LogEntry
|
||||||
|
if offset < total {
|
||||||
|
pagedLogs = allLogs[offset:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
ReturnJson(ctx, "apiOK", gin.H{
|
||||||
|
"logs": pagedLogs,
|
||||||
|
"total": total,
|
||||||
|
"page": params.Page,
|
||||||
|
"page_size": params.PageSize,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取登录失败日志(仅系统管理员可访问)
|
||||||
|
r.POST("/login_fail_logs", func(ctx *gin.Context) {
|
||||||
|
isAuth, authUser, data := AuthenticationAuthority(ctx)
|
||||||
|
if !isAuth {
|
||||||
|
ReturnJson(ctx, "userNoLogin", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为系统管理员
|
||||||
|
if !SysAdminCheck(authUser.ID) {
|
||||||
|
ReturnJson(ctx, "permission_denied", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 解析分页和搜索参数
|
// 解析分页和搜索参数
|
||||||
var params struct {
|
var params struct {
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export const authApi = {
|
|||||||
return api.post('/admin/login_fail_logs', params)
|
return api.post('/admin/login_fail_logs', params)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 获取操作日志(仅管理员可访问) */
|
||||||
|
getOperationLogs(params = {}) {
|
||||||
|
return api.post('/admin/operation_logs', params)
|
||||||
|
},
|
||||||
|
|
||||||
/** 修改密码 */
|
/** 修改密码 */
|
||||||
changePassword(oldPass, newPass) {
|
changePassword(oldPass, newPass) {
|
||||||
return api.post('/users/changePassword', { oldpass: oldPass, newpass: newPass })
|
return api.post('/users/changePassword', { oldpass: oldPass, newpass: newPass })
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
"click_button_or_drag": "Click upload button or drag & drop image file"
|
"click_button_or_drag": "Click upload button or drag & drop image file"
|
||||||
},
|
},
|
||||||
"purchase": {
|
"purchase": {
|
||||||
|
"title": "Purchase",
|
||||||
"purchase_list": "Purchase List",
|
"purchase_list": "Purchase List",
|
||||||
"item_name": "Item Name",
|
"item_name": "Item Name",
|
||||||
"purpose": "Purpose",
|
"purpose": "Purpose",
|
||||||
@@ -176,6 +177,7 @@
|
|||||||
"save_changes": "Save Changes"
|
"save_changes": "Save Changes"
|
||||||
},
|
},
|
||||||
"warehouse": {
|
"warehouse": {
|
||||||
|
"title": "Warehouse",
|
||||||
"container_list": "Container List",
|
"container_list": "Container List",
|
||||||
"container_detail": "Container Detail",
|
"container_detail": "Container Detail",
|
||||||
"overview": "Warehouse Overview",
|
"overview": "Warehouse Overview",
|
||||||
@@ -283,6 +285,7 @@
|
|||||||
"part_name": "Parts Name"
|
"part_name": "Parts Name"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
|
"title": "Schedule",
|
||||||
"my_schedule": "My Schedule",
|
"my_schedule": "My Schedule",
|
||||||
"event_title": "Event Title",
|
"event_title": "Event Title",
|
||||||
"event_date": "Event Date",
|
"event_date": "Event Date",
|
||||||
@@ -526,7 +529,31 @@
|
|||||||
"current_admins": "Current System Admins",
|
"current_admins": "Current System Admins",
|
||||||
"no_admins": "No system admins",
|
"no_admins": "No system admins",
|
||||||
"group_list": "Group List",
|
"group_list": "Group List",
|
||||||
"extended_info": "Extended Info"
|
"extended_info": "Extended Info",
|
||||||
|
"tab_operation_logs": "Operation Logs"
|
||||||
|
},
|
||||||
|
"operation_logs": {
|
||||||
|
"title": "Operation Logs",
|
||||||
|
"all": "All Modules",
|
||||||
|
"search_placeholder": "Search operation logs...",
|
||||||
|
"no_logs": "No operation logs",
|
||||||
|
"table_module": "Module",
|
||||||
|
"table_entity_id": "Entity ID",
|
||||||
|
"table_user": "User",
|
||||||
|
"table_action": "Action",
|
||||||
|
"table_ip": "IP Address",
|
||||||
|
"table_remark": "Remark",
|
||||||
|
"table_created_at": "Operation Time",
|
||||||
|
"module_customer": "Customer",
|
||||||
|
"module_purchase": "Purchase",
|
||||||
|
"module_schedule": "Schedule",
|
||||||
|
"module_warehouse": "Warehouse",
|
||||||
|
"module_work_order": "Work Order",
|
||||||
|
"action_create": "Create",
|
||||||
|
"action_update": "Update",
|
||||||
|
"action_delete": "Delete",
|
||||||
|
"action_query": "Query",
|
||||||
|
"action_move": "Move"
|
||||||
},
|
},
|
||||||
"customer": {
|
"customer": {
|
||||||
"title": "Customer Management",
|
"title": "Customer Management",
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
"click_button_or_drag": "点击上传按钮或拖放图片文件"
|
"click_button_or_drag": "点击上传按钮或拖放图片文件"
|
||||||
},
|
},
|
||||||
"purchase": {
|
"purchase": {
|
||||||
|
"title": "采购",
|
||||||
"purchase_list": "采购列表",
|
"purchase_list": "采购列表",
|
||||||
"item_name": "物品名称",
|
"item_name": "物品名称",
|
||||||
"purpose": "用途",
|
"purpose": "用途",
|
||||||
@@ -176,6 +177,7 @@
|
|||||||
"save_changes": "保存修改"
|
"save_changes": "保存修改"
|
||||||
},
|
},
|
||||||
"warehouse": {
|
"warehouse": {
|
||||||
|
"title": "仓库",
|
||||||
"container_list": "容器列表",
|
"container_list": "容器列表",
|
||||||
"container_detail": "容器详情",
|
"container_detail": "容器详情",
|
||||||
"overview": "仓库总览",
|
"overview": "仓库总览",
|
||||||
@@ -283,6 +285,7 @@
|
|||||||
"part_name": "物件名称"
|
"part_name": "物件名称"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
|
"title": "日程",
|
||||||
"my_schedule": "我的日程",
|
"my_schedule": "我的日程",
|
||||||
"event_title": "事件标题",
|
"event_title": "事件标题",
|
||||||
"event_date": "事件日期",
|
"event_date": "事件日期",
|
||||||
@@ -526,7 +529,31 @@
|
|||||||
"current_admins": "当前系统管理员列表",
|
"current_admins": "当前系统管理员列表",
|
||||||
"no_admins": "暂无系统管理员",
|
"no_admins": "暂无系统管理员",
|
||||||
"group_list": "用户组列表",
|
"group_list": "用户组列表",
|
||||||
"extended_info": "扩展信息"
|
"extended_info": "扩展信息",
|
||||||
|
"tab_operation_logs": "操作日志"
|
||||||
|
},
|
||||||
|
"operation_logs": {
|
||||||
|
"title": "操作日志",
|
||||||
|
"all": "全部模块",
|
||||||
|
"search_placeholder": "搜索操作记录...",
|
||||||
|
"no_logs": "暂无操作记录",
|
||||||
|
"table_module": "模块",
|
||||||
|
"table_entity_id": "实体ID",
|
||||||
|
"table_user": "操作人",
|
||||||
|
"table_action": "操作类型",
|
||||||
|
"table_ip": "IP地址",
|
||||||
|
"table_remark": "备注",
|
||||||
|
"table_created_at": "操作时间",
|
||||||
|
"module_customer": "客户",
|
||||||
|
"module_purchase": "采购",
|
||||||
|
"module_schedule": "日程",
|
||||||
|
"module_warehouse": "仓库",
|
||||||
|
"module_work_order": "工单",
|
||||||
|
"action_create": "创建",
|
||||||
|
"action_update": "更新",
|
||||||
|
"action_delete": "删除",
|
||||||
|
"action_query": "查询",
|
||||||
|
"action_move": "移动"
|
||||||
},
|
},
|
||||||
"customer": {
|
"customer": {
|
||||||
"title": "客户管理",
|
"title": "客户管理",
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onActivated } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useUsersStore } from '@/stores/users'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
import { IconSearch, IconRefresh, IconChevronLeft, IconChevronRight } from '@tabler/icons-vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const usersStore = useUsersStore()
|
||||||
|
|
||||||
|
const logs = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const search = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
|
||||||
|
|
||||||
|
// 模块列表
|
||||||
|
const modules = [
|
||||||
|
{ id: 'all', label: 'operation_logs.all' },
|
||||||
|
{ id: 'customer', label: 'customer.title' },
|
||||||
|
{ id: 'purchase', label: 'purchase.title' },
|
||||||
|
{ id: 'schedule', label: 'schedule.title' },
|
||||||
|
{ id: 'warehouse', label: 'warehouse.title' },
|
||||||
|
{ id: 'work_order', label: 'work_order.title' },
|
||||||
|
]
|
||||||
|
const activeModule = ref('all')
|
||||||
|
|
||||||
|
async function fetchLogs() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.getOperationLogs({
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
module: activeModule.value,
|
||||||
|
search: search.value,
|
||||||
|
})
|
||||||
|
if (res.errCode === 0) {
|
||||||
|
logs.value = res.data.logs || []
|
||||||
|
total.value = res.data.total || 0
|
||||||
|
page.value = res.data.page || 1
|
||||||
|
pageSize.value = res.data.page_size || 20
|
||||||
|
// 预加载用户信息
|
||||||
|
logs.value.forEach(log => {
|
||||||
|
if (log.user_id > 0) {
|
||||||
|
usersStore.fetchUser(log.user_id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 错误已由拦截器处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
page.value = 1
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(newPage) {
|
||||||
|
page.value = newPage
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onModuleChange(moduleId) {
|
||||||
|
activeModule.value = moduleId
|
||||||
|
page.value = 1
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActionType(actionType) {
|
||||||
|
const key = `operation_logs.action_${actionType}`
|
||||||
|
const text = t(key)
|
||||||
|
return text === key ? actionType : text
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModuleClass(module) {
|
||||||
|
const map = {
|
||||||
|
customer: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-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',
|
||||||
|
warehouse: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-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'
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ fetchLogs })
|
||||||
|
|
||||||
|
onActivated(() => fetchLogs())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('sysadmin.tab_operation_logs') }}</h2>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.total_logs', { count: total }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<!-- Left: Module Selector -->
|
||||||
|
<div class="w-48 flex-shrink-0">
|
||||||
|
<nav class="space-y-1">
|
||||||
|
<button
|
||||||
|
v-for="mod in modules"
|
||||||
|
:key="mod.id"
|
||||||
|
@click="onModuleChange(mod.id)"
|
||||||
|
:class="[
|
||||||
|
'w-full rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors',
|
||||||
|
activeModule === mod.id
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100 dark:text-dk-subtle dark:hover:bg-dk-muted',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ t(mod.label) }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Logs Table -->
|
||||||
|
<div class="flex-1 space-y-4">
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<IconSearch class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('operation_logs.search_placeholder')"
|
||||||
|
class="w-full rounded-md border border-gray-300 py-2 pl-9 pr-4 text-sm focus:border-blue-500 focus:outline-none dark:border-dk-muted dark:bg-dk-base dark:text-dk-text"
|
||||||
|
@keyup.enter="onSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="onSearch"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{{ t('common.search') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="fetchLogs"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 dark:border-dk-muted dark:text-dk-subtle dark:hover:bg-dk-card"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
<IconRefresh :size="18" :class="{ 'animate-spin': loading }" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logs Table -->
|
||||||
|
<div class="overflow-hidden rounded-md border border-gray-200 dark:border-dk-muted">
|
||||||
|
<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-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('operation_logs.table_module') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('operation_logs.table_entity_id') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('operation_logs.table_user') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('operation_logs.table_action') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('operation_logs.table_ip') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('operation_logs.table_remark') }}</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('operation_logs.table_created_at') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dk-muted dark:bg-dk-card">
|
||||||
|
<tr v-if="loading" class="text-center">
|
||||||
|
<td colspan="7" class="py-8 text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.loading') }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="logs.length === 0" class="text-center">
|
||||||
|
<td colspan="7" class="py-8 text-gray-500 dark:text-dk-subtle">{{ t('operation_logs.no_logs') }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="log in logs" :key="log.id" class="hover:bg-gray-50 dark:hover:bg-dk-base">
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<span :class="['rounded-full px-2 py-0.5 text-xs font-medium', getModuleClass(log.module)]">
|
||||||
|
{{ t(`operation_logs.module_${log.module}`) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-dk-text">{{ log.entity_id }}</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
v-if="log.user_id > 0"
|
||||||
|
:src="usersStore.getAvatarUrlFromUserID(log.user_id)"
|
||||||
|
class="h-6 w-6 rounded-full object-cover"
|
||||||
|
alt="avatar"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-900 dark:text-dk-text">
|
||||||
|
{{ usersStore.getUsernameFromUserID(log.user_id) || 'ID: ' + log.user_id }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<span :class="['rounded-full px-2 py-0.5 text-xs font-medium', log.action_type === 'delete' ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400']">
|
||||||
|
{{ formatActionType(log.action_type) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm font-mono text-gray-500 dark:text-dk-subtle">{{ log.ip }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500 dark:text-dk-subtle max-w-xs truncate">{{ log.remark || '-' }}</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-dk-subtle">{{ log.created_at ? new Date(log.created_at).toLocaleString() : '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('sysadmin.pagination', { current: page, total: totalPages }) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
@click="onPageChange(page - 1)"
|
||||||
|
:disabled="page <= 1 || loading"
|
||||||
|
class="flex items-center gap-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50 dark:border-dk-muted dark:text-dk-text"
|
||||||
|
>
|
||||||
|
<IconChevronLeft :size="16" /> {{ t('sysadmin.prev_page') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="onPageChange(page + 1)"
|
||||||
|
:disabled="page >= totalPages || loading"
|
||||||
|
class="flex items-center gap-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50 dark:border-dk-muted dark:text-dk-text"
|
||||||
|
>
|
||||||
|
{{ t('sysadmin.next_page') }} <IconChevronRight :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -8,6 +8,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import UsersTab from '@/views/sysadmin/UsersTab.vue'
|
import UsersTab from '@/views/sysadmin/UsersTab.vue'
|
||||||
import GroupsTab from '@/views/sysadmin/GroupsTab.vue'
|
import GroupsTab from '@/views/sysadmin/GroupsTab.vue'
|
||||||
import LogsTab from '@/views/sysadmin/LogsTab.vue'
|
import LogsTab from '@/views/sysadmin/LogsTab.vue'
|
||||||
|
import OperationLogsTab from '@/views/sysadmin/OperationLogsTab.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -22,11 +23,13 @@ const loading = ref(false)
|
|||||||
const usersTabRef = ref(null)
|
const usersTabRef = ref(null)
|
||||||
const groupsTabRef = ref(null)
|
const groupsTabRef = ref(null)
|
||||||
const logsTabRef = ref(null)
|
const logsTabRef = ref(null)
|
||||||
|
const operationLogsTabRef = ref(null)
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'users', label: t('sysadmin.tab_users') },
|
{ id: 'users', label: t('sysadmin.tab_users') },
|
||||||
{ id: 'groups', label: t('sysadmin.tab_groups') },
|
{ id: 'groups', label: t('sysadmin.tab_groups') },
|
||||||
{ id: 'logs', label: t('sysadmin.tab_logs') },
|
{ 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: 'customer', label: t('customer.title'), to: '/customer' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -93,6 +96,7 @@ onMounted(() => {
|
|||||||
<UsersTab v-if="activeTab === 'users'" ref="usersTabRef" />
|
<UsersTab v-if="activeTab === 'users'" ref="usersTabRef" />
|
||||||
<GroupsTab v-else-if="activeTab === 'groups'" ref="groupsTabRef" />
|
<GroupsTab v-else-if="activeTab === 'groups'" ref="groupsTabRef" />
|
||||||
<LogsTab v-else-if="activeTab === 'logs'" ref="logsTabRef" />
|
<LogsTab v-else-if="activeTab === 'logs'" ref="logsTabRef" />
|
||||||
|
<OperationLogsTab v-else-if="activeTab === 'operation_logs'" ref="operationLogsTabRef" />
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user