267 lines
11 KiB
Vue
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>
|