diff --git a/src/components/common/NotificationContainer.tsx b/src/components/common/NotificationContainer.tsx index 607f07e..e731a66 100644 --- a/src/components/common/NotificationContainer.tsx +++ b/src/components/common/NotificationContainer.tsx @@ -1,16 +1,89 @@ +import { useEffect, useRef, useState } from 'react'; import { useNotificationStore } from '@/stores'; +import type { Notification } from '@/types'; + +interface AnimatedNotification extends Notification { + isExiting?: boolean; +} + +const ANIMATION_DURATION = 300; // ms export function NotificationContainer() { const { notifications, removeNotification } = useNotificationStore(); + const [animatedNotifications, setAnimatedNotifications] = useState([]); + const prevNotificationsRef = useRef([]); - if (!notifications.length) return null; + // Track notifications and manage animation states + useEffect(() => { + const prevNotifications = prevNotificationsRef.current; + const prevIds = new Set(prevNotifications.map((n) => n.id)); + const currentIds = new Set(notifications.map((n) => n.id)); + + // Find new notifications (for enter animation) + const newNotifications = notifications.filter((n) => !prevIds.has(n.id)); + + // Find removed notifications (for exit animation) + const removedIds = new Set( + prevNotifications.filter((n) => !currentIds.has(n.id)).map((n) => n.id) + ); + + setAnimatedNotifications((prev) => { + // Mark removed notifications as exiting + let updated = prev.map((n) => + removedIds.has(n.id) ? { ...n, isExiting: true } : n + ); + + // Add new notifications + newNotifications.forEach((n) => { + if (!updated.find((an) => an.id === n.id)) { + updated.push({ ...n, isExiting: false }); + } + }); + + // Remove notifications that are not in current and not exiting + // (they've already completed their exit animation) + updated = updated.filter( + (n) => currentIds.has(n.id) || n.isExiting + ); + + return updated; + }); + + // Clean up exited notifications after animation + if (removedIds.size > 0) { + setTimeout(() => { + setAnimatedNotifications((prev) => + prev.filter((n) => !removedIds.has(n.id)) + ); + }, ANIMATION_DURATION); + } + + prevNotificationsRef.current = notifications; + }, [notifications]); + + const handleClose = (id: string) => { + // Start exit animation + setAnimatedNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, isExiting: true } : n)) + ); + + // Actually remove after animation + setTimeout(() => { + removeNotification(id); + }, ANIMATION_DURATION); + }; + + if (!animatedNotifications.length) return null; return (
- {notifications.map((notification) => ( -
+ {animatedNotifications.map((notification) => ( +
{notification.message}
-
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index bf907a7..de3203d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -551,7 +551,8 @@ "save_success": "Configuration saved successfully", "error_yaml_not_supported": "Server did not return YAML. Verify the /config.yaml endpoint is available.", "editor_placeholder": "key: value", - "search_placeholder": "Search config... (Enter for next, Shift+Enter for previous)", + "search_placeholder": "Type then click the search button (or press Enter) to search", + "search_button": "Search", "search_no_results": "No results", "search_prev": "Previous", "search_next": "Next" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 1188501..d83abdd 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -551,7 +551,8 @@ "save_success": "配置已保存", "error_yaml_not_supported": "服务器未返回 YAML 格式,请确认 /config.yaml 接口可用", "editor_placeholder": "key: value", - "search_placeholder": "搜索配置内容... (Enter 下一个, Shift+Enter 上一个)", + "search_placeholder": "输入关键字后点击右侧搜索按钮(或 Enter)进行搜索", + "search_button": "搜索", "search_no_results": "无结果", "search_prev": "上一个", "search_next": "下一个" diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index d3cab86..81d05f3 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -132,10 +132,10 @@ .fileGrid { display: grid; gap: $spacing-md; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); @include tablet { - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(2, minmax(0, 1fr)); } @include mobile { @@ -240,6 +240,16 @@ padding-top: $spacing-sm; } +.iconButton:global(.btn.btn-sm) { + width: 34px; + height: 34px; + min-width: 34px; + padding: 0; + box-sizing: border-box; + border-radius: 6px; + gap: 0; +} + .actionIcon { font-style: normal; font-size: 14px; diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 46e3be8..d9cb0d2 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -2,10 +2,11 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { Input } from '@/components/ui/Input'; import { Modal } from '@/components/ui/Modal'; import { EmptyState } from '@/components/ui/EmptyState'; -import { useAuthStore, useNotificationStore } from '@/stores'; +import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores'; import { authFilesApi, usageApi } from '@/services/api'; import { apiClient } from '@/services/api/client'; import type { AuthFileItem } from '@/types'; @@ -13,19 +14,51 @@ import type { KeyStats, KeyStatBucket } from '@/utils/usage'; import { formatFileSize } from '@/utils/format'; import styles from './AuthFilesPage.module.scss'; -// 标签类型颜色配置 -const TYPE_COLORS: Record = { - qwen: { bg: 'rgba(59, 130, 246, 0.15)', text: '#3b82f6' }, - gemini: { bg: 'rgba(34, 197, 94, 0.15)', text: '#22c55e' }, - 'gemini-cli': { bg: 'rgba(6, 182, 212, 0.15)', text: '#06b6d4' }, - aistudio: { bg: 'rgba(139, 92, 246, 0.15)', text: '#8b5cf6' }, - claude: { bg: 'rgba(249, 115, 22, 0.15)', text: '#f97316' }, - codex: { bg: 'rgba(236, 72, 153, 0.15)', text: '#ec4899' }, - antigravity: { bg: 'rgba(245, 158, 11, 0.15)', text: '#f59e0b' }, - iflow: { bg: 'rgba(132, 204, 22, 0.15)', text: '#84cc16' }, - vertex: { bg: 'rgba(239, 68, 68, 0.15)', text: '#ef4444' }, - empty: { bg: 'rgba(107, 114, 128, 0.15)', text: '#6b7280' }, - unknown: { bg: 'rgba(156, 163, 175, 0.15)', text: '#9ca3af' } +type ThemeColors = { bg: string; text: string; border?: string }; +type TypeColorSet = { light: ThemeColors; dark?: ThemeColors }; + +// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色) +const TYPE_COLORS: Record = { + qwen: { + light: { bg: '#e8f5e9', text: '#2e7d32' }, + dark: { bg: '#1b5e20', text: '#81c784' } + }, + gemini: { + light: { bg: '#e3f2fd', text: '#1565c0' }, + dark: { bg: '#0d47a1', text: '#64b5f6' } + }, + 'gemini-cli': { + light: { bg: '#e7efff', text: '#1e4fa3' }, + dark: { bg: '#1c3f73', text: '#a8c7ff' } + }, + aistudio: { + light: { bg: '#f0f2f5', text: '#2f343c' }, + dark: { bg: '#373c42', text: '#cfd3db' } + }, + claude: { + light: { bg: '#fce4ec', text: '#c2185b' }, + dark: { bg: '#880e4f', text: '#f48fb1' } + }, + codex: { + light: { bg: '#fff3e0', text: '#ef6c00' }, + dark: { bg: '#e65100', text: '#ffb74d' } + }, + antigravity: { + light: { bg: '#e0f7fa', text: '#006064' }, + dark: { bg: '#004d40', text: '#80deea' } + }, + iflow: { + light: { bg: '#f3e5f5', text: '#7b1fa2' }, + dark: { bg: '#4a148c', text: '#ce93d8' } + }, + empty: { + light: { bg: '#f5f5f5', text: '#616161' }, + dark: { bg: '#424242', text: '#bdbdbd' } + }, + unknown: { + light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' }, + dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' } + } }; interface ExcludedFormState { @@ -88,6 +121,7 @@ export function AuthFilesPage() { const { t } = useTranslation(); const { showNotification } = useNotificationStore(); const connectionStatus = useAuthStore((state) => state.connectionStatus); + const theme = useThemeStore((state) => state.theme); const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); @@ -381,8 +415,9 @@ export function AuthFilesPage() { }; // 获取类型颜色 - const getTypeColor = (type: string) => { - return TYPE_COLORS[type] || TYPE_COLORS.unknown; + const getTypeColor = (type: string): ThemeColors => { + const set = TYPE_COLORS[type] || TYPE_COLORS.unknown; + return theme === 'dark' && set.dark ? set.dark : set.light; }; // OAuth 排除相关方法 @@ -441,13 +476,14 @@ export function AuthFilesPage() { {existingTypes.map((type) => { const isActive = filter === type; const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type); + const activeTextColor = theme === 'dark' ? '#111827' : '#fff'; return ( )} diff --git a/src/pages/ConfigPage.module.scss b/src/pages/ConfigPage.module.scss index 5da1f8c..3bcae7a 100644 --- a/src/pages/ConfigPage.module.scss +++ b/src/pages/ConfigPage.module.scss @@ -23,43 +23,65 @@ gap: $spacing-lg; } -.searchBar { - display: flex; - align-items: center; - gap: $spacing-sm; - - @include mobile { - flex-direction: column; - align-items: stretch; - } -} - .searchInputWrapper { flex: 1; position: relative; display: flex; align-items: center; + + // The shared Input component adds a wrapper (.form-group) with margin-bottom. + // In the floating toolbar we want the input to be compact. + :global(.form-group) { + margin-bottom: 0; + } } .searchInput { flex: 1; - padding-right: 80px !important; + border-radius: $radius-full !important; + padding-right: 132px !important; } .searchCount { - position: absolute; - right: 12px; - top: 50%; - transform: translateY(-50%); font-size: 12px; color: var(--text-secondary); background: var(--bg-secondary); padding: 2px 8px; - border-radius: $radius-sm; + border-radius: $radius-full; pointer-events: none; white-space: nowrap; } +.searchRight { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.searchButton { + @include button-reset; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: $radius-full; + background: var(--primary-color); + border: 1px solid var(--primary-color); + color: #fff; + transition: background-color $transition-fast, border-color $transition-fast, opacity $transition-fast; + + &:hover:not(:disabled) { + background: var(--primary-hover); + border-color: var(--primary-hover); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + .searchActions { display: flex; gap: 4px; @@ -67,7 +89,10 @@ button { min-width: 32px; - padding: 0 8px; + width: 32px; + height: 32px; + padding: 0 !important; + border-radius: $radius-full; } } @@ -106,6 +131,22 @@ border: 1px solid var(--border-color); border-radius: $radius-lg; overflow: hidden; + position: relative; + --floating-controls-height: 0px; + + // Floating search toolbar on top of the editor (but not covering content). + .floatingControls { + position: absolute; + top: 12px; + left: 12px; + right: 12px; + z-index: 10; + display: flex; + align-items: center; + gap: $spacing-sm; + flex-wrap: wrap; + pointer-events: auto; + } // CodeMirror theme overrides :global { @@ -117,6 +158,7 @@ .cm-scroller { overflow: auto; + padding-top: calc(var(--floating-controls-height, 0px) + #{$spacing-md}); } .cm-gutters { diff --git a/src/pages/ConfigPage.tsx b/src/pages/ConfigPage.tsx index 217e634..4caedbc 100644 --- a/src/pages/ConfigPage.tsx +++ b/src/pages/ConfigPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; import { yaml } from '@codemirror/lang-yaml'; @@ -26,7 +26,10 @@ export function ConfigPage() { // Search state const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState<{ current: number; total: number }>({ current: 0, total: 0 }); + const [lastSearchedQuery, setLastSearchedQuery] = useState(''); const editorRef = useRef(null); + const floatingControlsRef = useRef(null); + const editorWrapperRef = useRef(null); const disableControls = connectionStatus !== 'connected'; @@ -92,7 +95,8 @@ export function ConfigPage() { } // Find current match based on cursor position - const cursorPos = view.state.selection.main.head; + const selection = view.state.selection.main; + const cursorPos = direction === 'prev' ? selection.from : selection.to; let currentIndex = 0; if (direction === 'next') { @@ -134,27 +138,60 @@ export function ConfigPage() { const handleSearchChange = useCallback((value: string) => { setSearchQuery(value); - if (value) { - performSearch(value); + // Do not auto-search on each keystroke. Clear previous results when query changes. + if (!value) { + setSearchResults({ current: 0, total: 0 }); + setLastSearchedQuery(''); } else { setSearchResults({ current: 0, total: 0 }); } - }, [performSearch]); + }, []); + + const executeSearch = useCallback((direction: 'next' | 'prev' = 'next') => { + if (!searchQuery) return; + setLastSearchedQuery(searchQuery); + performSearch(searchQuery, direction); + }, [searchQuery, performSearch]); const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); - performSearch(searchQuery, e.shiftKey ? 'prev' : 'next'); + executeSearch(e.shiftKey ? 'prev' : 'next'); } - }, [searchQuery, performSearch]); + }, [executeSearch]); const handlePrevMatch = useCallback(() => { - performSearch(searchQuery, 'prev'); - }, [searchQuery, performSearch]); + if (!lastSearchedQuery) return; + performSearch(lastSearchedQuery, 'prev'); + }, [lastSearchedQuery, performSearch]); const handleNextMatch = useCallback(() => { - performSearch(searchQuery, 'next'); - }, [searchQuery, performSearch]); + if (!lastSearchedQuery) return; + performSearch(lastSearchedQuery, 'next'); + }, [lastSearchedQuery, performSearch]); + + // Keep floating controls from covering editor content by syncing its height to a CSS variable. + useLayoutEffect(() => { + const controlsEl = floatingControlsRef.current; + const wrapperEl = editorWrapperRef.current; + if (!controlsEl || !wrapperEl) return; + + const updatePadding = () => { + const height = controlsEl.getBoundingClientRect().height; + wrapperEl.style.setProperty('--floating-controls-height', `${height}px`); + }; + + updatePadding(); + window.addEventListener('resize', updatePadding); + + const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updatePadding); + ro?.observe(controlsEl); + + return () => { + ro?.disconnect(); + window.removeEventListener('resize', updatePadding); + }; + }, []); // CodeMirror extensions const extensions = useMemo(() => [ @@ -188,50 +225,77 @@ export function ConfigPage() {
- {/* Search bar */} -
-
- handleSearchChange(e.target.value)} - onKeyDown={handleSearchKeyDown} - placeholder={t('config_management.search_placeholder', { defaultValue: '搜索配置内容... (Enter 下一个, Shift+Enter 上一个)' })} - disabled={disableControls || loading} - className={styles.searchInput} - /> - {searchQuery && ( - - {searchResults.total > 0 - ? `${searchResults.current} / ${searchResults.total}` - : t('config_management.search_no_results', { defaultValue: '无结果' })} - - )} -
-
- - -
-
- {/* Editor */} {error &&
{error}
} -
+
+ {/* Floating search controls */} +
+
+ handleSearchChange(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder={t('config_management.search_placeholder', { + defaultValue: '输入关键字后点击右侧搜索按钮(或 Enter)进行搜索' + })} + disabled={disableControls || loading} + className={styles.searchInput} + rightElement={ +
+ {searchQuery && lastSearchedQuery === searchQuery && ( + + {searchResults.total > 0 + ? `${searchResults.current} / ${searchResults.total}` + : t('config_management.search_no_results', { defaultValue: '无结果' })} + + )} + +
+ } + /> +
+
+ + +
+