Files
huihuiSquare/frontend/src/views/Logs.vue
T
stefanfeng b43ee777fc feat: 互动记录/数据看板自动刷新 + 日志时间格式修复
1. Interactions.vue: 每30秒自动刷新互动记录列表
2. Dashboard.vue: 每30秒自动刷新数据看板
3. Logs.vue: 时间格式修复(T→空格,去掉时区标识)
4. logs.py: created_at 改用 strftime 输出 +08:00 格式(而非 isoformat 的 +00:00)

页面保持浏览时自动获取最新数据,离开页面时自动清除定时器
2026-04-07 14:51:59 +08:00

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>