2026-08-13 11:57:44 +08:00
< script setup lang = "ts" >
import { ref , watch } from 'vue'
import { Eye , ScrollText } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getPlatformAuditLog , listPlatformAuditLogs , type PlatformAuditLogDetail , type PlatformAuditLogListItem , type PlatformAuditLogQuery } from '../api/platformAudit'
2026-08-17 19:52:04 +08:00
import { auditEventLabel } from '../presentation'
2026-08-13 11:57:44 +08:00
const props = defineProps < { customerId : string ; customerName : string } > ()
const access = useAccessPreviewStore ()
const rows = ref < PlatformAuditLogListItem [] >([])
const page = ref ( 1 )
const pageSize = ref ( 20 )
const total = ref ( 0 )
const loading = ref ( false )
const loadError = ref < ApiErrorDetails | null >( null )
const detailOpen = ref ( false )
const detailLoading = ref ( false )
const detail = ref < PlatformAuditLogDetail | null >( null )
const detailError = ref < ApiErrorDetails | null >( null )
const emptyFilters = () : QueryModel => ({ keyword : '' , success : '' , occurredRange : [] })
const filters = ref < QueryModel >( emptyFilters ())
const appliedFilters = ref < QueryModel >( emptyFilters ())
let requestSerial = 0
const queryFields : QueryField [] = [
{ key : 'keyword' , type : 'input' , label : '关键词' , width : 260 , placeholder : '操作内容/操作人/资源或请求标识' },
{ key : 'success' , type : 'select' , label : '结果' , width : 150 , options : [{ label : '成功' , value : true }, { label : '失败' , value : false }] },
{ key : 'occurredRange' , type : 'daterange' , label : '发生日期' , width : 280 },
]
const columns : DataTableColumn [] = [
{ key : 'actions' , label : '操作' , width : 92 , fixed : 'left' , slotName : 'actions' },
{ key : 'description' , label : '操作内容' , minWidth : 190 , slotName : 'description' },
2026-08-14 16:06:30 +08:00
{ key : 'clientIp' , label : 'IP 地址' , width : 140 , slotName : 'clientIp' },
2026-08-17 19:52:04 +08:00
{ key : 'eventType' , label : '事件类型' , minWidth : 220 , slotName : 'eventType' },
2026-08-13 11:57:44 +08:00
{ key : 'username' , label : '操作人' , width : 140 , slotName : 'username' },
{ key : 'serviceName' , label : '来源服务' , width : 150 , slotName : 'serviceName' },
{ key : 'success' , label : '结果' , width : 90 , slotName : 'success' },
{ key : 'occurredAt' , label : '发生时间' , width : 170 , slotName : 'occurredAt' },
]
const display = ( value ?: string | null ) => value ? . trim () || '—'
const formatDate = ( value ?: string | null ) => value ? new Date ( value ). toLocaleString ( 'zh-CN' , { hour12 : false }) : '—'
function formatSnapshot ( value ?: string | null ) {
if ( ! value ? . trim ()) return '无'
try { return JSON . stringify ( JSON . parse ( value ), null , 2 ) }
catch { return value }
}
function localDayIso ( value : string , end : boolean ) {
const [ year , month , day ] = value . split ( '-' ). map ( Number )
if ( ! year || ! month || ! day ) return undefined
return new Date ( year , month - 1 , day , end ? 23 : 0 , end ? 59 : 0 , end ? 59 : 0 , end ? 999 : 0 ). toISOString ()
}
function toQuery ( customerId : string ) : PlatformAuditLogQuery {
const range = Array . isArray ( appliedFilters . value . occurredRange ) ? appliedFilters . value . occurredRange . map ( String ) : []
const success = appliedFilters . value . success
return {
accountId : customerId ,
page : page . value ,
size : pageSize . value ,
keyword : String ( appliedFilters . value . keyword || '' ). trim () || undefined ,
success : typeof success === 'boolean' ? success : undefined ,
occurredFrom : range [ 0 ] ? localDayIso ( range [ 0 ], false ) : undefined ,
occurredTo : range [ 1 ] ? localDayIso ( range [ 1 ], true ) : undefined ,
}
}
async function load () {
const customerId = props . customerId . trim ()
const serial = ++ requestSerial
rows . value = []
total . value = 0
loadError . value = null
if ( ! customerId || ! access . can ( 'platform:audit:list' )) return
loading . value = true
try {
const result = await listPlatformAuditLogs ( toQuery ( customerId ))
if ( serial !== requestSerial || customerId !== props . customerId . trim ()) return
rows . value = result . records
total . value = result . total
} catch ( error ) {
if ( serial === requestSerial ) loadError . value = getApiErrorDetails ( error , '客户操作日志加载失败,请稍后重试' )
} finally {
if ( serial === requestSerial ) loading . value = false
}
}
function applyFilters ( model : QueryModel ) {
appliedFilters . value = {
keyword : String ( model . keyword || '' ). trim (),
success : typeof model . success === 'boolean' ? model . success : '' ,
occurredRange : Array . isArray ( model . occurredRange ) ? model . occurredRange . map ( String ) : [],
}
if ( page . value === 1 ) void load ()
else page . value = 1
}
async function openDetail ( row : PlatformAuditLogListItem ) {
detailOpen . value = true
detailLoading . value = true
detail . value = null
detailError . value = null
try { detail . value = await getPlatformAuditLog ( row . id ) }
catch ( error ) { detailError . value = getApiErrorDetails ( error , '操作日志详情加载失败,请稍后重试' ) }
finally { detailLoading . value = false }
}
watch (() => props . customerId , () => {
filters . value = emptyFilters ()
appliedFilters . value = emptyFilters ()
if ( page . value === 1 ) void load ()
else page . value = 1
}, { immediate : true })
watch ([ page , pageSize ], ([ nextPage , nextSize ], [ previousPage , previousSize ]) => {
if ( nextPage !== previousPage || nextSize !== previousSize ) void load ()
})
</ script >
< template >
< section class = "customer-audit-panel" >
< div class = "panel-title" >< div >< h2 > 操作日志 </ h2 >< p >{{ customerName }} 已归属当前客户的档案与业务操作记录 </ p ></ div >< ScrollText :size = "19" /></ div >
< div v-if = "!access.can('platform:audit:list')" class="module-empty"><ScrollText :size="30" />< strong > 暂无操作日志查看权限 </ strong >< p > 请联系管理员开通平台操作日志查看权限 。 </ p ></ div >
< template v-else >
< QueryToolbar v-model = "filters" class="audit-query" ariaLabel="客户操作日志筛选" :fields="queryFields" preference-key="customer-audit-logs-query" @search="applyFilters" />
< LoadErrorState v-if = "loadError" title="客户操作日志加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></ template ></ LoadErrorState >
< DataTable v-else v -model:page = "page" v -model:page-size = "pageSize" :data = "rows" :columns = "columns" :loading = "loading" :total = "total" row -key =" id " aria -label =" 客户操作日志列表 " empty -text =" 当前客户暂无操作日志 " preference -key =" customer -audit-logs " border stripe >
< template # actions = "{ row }" >< el-button link type = "primary" : aria -label ="` 查看操作日志 $ { ( row as PlatformAuditLogListItem ) .id } 详情 `" @click ="openDetail(row as PlatformAuditLogListItem)" >< Eye :size = "15" /> 详情 </ el-button ></ template >
< template # description = "{ row }" >{{ display (( row as PlatformAuditLogListItem ). description ) }}</ template >
2026-08-14 16:06:30 +08:00
< template # clientIp = "{ row }" >{{ display (( row as PlatformAuditLogListItem ). clientIp ) }}</ template >
2026-08-17 19:52:04 +08:00
< template # eventType = "{ row }" >{{ auditEventLabel (( row as PlatformAuditLogListItem ). eventType ) }}</ template >
2026-08-13 11:57:44 +08:00
< template # username = "{ row }" >{{ display (( row as PlatformAuditLogListItem ). username ) }}</ template >
< template # serviceName = "{ row }" >{{ display (( row as PlatformAuditLogListItem ). serviceName ) }}</ template >
< template # success = "{ row }" >< el-tag : type = "(row as PlatformAuditLogListItem).success ? 'success' : 'danger'" >{{ ( row as PlatformAuditLogListItem ). success ? '成功' : '失败' }}</ el-tag ></ template >
< template # occurredAt = "{ row }" >{{ formatDate (( row as PlatformAuditLogListItem ). occurredAt ) }}</ template >
< template # pagination -left > 共 {{ total }} 条客户操作记录 </ template >
</ DataTable >
</ template >
< AppDrawer v-model = "detailOpen" title="操作日志详情" size="min(860px, 94vw)" :loading="detailLoading" :show-footer="false" >
< LoadErrorState v-if = "detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></ template ></ LoadErrorState >
< template v-else-if = "detail" >
< el-descriptions :column = "2" border >
< el-descriptions-item label = "操作内容" >{{ display ( detail . description ) }}</ el-descriptions-item >< el-descriptions-item label = "结果" >{{ detail . success ? '成功' : '失败' }}</ el-descriptions-item >
< el-descriptions-item label = "操作人" >{{ display ( detail . username ) }}</ el-descriptions-item >< el-descriptions-item label = "发生时间" >{{ formatDate ( detail . occurredAt ) }}</ el-descriptions-item >
2026-08-17 19:52:04 +08:00
< el-descriptions-item label = "事件类型" >{{ auditEventLabel ( detail . eventType ) }}</ el-descriptions-item >< el-descriptions-item label = "资源" >{{ detail . resourceType }} / {{ display ( detail . resourceId ) }}</ el-descriptions-item >
2026-08-13 11:57:44 +08:00
< el-descriptions-item label = "请求" >{{ display ( detail . httpMethod ) }} {{ display ( detail . requestUri ) }}</ el-descriptions-item >< el-descriptions-item label = "耗时" >{{ detail . durationMs == null ? '—' : ` ${ detail . durationMs } ms` }}</ el-descriptions-item >
< el-descriptions-item label = "来源服务" >{{ display ( detail . serviceName ) }}</ el-descriptions-item >< el-descriptions-item label = "请求标识" >{{ display ( detail . requestId ) }}</ el-descriptions-item >
< el-descriptions-item v-if = "!detail.success" label="失败原因" :span="2" >{{ display ( detail.errorMessage || detail.errorCode ) }} </ el -descriptions-item >
</ el-descriptions >
< div class = "snapshot-grid" >< section >< h3 > 操作前快照 </ h3 >< pre >{{ formatSnapshot ( detail . beforeData ) }}</ pre ></ section >< section >< h3 > 操作结果快照 </ h3 >< pre >{{ formatSnapshot ( detail . afterData ) }}</ pre ></ section ></ div >
</ template >
</ AppDrawer >
</ section >
</ template >
< style scoped >
. customer - audit - panel { min - width : 0 ; }. panel - title { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 16 px ; margin - bottom : 18 px ; padding - bottom : 14 px ; border - bottom : 1 px solid var ( -- cc - border ); }. panel - title h2 { margin : 0 ; color : # 344054 ; font - size : 16 px ; }. panel - title p { margin : 5 px 0 0 ; color : var ( -- cc - muted ); font - size : 11 px ; }. panel - title > svg { color : var ( -- cc - primary ); }. audit - query { margin - bottom : 16 px ; }. module - empty { display : flex ; min - height : 260 px ; align - items : center ; justify - content : center ; flex - direction : column ; gap : 7 px ; color : # 98 a2b3 ; text - align : center ; }. module - empty strong { color : # 475467 ; font - size : 13 px ; }. module - empty p { margin : 0 ; color : var ( -- cc - muted ); font - size : 12 px ; }. snapshot - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 16 px ; margin - top : 18 px ; }. snapshot - grid section { min - width : 0 ; }. snapshot - grid h3 { margin : 0 0 8 px ; color : # 475467 ; font - size : 14 px ; }. snapshot - grid pre { min - height : 160 px ; margin : 0 ; padding : 14 px ; overflow : auto ; border : 1 px solid var ( -- cc - border ); border - radius : 6 px ; background : # f8fafc ; color : # 344054 ; font : 12 px / 1.6 ui - monospace , SFMono - Regular , Consolas , monospace ; white - space : pre - wrap ; overflow - wrap : anywhere ; } @ media ( max - width : 720 px ){. snapshot - grid { grid - template - columns : 1 fr }}
</ style >