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
+5
View File
@@ -61,6 +61,11 @@ export const authApi = {
return api.post('/admin/login_fail_logs', params)
},
/** 获取操作日志(仅管理员可访问) */
getOperationLogs(params = {}) {
return api.post('/admin/operation_logs', params)
},
/** 修改密码 */
changePassword(oldPass, 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"
},
"purchase": {
"title": "Purchase",
"purchase_list": "Purchase List",
"item_name": "Item Name",
"purpose": "Purpose",
@@ -176,6 +177,7 @@
"save_changes": "Save Changes"
},
"warehouse": {
"title": "Warehouse",
"container_list": "Container List",
"container_detail": "Container Detail",
"overview": "Warehouse Overview",
@@ -283,6 +285,7 @@
"part_name": "Parts Name"
},
"schedule": {
"title": "Schedule",
"my_schedule": "My Schedule",
"event_title": "Event Title",
"event_date": "Event Date",
@@ -526,7 +529,31 @@
"current_admins": "Current System Admins",
"no_admins": "No system admins",
"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": {
"title": "Customer Management",
+28 -1
View File
@@ -67,6 +67,7 @@
"click_button_or_drag": "点击上传按钮或拖放图片文件"
},
"purchase": {
"title": "采购",
"purchase_list": "采购列表",
"item_name": "物品名称",
"purpose": "用途",
@@ -176,6 +177,7 @@
"save_changes": "保存修改"
},
"warehouse": {
"title": "仓库",
"container_list": "容器列表",
"container_detail": "容器详情",
"overview": "仓库总览",
@@ -283,6 +285,7 @@
"part_name": "物件名称"
},
"schedule": {
"title": "日程",
"my_schedule": "我的日程",
"event_title": "事件标题",
"event_date": "事件日期",
@@ -526,7 +529,31 @@
"current_admins": "当前系统管理员列表",
"no_admins": "暂无系统管理员",
"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": {
"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 GroupsTab from '@/views/sysadmin/GroupsTab.vue'
import LogsTab from '@/views/sysadmin/LogsTab.vue'
import OperationLogsTab from '@/views/sysadmin/OperationLogsTab.vue'
const { t } = useI18n()
const userStore = useUserStore()
@@ -22,11 +23,13 @@ const loading = ref(false)
const usersTabRef = ref(null)
const groupsTabRef = ref(null)
const logsTabRef = ref(null)
const operationLogsTabRef = ref(null)
const tabs = [
{ id: 'users', label: t('sysadmin.tab_users') },
{ id: 'groups', label: t('sysadmin.tab_groups') },
{ id: 'logs', label: t('sysadmin.tab_logs') },
{ id: 'operation_logs', label: t('sysadmin.tab_operation_logs') },
{ id: 'customer', label: t('customer.title'), to: '/customer' },
]
@@ -93,6 +96,7 @@ onMounted(() => {
<UsersTab v-if="activeTab === 'users'" ref="usersTabRef" />
<GroupsTab v-else-if="activeTab === 'groups'" ref="groupsTabRef" />
<LogsTab v-else-if="activeTab === 'logs'" ref="logsTabRef" />
<OperationLogsTab v-else-if="activeTab === 'operation_logs'" ref="operationLogsTabRef" />
</KeepAlive>
</div>