Files
clash-verge-rev/src/pages/logs.tsx
T
Tunglies b1eb011050 refactor(ui): extract useProxyDelayState hook and tighten types
- Extract shared delay state logic from ProxyItem and ProxyItemMini
  into `useProxyDelayState` hook (src/hooks/use-proxy-delay-state.ts);
  JSX display thresholds preserved per-component.
- Deduplicate rAF/setTimeout/Promise scheduling in DelayManager via
  a private `scheduleOnNextFrame` helper.
- Drop no-op `useMemo(() => MODES, [])` in proxies page.
- Replace `any` on `setClashLog` prev params and layout-traffic sx
  objects with proper types (`pre!`, `Pick<BoxProps|...>`).
2026-04-19 17:25:57 +08:00

201 lines
5.5 KiB
TypeScript

import {
PlayCircleOutlineRounded,
PauseCircleOutlineRounded,
SwapVertRounded,
} from '@mui/icons-material'
import { Box, Button, IconButton, MenuItem } from '@mui/material'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
BaseEmpty,
BasePage,
BaseSearchBox,
BaseStyledSelect,
type SearchState,
VirtualList,
type VirtualListHandle,
} from '@/components/base'
import LogItem from '@/components/log/log-item'
import { useClashLog } from '@/hooks/use-clash-log'
import { useLogData } from '@/hooks/use-log-data'
const LogPage = () => {
const { t } = useTranslation()
const [clashLog, setClashLog] = useClashLog()
const enableLog = clashLog.enable
const logState = clashLog.logFilter
const logOrder = clashLog.logOrder ?? 'asc'
const isDescending = logOrder === 'desc'
const [match, setMatch] = useState(() => (_: string) => true)
const [searchState, setSearchState] = useState<SearchState>()
const {
response: { data: logData },
refreshGetClashLog,
} = useLogData()
const filterLogs = useMemo(() => {
if (!logData || logData.length === 0) {
return []
}
// Server-side filtering handles level filtering via query parameters
// We only need to apply search filtering here
return logData.filter((data) => {
// 构建完整的搜索文本,包含时间、类型和内容
const searchText =
`${data.time || ''} ${data.type} ${data.payload}`.toLowerCase()
const matchesSearch = match(searchText)
return (
(logState == 'all' ? true : data.type.includes(logState)) &&
matchesSearch
)
})
}, [logData, logState, match])
const filteredLogs = useMemo(
() => (isDescending ? [...filterLogs].reverse() : filterLogs),
[filterLogs, isDescending],
)
const virtuosoRef = useRef<VirtualListHandle>(null)
useEffect(() => {
if (!isDescending && filteredLogs.length > 0) {
virtuosoRef.current?.scrollToIndex(filteredLogs.length - 1, {
behavior: 'smooth',
})
}
}, [filteredLogs.length, isDescending])
const handleLogLevelChange = (newLevel: LogFilter) => {
setClashLog((pre) => ({ ...pre!, logFilter: newLevel }))
}
const handleToggleLog = async () => {
setClashLog((pre) => ({ ...pre!, enable: !enableLog }))
}
const handleToggleOrder = () => {
setClashLog((pre) => ({
...pre!,
logOrder: pre!.logOrder === 'desc' ? 'asc' : 'desc',
}))
}
return (
<BasePage
full
title={t('logs.page.title')}
contentStyle={{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
}}
header={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<IconButton
title={t(
enableLog ? 'shared.actions.pause' : 'shared.actions.resume',
)}
aria-label={t(
enableLog ? 'shared.actions.pause' : 'shared.actions.resume',
)}
size="small"
color="inherit"
onClick={handleToggleLog}
>
{enableLog ? (
<PauseCircleOutlineRounded />
) : (
<PlayCircleOutlineRounded />
)}
</IconButton>
<IconButton
title={t(
isDescending
? 'logs.actions.showAscending'
: 'logs.actions.showDescending',
)}
aria-label={t(
isDescending
? 'logs.actions.showAscending'
: 'logs.actions.showDescending',
)}
size="small"
color="inherit"
onClick={handleToggleOrder}
>
<SwapVertRounded
sx={{
transform: isDescending ? 'scaleY(-1)' : 'none',
transition: 'transform 0.2s ease',
}}
/>
</IconButton>
<Button
size="small"
variant="contained"
onClick={() => {
refreshGetClashLog(true)
}}
>
{t('shared.actions.clear')}
</Button>
</Box>
}
>
<Box
sx={{
pt: 1,
mb: 0.5,
mx: '10px',
height: '39px',
display: 'flex',
alignItems: 'center',
}}
>
<BaseStyledSelect
value={logState}
onChange={(e) => handleLogLevelChange(e.target.value as LogFilter)}
>
<MenuItem value="all">{t('shared.filters.logLevels.all')}</MenuItem>
<MenuItem value="debug">
{t('shared.filters.logLevels.debug')}
</MenuItem>
<MenuItem value="info">{t('shared.filters.logLevels.info')}</MenuItem>
<MenuItem value="warn">{t('shared.filters.logLevels.warn')}</MenuItem>
<MenuItem value="err">{t('shared.filters.logLevels.error')}</MenuItem>
</BaseStyledSelect>
<BaseSearchBox
onSearch={(matcher, state) => {
setMatch(() => matcher)
setSearchState(state)
}}
/>
</Box>
{filteredLogs.length > 0 ? (
<VirtualList
ref={virtuosoRef}
count={filteredLogs.length}
estimateSize={50}
renderItem={(i) => (
<LogItem value={filteredLogs[i]} searchState={searchState} />
)}
style={{ flex: 1 }}
/>
) : (
<BaseEmpty />
)}
</BasePage>
)
}
export default LogPage