feat:log
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m41s

This commit is contained in:
sexygoat
2026-04-27 15:06:27 +08:00
parent 853c87b8a2
commit a055ff166a
7 changed files with 479 additions and 4 deletions

View File

@@ -19,7 +19,9 @@ import type {
AssetOrdersParams,
AssetOrdersResponse,
UpdateAssetRealnameStatusRequest,
DtoUpdateAssetRealnameStatusResponse
DtoUpdateAssetRealnameStatusResponse,
AssetOperationLogsResponse,
AssetOperationLogsParams
} from '@/types/api'
export class AssetService extends BaseService {
@@ -231,4 +233,22 @@ export class AssetService extends BaseService {
data
)
}
// ========== 资产操作审计日志 ==========
/**
* 查询资产操作审计日志
* GET /api/admin/assets/:identifier/operation-logs
* @param identifier 资产标识符
* @param params 查询参数
*/
static getOperationLogs(
identifier: string,
params?: AssetOperationLogsParams
): Promise<BaseResponse<AssetOperationLogsResponse>> {
return this.get<BaseResponse<AssetOperationLogsResponse>>(
`/api/admin/assets/${identifier}/operation-logs`,
params
)
}
}

View File

@@ -434,3 +434,52 @@ export interface DtoUpdateAssetRealnameStatusResponse {
real_name_status: number // 更新后的实名状态 (0:未实名, 1:已实名)
real_name_status_name: string // 实名状态名称(中文)
}
// ========== 资产操作审计日志 ==========
/**
* 操作审计日志项
*/
export interface AssetOperationLogItem {
id: number // 日志ID
asset_id: number // 资产ID
asset_identifier: string // 资产标识符
asset_type: string // 资产类型
operation_type: string // 操作类型
operation_desc: string // 操作描述
result_status: 'success' | 'failed' | 'denied' // 执行结果
created_at: string // 创建时间
operator_id?: number // 操作人ID
operator_name: string // 操作人名称
operator_type: string // 操作人类型
before_data: Record<string, any> // 操作前数据(原始)
after_data: Record<string, any> // 操作后数据(原始)
operation_content_before?: Record<string, any> // 操作内容(变更前)
operation_content_after?: Record<string, any> // 操作内容(变更后)
operation_fields_desc?: Record<string, string> // 字段中文说明
batch_total: number // 批量总数
success_count: number // 成功数量
fail_count: number // 失败数量
failed_items?: Array<{ iccid?: string; reason?: string }> // 失败明细
reason?: string // 原因说明
}
/**
* 资产操作审计日志响应
*/
export interface AssetOperationLogsResponse {
items: AssetOperationLogItem[] // 日志列表
page: number // 当前页码
page_size: number // 每页数量
total: number // 总记录数
}
/**
* 资产操作审计日志查询参数
*/
export interface AssetOperationLogsParams {
page?: number // 页码默认1
page_size?: number // 每页条数默认20最大100
operation_type?: string // 操作类型
result_status?: 'success' | 'failed' | 'denied' // 执行结果
}

View File

@@ -0,0 +1,395 @@
<template>
<ElCard shadow="never" class="info-card operation-logs-card" v-loading="loading">
<template #header>
<div class="card-header">
<span class="header-title">操作审计日志</span>
<div class="filter-section">
<ElSelect
v-model="filterForm.operation_type"
placeholder="操作类型"
clearable
style="width: 180px"
@change="handleFilterChange"
>
<ElOption label="设备WiFi设置" value="device_set_wifi" />
<ElOption label="设备切卡" value="device_switch_card" />
<ElOption label="设备切卡模式" value="device_switch_mode" />
<ElOption label="设备停机" value="device_stop" />
<ElOption label="设备复机" value="device_start" />
<ElOption label="设备重启" value="device_reboot" />
<ElOption label="设备重置" value="device_reset" />
<ElOption label="卡分配" value="card_allocate" />
<ElOption label="卡回收" value="card_recall" />
<ElOption label="卡停机" value="card_stop" />
<ElOption label="卡复机" value="card_start" />
<ElOption label="设备分配" value="device_allocate" />
<ElOption label="设备回收" value="device_recall" />
<ElOption label="实名策略" value="asset_realname_policy" />
<ElOption label="实名状态" value="asset_realname_status" />
<ElOption label="轮询开关" value="asset_polling" />
</ElSelect>
<ElSelect
v-model="filterForm.result_status"
placeholder="执行结果"
clearable
style="width: 120px"
@change="handleFilterChange"
>
<ElOption label="成功" value="success" />
<ElOption label="失败" value="failed" />
<ElOption label="拒绝" value="denied" />
</ElSelect>
<ElButton type="primary" @click="handleQuery">查询</ElButton>
<ElButton @click="handleReset">重置</ElButton>
<ElTag type="info" size="small"> {{ total }} </ElTag>
</div>
</div>
</template>
<div class="logs-table-wrapper">
<ElTable :data="logList" class="logs-table" border max-height="400">
<ElTableColumn label="操作时间" width="170" align="center">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</ElTableColumn>
<ElTableColumn label="操作类型" width="160" align="center">
<template #default="{ row }">
<ElTag :type="getOperationTypeTag(row.operation_type)" size="small">
{{ row.operation_desc }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="操作人" width="130" align="center">
<template #default="{ row }">
{{ row.operator_name || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作人类型" width="110" align="center">
<template #default="{ row }">
{{ getOperatorTypeText(row.operator_type) }}
</template>
</ElTableColumn>
<ElTableColumn label="执行结果" width="100" align="center">
<template #default="{ row }">
<ElTag :type="getResultStatusTag(row.result_status)" size="small">
{{ getResultStatusText(row.result_status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="变更内容" min-width="250">
<template #default="{ row }">
<div v-if="getChangeContent(row)" class="change-content">
<span
v-for="(change, index) in getChangeContent(row)"
:key="index"
class="change-item"
>
<span class="field-name">{{ change.fieldName }}</span>
<span class="field-value">
<template v-if="change.beforeValue !== undefined">
<span class="old-value">{{ formatValue(change.beforeValue) }}</span>
<span class="arrow"></span>
<span class="new-value">{{ formatValue(change.afterValue) }}</span>
</template>
<template v-else>
<span class="new-value">{{ formatValue(change.afterValue) }}</span>
</template>
</span>
</span>
</div>
<span v-else-if="row.fail_count > 0 && row.failed_items?.length" class="fail-reason">
失败 {{ row.fail_count }}
</span>
<span v-else>-</span>
</template>
</ElTableColumn>
</ElTable>
<ElPagination
v-if="total > 0"
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
@current-change="handlePageChange"
@size-change="handleSizeChange"
style="margin-top: 16px; justify-content: flex-end"
/>
</div>
</ElCard>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import {
ElCard,
ElTable,
ElTableColumn,
ElTag,
ElButton,
ElSelect,
ElOption,
ElPagination,
ElMessage
} from 'element-plus'
import { AssetService } from '@/api/modules'
import type { AssetOperationLogItem } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
interface Props {
assetIdentifier?: string
assetType?: string
}
interface ChangeItem {
fieldName: string
beforeValue?: any
afterValue?: any
}
const props = defineProps<Props>()
const loading = ref(false)
const logList = ref<AssetOperationLogItem[]>([])
const total = ref(0)
const pagination = reactive({
page: 1,
page_size: 20
})
const filterForm = reactive({
operation_type: '',
result_status: '' as '' | 'success' | 'failed' | 'denied'
})
const loadLogs = async () => {
if (!props.assetIdentifier) return
try {
loading.value = true
const res = await AssetService.getOperationLogs(props.assetIdentifier, {
page: pagination.page,
page_size: pagination.page_size,
operation_type: filterForm.operation_type || undefined,
result_status: filterForm.result_status || undefined
})
if (res.code === 0 && res.data) {
logList.value = res.data.items || []
total.value = res.data.total || 0
}
} catch (error) {
console.error('加载操作日志失败:', error)
ElMessage.error('加载操作日志失败')
} finally {
loading.value = false
}
}
const handleQuery = () => {
pagination.page = 1
loadLogs()
}
const handleReset = () => {
filterForm.operation_type = ''
filterForm.result_status = ''
pagination.page = 1
loadLogs()
}
const handleFilterChange = () => {
handleQuery()
}
const handlePageChange = () => {
loadLogs()
}
const handleSizeChange = () => {
pagination.page = 1
loadLogs()
}
const getOperationTypeTag = (type: string) => {
const tagMap: Record<string, string> = {
device_set_wifi: 'primary',
device_switch_card: 'success',
device_switch_mode: 'success',
device_stop: 'danger',
device_start: 'success',
device_reboot: 'warning',
device_reset: 'warning',
device_allocate: 'primary',
device_recall: 'warning',
card_allocate: 'primary',
card_recall: 'warning',
card_stop: 'danger',
card_start: 'success',
asset_realname_policy: 'info',
asset_realname_status: 'info',
asset_polling: 'info'
}
return tagMap[type] || 'info'
}
const getResultStatusTag = (status: string) => {
const tagMap: Record<string, string> = {
success: 'success',
failed: 'danger',
denied: 'warning'
}
return tagMap[status] || 'info'
}
const getResultStatusText = (status: string) => {
const textMap: Record<string, string> = {
success: '成功',
failed: '失败',
denied: '拒绝'
}
return textMap[status] || status
}
const getOperatorTypeText = (type: string) => {
const textMap: Record<string, string> = {
admin_user: '后台用户',
enterprise_user: '企业用户',
api_user: 'API用户'
}
return textMap[type] || type
}
const formatValue = (value: any): string => {
if (value === null || value === undefined) return '未知'
if (typeof value === 'boolean') return value ? '是' : '否'
if (typeof value === 'number') return String(value)
if (typeof value === 'string') return value || '空'
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
const formatFieldValue = (key: string, value: any): string => {
if (key === 'enable_polling') return value ? '开启' : '关闭'
if (key === 'realname_policy') {
const map: Record<string, string> = {
none: '无需实名',
before_order: '先实名后充值',
after_order: '先充值后实名'
}
return map[value] || formatValue(value)
}
if (key === 'real_name_status') return value === 1 ? '已实名' : '未实名'
if (key === 'switch_mode') return value === 0 ? '自动' : '手动'
if (key === 'new_status') {
const map: Record<string, string> = {
allocated: '已分配',
recalled: '已回收'
}
return map[value] || formatValue(value)
}
return formatValue(value)
}
const getChangeContent = (row: AssetOperationLogItem): ChangeItem[] => {
const after = row.operation_content_after
const before = row.operation_content_before
const desc = row.operation_fields_desc || {}
if (!after || Object.keys(after).length === 0) return []
const changes: ChangeItem[] = []
for (const key of Object.keys(after)) {
const fieldName = desc[key] || key
changes.push({
fieldName,
beforeValue: before?.[key],
afterValue: after[key]
})
}
return changes
}
watch(
() => props.assetIdentifier,
(newVal) => {
if (newVal) {
loadLogs()
}
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.operation-logs-card {
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
.header-title {
font-size: 16px;
font-weight: 500;
}
.filter-section {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
}
.logs-table-wrapper {
.logs-table {
:deep(.el-table__header-wrapper) {
th {
background-color: var(--el-fill-color-light);
}
}
.change-content {
display: flex;
flex-direction: column;
gap: 4px;
.change-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
.field-name {
color: var(--el-text-color-secondary);
min-width: 70px;
}
.field-value {
.old-value {
text-decoration: line-through;
color: var(--el-text-color-secondary);
}
.arrow {
color: var(--el-text-color-light);
margin: 0 4px;
}
.new-value {
color: var(--el-color-success);
}
}
}
}
.fail-reason {
color: var(--el-color-danger);
font-size: 12px;
}
}
}
}
</style>

View File

@@ -82,6 +82,14 @@
@navigate-to-device="handleNavigateToDevice"
/>
</div>
<!-- 第五行操作审计日志 -->
<div class="row full-width">
<OperationLogsCard
:asset-identifier="cardInfo.identifier"
:asset-type="cardInfo.asset_type"
/>
</div>
</div>
</div>
@@ -162,6 +170,7 @@
import CurrentPackageCard from './components/CurrentPackageCard.vue'
import PackageListCard from './components/PackageListCard.vue'
import WalletTransactionCard from './components/WalletTransactionCard.vue'
import OperationLogsCard from './components/OperationLogsCard.vue'
// 引入对话框组件
import {

View File

@@ -672,7 +672,7 @@
{
prop: 'package_name',
label: '套餐名称',
minWidth: 160,
width: 200,
showOverflowTooltip: true,
formatter: (row: PackageResponse) => {
const hasPermission = hasAuth('package:detail')

View File

@@ -564,7 +564,8 @@
{
prop: 'series_name',
label: '系列名称',
minWidth: 150,
minWidth: 200,
showOverflowTooltip: true,
formatter: (row: PackageSeriesResponse) => {
const hasPermission = hasAuth('package_series:detail')
return h(

View File

@@ -1141,7 +1141,8 @@
{
prop: 'series_name',
label: '系列名称',
minWidth: 150,
width: 200,
showOverflowTooltip: true,
formatter: (row: ShopSeriesGrantResponse) => {
const hasPermission = hasAuth('series_grants:detail')
return h(