XuqmGroup-Web/tenant-platform/src/views/logs/OperationLogView.vue

487 行
14 KiB
Vue

<template>
<div>
<h2 style="margin-bottom: 24px">操作日志</h2>
<el-card shadow="never">
<el-tabs v-model="activeSource">
<el-tab-pane label="租户平台" name="TENANT">
<div class="toolbar responsive-toolbar">
<el-select
v-model="tenantFilters.moduleType"
placeholder="模块"
style="width: 180px"
clearable
@change="handleTenantModuleChange"
>
<el-option label="全部模块" value="" />
<el-option label="控制台" value="CONSOLE" />
<el-option label="应用管理" value="APP" />
<el-option label="子账号管理" value="SUB_ACCOUNT" />
<el-option label="服务管理" value="SERVICE" />
<el-option label="密钥验证" value="APP_SECRET" />
<el-option label="邮箱验证" value="EMAIL_VERIFY" />
</el-select>
<el-button :loading="tenantLoading" @click="loadTenantLogs">刷新</el-button>
</div>
<div class="table-wrap">
<el-table :data="tenantLogs" v-loading="tenantLoading" border stripe>
<el-table-column prop="createdAt" label="时间" width="170" sortable>
<template #default="{ row }">
<span class="time-text">{{ formatTime(row.createdAt) }}</span>
</template>
</el-table-column>
<el-table-column prop="moduleType" label="模块" width="110">
<template #default="{ row }">
<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>
</el-table-column>
</el-table>
</div>
<el-pagination
style="margin-top: 16px"
layout="total, prev, pager, next"
:total="tenantTotal"
:page-size="tenantPageSize"
:current-page="tenantPage + 1"
@current-change="handleTenantPageChange"
/>
</el-tab-pane>
<el-tab-pane label="版本管理" name="UPDATE">
<div class="toolbar responsive-toolbar">
<el-select
v-model="updateAppKey"
placeholder="选择应用"
style="width: 320px"
filterable
@change="handleUpdateAppChange"
>
<el-option
v-for="app in apps"
:key="app.appKey"
:label="`${app.name} · ${app.packageName}`"
:value="app.appKey"
/>
</el-select>
<el-input-number v-model="updateLimit" :min="20" :max="200" :step="10" controls-position="right" />
<el-button :loading="updateLoading" @click="loadUpdateLogs">刷新</el-button>
</div>
<div class="table-wrap">
<el-table :data="updateLogs" v-loading="updateLoading" border stripe>
<el-table-column prop="createdAt" label="时间" width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="resourceType" label="资源类型" width="140">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ updateResourceLabel(row.resourceType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="action" label="动作" width="160">
<template #default="{ row }">{{ updateActionLabel(row.action) }}</template>
</el-table-column>
<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="320">
<template #default="{ row }">
<span class="detail">{{ row.reason || formatDetail(row.detailJson) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { appApi, type App } from '@/api/app'
import { updateAdminApi, type OperationLog as UpdateOperationLog } from '@/api/update'
import { operationLogApi, type TenantOperationLog } from '@/api/operationLog'
import { formatTime } from '@/utils/date'
const activeSource = ref<'TENANT' | 'UPDATE'>('TENANT')
const tenantLoading = ref(false)
const tenantLogs = ref<TenantOperationLog[]>([])
const tenantTotal = ref(0)
const tenantPage = ref(0)
const tenantPageSize = ref(20)
const tenantFilters = reactive({
moduleType: '',
})
const updateLoading = ref(false)
const updateLogs = ref<UpdateOperationLog[]>([])
const updateLimit = ref(100)
const apps = ref<App[]>([])
const updateAppKey = ref('')
onMounted(async () => {
await Promise.all([
loadApps(),
loadTenantLogs(),
])
})
watch(activeSource, async (value) => {
if (value === 'UPDATE' && !apps.value.length) {
await loadApps()
}
if (value === 'UPDATE') {
await loadUpdateLogs()
}
})
async function loadApps() {
if (apps.value.length) return
try {
const res = await appApi.list()
apps.value = res.data.data
if (!updateAppKey.value && apps.value.length) {
updateAppKey.value = apps.value[0].appKey
}
} catch {
// ignore
}
}
async function loadTenantLogs() {
tenantLoading.value = true
try {
const res = await operationLogApi.list({
moduleType: tenantFilters.moduleType || undefined,
page: tenantPage.value,
size: tenantPageSize.value,
})
const data = res.data.data
tenantLogs.value = data.content ?? []
tenantTotal.value = data.totalElements ?? 0
} finally {
tenantLoading.value = false
}
}
async function loadUpdateLogs() {
if (!updateAppKey.value) {
updateLogs.value = []
return
}
updateLoading.value = true
try {
const res = await updateAdminApi.listOperationLogs(updateAppKey.value, updateLimit.value)
updateLogs.value = res.data.data ?? []
} finally {
updateLoading.value = false
}
}
function handleTenantPageChange(nextPage: number) {
tenantPage.value = nextPage - 1
loadTenantLogs()
}
function handleTenantModuleChange() {
tenantPage.value = 0
loadTenantLogs()
}
function handleUpdateAppChange() {
loadUpdateLogs()
}
// ── Label mappings ──────────────────────────────────────────────────────────
function tenantModuleLabel(moduleType: string) {
return {
CONSOLE: '控制台',
APP: '应用管理',
SUB_ACCOUNT: '子账号',
SERVICE: '服务管理',
APP_SECRET: '密钥管理',
EMAIL_VERIFY: '邮箱验证',
}[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) {
return {
APP_VERSION: '应用版本',
RN_BUNDLE: 'RN 包',
STORE_CONFIG: '应用市场',
PUBLISH_CONFIG: '发布配置',
GRAY_MEMBER: '灰度成员',
STORE_SUBMIT: '市场提交流程',
SERVICE: '服务配置',
}[resourceType] ?? resourceType
}
function updateActionLabel(action: string) {
return {
UPLOAD: '上传',
PUBLISH: '发布',
REPUBLISH: '重新发布',
SCHEDULE_PUBLISH: '定时发布',
UPDATE_FORCE: '修改强更',
UNPUBLISH: '下架',
GRAY_UPDATE: '灰度调整',
STORE_SUBMIT: '提交市场',
CREATE_STORE_CONFIG: '创建配置',
UPDATE_STORE_CONFIG: '更新配置',
DELETE_STORE_CONFIG: '删除配置',
AUTO_PUBLISH: '自动发布',
REQUEST_SERVICE_ACTIVATION: '申请开通',
UPDATE_SERVICE_CONFIG: '更新服务配置',
DISABLE_SERVICE: '停用服务',
VIEW_DASHBOARD: '查看控制台',
}[action] ?? action
}
function formatDetail(detailJson?: string) {
if (!detailJson) return '-'
try {
const parsed = JSON.parse(detailJson)
if (parsed === null || parsed === undefined) return '-'
return typeof parsed === 'string' ? parsed : JSON.stringify(parsed)
} catch {
return detailJson
}
}
</script>
<style scoped>
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 16px;
align-items: center;
}
.responsive-toolbar {
flex-wrap: wrap;
}
.table-wrap {
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 {
display: inline-block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
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) {
.responsive-toolbar {
align-items: stretch;
}
.responsive-toolbar :deep(.el-select),
.responsive-toolbar :deep(.el-button),
.responsive-toolbar :deep(.el-input-number) {
width: 100%;
}
.table-wrap :deep(.el-table) {
min-width: 900px;
}
}
</style>