summaryrefslogtreecommitdiff
path: root/src/components/logs
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/logs')
-rw-r--r--src/components/logs/LogSearch.ts6
-rw-r--r--src/components/logs/Logs.module.scss257
-rw-r--r--src/components/logs/Logs.tsx144
-rw-r--r--src/components/logs/LogsHeader.module.scss33
-rw-r--r--src/components/logs/LogsHeader.tsx67
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>
+ );
+}