1. Interactions.vue: 每30秒自动刷新互动记录列表 2. Dashboard.vue: 每30秒自动刷新数据看板 3. Logs.vue: 时间格式修复(T→空格,去掉时区标识) 4. logs.py: created_at 改用 strftime 输出 +08:00 格式(而非 isoformat 的 +00:00) 页面保持浏览时自动获取最新数据,离开页面时自动清除定时器
188 lines
8.1 KiB
Vue
188 lines
8.1 KiB
Vue
<template>
|
|
<div class="page-container">
|
|
<div class="page-header">
|
|
<span class="page-title">日志管理</span>
|
|
</div>
|
|
|
|
<el-tabs v-model="activeTab" class="log-tabs">
|
|
<!-- Login logs -->
|
|
<el-tab-pane label="登录日志" name="login">
|
|
<div class="filter-bar">
|
|
<el-select v-model="loginFilter.action" placeholder="操作类型" clearable @change="loadLoginLogs" style="width:130px">
|
|
<el-option label="登录" value="login"/>
|
|
<el-option label="登出" value="logout"/>
|
|
<el-option label="刷新" value="refresh"/>
|
|
<el-option label="失败" value="fail"/>
|
|
</el-select>
|
|
<el-button @click="loadLoginLogs"><el-icon><Refresh /></el-icon></el-button>
|
|
</div>
|
|
<el-table :data="loginLogs" v-loading="loginLoading" stripe class="data-table">
|
|
<el-table-column label="用户账号" prop="user_account" width="180" />
|
|
<el-table-column label="操作" width="90">
|
|
<template #default="{ row }">
|
|
<el-tag :type="actionType[row.action] || 'info'" size="small">{{ actionLabel[row.action] || row.action }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="会话ID" prop="session_id" show-overflow-tooltip />
|
|
<el-table-column label="失败原因" prop="error_msg" show-overflow-tooltip>
|
|
<template #default="{ row }">
|
|
<span style="color:var(--color-accent-red);font-size:12px">{{ row.error_msg || '--' }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="时间" width="160">
|
|
<template #default="{ row }">
|
|
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.created_at ? row.created_at.replace('T',' ').replace('+08:00','').replace('+00:00','').slice(0,16) : '--' }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<el-pagination v-model:current-page="loginPage" :page-size="50" :total="loginTotal"
|
|
layout="total, prev, pager, next" @current-change="loadLoginLogs" style="margin-top:12px;display:flex;justify-content:flex-end" />
|
|
</el-tab-pane>
|
|
|
|
<!-- Log files -->
|
|
<el-tab-pane label="日志文件" name="files">
|
|
<div style="display:flex;gap:16px;height:600px">
|
|
<!-- File list -->
|
|
<div class="file-list">
|
|
<div class="file-list-header">日志文件</div>
|
|
<div
|
|
v-for="f in logFiles" :key="f.name"
|
|
class="file-item" :class="{ active: selectedFile === f.name }"
|
|
@click="openFile(f.name)"
|
|
>
|
|
<el-icon style="flex-shrink:0"><Document /></el-icon>
|
|
<div class="file-info">
|
|
<div class="file-name">{{ f.name }}</div>
|
|
<div class="file-size">{{ f.size_kb }} KB</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="!logFiles.length" style="padding:20px;color:var(--color-text-muted);font-size:13px;text-align:center">暂无日志文件</div>
|
|
</div>
|
|
|
|
<!-- File content -->
|
|
<div class="file-content" v-if="selectedFile">
|
|
<div class="file-toolbar">
|
|
<span style="font-size:13px;font-weight:600">{{ selectedFile }}</span>
|
|
<div style="display:flex;gap:8px">
|
|
<el-select v-model="tailLines" style="width:100px" @change="loadFileContent">
|
|
<el-option label="最后100行" :value="100"/>
|
|
<el-option label="最后500行" :value="500"/>
|
|
<el-option label="最后1000行" :value="1000"/>
|
|
</el-select>
|
|
<el-button size="small" @click="loadFileContent"><el-icon><Refresh /></el-icon></el-button>
|
|
<el-button size="small" type="primary" @click="downloadFile">下载</el-button>
|
|
</div>
|
|
</div>
|
|
<div class="log-content" ref="logContentRef">
|
|
<div v-for="(line, i) in fileLines" :key="i" class="log-line" :class="getLineClass(line)">{{ line }}</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="file-empty">
|
|
<el-empty description="选择左侧日志文件查看内容" />
|
|
</div>
|
|
</div>
|
|
</el-tab-pane>
|
|
</el-tabs>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, reactive, nextTick, onMounted } from 'vue'
|
|
import { getLoginLogs, getLogFiles, tailLogFile } from '@/api'
|
|
|
|
const activeTab = ref('login')
|
|
const loginLogs = ref([])
|
|
const loginLoading = ref(false)
|
|
const loginPage = ref(1)
|
|
const loginTotal = ref(0)
|
|
const loginFilter = reactive({ action: '' })
|
|
|
|
const logFiles = ref([])
|
|
const selectedFile = ref('')
|
|
const fileLines = ref([])
|
|
const tailLines = ref(100)
|
|
const logContentRef = ref()
|
|
|
|
const actionLabel = { login: '登录', logout: '登出', refresh: '刷新', fail: '失败' }
|
|
const actionType = { login: 'success', logout: 'info', refresh: 'warning', fail: 'danger' }
|
|
|
|
function getLineClass(line) {
|
|
if (line.includes('ERROR') || line.includes('error')) return 'line-error'
|
|
if (line.includes('WARNING') || line.includes('WARN')) return 'line-warn'
|
|
if (line.includes('INFO')) return 'line-info'
|
|
return ''
|
|
}
|
|
|
|
async function loadLoginLogs() {
|
|
loginLoading.value = true
|
|
try {
|
|
const res = await getLoginLogs({ page: loginPage.value, page_size: 50, action: loginFilter.action || undefined })
|
|
loginLogs.value = res.data?.items || []
|
|
loginTotal.value = res.data?.total || 0
|
|
} finally { loginLoading.value = false }
|
|
}
|
|
|
|
async function loadLogFiles() {
|
|
const res = await getLogFiles()
|
|
logFiles.value = res.data || []
|
|
}
|
|
|
|
async function openFile(name) {
|
|
selectedFile.value = name
|
|
await loadFileContent()
|
|
}
|
|
|
|
async function loadFileContent() {
|
|
if (!selectedFile.value) return
|
|
const res = await tailLogFile(selectedFile.value, tailLines.value)
|
|
fileLines.value = (res.data?.lines || []).map(l => l.replace(/\n$/, ''))
|
|
await nextTick()
|
|
if (logContentRef.value) logContentRef.value.scrollTop = logContentRef.value.scrollHeight
|
|
}
|
|
|
|
function downloadFile() {
|
|
window.open(`/api/logs/files/${selectedFile.value}/download`, '_blank')
|
|
}
|
|
|
|
onMounted(() => { loadLoginLogs(); loadLogFiles() })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; }
|
|
.data-table { border-radius: 8px; }
|
|
:deep(.log-tabs .el-tabs__header) { margin-bottom: 16px; }
|
|
:deep(.el-tabs__item) { color: var(--color-text-muted) !important; }
|
|
:deep(.el-tabs__item.is-active) { color: var(--color-accent) !important; }
|
|
|
|
.file-list {
|
|
width: 220px; flex-shrink: 0;
|
|
background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 8px;
|
|
overflow-y: auto;
|
|
}
|
|
.file-list-header { padding: 12px 14px; font-size: 12px; font-weight: 600; color: var(--color-text-muted); border-bottom: 1px solid var(--color-border); }
|
|
.file-item {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 10px 14px; cursor: pointer; transition: background 0.15s;
|
|
border-bottom: 1px solid var(--color-border-light);
|
|
}
|
|
.file-item:hover { background: rgba(88,166,255,0.06); }
|
|
.file-item.active { background: rgba(88,166,255,0.12); color: var(--color-accent); }
|
|
.file-name { font-size: 12px; font-weight: 500; }
|
|
.file-size { font-size: 11px; color: var(--color-text-muted); }
|
|
|
|
.file-content {
|
|
flex: 1; display: flex; flex-direction: column;
|
|
background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 8px; overflow: hidden;
|
|
}
|
|
.file-toolbar {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 10px 14px; border-bottom: 1px solid var(--color-border); flex-shrink: 0;
|
|
}
|
|
.log-content { flex: 1; overflow-y: auto; padding: 10px 14px; font-family: 'Consolas','Monaco',monospace; font-size: 12px; line-height: 1.6; }
|
|
.log-line { padding: 1px 0; color: var(--color-text-muted); white-space: pre-wrap; word-break: break-all; }
|
|
.line-error { color: var(--color-accent-red); }
|
|
.line-warn { color: var(--color-accent-orange); }
|
|
.line-info { color: var(--color-text); }
|
|
.file-empty { flex: 1; display: flex; align-items: center; justify-content: center; background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 8px; }
|
|
</style>
|