feat(log): 优化操作日志记录和展示功能

- 在OperationLogEntity实体中新增summary和ipAddress字段存储摘要和IP信息
- 修改operationLogService.record方法支持传入操作摘要信息
- 实现客户端IP地址解析功能,支持X-Forwarded-For和X-Real-IP头
- 更新系统更新服务中的数据库表结构迁移逻辑,增加NOT NULL列处理
- 优化前端操作日志页面展示,添加标签分类和详情弹窗功能
- 在系统更新流式响应中增加网络连接异常处理机制
- 添加Nginx代理配置中的缓冲区设置以支持实时日志流式传输
这个提交包含在:
XuqmGroup 2026-05-27 12:27:43 +08:00
父节点 38e138f955
当前提交 4b13f64966
共有 6 个文件被更改,包括 315 次插入63 次删除

查看文件

@ -120,6 +120,8 @@ export interface OpsLogItem {
action: string action: string
operator: string operator: string
detailJson: string detailJson: string
summary?: string
ipAddress?: string
createdAt: string createdAt: string
} }

查看文件

@ -6,22 +6,44 @@
</div> </div>
<el-table :data="logs" v-loading="loading" border stripe> <el-table :data="logs" v-loading="loading" border stripe>
<el-table-column prop="createdAt" label="时间" width="180"> <el-table-column prop="createdAt" label="时间" width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template> <template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="operator" label="操作者" width="140" /> <el-table-column prop="operator" label="操作者" width="120" />
<el-table-column prop="moduleType" label="模块" width="100"> <el-table-column prop="moduleType" label="模块" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag size="small">{{ row.moduleType }}</el-tag> <el-tag size="small" :type="moduleTagType(row.moduleType)" effect="plain">{{ row.moduleType }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="action" label="动作" width="180" /> <el-table-column label="操作摘要" min-width="300">
<el-table-column prop="resourceType" label="资源类型" width="120" />
<el-table-column prop="resourceId" label="资源ID" width="220" show-overflow-tooltip />
<el-table-column prop="tenantId" label="租户ID" width="220" show-overflow-tooltip />
<el-table-column label="详情" min-width="200">
<template #default="{ row }"> <template #default="{ row }">
<span class="detail-text">{{ formatDetail(row.detailJson) }}</span> <div style="display:flex;align-items:center;gap:8px">
<el-tag size="small" :type="actionTagType(row.action)" effect="dark">{{ row.action }}</el-tag>
<span style="font-size:13px;color:#303133">{{ row.summary || row.resourceId || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="tenantId" label="租户ID" width="220" show-overflow-tooltip />
<el-table-column prop="ipAddress" label="IP" width="140">
<template #default="{ row }">
<span style="font-family:monospace;font-size:12px;color:#909399">{{ row.ipAddress || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="详情" width="80" align="center">
<template #default="{ row }">
<el-popover v-if="hasDetail(row.detailJson)" placement="left" :width="360" trigger="click">
<template #reference>
<el-button link type="primary" size="small">查看</el-button>
</template>
<div style="max-height:400px;overflow-y:auto">
<div v-for="(item, idx) in parseDetail(row.detailJson)" :key="idx"
style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid #f0f0f0;font-size:13px">
<span style="flex-shrink:0;width:80px;color:#909399;font-weight:500">{{ item.key }}</span>
<span style="flex:1;word-break:break-all;color:#303133">{{ item.value }}</span>
</div>
</div>
</el-popover>
<span v-else style="color:#c0c4cc">-</span>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -43,14 +65,30 @@ const page = ref(0)
const size = ref(20) const size = ref(20)
const total = ref(0) const total = ref(0)
function formatDetail(json: string) { function moduleTagType(m: string): '' | 'success' | 'warning' | 'info' | 'danger' {
if (!json) return '-' return { CONSOLE: 'info', APP: '', SUB_ACCOUNT: 'warning', SERVICE: 'success', APP_SECRET: 'danger', EMAIL_VERIFY: 'info' }[m] as any ?? ''
}
function actionTagType(a: string): '' | 'success' | 'warning' | 'info' | 'danger' {
if (a.startsWith('CREATE')) return 'success'
if (a.startsWith('DELETE') || a.startsWith('DISABLE')) return 'danger'
if (a.startsWith('RESET') || a.startsWith('REGENERATE')) return 'warning'
if (a.startsWith('VIEW')) return 'info'
return ''
}
function hasDetail(json: string) {
if (!json) return false
try { const o = JSON.parse(json); return o && typeof o === 'object' && Object.keys(o).length > 0 } catch { return false }
}
function parseDetail(json: string): { key: string; value: string }[] {
if (!json) return []
try { try {
const obj = JSON.parse(json) const o = JSON.parse(json)
return Object.entries(obj).map(([k, v]) => `${k}: ${v}`).join(', ') if (!o || typeof o !== 'object') return []
} catch { return Object.entries(o).map(([k, v]) => ({ key: k, value: typeof v === 'object' ? JSON.stringify(v) : String(v ?? '') }))
return json } catch { return [{ key: 'raw', value: json }] }
}
} }
async function loadLogs() { async function loadLogs() {
@ -68,11 +106,3 @@ async function loadLogs() {
onMounted(loadLogs) onMounted(loadLogs)
</script> </script>
<style scoped>
.detail-text {
color: #606266;
font-size: 13px;
word-break: break-all;
}
</style>

查看文件

@ -9,6 +9,8 @@ export interface TenantOperationLog {
action: string action: string
operator?: string operator?: string
detailJson?: string detailJson?: string
summary?: string
ipAddress?: string
createdAt: string createdAt: string
} }

查看文件

@ -30,6 +30,7 @@ async function streamOperation(
const reader = res.body!.getReader() const reader = res.body!.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buf = '' let buf = ''
try {
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) break
@ -41,6 +42,14 @@ async function streamOperation(
} }
} }
if (buf) onLine(buf) if (buf) onLine(buf)
} catch (e: any) {
// Connection drop during RESTART_SELF is expected - don't throw
if (e?.name === 'TypeError' || e?.message?.includes('network') || e?.message?.includes('fetch')) {
onLine('>>> 连接已中断(服务正在重启)')
return
}
throw e
}
} }
export async function getRunningServices(): Promise<string[]> { export async function getRunningServices(): Promise<string[]> {

查看文件

@ -26,21 +26,62 @@
<div class="table-wrap"> <div class="table-wrap">
<el-table :data="tenantLogs" v-loading="tenantLoading" border stripe> <el-table :data="tenantLogs" v-loading="tenantLoading" border stripe>
<el-table-column prop="createdAt" label="时间" width="180"> <el-table-column prop="createdAt" label="时间" width="170" sortable>
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="moduleType" label="模块" width="140">
<template #default="{ row }"> <template #default="{ row }">
<el-tag size="small" effect="plain">{{ tenantModuleLabel(row.moduleType) }}</el-tag> <span class="time-text">{{ formatTime(row.createdAt) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="action" label="动作" width="180" /> <el-table-column prop="moduleType" label="模块" width="110">
<el-table-column prop="resourceType" label="资源类型" width="140" />
<el-table-column prop="resourceId" label="资源ID" min-width="220" show-overflow-tooltip />
<el-table-column prop="operator" label="操作人" width="180" />
<el-table-column label="详情" min-width="280">
<template #default="{ row }"> <template #default="{ row }">
<span class="detail">{{ formatDetail(row.detailJson) }}</span> <el-tag size="small" :type="moduleTagType(row.moduleType)" effect="plain">
{{ tenantModuleLabel(row.moduleType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作摘要" min-width="320">
<template #default="{ row }">
<div class="summary-cell">
<el-tag
size="small"
:type="actionTagType(row.action)"
effect="dark"
class="action-tag"
>
{{ actionLabel(row.action) }}
</el-tag>
<span class="summary-text">{{ row.summary || generateSummary(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" width="120" />
<el-table-column prop="ipAddress" label="IP 地址" width="140">
<template #default="{ row }">
<span class="ip-text">{{ row.ipAddress || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="详细数据" width="80" align="center">
<template #default="{ row }">
<el-popover
v-if="hasDetail(row.detailJson)"
placement="left"
:width="380"
trigger="click"
>
<template #reference>
<el-button link type="primary" size="small">查看</el-button>
</template>
<div class="detail-popover">
<div
v-for="(item, idx) in parseDetail(row.detailJson)"
:key="idx"
class="detail-row"
>
<span class="detail-key">{{ item.key }}</span>
<span class="detail-value">{{ item.value }}</span>
</div>
</div>
</el-popover>
<span v-else class="no-detail">-</span>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -78,7 +119,7 @@
<div class="table-wrap"> <div class="table-wrap">
<el-table :data="updateLogs" v-loading="updateLoading" border stripe> <el-table :data="updateLogs" v-loading="updateLoading" border stripe>
<el-table-column prop="createdAt" label="时间" width="180"> <el-table-column prop="createdAt" label="时间" width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template> <template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="resourceType" label="资源类型" width="140"> <el-table-column prop="resourceType" label="资源类型" width="140">
@ -153,7 +194,7 @@ async function loadApps() {
updateAppKey.value = apps.value[0].appKey updateAppKey.value = apps.value[0].appKey
} }
} catch { } catch {
// ignore; empty state will be shown in the selector // ignore
} }
} }
@ -201,17 +242,109 @@ function handleUpdateAppChange() {
loadUpdateLogs() loadUpdateLogs()
} }
// Label mappings
function tenantModuleLabel(moduleType: string) { function tenantModuleLabel(moduleType: string) {
return { return {
CONSOLE: '控制台', CONSOLE: '控制台',
APP: '应用管理', APP: '应用管理',
SUB_ACCOUNT: '子账号管理', SUB_ACCOUNT: '子账号',
SERVICE: '服务管理', SERVICE: '服务管理',
APP_SECRET: '应用密钥', APP_SECRET: '密钥管理',
EMAIL_VERIFY: '邮箱验证', EMAIL_VERIFY: '邮箱验证',
}[moduleType] ?? moduleType }[moduleType] ?? moduleType
} }
function moduleTagType(moduleType: string): '' | 'success' | 'warning' | 'info' | 'danger' {
return {
CONSOLE: 'info',
APP: '',
SUB_ACCOUNT: 'warning',
SERVICE: 'success',
APP_SECRET: 'danger',
EMAIL_VERIFY: 'info',
}[moduleType] as any ?? ''
}
function actionLabel(action: string): string {
return {
CREATE_APP: '创建',
UPDATE_APP: '编辑',
DELETE_APP: '删除',
RESET_APP_SECRET: '重置密钥',
REQUEST_SECRET_VERIFY: '验证',
REVEAL_APP_SECRET: '查看密钥',
CREATE_SUB_ACCOUNT: '创建',
DISABLE_SUB_ACCOUNT: '禁用',
SEND_VERIFY_CODE: '发送验证码',
VERIFY_EMAIL: '验证邮箱',
DISABLE_SERVICE: '停用',
UPDATE_SERVICE_CONFIG: '更新配置',
REQUEST_SERVICE_ACTIVATION: '申请开通',
REGENERATE_KEY: '重新生成密钥',
VIEW_DASHBOARD: '查看',
}[action] ?? action
}
function actionTagType(action: string): '' | 'success' | 'warning' | 'info' | 'danger' {
if (action.startsWith('CREATE')) return 'success'
if (action.startsWith('DELETE')) return 'danger'
if (action.startsWith('DISABLE')) return 'danger'
if (action.startsWith('RESET') || action.startsWith('REGENERATE')) return 'warning'
if (action.startsWith('UPDATE') || action.startsWith('REVEAL')) return ''
if (action.startsWith('VIEW')) return 'info'
if (action.startsWith('SEND') || action.startsWith('VERIFY') || action.startsWith('REQUEST')) return 'info'
return ''
}
function generateSummary(row: TenantOperationLog): string {
const action = actionLabel(row.action)
const resource = row.resourceId || ''
return `${action} ${resource}`
}
function hasDetail(detailJson?: string): boolean {
if (!detailJson) return false
try {
const parsed = JSON.parse(detailJson)
return parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0
} catch {
return false
}
}
const DETAIL_KEY_LABELS: Record<string, string> = {
name: '应用名称',
packageName: '包名',
appKey: 'AppKey',
username: '用户名',
nickname: '昵称',
email: '邮箱',
platform: '平台',
serviceType: '服务类型',
purpose: '用途',
applyReason: '申请原因',
before: '变更前',
after: '变更后',
appCount: '应用数',
serviceCount: '服务数',
subAccountCount: '子账号数',
}
function parseDetail(detailJson?: string): { key: string; value: string }[] {
if (!detailJson) return []
try {
const parsed = JSON.parse(detailJson)
if (!parsed || typeof parsed !== 'object') return []
return Object.entries(parsed).map(([k, v]) => ({
key: DETAIL_KEY_LABELS[k] ?? k,
value: typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? ''),
}))
} catch {
return [{ key: '原始数据', value: detailJson }]
}
}
function updateResourceLabel(resourceType: string) { function updateResourceLabel(resourceType: string) {
return { return {
APP_VERSION: '应用版本', APP_VERSION: '应用版本',
@ -255,7 +388,6 @@ function formatDetail(detailJson?: string) {
return detailJson return detailJson
} }
} }
</script> </script>
<style scoped> <style scoped>
@ -265,15 +397,40 @@ function formatDetail(detailJson?: string) {
margin-bottom: 16px; margin-bottom: 16px;
align-items: center; align-items: center;
} }
.responsive-toolbar { .responsive-toolbar {
flex-wrap: wrap; flex-wrap: wrap;
} }
.table-wrap { .table-wrap {
overflow-x: auto; overflow-x: auto;
} }
.time-text {
font-size: 13px;
color: #606266;
}
.summary-cell {
display: flex;
align-items: center;
gap: 8px;
}
.action-tag {
flex-shrink: 0;
}
.summary-text {
font-size: 13px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ip-text {
font-family: monospace;
font-size: 12px;
color: #909399;
}
.detail { .detail {
display: inline-block; display: inline-block;
max-width: 100%; max-width: 100%;
@ -282,17 +439,46 @@ function formatDetail(detailJson?: string) {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.detail-popover {
max-height: 400px;
overflow-y: auto;
}
.detail-row {
display: flex;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid #f0f0f0;
font-size: 13px;
}
.detail-row:last-child {
border-bottom: none;
}
.detail-key {
flex-shrink: 0;
width: 80px;
color: #909399;
font-weight: 500;
}
.detail-value {
flex: 1;
word-break: break-all;
color: #303133;
white-space: pre-wrap;
}
.no-detail {
color: #c0c4cc;
}
@media (max-width: 767px) { @media (max-width: 767px) {
.responsive-toolbar { .responsive-toolbar {
align-items: stretch; align-items: stretch;
} }
.responsive-toolbar :deep(.el-select), .responsive-toolbar :deep(.el-select),
.responsive-toolbar :deep(.el-button), .responsive-toolbar :deep(.el-button),
.responsive-toolbar :deep(.el-input-number) { .responsive-toolbar :deep(.el-input-number) {
width: 100%; width: 100%;
} }
.table-wrap :deep(.el-table) { .table-wrap :deep(.el-table) {
min-width: 900px; min-width: 900px;
} }

查看文件

@ -206,8 +206,8 @@
v-model="showUpdateDialog" v-model="showUpdateDialog"
:title="operationType === 'update' ? '一键更新' : '重置容器'" :title="operationType === 'update' ? '一键更新' : '重置容器'"
width="600px" width="600px"
:close-on-click-modal="!updating" :close-on-click-modal="!updating && !selfRestarting"
:close-on-press-escape="!updating" :close-on-press-escape="!updating && !selfRestarting"
@closed="resetUpdateDialog" @closed="resetUpdateDialog"
> >
<div v-if="!updating && !updateDone && !updateError" style="color:#606266;"> <div v-if="!updating && !updateDone && !updateError" style="color:#606266;">
@ -233,7 +233,7 @@
<div v-if="selfRestarting" class="reconnect-tip"> <div v-if="selfRestarting" class="reconnect-tip">
<el-icon class="is-loading"><Loading /></el-icon> <el-icon class="is-loading"><Loading /></el-icon>
<span>正在等待服务重启...</span> <span>正在等待服务重启页面将在服务恢复后自动刷新...</span>
</div> </div>
<div v-if="updateDone" style="margin-top:12px"> <div v-if="updateDone" style="margin-top:12px">
@ -497,9 +497,11 @@ async function startOperation() {
const streamFn = operationType.value === 'update' ? streamSystemUpdate : streamSystemReset const streamFn = operationType.value === 'update' ? streamSystemUpdate : streamSystemReset
const failMsg = operationType.value === 'update' ? '更新失败' : '重置失败' const failMsg = operationType.value === 'update' ? '更新失败' : '重置失败'
let seenRestartSelf = false
try { try {
await streamFn((line) => { await streamFn((line) => {
if (line === 'RESTART_SELF') { if (line === 'RESTART_SELF') {
seenRestartSelf = true
selfRestarting.value = true selfRestarting.value = true
updating.value = false updating.value = false
pollForRecovery() pollForRecovery()
@ -515,29 +517,50 @@ async function startOperation() {
updateDone.value = true updateDone.value = true
} }
} catch (e: any) { } catch (e: any) {
// Connection drop during self-restart is expected
if (seenRestartSelf || selfRestarting.value) {
if (!selfRestarting.value) { if (!selfRestarting.value) {
selfRestarting.value = true
updating.value = false
pollForRecovery()
}
return
}
updating.value = false updating.value = false
updateError.value = e?.message ?? failMsg updateError.value = e?.message ?? failMsg
} }
}
} }
async function pollForRecovery() { async function pollForRecovery() {
const deadline = Date.now() + 90_000 const deadline = Date.now() + 120_000
let attempt = 0
while (Date.now() < deadline) { while (Date.now() < deadline) {
attempt++
await new Promise(r => setTimeout(r, 3000)) await new Promise(r => setTimeout(r, 3000))
try { try {
await getDeploymentStatus() await getDeploymentStatus()
selfRestarting.value = false selfRestarting.value = false
updateDone.value = true updateDone.value = true
updateLog.value.push(operationType.value === 'update' ? '>>> tenant-service 已重启,更新完成 ✓' : '>>> tenant-service 已重启,重置完成 ✓') updateLog.value.push(operationType.value === 'update'
? '>>> tenant-service 已重启,更新完成 ✓'
: '>>> tenant-service 已重启,重置完成 ✓')
// Auto-reload after 2s to refresh all page state
setTimeout(() => {
window.location.reload()
}, 2000)
return return
} catch { } catch {
// still restarting if (attempt % 5 === 0) {
updateLog.value.push(`>>> 等待服务恢复中... (第 ${attempt} 次尝试)`)
nextTick(() => {
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
})
}
} }
} }
selfRestarting.value = false selfRestarting.value = false
updateError.value = '等待 tenant-service 重启超时,请手动刷新页面' updateError.value = '等待服务重启超时2 分钟),请手动刷新页面确认更新结果'
updateLog.value.push('>>> 等待超时,请手动刷新页面检查服务状态')
} }
const fmt = formatTime const fmt = formatTime