feat(system-logs): 两端平台新增服务日志查看页面

- tenant-platform: /system-logs 页面,私有化模式下侧边栏显示;支持服务切换、
  行数选择、刷新间隔配置(5s/10s/30s/1min)、自动滚动
- ops-platform: /system-logs 页面,始终可见;复用相同交互,通过 ROLE_OPS 接口获取日志

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-22 23:22:58 +08:00
父节点 e999d4d443
当前提交 ad734ff204
共有 8 个文件被更改,包括 661 次插入3 次删除

查看文件

@ -279,4 +279,13 @@ export const opsApi = {
sendPushTestOffline: (payload: { appKey: string; userId: string; title: string; body: string; payload?: string }) => sendPushTestOffline: (payload: { appKey: string; userId: string; title: string; body: string; payload?: string }) =>
client.post<{ data: PushTestResult }>('/ops/push/test-offline', payload), client.post<{ data: PushTestResult }>('/ops/push/test-offline', payload),
getRunningServices: () =>
client.get<{ data: string[] }>('/ops/system/services'),
fetchServiceLogs: (service: string, lines: number) =>
client.get<string>(`/ops/system/logs/${encodeURIComponent(service)}`, {
params: { lines },
responseType: 'text',
}),
} }

查看文件

@ -19,6 +19,7 @@ const router = createRouter({
{ path: 'push', component: () => import('@/views/push/PushDiagnosticsView.vue') }, { path: 'push', component: () => import('@/views/push/PushDiagnosticsView.vue') },
{ path: 'operation-logs', component: () => import('@/views/logs/OperationLogView.vue') }, { path: 'operation-logs', component: () => import('@/views/logs/OperationLogView.vue') },
{ path: 'risk-control', component: () => import('@/views/risk/RiskControlView.vue') }, { path: 'risk-control', component: () => import('@/views/risk/RiskControlView.vue') },
{ path: 'system-logs', component: () => import('@/views/system/ServerLogsView.vue') },
], ],
}, },
], ],

查看文件

@ -66,7 +66,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Avatar, Bell, Menu, TrendCharts, Grid, Document, Warning, Promotion } from '@element-plus/icons-vue' import { Avatar, Bell, Menu, Monitor, TrendCharts, Grid, Document, Warning, Promotion } from '@element-plus/icons-vue'
const router = useRouter() const router = useRouter()
const isMobile = ref(false) const isMobile = ref(false)
@ -79,6 +79,7 @@ const navItems = computed(() => [
{ path: '/apps', label: '应用管理', icon: Grid }, { path: '/apps', label: '应用管理', icon: Grid },
{ path: '/push', label: 'Push 诊断', icon: Promotion }, { path: '/push', label: 'Push 诊断', icon: Promotion },
{ path: '/operation-logs', label: '操作日志', icon: Document }, { path: '/operation-logs', label: '操作日志', icon: Document },
{ path: '/system-logs', label: '服务日志', icon: Monitor },
{ path: '/risk-control', label: '风控配置', icon: Warning }, { path: '/risk-control', label: '风控配置', icon: Warning },
]) ])

查看文件

@ -0,0 +1,292 @@
<template>
<div>
<div class="page-header">
<div>
<h2>服务日志</h2>
<p class="subtitle">查看各服务的运行日志支持定时自动刷新</p>
</div>
</div>
<el-card style="margin-bottom: 12px">
<el-skeleton v-if="loadingServices" :rows="1" animated style="margin-bottom:12px" />
<template v-else-if="services.length === 0">
<el-empty description="未检测到正在运行的服务" :image-size="60" />
</template>
<template v-else>
<el-tabs v-model="activeService" type="card" class="service-tabs" @tab-change="onServiceChange">
<el-tab-pane v-for="svc in services" :key="svc" :label="svc" :name="svc" />
</el-tabs>
<div class="controls">
<div class="controls-left">
<span class="control-label">显示行数</span>
<el-select v-model="lineCount" style="width:100px" size="small" @change="fetchLogs">
<el-option :value="100" label="100 行" />
<el-option :value="200" label="200 行" />
<el-option :value="500" label="500 行" />
<el-option :value="1000" label="1000 行" />
</el-select>
<span class="control-label">刷新间隔</span>
<el-select v-model="refreshInterval" style="width:110px" size="small" @change="onIntervalChange">
<el-option :value="0" label="手动" />
<el-option :value="5" label="5 秒" />
<el-option :value="10" label="10 秒" />
<el-option :value="30" label="30 秒" />
<el-option :value="60" label="1 分钟" />
</el-select>
<el-button
v-if="refreshInterval > 0"
:type="autoRefreshing ? 'danger' : 'success'"
size="small"
@click="toggleAutoRefresh"
>
{{ autoRefreshing ? '停止刷新' : '开始刷新' }}
</el-button>
</div>
<div class="controls-right">
<el-checkbox v-model="autoScroll" size="small">自动滚动</el-checkbox>
<el-button size="small" :loading="fetching" @click="fetchLogs">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button size="small" @click="clearLog">清除</el-button>
</div>
</div>
<div class="status-bar">
<template v-if="lastFetchTime">
<span>最后更新{{ lastFetchTime }}</span>
<span class="sep">·</span>
<span>{{ lineCount }} </span>
<span v-if="autoRefreshing" class="live-badge">
<span class="live-dot" />
实时刷新
</span>
<span v-if="fetchError" class="error-badge">{{ fetchError }}</span>
</template>
<template v-else-if="fetching">
<span>加载中...</span>
</template>
<template v-else>
<span style="color:#c0c4cc">尚未加载</span>
</template>
</div>
</template>
</el-card>
<el-card v-if="services.length > 0" class="log-card">
<div class="log-wrap">
<pre ref="logEl" class="log-content">{{ logContent || (fetching ? '加载中...' : '暂无日志,点击刷新按钮获取。') }}</pre>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { opsApi } from '@/api/ops'
const services = ref<string[]>([])
const activeService = ref('')
const loadingServices = ref(false)
const lineCount = ref(200)
const refreshInterval = ref(0)
const autoRefreshing = ref(false)
const autoScroll = ref(true)
const logContent = ref('')
const fetching = ref(false)
const lastFetchTime = ref('')
const fetchError = ref('')
const logEl = ref<HTMLPreElement | null>(null)
let refreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
loadingServices.value = true
try {
const res = await opsApi.getRunningServices()
services.value = res.data.data
if (services.value.length > 0) {
activeService.value = services.value[0]
await fetchLogs()
}
} catch {
ElMessage.error('获取服务列表失败')
} finally {
loadingServices.value = false
}
})
onUnmounted(() => {
stopAutoRefresh()
})
async function onServiceChange() {
logContent.value = ''
lastFetchTime.value = ''
fetchError.value = ''
if (autoRefreshing.value) {
stopAutoRefresh()
await fetchLogs()
startAutoRefresh()
} else {
await fetchLogs()
}
}
async function fetchLogs() {
if (!activeService.value || fetching.value) return
fetching.value = true
fetchError.value = ''
try {
const res = await opsApi.fetchServiceLogs(activeService.value, lineCount.value)
logContent.value = res.data as unknown as string
lastFetchTime.value = new Date().toLocaleTimeString()
if (autoScroll.value) {
await nextTick()
scrollToBottom()
}
} catch (e: any) {
fetchError.value = e?.message ?? '获取失败'
} finally {
fetching.value = false
}
}
function clearLog() {
logContent.value = ''
lastFetchTime.value = ''
fetchError.value = ''
}
function scrollToBottom() {
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
}
function onIntervalChange() {
stopAutoRefresh()
if (refreshInterval.value > 0) startAutoRefresh()
}
function toggleAutoRefresh() {
autoRefreshing.value ? stopAutoRefresh() : startAutoRefresh()
}
function startAutoRefresh() {
if (refreshInterval.value <= 0) return
autoRefreshing.value = true
fetchLogs()
refreshTimer = setInterval(fetchLogs, refreshInterval.value * 1000)
}
function stopAutoRefresh() {
autoRefreshing.value = false
if (refreshTimer !== null) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
watch(lineCount, () => {
if (autoRefreshing.value) {
stopAutoRefresh()
startAutoRefresh()
}
})
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.subtitle {
margin: 6px 0 0;
color: #606266;
}
.service-tabs {
margin-bottom: 12px;
}
.service-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
.controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
padding: 8px 0;
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 8px;
}
.controls-left,
.controls-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.control-label {
font-size: 13px;
color: #606266;
white-space: nowrap;
}
.status-bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #909399;
min-height: 20px;
}
.sep { color: #dcdfe6; }
.live-badge {
display: flex;
align-items: center;
gap: 4px;
color: #67c23a;
font-weight: 500;
}
.live-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #67c23a;
animation: blink 1.2s ease-in-out infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.2; }
}
.error-badge { color: #f56c6c; }
.log-card :deep(.el-card__body) { padding: 0; }
.log-wrap {
background: #0d1117;
border-radius: 4px;
overflow: hidden;
}
.log-content {
margin: 0;
padding: 14px 16px;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
font-size: 12px;
line-height: 1.65;
color: #c9d1d9;
white-space: pre-wrap;
word-break: break-all;
height: 520px;
overflow-y: auto;
}
</style>

查看文件

@ -43,6 +43,26 @@ async function streamOperation(
if (buf) onLine(buf) if (buf) onLine(buf)
} }
export async function getRunningServices(): Promise<string[]> {
const token = localStorage.getItem('token') ?? ''
const res = await fetch(`${BASE}/system/services`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json()
return json.data as string[]
}
export async function fetchServiceLogs(service: string, lines: number): Promise<string> {
const token = localStorage.getItem('token') ?? ''
const res = await fetch(
`${BASE}/system/logs/${encodeURIComponent(service)}?lines=${lines}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.text()
}
export async function getSystemVersion(): Promise<{ currentVersion: string }> { export async function getSystemVersion(): Promise<{ currentVersion: string }> {
const token = localStorage.getItem('token') ?? '' const token = localStorage.getItem('token') ?? ''
const res = await fetch(`${BASE}/system/version`, { const res = await fetch(`${BASE}/system/version`, {

查看文件

@ -101,6 +101,10 @@ const router = createRouter({
path: 'services/license/:appKey?', path: 'services/license/:appKey?',
component: () => import('@/views/license/LicenseManagementView.vue'), component: () => import('@/views/license/LicenseManagementView.vue'),
}, },
{
path: 'system-logs',
component: () => import('@/views/system/ServerLogsView.vue'),
},
{ {
path: 'accounts', path: 'accounts',
component: () => import('@/views/accounts/SubAccountView.vue'), component: () => import('@/views/accounts/SubAccountView.vue'),

查看文件

@ -23,6 +23,7 @@
<el-menu-item index="/services/license"><el-icon><Key /></el-icon><span></span></el-menu-item> <el-menu-item index="/services/license"><el-icon><Key /></el-icon><span></span></el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-menu-item index="/security"><el-icon><Lock /></el-icon><span></span></el-menu-item> <el-menu-item index="/security"><el-icon><Lock /></el-icon><span></span></el-menu-item>
<el-menu-item v-if="isPrivateDeploy" index="/system-logs"><el-icon><Monitor /></el-icon><span></span></el-menu-item>
<el-menu-item index="/operation-logs"><el-icon><Document /></el-icon><span></span></el-menu-item> <el-menu-item index="/operation-logs"><el-icon><Document /></el-icon><span></span></el-menu-item>
<el-menu-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span></span></el-menu-item> <el-menu-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span></span></el-menu-item>
</el-menu> </el-menu>
@ -58,6 +59,7 @@
<el-menu-item index="/services/license"><el-icon><Key /></el-icon><span></span></el-menu-item> <el-menu-item index="/services/license"><el-icon><Key /></el-icon><span></span></el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-menu-item index="/security"><el-icon><Lock /></el-icon><span></span></el-menu-item> <el-menu-item index="/security"><el-icon><Lock /></el-icon><span></span></el-menu-item>
<el-menu-item v-if="isPrivateDeploy" index="/system-logs"><el-icon><Monitor /></el-icon><span></span></el-menu-item>
<el-menu-item index="/operation-logs"><el-icon><Document /></el-icon><span></span></el-menu-item> <el-menu-item index="/operation-logs"><el-icon><Document /></el-icon><span></span></el-menu-item>
<el-menu-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span></span></el-menu-item> <el-menu-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span></span></el-menu-item>
</el-menu> </el-menu>
@ -106,13 +108,15 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Bell, ChatDotRound, Document, Grid, Key, List, Lock, Menu, Odometer, Upload, User } from '@element-plus/icons-vue' import { Bell, ChatDotRound, Document, Grid, Key, List, Lock, Menu, Monitor, Odometer, Upload, User } from '@element-plus/icons-vue'
import { getDeploymentStatus } from '@/api/system'
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const isMobile = ref(false) const isMobile = ref(false)
const drawerVisible = ref(false) const drawerVisible = ref(false)
const isPrivateDeploy = ref(false)
const openedMenus = computed(() => const openedMenus = computed(() =>
route.path.startsWith('/services/') ? ['services'] : [], route.path.startsWith('/services/') ? ['services'] : [],
@ -143,9 +147,15 @@ watch(
}, },
) )
onMounted(() => { onMounted(async () => {
updateViewport() updateViewport()
window.addEventListener('resize', updateViewport) window.addEventListener('resize', updateViewport)
try {
const status = await getDeploymentStatus()
isPrivateDeploy.value = status.mode === 'PRIVATE'
} catch {
isPrivateDeploy.value = false
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {

查看文件

@ -0,0 +1,321 @@
<template>
<div>
<div class="page-header">
<div>
<h2>服务日志</h2>
<p class="subtitle">查看各服务的运行日志支持定时自动刷新</p>
</div>
</div>
<el-card style="margin-bottom: 12px">
<!-- 服务选项卡 -->
<el-skeleton v-if="loadingServices" :rows="1" animated style="margin-bottom:12px" />
<template v-else-if="services.length === 0">
<el-empty description="未检测到正在运行的服务" :image-size="60" />
</template>
<template v-else>
<el-tabs v-model="activeService" type="card" class="service-tabs" @tab-change="onServiceChange">
<el-tab-pane v-for="svc in services" :key="svc" :label="svc" :name="svc" />
</el-tabs>
<!-- 控制栏 -->
<div class="controls">
<div class="controls-left">
<span class="control-label">显示行数</span>
<el-select v-model="lineCount" style="width:100px" size="small" @change="fetchLogs">
<el-option :value="100" label="100 行" />
<el-option :value="200" label="200 行" />
<el-option :value="500" label="500 行" />
<el-option :value="1000" label="1000 行" />
</el-select>
<span class="control-label">刷新间隔</span>
<el-select v-model="refreshInterval" style="width:110px" size="small" @change="onIntervalChange">
<el-option :value="0" label="手动" />
<el-option :value="5" label="5 秒" />
<el-option :value="10" label="10 秒" />
<el-option :value="30" label="30 秒" />
<el-option :value="60" label="1 分钟" />
</el-select>
<el-button
v-if="refreshInterval > 0"
:type="autoRefreshing ? 'danger' : 'success'"
size="small"
@click="toggleAutoRefresh"
>
{{ autoRefreshing ? '停止刷新' : '开始刷新' }}
</el-button>
</div>
<div class="controls-right">
<el-checkbox v-model="autoScroll" size="small">自动滚动</el-checkbox>
<el-button size="small" :loading="fetching" @click="fetchLogs">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button size="small" @click="clearLog">清除</el-button>
</div>
</div>
<!-- 状态栏 -->
<div class="status-bar">
<template v-if="lastFetchTime">
<span>最后更新{{ lastFetchTime }}</span>
<span class="sep">·</span>
<span>{{ lineCount }} </span>
<span v-if="autoRefreshing" class="live-badge">
<span class="live-dot" />
实时刷新
</span>
<span v-if="fetchError" class="error-badge">{{ fetchError }}</span>
</template>
<template v-else-if="fetching">
<span>加载中...</span>
</template>
<template v-else>
<span style="color:#c0c4cc">尚未加载</span>
</template>
</div>
</template>
</el-card>
<!-- 日志显示区 -->
<el-card v-if="services.length > 0" class="log-card">
<div class="log-wrap">
<pre ref="logEl" class="log-content">{{ logContent || (fetching ? '加载中...' : '暂无日志,点击刷新按钮获取。') }}</pre>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { getRunningServices, fetchServiceLogs } from '@/api/system'
const services = ref<string[]>([])
const activeService = ref('')
const loadingServices = ref(false)
const lineCount = ref(200)
const refreshInterval = ref(0)
const autoRefreshing = ref(false)
const autoScroll = ref(true)
const logContent = ref('')
const fetching = ref(false)
const lastFetchTime = ref('')
const fetchError = ref('')
const logEl = ref<HTMLPreElement | null>(null)
let refreshTimer: ReturnType<typeof setInterval> | null = null
//
onMounted(async () => {
loadingServices.value = true
try {
services.value = await getRunningServices()
if (services.value.length > 0) {
activeService.value = services.value[0]
await fetchLogs()
}
} catch {
ElMessage.error('获取服务列表失败')
} finally {
loadingServices.value = false
}
})
onUnmounted(() => {
stopAutoRefresh()
})
//
async function onServiceChange() {
logContent.value = ''
lastFetchTime.value = ''
fetchError.value = ''
if (autoRefreshing.value) {
stopAutoRefresh()
await fetchLogs()
startAutoRefresh()
} else {
await fetchLogs()
}
}
//
async function fetchLogs() {
if (!activeService.value || fetching.value) return
fetching.value = true
fetchError.value = ''
try {
const text = await fetchServiceLogs(activeService.value, lineCount.value)
logContent.value = text
lastFetchTime.value = new Date().toLocaleTimeString()
if (autoScroll.value) {
await nextTick()
scrollToBottom()
}
} catch (e: any) {
fetchError.value = e?.message ?? '获取失败'
} finally {
fetching.value = false
}
}
function clearLog() {
logContent.value = ''
lastFetchTime.value = ''
fetchError.value = ''
}
function scrollToBottom() {
if (logEl.value) {
logEl.value.scrollTop = logEl.value.scrollHeight
}
}
//
function onIntervalChange() {
stopAutoRefresh()
if (refreshInterval.value > 0) {
startAutoRefresh()
}
}
function toggleAutoRefresh() {
if (autoRefreshing.value) {
stopAutoRefresh()
} else {
startAutoRefresh()
}
}
function startAutoRefresh() {
if (refreshInterval.value <= 0) return
autoRefreshing.value = true
fetchLogs()
refreshTimer = setInterval(fetchLogs, refreshInterval.value * 1000)
}
function stopAutoRefresh() {
autoRefreshing.value = false
if (refreshTimer !== null) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
watch(lineCount, () => {
if (autoRefreshing.value) {
stopAutoRefresh()
startAutoRefresh()
}
})
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.subtitle {
margin: 6px 0 0;
color: #606266;
}
.service-tabs {
margin-bottom: 12px;
}
.service-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
.controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
padding: 8px 0;
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 8px;
}
.controls-left,
.controls-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.control-label {
font-size: 13px;
color: #606266;
white-space: nowrap;
}
.status-bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #909399;
min-height: 20px;
}
.sep {
color: #dcdfe6;
}
.live-badge {
display: flex;
align-items: center;
gap: 4px;
color: #67c23a;
font-weight: 500;
}
.live-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #67c23a;
animation: blink 1.2s ease-in-out infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.2; }
}
.error-badge {
color: #f56c6c;
}
.log-card :deep(.el-card__body) {
padding: 0;
}
.log-wrap {
background: #0d1117;
border-radius: 4px;
overflow: hidden;
}
.log-content {
margin: 0;
padding: 14px 16px;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
font-size: 12px;
line-height: 1.65;
color: #c9d1d9;
white-space: pre-wrap;
word-break: break-all;
height: 520px;
overflow-y: auto;
}
</style>