up实现查看日志功能

This commit is contained in:
2026-04-29 17:09:08 +08:00
parent 81ae9304a3
commit 43002c4745
8 changed files with 593 additions and 123 deletions
+21 -54
View File
@@ -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` 节点
+5 -5
View File
@@ -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,
+215 -8
View File
@@ -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"`
} }
@@ -396,9 +410,9 @@ func ApiSysAdmin(r *gin.RouterGroup) {
} }
ReturnJson(ctx, "apiOK", nil) ReturnJson(ctx, "apiOK", nil)
}) })
// 移除用户组成员(仅系统管理员可访问) // 移除用户组成员(仅系统管理员可访问)
r.POST("/remove_group_member", func(ctx *gin.Context) { r.POST("/remove_group_member", func(ctx *gin.Context) {
isAuth, adminUser, data := AuthenticationAuthority(ctx) isAuth, adminUser, data := AuthenticationAuthority(ctx)
if !isAuth { if !isAuth {
@@ -451,16 +465,209 @@ 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, &params); 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"`
+5
View File
@@ -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 })
+28 -1
View File
@@ -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",
+28 -1
View File
@@ -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>