Files
toolbox/resources/js/components/admin/OperationLogs.vue
2025-12-25 19:16:41 +08:00

267 lines
11 KiB
Vue

<template>
<div class="space-y-4">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 flex flex-col h-full">
<!-- Header & Filters -->
<div class="bg-gray-50 px-4 py-3 border-b border-gray-200 flex flex-col md:flex-row justify-between items-start md:items-center gap-3">
<div class="flex items-center gap-2">
<h3 class="text-base font-bold text-gray-800">操作日志</h3>
<span v-if="currentIp" class="text-xs bg-blue-50 text-blue-600 px-2 py-0.5 rounded border border-blue-100 font-mono">
IP: {{ currentIp }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2 w-full md:w-auto">
<div class="relative flex-grow md:flex-grow-0">
<input
v-model="filters.q"
type="text"
class="w-full md:w-56 pl-3 pr-8 py-1.5 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500"
placeholder="搜索 IP / 用户 / 路径"
@keyup.enter="applyFilters"
/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4 absolute right-2.5 top-2 text-gray-400">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
</svg>
</div>
<select
v-model="filters.method"
class="py-1.5 pl-2 pr-8 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 bg-white"
@change="applyFilters"
>
<option value="">全部方法</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</select>
<select
v-model.number="logsPerPage"
class="py-1.5 pl-2 pr-8 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 bg-white"
@change="applyFilters"
>
<option :value="20">20/</option>
<option :value="50">50/</option>
<option :value="100">100/</option>
</select>
<button
class="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-gray-100 rounded transition-colors"
:disabled="logsLoading"
@click="loadLogs"
title="刷新"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5" :class="{'animate-spin': logsLoading}">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0v2.433l-.31-.31a7 7 0 00-11.712 3.138.75.75 0 001.449.39 5.5 5.5 0 019.201-2.466l.312.312h-2.433a.75.75 0 000 1.5h4.185a.75.75 0 00.53-.219z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<div v-if="logsError" class="mx-4 mt-4 text-sm text-red-600 bg-red-50 px-3 py-2 rounded border border-red-100">
{{ logsError }}
</div>
<!-- Table -->
<div class="overflow-x-auto flex-1">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-40">时间</th>
<th v-if="isAdmin" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-32">用户</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-36">IP</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20">方法</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">路径</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20">状态</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20">Payload</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<template v-for="log in logs" :key="log.id">
<tr class="hover:bg-gray-50 text-sm">
<td class="px-4 py-2 whitespace-nowrap text-gray-500">{{ formatTime(log.created_at) }}</td>
<td v-if="isAdmin" class="px-4 py-2 whitespace-nowrap font-medium text-gray-700">{{ log.user_label || '-' }}</td>
<td class="px-4 py-2 whitespace-nowrap font-mono text-gray-600">{{ log.ip_address }}</td>
<td class="px-4 py-2 whitespace-nowrap font-bold" :class="methodColor(log.method)">{{ log.method }}</td>
<td class="px-4 py-2 whitespace-nowrap text-gray-600 font-mono truncate max-w-xs" :title="log.path">{{ log.path }}</td>
<td class="px-4 py-2 whitespace-nowrap">
<span :class="statusColor(log.status_code)" class="px-2 py-0.5 rounded text-xs font-mono border">
{{ log.status_code }}
</span>
</td>
<td class="px-4 py-2 whitespace-nowrap">
<button
v-if="hasPayload(log.request_payload)"
@click="log._expanded = !log._expanded"
class="text-blue-600 hover:text-blue-800 text-xs font-medium flex items-center gap-1 focus:outline-none"
>
{{ log._expanded ? '收起' : '查看' }}
</button>
<span v-else class="text-gray-300">-</span>
</td>
</tr>
<!-- Expanded Payload Row -->
<tr v-if="log._expanded && hasPayload(log.request_payload)" class="bg-gray-50/50">
<td :colspan="logColumnCount" class="px-4 py-3 border-b border-gray-100">
<pre class="text-xs font-mono text-gray-600 bg-white border border-gray-200 rounded p-3 overflow-x-auto custom-scrollbar">{{ formatPayload(log.request_payload) }}</pre>
</td>
</tr>
</template>
<tr v-if="logs.length === 0">
<td :colspan="logColumnCount" class="px-4 py-10 text-center text-sm text-gray-400">
暂无操作记录
</td>
</tr>
</tbody>
</table>
</div>
<!-- Footer / Pagination -->
<div class="bg-gray-50 border-t border-gray-200 px-4 py-3 flex items-center justify-between">
<div class="text-sm text-gray-500 font-medium">
<span class="text-gray-900">{{ logsTotal }}</span> 条记录
</div>
<div class="flex items-center gap-2">
<button
class="p-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-30 disabled:hover:bg-white text-gray-600 transition-colors shadow-sm"
:disabled="logsPage <= 1"
@click="changePage(logsPage - 1)"
title="上一页"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
</svg>
</button>
<span class="text-sm font-semibold text-gray-700 min-w-[4rem] text-center">
{{ logsPage }} / {{ logsLastPage || 1 }}
</span>
<button
class="p-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-30 disabled:hover:bg-white text-gray-600 transition-colors shadow-sm"
:disabled="logsPage >= logsLastPage"
@click="changePage(logsPage + 1)"
title="下一页"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'OperationLogs',
props: {
isAdmin: {
type: Boolean,
default: false
},
currentIp: {
type: String,
default: ''
}
},
data() {
return {
logs: [],
logsLoading: false,
logsError: '',
logsPage: 1,
logsPerPage: 50,
logsTotal: 0,
logsLastPage: 1,
filters: {
q: '',
method: ''
}
};
},
computed: {
logColumnCount() {
return this.isAdmin ? 7 : 6;
}
},
mounted() {
this.loadLogs();
},
methods: {
formatTime(value) {
if (!value) {
return '-';
}
return value.replace('T', ' ').replace('Z', '').substring(0, 19);
},
hasPayload(payload) {
return payload && Object.keys(payload).length > 0;
},
formatPayload(payload) {
return JSON.stringify(payload, null, 2);
},
methodColor(method) {
const map = {
'POST': 'text-green-600',
'PUT': 'text-amber-600',
'PATCH': 'text-amber-600',
'DELETE': 'text-red-600'
};
return map[method] || 'text-gray-600';
},
statusColor(code) {
if (code >= 200 && code < 300) return 'text-green-700 bg-green-50 border-green-200';
if (code >= 300 && code < 400) return 'text-blue-700 bg-blue-50 border-blue-200';
if (code >= 400 && code < 500) return 'text-amber-700 bg-amber-50 border-amber-200';
if (code >= 500) return 'text-red-700 bg-red-50 border-red-200';
return 'text-gray-700 bg-gray-50 border-gray-200';
},
buildLogQuery() {
const params = new URLSearchParams();
params.set('page', String(this.logsPage));
params.set('per_page', String(this.logsPerPage));
if (this.filters.q) {
params.set('q', this.filters.q);
}
if (this.filters.method) {
params.set('method', this.filters.method);
}
return params.toString();
},
async loadLogs() {
this.logsLoading = true;
this.logsError = '';
try {
const response = await fetch(`/api/operation-logs?${this.buildLogQuery()}`);
const data = await response.json();
if (!data.success) {
this.logsError = data.message || '加载失败';
return;
}
const pagination = data.data.pagination || {};
this.logs = (data.data.logs || []).map(l => ({...l, _expanded: false}));
this.logsTotal = pagination.total || 0;
this.logsLastPage = pagination.last_page || 1;
this.logsPage = pagination.current_page || 1;
} catch (error) {
this.logsError = error.message;
} finally {
this.logsLoading = false;
}
},
applyFilters() {
this.logsPage = 1;
this.loadLogs();
},
changePage(page) {
this.logsPage = page;
this.loadLogs();
}
}
};
</script>