#feature: update layout

This commit is contained in:
2025-12-25 19:16:41 +08:00
parent cd11a856bb
commit cd1e3d4859
3 changed files with 362 additions and 276 deletions

View File

@@ -1,120 +1,154 @@
<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 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>
<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">
<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>
<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">
<!-- 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="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>
<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>
<tr v-if="logs.length === 0">
<td :colspan="logColumnCount" class="px-4 py-6 text-center text-gray-400">暂无记录</td>
<!-- 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>
</tbody>
</table>
</div>
</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>
<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>
<!-- 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>
@@ -159,7 +193,7 @@ export default {
if (!value) {
return '-';
}
return value.replace('T', ' ').replace('Z', '');
return value.replace('T', ' ').replace('Z', '').substring(0, 19);
},
hasPayload(payload) {
return payload && Object.keys(payload).length > 0;
@@ -167,6 +201,22 @@ export default {
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));
@@ -193,7 +243,7 @@ export default {
}
const pagination = data.data.pagination || {};
this.logs = data.data.logs || [];
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;