diff options
Diffstat (limited to 'src/components/logs')
| -rw-r--r-- | src/components/logs/LogSearch.ts | 6 | ||||
| -rw-r--r-- | src/components/logs/Logs.module.scss | 257 | ||||
| -rw-r--r-- | src/components/logs/Logs.tsx | 144 | ||||
| -rw-r--r-- | src/components/logs/LogsHeader.module.scss | 33 | ||||
| -rw-r--r-- | src/components/logs/LogsHeader.tsx | 67 |
5 files changed, 311 insertions, 196 deletions
diff --git a/src/components/logs/LogSearch.ts b/src/components/logs/LogSearch.ts deleted file mode 100644 index d5972a4..0000000 --- a/src/components/logs/LogSearch.ts +++ /dev/null @@ -1,6 +0,0 @@ -import Search from '~/components/Search'; -import { connect } from '~/components/StateProvider'; -import { getSearchText, updateSearchText } from '~/store/logs'; - -const mapState = (s) => ({ searchText: getSearchText(s), updateSearchText }); -export default connect(mapState)(Search); diff --git a/src/components/logs/Logs.module.scss b/src/components/logs/Logs.module.scss index c90071d..47b996f 100644 --- a/src/components/logs/Logs.module.scss +++ b/src/components/logs/Logs.module.scss @@ -1,190 +1,217 @@ -.headerControls { +.page { display: flex; - align-items: center; - gap: 10px; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.listArea { + display: flex; + flex-direction: column; flex: 1; - justify-content: flex-end; - max-width: 360px; + min-height: 0; + margin: 12px 32px 24px; - & > div { - flex: 1; + @media (max-width: 1024px) { + margin: 12px 16px 16px; } @media (max-width: 768px) { - max-width: none; - justify-content: stretch; + margin: 8px 12px 10px; } } -.searchWrapper { +// 与连接页表格 / 规则页列表同一张卡:窄屏下退掉卡片边界 +.card { + position: relative; + display: flex; + flex-direction: column; flex: 1; - max-width: 300px; + min-height: 0; + border-radius: 16px; + background: var(--color-card); + border: 1px solid var(--color-card-border); + box-shadow: var(--shadow-card); + overflow: hidden; @media (max-width: 768px) { - max-width: none; + background: transparent; + border: none; + box-shadow: none; + border-radius: 0; } } -.levelSelect { - flex-shrink: 0; - width: auto; - min-width: 90px; -} +.scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; -.clearBtn { - background: none; - border: none; - color: var(--color-text-secondary); - cursor: pointer; - padding: 8px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - transition: - background-color 0.2s, - color 0.2s; + &::-webkit-scrollbar { + width: 6px; + } - &:hover { - background-color: var(--bg-near-transparent); - color: var(--color-text); + &::-webkit-scrollbar-thumb { + background-color: var(--color-card-border); + border-radius: 3px; } } -.logLine { +/* ---------- 日志行 ---------- */ + +.line { display: flex; - font-family: var(--font-normal); - font-size: 12px; - padding: 4px 8px; - border-bottom: 1px solid var(--bg-near-transparent); - word-break: break-all; + align-items: baseline; + gap: 10px; + padding: 5px 18px; + font-size: 0.75rem; + border-bottom: 1px solid var(--color-card-border); &:hover { - background-color: var(--bg-near-transparent); + background-color: var(--color-hover-soft); } + // 这里不用 content-visibility:自动跟随是拿 scrollHeight 直接顶到底的, + // 屏外行只有估算高度会让落点跳。日志上限 300 行,靠 LogLine 的 memo 就够了 + @media (max-width: 768px) { - flex-direction: column; + // 窄屏放不下三列,时间和级别并排占一行,正文另起一行 + flex-wrap: wrap; + gap: 6px; + padding: 6px 12px; } } -.logMeta { - display: flex; - align-items: center; +.time { flex-shrink: 0; - margin-right: 12px; - min-width: 180px; + width: 148px; + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--color-text-secondary); + opacity: 0.8; @media (max-width: 768px) { - margin-bottom: 4px; + width: auto; } } -.logTime { - color: var(--color-text-secondary); - margin-right: 8px; - opacity: 0.8; -} - -.logType { - text-transform: uppercase; - font-weight: bold; - font-size: 10px; - padding: 1px 6px; - border-radius: 3px; - min-width: 50px; +.type { + flex-shrink: 0; + width: 44px; text-align: center; + text-transform: uppercase; + font-weight: 700; + font-size: 0.6rem; + padding: 1px 0; + border-radius: 4px; &[data-type='debug'] { - color: #389d3d; - background-color: rgba(56, 157, 61, 0.1); + color: var(--color-success); + background-color: var(--color-success-soft-bg); } + &[data-type='info'] { - color: #0ea5e9; - background-color: rgba(14, 165, 233, 0.1); + color: var(--color-accent-soft-fg); + background-color: var(--color-accent-soft-bg); } + &[data-type='warning'] { - color: #f59e0b; - background-color: rgba(245, 158, 11, 0.1); + color: var(--color-warn); + background-color: var(--color-warn-soft-bg); } + &[data-type='error'] { - color: #ef4444; - background-color: rgba(239, 68, 68, 0.1); + color: var(--color-danger); + background-color: var(--color-danger-soft-bg); } } -.logText { - color: var(--color-text); - line-height: 1.5; +.payload { flex: 1; -} - -.logsWrapper { - position: relative; - margin: 20px 45px; - padding: 10px; - background-color: var(--bg-log-info-card); - border-radius: 8px; + min-width: 0; color: var(--color-text); - overflow-y: auto; - box-shadow: - 0 4px 6px -1px rgba(0, 0, 0, 0.1), - 0 2px 4px -1px rgba(0, 0, 0, 0.06); + line-height: 1.5; + word-break: break-all; @media (max-width: 768px) { - margin: 10px 15px; - } - - &::-webkit-scrollbar { - width: 6px; - } - &::-webkit-scrollbar-thumb { - background-color: var(--bg-near-transparent); - border-radius: 3px; + flex-basis: 100%; } } -.scrollToBottomBtn { +/* ---------- 回到底部 ---------- */ + +.toBottomBtn { position: absolute; - bottom: 80px; - right: 20px; - background-color: var(--color-focus-blue); - color: white; - border: none; - border-radius: 50%; - width: 36px; - height: 36px; + right: 18px; + bottom: 52px; + z-index: 2; display: flex; align-items: center; justify-content: center; + appearance: none; + width: 34px; + height: 34px; + border: 1px solid var(--color-card-border); + border-radius: 50%; + background: var(--color-card); + color: var(--color-text); cursor: pointer; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); - z-index: 10; - transition: transform 0.2s; + box-shadow: var(--shadow-popover); + transition: + border-color 0.15s ease, + color 0.15s ease; &:hover { - transform: scale(1.1); + border-color: var(--color-focus-blue); + color: var(--color-focus-blue); } - @media (max-width: 768px) { - right: 25px; + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: 2px; } } -.logPlaceholder { +/* ---------- 空态 ---------- */ + +.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 6px; + height: 100%; +} + +.emptyTitle { + font-size: 0.88rem; + color: var(--color-text); +} + +.emptyHint { + font-size: 0.78rem; color: var(--color-text-secondary); +} - div:nth-child(2) { - font-size: 1.2em; - margin-top: 20px; - opacity: 0.6; - } +/* ---------- 页脚 ---------- */ + +.footer { + display: flex; + align-items: center; + gap: 14px; + height: 36px; + flex-shrink: 0; + padding: 0 18px; + border-top: 1px solid var(--color-card-border); + background: var(--color-track); + font-size: 0.72rem; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; } -.logPlaceholderIcon { - opacity: 0.2; +.paused { + margin-left: auto; + color: var(--color-warn); } diff --git a/src/components/logs/Logs.tsx b/src/components/logs/Logs.tsx index 71acbb8..a8510b8 100644 --- a/src/components/logs/Logs.tsx +++ b/src/components/logs/Logs.tsx @@ -1,109 +1,103 @@ +import { useAtomValue, useSetAtom } from 'jotai'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import ContentHeader from '~/components/ContentHeader'; -import { Fab, position as fabPosition } from '~/components/shared/Fab'; -import { ArrowDown, Pause, Play, Trash2 } from '~/components/shared/FeatherIcons'; -import Select from '~/components/shared/Select'; -import { useStoreActions } from '~/components/StateProvider'; -import SvgYacd from '~/components/SvgYacd'; -import useRemainingViewPortHeight from '~/hooks/useRemainingViewPortHeight'; -import { LOG_LEVEL_OPTIONS } from '~/modules/config/utils'; -import { useLogsPage } from '~/modules/logs/hooks'; -import { LOG_TYPES, LOGS_HEIGHT_RATIO } from '~/modules/logs/utils'; +import { ArrowDown } from '~/components/shared/FeatherIcons'; +import { useFilteredLogs, useLogsPage } from '~/modules/logs/hooks'; +import { LOG_TYPES } from '~/modules/logs/utils'; import { updateConfigs } from '~/store/configs'; -import { clearLogs } from '~/store/logs'; -import { DispatchFn, Log } from '~/store/types'; -import { ClashAPIConfig } from '~/types'; +import { clearLogsAtom, logsForDisplayAtom } from '~/store/logs'; +import { useStoreActions } from '~/store/StateProvider'; +import { DispatchFn } from '~/store/types'; +import { ClashAPIConfig, Log } from '~/types'; import s from './Logs.module.scss'; -import LogSearch from './LogSearch'; +import { LogsHeader } from './LogsHeader'; -type LogLineProps = Partial<Log>; - -function LogLine({ time, payload, type }: LogLineProps) { +// 一屏最多几十行,但列表整体可以有几百行;memo 之后新日志进来只渲染新增的那一条 +const LogLine = React.memo(function LogLine({ time, payload, type }: Log) { return ( - <div className={s.logLine}> - <div className={s.logMeta}> - <span className={s.logTime}>{time}</span> - <span className={s.logType} data-type={type}> - {LOG_TYPES[type]} - </span> - </div> - <div className={s.logText}>{payload}</div> + <div className={s.line}> + <span className={s.time}>{time}</span> + <span className={s.type} data-type={type}> + {LOG_TYPES[type]} + </span> + <span className={s.payload}>{payload}</span> </div> ); -} +}); type Props = { dispatch: DispatchFn; logLevel: string; apiConfig: ClashAPIConfig; - logs: Log[]; logStreamingPaused: boolean; }; -export default function Logs({ dispatch, logLevel, apiConfig, logs, logStreamingPaused }: Props) { +export default function Logs({ dispatch, logLevel, apiConfig, logStreamingPaused }: Props) { + const { t } = useTranslation(); const actions = useStoreActions(); + const logs = useAtomValue(logsForDisplayAtom); const { toggleIsRefreshPaused, scrollRef, isAtBottom, scrollToBottom, onScroll } = useLogsPage({ - dispatch, logLevel, apiConfig, logs, logStreamingPaused, updateAppConfig: actions.app.updateAppConfig, }); - const [refLogsContainer, containerHeight] = useRemainingViewPortHeight(); - const { t } = useTranslation(); + + const visibleLogs = useFilteredLogs(logs); + + const setLogLevel = React.useCallback( + (level: string) => dispatch(updateConfigs(apiConfig, { 'log-level': level })), + [dispatch, apiConfig], + ); + const onClear = useSetAtom(clearLogsAtom); return ( - <div> - <ContentHeader> - <div className={s.headerControls}> - <LogSearch className={s.searchWrapper} /> - <Select - className={s.levelSelect} - options={LOG_LEVEL_OPTIONS} - selected={logLevel ? logLevel.toLowerCase() : 'info'} - onChange={(e) => dispatch(updateConfigs(apiConfig, { 'log-level': e.target.value }))} - /> - <button className={s.clearBtn} onClick={() => dispatch(clearLogs())} title={t('Clear')}> - <Trash2 size={18} /> - </button> - </div> - </ContentHeader> - <div ref={refLogsContainer} style={{ position: 'relative' }}> - <div - className={s.logsWrapper} - style={{ height: containerHeight * LOGS_HEIGHT_RATIO }} - ref={scrollRef} - onScroll={onScroll} - > - {logs.length === 0 ? ( - <div className={s.logPlaceholder} style={{ height: '100%' }}> - <div className={s.logPlaceholderIcon}> - <SvgYacd width={200} height={200} /> + <div className={s.page}> + <LogsHeader + logLevel={logLevel} + setLogLevel={setLogLevel} + isPaused={logStreamingPaused} + toggleIsPaused={toggleIsRefreshPaused} + onClear={onClear} + /> + + <div className={s.listArea}> + <div className={s.card}> + <div className={s.scroll} ref={scrollRef} onScroll={onScroll}> + {visibleLogs.length === 0 ? ( + <div className={s.empty}> + <span className={s.emptyTitle}> + {logs.length === 0 ? t('no_logs') : t('logs_no_match')} + </span> + <span className={s.emptyHint}> + {logs.length === 0 ? t('logs_empty_hint') : t('rules_empty_hint')} + </span> </div> - <div>{t('no_logs')}</div> - </div> - ) : ( - logs.map((log, index) => <LogLine {...log} key={log.id || index} />) - )} - </div> + ) : ( + visibleLogs.map((log, index) => <LogLine {...log} key={log.id || index} />) + )} + </div> - {logs.length > 0 && !isAtBottom && ( - <button className={s.scrollToBottomBtn} onClick={scrollToBottom}> - <ArrowDown size={16} /> - </button> - )} + {visibleLogs.length > 0 && !isAtBottom ? ( + <button + type="button" + className={s.toBottomBtn} + onClick={scrollToBottom} + aria-label={t('logs_scroll_to_bottom')} + title={t('logs_scroll_to_bottom')} + > + <ArrowDown size={16} /> + </button> + ) : null} - <Fab - icon={logStreamingPaused ? <Play size={16} /> : <Pause size={16} />} - mainButtonStyles={logStreamingPaused ? { background: '#e74c3c' } : {}} - style={fabPosition} - text={logStreamingPaused ? t('Resume Refresh') : t('Pause Refresh')} - onClick={toggleIsRefreshPaused} - ></Fab> + <div className={s.footer}> + <span>{t('logs_shown', { count: visibleLogs.length })}</span> + {logStreamingPaused ? <span className={s.paused}>{t('logs_paused')}</span> : null} + </div> + </div> </div> </div> ); diff --git a/src/components/logs/LogsHeader.module.scss b/src/components/logs/LogsHeader.module.scss new file mode 100644 index 0000000..0509bb9 --- /dev/null +++ b/src/components/logs/LogsHeader.module.scss @@ -0,0 +1,33 @@ +/** + * 只放日志页特有的东西,顶栏本身的样式在 shared/PageHeader.module.scss。 + * DOM 顺序即桌面端视觉顺序(标题 · 级别 · 搜索 · 操作), + * 窄屏把操作钮提到第一行,级别和搜索并排到第二行。 + */ + +.levelSelect { + min-width: 104px; + + @media (max-width: 768px) { + order: 4; + min-width: 88px; + max-width: 110px; + } +} + +.search { + @media (max-width: 768px) { + order: 3; + } +} + +.actions { + @media (max-width: 768px) { + order: 1; + } +} + +.rowBreak { + @media (max-width: 768px) { + order: 2; + } +} diff --git a/src/components/logs/LogsHeader.tsx b/src/components/logs/LogsHeader.tsx new file mode 100644 index 0000000..5092429 --- /dev/null +++ b/src/components/logs/LogsHeader.tsx @@ -0,0 +1,67 @@ +import { useTranslation } from 'react-i18next'; + +import { Pause, Play, Trash2 } from '~/components/shared/FeatherIcons'; +import { + HeaderActions, + HeaderButton, + HeaderRowBreak, + HeaderSearch, + headerSearchInlineClass, + headerSelectClass, + HeaderTitle, + PageHeader, +} from '~/components/shared/PageHeader'; +import Select from '~/components/shared/Select'; +import { TextFilter } from '~/components/shared/TextFilter'; +import { LOG_LEVEL_OPTIONS } from '~/modules/config/utils'; +import { logFilterText } from '~/store/logs'; + +import s from './LogsHeader.module.scss'; + +type Props = { + logLevel: string; + setLogLevel: (level: string) => void; + isPaused: boolean; + toggleIsPaused: () => void; + onClear: () => void; +}; + +export function LogsHeader({ logLevel, setLogLevel, isPaused, toggleIsPaused, onClear }: Props) { + const { t } = useTranslation(); + + return ( + <PageHeader> + <HeaderTitle>{t('Logs')}</HeaderTitle> + + <Select + options={LOG_LEVEL_OPTIONS} + selected={logLevel ? logLevel.toLowerCase() : 'info'} + className={`${headerSelectClass} ${s.levelSelect}`} + aria-label={t('log_level')} + onChange={(e) => setLogLevel(e.target.value)} + /> + + <HeaderSearch className={`${headerSearchInlineClass} ${s.search}`}> + <TextFilter textAtom={logFilterText} placeholder={t('search_logs_placeholder')} /> + </HeaderSearch> + + <HeaderActions className={s.actions}> + <HeaderButton + variant={isPaused ? 'paused' : 'ghost'} + icon={isPaused ? <Play size={14} /> : <Pause size={14} />} + label={isPaused ? t('Resume Refresh') : t('Pause Refresh')} + onClick={toggleIsPaused} + /> + <HeaderButton + variant="danger" + icon={<Trash2 size={14} />} + label={t('Clear')} + onClick={onClear} + /> + </HeaderActions> + + {/* 窄屏下强制换行,让级别筛选和搜索独占第二行 */} + <HeaderRowBreak className={s.rowBreak} /> + </PageHeader> + ); +} |
