@@ -0,0 +1,170 @@
/**
* 日志管理 — 系统管理员查看全系统操作审计日志(谁/何时/何角色/动作/目标/方法/路径/结果/IP/耗时)。
* 支持按动作、关键词、时间范围、成功/失败筛选与分页,并可展开查看明细。
*/
import { useCallback , useEffect , useState , Fragment } from 'react' ;
import { colorVar , FONT_FAMILY , RADIUS , space , typographyStyle } from '../design-system/components/styles.js' ;
import { Card , Icon } from '../design-system/index.js' ;
import { fetchSystemLogs , type SystemLogItem } from '../api/client.js' ;
const ROLE_FG : Record < string , string > = {
'商务/销售' : '#15803D' , '风控' : '#B45309' , '管理层' : '#4F46E5' , '系统管理员' : '#0891B2' ,
} ;
function fmt ( ts : string ) : string {
const d = new Date ( ts ) ;
if ( Number . isNaN ( d . getTime ( ) ) ) return ts ;
return d . toLocaleString ( 'zh-CN' , { year : '2-digit' , month : '2-digit' , day : '2-digit' , hour : '2-digit' , minute : '2-digit' , second : '2-digit' } ) ;
}
export function SystemLogs ( ) : JSX . Element {
const [ items , setItems ] = useState < SystemLogItem [ ] > ( [ ] ) ;
const [ total , setTotal ] = useState ( 0 ) ;
const [ page , setPage ] = useState ( 1 ) ;
const [ pageSize , setPageSize ] = useState ( 20 ) ;
const [ actions , setActions ] = useState < string [ ] > ( [ ] ) ;
const [ action , setAction ] = useState ( '' ) ;
const [ success , setSuccess ] = useState < '' | 'true' | 'false' > ( '' ) ;
const [ q , setQ ] = useState ( '' ) ;
const [ qInput , setQInput ] = useState ( '' ) ;
const [ from , setFrom ] = useState ( '' ) ;
const [ to , setTo ] = useState ( '' ) ;
const [ loading , setLoading ] = useState ( false ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
const [ expanded , setExpanded ] = useState < number | null > ( null ) ;
useEffect ( ( ) = > {
const t = setTimeout ( ( ) = > { setQ ( qInput ) ; setPage ( 1 ) ; } , 350 ) ;
return ( ) = > clearTimeout ( t ) ;
} , [ qInput ] ) ;
const load = useCallback ( ( ) = > {
setLoading ( true ) ;
fetchSystemLogs ( {
page , pageSize ,
. . . ( action ? { action } : { } ) ,
. . . ( q ? { q } : { } ) ,
. . . ( from ? { from : new Date ( from ) . toISOString ( ) } : { } ) ,
. . . ( to ? { to : new Date ( to ) . toISOString ( ) } : { } ) ,
. . . ( success ? { success } : { } ) ,
} )
. then ( ( res ) = > { setItems ( res . items ) ; setTotal ( res . total ) ; setActions ( res . actions ) ; setError ( null ) ; } )
. catch ( ( e : unknown ) = > setError ( e instanceof Error ? e . message : '加载失败' ) )
. finally ( ( ) = > setLoading ( false ) ) ;
} , [ page , pageSize , action , q , from , to , success ] ) ;
useEffect ( ( ) = > { load ( ) ; } , [ load ] ) ;
const totalPages = Math . max ( 1 , Math . ceil ( total / pageSize ) ) ;
const input : React.CSSProperties = {
padding : ` ${ space ( 1 ) } px ${ space ( 2 ) } px ` , border : ` 1px solid ${ colorVar ( 'color.border.default' ) } ` ,
borderRadius : ` ${ RADIUS . md } px ` , fontFamily : FONT_FAMILY , . . . typographyStyle ( 'caption' ) ,
backgroundColor : colorVar ( 'color.bg.canvas' ) , color : colorVar ( 'color.text.primary' ) ,
} ;
const th : React.CSSProperties = { textAlign : 'left' , padding : ` ${ space ( 2 ) } px ${ space ( 2 ) } px ` , borderBottom : ` 2px solid ${ colorVar ( 'color.border.default' ) } ` , color : colorVar ( 'color.text.secondary' ) , . . . typographyStyle ( 'caption' ) , fontWeight : 700 , whiteSpace : 'nowrap' } ;
const td : React.CSSProperties = { padding : ` ${ space ( 2 ) } px ${ space ( 2 ) } px ` , borderBottom : ` 1px solid ${ colorVar ( 'color.border.default' ) } ` , . . . typographyStyle ( 'caption' ) , verticalAlign : 'top' } ;
return (
< div style = { { fontFamily : FONT_FAMILY , maxWidth : 1240 , margin : '0 auto' } } >
< div style = { { marginBottom : ` ${ space ( 4 ) } px ` } } >
< h1 style = { { . . . typographyStyle ( 'heading' ) , color : colorVar ( 'color.text.primary' ) , margin : 0 } } > 日 志 管 理 < / h1 >
< p style = { { . . . typographyStyle ( 'caption' ) , color : colorVar ( 'color.text.secondary' ) , margin : ` ${ space ( 1 ) } px 0 0 ` } } >
全 系 统 操 作 审 计 : 记 录 每 一 次 写 操 作 的 操 作 人 、 角 色 、 动 作 、 目 标 、 方 法 、 路 径 、 结 果 、 IP 与 耗 时 , 供 合 规 审 计 与 追 溯 。
< / p >
< / div >
< Card title = {
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' , flexWrap : 'wrap' , gap : ` ${ space ( 2 ) } px ` } } >
< span > 操 作 日 志 ( { total } ) < / span >
< div style = { { display : 'flex' , gap : ` ${ space ( 2 ) } px ` , flexWrap : 'wrap' , alignItems : 'center' } } >
< input style = { { . . . input , width : 200 } } placeholder = "搜索 操作人/动作/路径/目标" value = { qInput } onChange = { ( e ) = > setQInput ( e . target . value ) } / >
< select style = { input } value = { action } onChange = { ( e ) = > { setAction ( e . target . value ) ; setPage ( 1 ) ; } } >
< option value = "" > 全 部 动 作 < / option >
{ actions . map ( ( a ) = > < option key = { a } value = { a } > { a } < / option > ) }
< / select >
< select style = { input } value = { success } onChange = { ( e ) = > { setSuccess ( e . target . value as '' | 'true' | 'false' ) ; setPage ( 1 ) ; } } >
< option value = "" > 全 部 结 果 < / option >
< option value = "true" > 成 功 < / option >
< option value = "false" > 失 败 < / option >
< / select >
< input style = { input } type = "date" value = { from } onChange = { ( e ) = > { setFrom ( e . target . value ) ; setPage ( 1 ) ; } } title = "起始日期" / >
< input style = { input } type = "date" value = { to } onChange = { ( e ) = > { setTo ( e . target . value ) ; setPage ( 1 ) ; } } title = "结束日期" / >
< / div >
< / div >
} >
{ error !== null && < div style = { { color : colorVar ( 'color.risk.critical' ) , marginBottom : ` ${ space ( 2 ) } px ` } } > { error } < / div > }
{ loading ? < p style = { { color : colorVar ( 'color.text.secondary' ) } } > 加 载 中 … < / p > : (
< div style = { { overflowX : 'auto' } } >
< table style = { { width : '100%' , borderCollapse : 'collapse' } } >
< thead > < tr >
< th style = { th } > 时 间 < / th >
< th style = { th } > 操 作 人 < / th >
< th style = { th } > 角 色 < / th >
< th style = { th } > 动 作 < / th >
< th style = { th } > 方 法 < / th >
< th style = { th } > 目 标 / 路 径 < / th >
< th style = { th } > 结 果 < / th >
< th style = { th } > IP < / th >
< th style = { th } > 耗 时 < / th >
< / tr > < / thead >
< tbody >
{ items . map ( ( it ) = > (
< Fragment key = { it . id } >
< tr style = { { cursor : 'pointer' } } onClick = { ( ) = > setExpanded ( expanded === it . id ? null : it . id ) } >
< td style = { { . . . td , whiteSpace : 'nowrap' } } > { fmt ( it . ts ) } < / td >
< td style = { { . . . td , whiteSpace : 'nowrap' , fontWeight : 600 } } > { it . actorName ? ? '匿名' } < / td >
< td style = { { . . . td , whiteSpace : 'nowrap' , color : it.role ? ( ROLE_FG [ it . role ] ? ? colorVar ( 'color.text.primary' ) ) : colorVar ( 'color.text.secondary' ) , fontWeight : 600 } } > { it . role ? ? '—' } < / td >
< td style = { { . . . td , whiteSpace : 'nowrap' , color : colorVar ( 'color.text.primary' ) } } > { it . action } < / td >
< td style = { td } > < span style = { { fontFamily : 'monospace' , fontWeight : 700 , color : it.method === 'DELETE' ? '#BE123C' : it . method === 'PUT' ? '#B45309' : '#2563EB' } } > { it . method } < / span > < / td >
< td style = { { . . . td , maxWidth : 320 , wordBreak : 'break-all' , color : colorVar ( 'color.text.secondary' ) } } >
{ it . targetId && < span style = { { color : colorVar ( 'color.text.primary' ) , fontWeight : 600 } } > { it . targetId } < / span > }
< div style = { { fontFamily : 'monospace' , fontSize : '11px' } } > { it . path } < / div >
< / td >
< td style = { { . . . td , whiteSpace : 'nowrap' } } >
< span style = { { display : 'inline-flex' , alignItems : 'center' , gap : 4 , color : it.success ? '#15803D' : '#BE123C' , fontWeight : 600 } } >
< Icon name = { it . success ? 'check-circle' : 'alert' } size = { 13 } / > { it . status ? ? '-' }
< / span >
< / td >
< td style = { { . . . td , whiteSpace : 'nowrap' , color : colorVar ( 'color.text.secondary' ) , fontFamily : 'monospace' , fontSize : '11px' } } > { it . ip ? ? '—' } < / td >
< td style = { { . . . td , whiteSpace : 'nowrap' , color : colorVar ( 'color.text.secondary' ) } } > { it . durationMs ? ? '-' } ms < / td >
< / tr >
{ expanded === it . id && (
< tr >
< td colSpan = { 9 } style = { { . . . td , backgroundColor : colorVar ( 'color.bg.surface' ) } } >
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 4 , . . . typographyStyle ( 'caption' ) } } >
< span > 日 志 ID : { it . id } 操 作 人 ID : { it . actorId ? ? '—' } < / span >
{ it . query && < span > 查 询 参 数 : < span style = { { fontFamily : 'monospace' } } > { it . query } < / span > < / span > }
{ it . detail != null && < span > 明 细 : < span style = { { fontFamily : 'monospace' } } > { JSON . stringify ( it . detail ) } < / span > < / span > }
< / div >
< / td >
< / tr >
) }
< / Fragment >
) ) }
{ items . length === 0 && < tr > < td colSpan = { 9 } style = { { . . . td , color : colorVar ( 'color.text.secondary' ) , textAlign : 'center' } } > 暂 无 日 志 < / td > < / tr > }
< / tbody >
< / table >
< / div >
) }
{ /* 分页 */ }
< div style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' , marginTop : ` ${ space ( 3 ) } px ` , flexWrap : 'wrap' , gap : ` ${ space ( 2 ) } px ` } } >
< span style = { { . . . typographyStyle ( 'caption' ) , color : colorVar ( 'color.text.secondary' ) } } > 共 { total } 条 · 第 { page } / { totalPages } 页 < / span >
< div style = { { display : 'flex' , gap : ` ${ space ( 2 ) } px ` , alignItems : 'center' } } >
< select style = { input } value = { pageSize } onChange = { ( e ) = > { setPageSize ( Number ( e . target . value ) ) ; setPage ( 1 ) ; } } >
{ [ 20 , 50 , 100 ] . map ( ( s ) = > < option key = { s } value = { s } > 每 页 { s } < / option > ) }
< / select >
< button style = { pagerBtn } disabled = { page <= 1 } onClick = { ( ) = > setPage ( 1 ) } > 首 页 < / button >
< button style = { pagerBtn } disabled = { page <= 1 } onClick = { ( ) = > setPage ( page - 1 ) } > 上 一 页 < / button >
< button style = { pagerBtn } disabled = { page >= totalPages } onClick = { ( ) = > setPage ( page + 1 ) } > 下 一 页 < / button >
< button style = { pagerBtn } disabled = { page >= totalPages } onClick = { ( ) = > setPage ( totalPages ) } > 末 页 < / button >
< / div >
< / div >
< / Card >
< / div >
) ;
}
const pagerBtn : React.CSSProperties = {
padding : '4px 10px' , borderRadius : '6px' , border : '1px solid var(--color-border-default)' ,
background : 'transparent' , cursor : 'pointer' , fontFamily : FONT_FAMILY , fontSize : '12px' , color : 'var(--color-text-primary)' ,
} ;