Files
toolbox/resources/js/components/admin/OperationLogs.vue

217 lines
7.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="space-y-6">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-medium text-gray-900">操作日志</h3>
<p class="text-gray-500 mt-1">记录对系统产生影响的请求POST/PUT/PATCH/DELETE</p>
<p v-if="currentIp" class="text-xs text-gray-400 mt-1">当前 IP: {{ currentIp }}</p>
</div>
<button
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50"
:disabled="logsLoading"
@click="loadLogs"
>
{{ logsLoading ? '加载中...' : '刷新' }}
</button>
</div>
<div class="mt-6 flex flex-wrap items-center gap-3">
<input
v-model="filters.q"
type="text"
class="w-full md:w-64 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="搜索 IP / 用户 / 路径"
/>
<select
v-model="filters.method"
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<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="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option :value="20">20/</option>
<option :value="50">50/</option>
<option :value="100">100/</option>
</select>
<button
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
:disabled="logsLoading"
@click="applyFilters"
>
查询
</button>
</div>
<div v-if="logsError" class="mt-4 text-sm text-red-700 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{{ logsError }}
</div>
<div v-if="logsLoading" class="mt-4 text-sm text-gray-400">加载中...</div>
<div v-else class="mt-4">
<div class="overflow-x-auto border border-gray-200 rounded-lg">
<table class="min-w-full text-sm">
<thead class="bg-gray-50 text-gray-600">
<tr>
<th class="text-left px-4 py-3 font-medium">时间</th>
<th v-if="isAdmin" class="text-left px-4 py-3 font-medium">用户</th>
<th class="text-left px-4 py-3 font-medium">IP</th>
<th class="text-left px-4 py-3 font-medium">方法</th>
<th class="text-left px-4 py-3 font-medium">路径</th>
<th class="text-left px-4 py-3 font-medium">状态</th>
<th class="text-left px-4 py-3 font-medium">请求数据</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="log in logs" :key="log.id" class="text-gray-700">
<td class="px-4 py-3 whitespace-nowrap">{{ formatTime(log.created_at) }}</td>
<td v-if="isAdmin" class="px-4 py-3 whitespace-nowrap font-mono">{{ log.user_label || '-' }}</td>
<td class="px-4 py-3 whitespace-nowrap font-mono">{{ log.ip_address }}</td>
<td class="px-4 py-3 whitespace-nowrap font-mono">{{ log.method }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ log.path }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ log.status_code }}</td>
<td class="px-4 py-3">
<details v-if="hasPayload(log.request_payload)">
<summary class="cursor-pointer text-blue-600">查看</summary>
<pre class="mt-2 text-xs bg-gray-50 p-2 rounded whitespace-pre-wrap">{{ formatPayload(log.request_payload) }}</pre>
</details>
<span v-else>-</span>
</td>
</tr>
<tr v-if="logs.length === 0">
<td :colspan="logColumnCount" class="px-4 py-6 text-center text-gray-400">暂无记录</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between text-sm text-gray-500">
<div> {{ logsTotal }} </div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1 bg-gray-100 rounded-md hover:bg-gray-200 disabled:opacity-50"
:disabled="logsPage <= 1"
@click="changePage(logsPage - 1)"
>
上一页
</button>
<div> {{ logsPage }} / {{ logsLastPage || 1 }} </div>
<button
class="px-3 py-1 bg-gray-100 rounded-md hover:bg-gray-200 disabled:opacity-50"
:disabled="logsPage >= logsLastPage"
@click="changePage(logsPage + 1)"
>
下一页
</button>
</div>
</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', '');
},
hasPayload(payload) {
return payload && Object.keys(payload).length > 0;
},
formatPayload(payload) {
return JSON.stringify(payload, null, 2);
},
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 || [];
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>