feat(logs): implement tabbed view for logs and error files

This commit is contained in:
hkfires
2025-12-24 11:45:14 +08:00
parent 02a01e5afc
commit 0758cfe08a
4 changed files with 299 additions and 242 deletions

View File

@@ -608,6 +608,7 @@
"auto_refresh_disabled": "Auto refresh disabled", "auto_refresh_disabled": "Auto refresh disabled",
"load_more_hint": "Scroll up to load more", "load_more_hint": "Scroll up to load more",
"hidden_lines": "Hidden: {{count}} lines", "hidden_lines": "Hidden: {{count}} lines",
"loaded_lines": "Loaded: {{count}} lines",
"hide_management_logs": "Hide {{prefix}} logs", "hide_management_logs": "Hide {{prefix}} logs",
"search_placeholder": "Search logs by content or keyword", "search_placeholder": "Search logs by content or keyword",
"search_empty_title": "No matching logs found", "search_empty_title": "No matching logs found",

View File

@@ -608,6 +608,7 @@
"auto_refresh_disabled": "自动刷新已关闭", "auto_refresh_disabled": "自动刷新已关闭",
"load_more_hint": "向上滚动加载更多", "load_more_hint": "向上滚动加载更多",
"hidden_lines": "已隐藏 {{count}} 行", "hidden_lines": "已隐藏 {{count}} 行",
"loaded_lines": "已载入 {{count}} 行",
"hide_management_logs": "屏蔽 {{prefix}} 日志", "hide_management_logs": "屏蔽 {{prefix}} 日志",
"search_placeholder": "搜索日志内容或关键字", "search_placeholder": "搜索日志内容或关键字",
"search_empty_title": "未找到匹配的日志", "search_empty_title": "未找到匹配的日志",

View File

@@ -8,7 +8,38 @@
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin: 0 0 $spacing-xl 0; margin: 0 0 $spacing-lg 0;
}
.tabBar {
display: flex;
gap: $spacing-xs;
margin-bottom: $spacing-lg;
border-bottom: 1px solid var(--border-color);
}
.tabItem {
@include button-reset;
padding: 12px 20px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
background: transparent;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease;
&:hover {
color: var(--text-primary);
}
}
.tabActive {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
} }
.content { .content {
@@ -22,9 +53,12 @@
align-items: center; align-items: center;
gap: $spacing-sm; gap: $spacing-sm;
flex-wrap: wrap; flex-wrap: wrap;
margin-left: auto;
@include mobile { @include mobile {
align-items: flex-start; align-items: flex-start;
margin-left: 0;
width: 100%;
} }
} }
@@ -137,6 +171,12 @@
white-space: nowrap; white-space: nowrap;
} }
.loadMoreStats {
display: flex;
align-items: center;
gap: $spacing-md;
}
.logList { .logList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -346,11 +346,14 @@ const copyToClipboard = async (text: string) => {
} }
}; };
type TabType = 'logs' | 'errors';
export function LogsPage() { export function LogsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const [activeTab, setActiveTab] = useState<TabType>('logs');
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 }); const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -547,7 +550,7 @@ export function LogsPage() {
const isSearching = trimmedSearchQuery.length > 0; const isSearching = trimmedSearchQuery.length > 0;
const baseLines = isSearching ? logState.buffer : visibleLines; const baseLines = isSearching ? logState.buffer : visibleLines;
const { filteredLines, removedCount } = useMemo(() => { const { filteredLines } = useMemo(() => {
let working = baseLines; let working = baseLines;
let removed = 0; let removed = 0;
@@ -626,10 +629,65 @@ export function LogsPage() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1> <h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.tabBar}>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'logs' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('logs')}
>
{t('logs.log_content')}
</button>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'errors' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('errors')}
>
{t('logs.error_logs_modal_title')}
</button>
</div>
<div className={styles.content}> <div className={styles.content}>
<Card {activeTab === 'logs' && (
title={t('logs.log_content')} <Card>
extra={ {error && <div className="error-box">{error}</div>}
<div className={styles.filters}>
<div className={styles.searchWrapper}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('logs.search_placeholder')}
className={styles.searchInput}
rightElement={
searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title="Clear"
aria-label="Clear"
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
<ToggleSwitch
checked={hideManagementLogs}
onChange={setHideManagementLogs}
label={
<span className={styles.switchLabel}>
<IconEyeOff size={16} />
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
</span>
}
/>
<div className={styles.toolbar}> <div className={styles.toolbar}>
<Button <Button
variant="secondary" variant="secondary"
@@ -679,56 +737,6 @@ export function LogsPage() {
</span> </span>
</Button> </Button>
</div> </div>
}
>
{error && <div className="error-box">{error}</div>}
<div className={styles.filters}>
<div className={styles.searchWrapper}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('logs.search_placeholder')}
className={styles.searchInput}
rightElement={
searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title="Clear"
aria-label="Clear"
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
<ToggleSwitch
checked={hideManagementLogs}
onChange={setHideManagementLogs}
label={
<span className={styles.switchLabel}>
<IconEyeOff size={16} />
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
</span>
}
/>
<div className={styles.filterStats}>
<span>
{parsedVisibleLines.length} {t('logs.lines')}
</span>
{removedCount > 0 && (
<span className={styles.removedCount}>
{t('logs.removed')} {removedCount}
</span>
)}
</div>
</div> </div>
{loading ? ( {loading ? (
@@ -738,10 +746,15 @@ export function LogsPage() {
{canLoadMore && ( {canLoadMore && (
<div className={styles.loadMoreBanner}> <div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span> <span>{t('logs.load_more_hint')}</span>
<div className={styles.loadMoreStats}>
<span>
{t('logs.loaded_lines', { count: parsedVisibleLines.length })}
</span>
<span className={styles.loadMoreCount}> <span className={styles.loadMoreCount}>
{t('logs.hidden_lines', { count: logState.visibleFrom })} {t('logs.hidden_lines', { count: logState.visibleFrom })}
</span> </span>
</div> </div>
</div>
)} )}
<div className={styles.logList}> <div className={styles.logList}>
{parsedVisibleLines.map((line, index) => { {parsedVisibleLines.map((line, index) => {
@@ -845,9 +858,10 @@ export function LogsPage() {
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} /> <EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)} )}
</Card> </Card>
)}
{activeTab === 'errors' && (
<Card <Card
title={t('logs.error_logs_modal_title')}
extra={ extra={
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}> <Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
{t('common.refresh')} {t('common.refresh')}
@@ -881,6 +895,7 @@ export function LogsPage() {
</div> </div>
)} )}
</Card> </Card>
)}
</div> </div>
</div> </div>
); );