feat: add notification animations and improve UI across pages Add enter/exit animations to NotificationContainer with smooth slide effects Refactor ConfigPage search bar to float over editor with improved UX Enhance AuthFilesPage type badges with proper light/dark theme color support Fix grid layout in AuthFilesPage to use consistent 3-column layout Update icon button sizing and loading state handlin Update i18n translations for search functionality

This commit is contained in:
Supra4E8C
2025-12-13 00:46:07 +08:00
parent 7c0a2280a4
commit bcf82252ea
8 changed files with 373 additions and 100 deletions

View File

@@ -1,16 +1,89 @@
import { useEffect, useRef, useState } from 'react';
import { useNotificationStore } from '@/stores'; import { useNotificationStore } from '@/stores';
import type { Notification } from '@/types';
interface AnimatedNotification extends Notification {
isExiting?: boolean;
}
const ANIMATION_DURATION = 300; // ms
export function NotificationContainer() { export function NotificationContainer() {
const { notifications, removeNotification } = useNotificationStore(); const { notifications, removeNotification } = useNotificationStore();
const [animatedNotifications, setAnimatedNotifications] = useState<AnimatedNotification[]>([]);
const prevNotificationsRef = useRef<Notification[]>([]);
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 ( return (
<div className="notification-container"> <div className="notification-container">
{notifications.map((notification) => ( {animatedNotifications.map((notification) => (
<div key={notification.id} className={`notification ${notification.type}`}> <div
key={notification.id}
className={`notification ${notification.type} ${notification.isExiting ? 'exiting' : 'entering'}`}
>
<div className="message">{notification.message}</div> <div className="message">{notification.message}</div>
<button className="close-btn" onClick={() => removeNotification(notification.id)}> <button className="close-btn" onClick={() => handleClose(notification.id)}>
× ×
</button> </button>
</div> </div>

View File

@@ -551,7 +551,8 @@
"save_success": "Configuration saved successfully", "save_success": "Configuration saved successfully",
"error_yaml_not_supported": "Server did not return YAML. Verify the /config.yaml endpoint is available.", "error_yaml_not_supported": "Server did not return YAML. Verify the /config.yaml endpoint is available.",
"editor_placeholder": "key: value", "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_no_results": "No results",
"search_prev": "Previous", "search_prev": "Previous",
"search_next": "Next" "search_next": "Next"

View File

@@ -551,7 +551,8 @@
"save_success": "配置已保存", "save_success": "配置已保存",
"error_yaml_not_supported": "服务器未返回 YAML 格式,请确认 /config.yaml 接口可用", "error_yaml_not_supported": "服务器未返回 YAML 格式,请确认 /config.yaml 接口可用",
"editor_placeholder": "key: value", "editor_placeholder": "key: value",
"search_placeholder": "搜索配置内容... (Enter 下一个, Shift+Enter 上一个)", "search_placeholder": "输入关键字后点击右侧搜索按钮(或 Enter进行搜索",
"search_button": "搜索",
"search_no_results": "无结果", "search_no_results": "无结果",
"search_prev": "上一个", "search_prev": "上一个",
"search_next": "下一个" "search_next": "下一个"

View File

@@ -132,10 +132,10 @@
.fileGrid { .fileGrid {
display: grid; display: grid;
gap: $spacing-md; gap: $spacing-md;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@include tablet { @include tablet {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@include mobile { @include mobile {
@@ -240,6 +240,16 @@
padding-top: $spacing-sm; 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 { .actionIcon {
font-style: normal; font-style: normal;
font-size: 14px; font-size: 14px;

View File

@@ -2,10 +2,11 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { useAuthStore, useNotificationStore } from '@/stores'; import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import { authFilesApi, usageApi } from '@/services/api'; import { authFilesApi, usageApi } from '@/services/api';
import { apiClient } from '@/services/api/client'; import { apiClient } from '@/services/api/client';
import type { AuthFileItem } from '@/types'; import type { AuthFileItem } from '@/types';
@@ -13,19 +14,51 @@ import type { KeyStats, KeyStatBucket } from '@/utils/usage';
import { formatFileSize } from '@/utils/format'; import { formatFileSize } from '@/utils/format';
import styles from './AuthFilesPage.module.scss'; import styles from './AuthFilesPage.module.scss';
// 标签类型颜色配置 type ThemeColors = { bg: string; text: string; border?: string };
const TYPE_COLORS: Record<string, { bg: string; text: string }> = { type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
qwen: { bg: 'rgba(59, 130, 246, 0.15)', text: '#3b82f6' },
gemini: { bg: 'rgba(34, 197, 94, 0.15)', text: '#22c55e' }, // 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
'gemini-cli': { bg: 'rgba(6, 182, 212, 0.15)', text: '#06b6d4' }, const TYPE_COLORS: Record<string, TypeColorSet> = {
aistudio: { bg: 'rgba(139, 92, 246, 0.15)', text: '#8b5cf6' }, qwen: {
claude: { bg: 'rgba(249, 115, 22, 0.15)', text: '#f97316' }, light: { bg: '#e8f5e9', text: '#2e7d32' },
codex: { bg: 'rgba(236, 72, 153, 0.15)', text: '#ec4899' }, dark: { bg: '#1b5e20', text: '#81c784' }
antigravity: { bg: 'rgba(245, 158, 11, 0.15)', text: '#f59e0b' }, },
iflow: { bg: 'rgba(132, 204, 22, 0.15)', text: '#84cc16' }, gemini: {
vertex: { bg: 'rgba(239, 68, 68, 0.15)', text: '#ef4444' }, light: { bg: '#e3f2fd', text: '#1565c0' },
empty: { bg: 'rgba(107, 114, 128, 0.15)', text: '#6b7280' }, dark: { bg: '#0d47a1', text: '#64b5f6' }
unknown: { bg: 'rgba(156, 163, 175, 0.15)', text: '#9ca3af' } },
'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 { interface ExcludedFormState {
@@ -88,6 +121,7 @@ export function AuthFilesPage() {
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 theme = useThemeStore((state) => state.theme);
const [files, setFiles] = useState<AuthFileItem[]>([]); const [files, setFiles] = useState<AuthFileItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -381,8 +415,9 @@ export function AuthFilesPage() {
}; };
// 获取类型颜色 // 获取类型颜色
const getTypeColor = (type: string) => { const getTypeColor = (type: string): ThemeColors => {
return TYPE_COLORS[type] || TYPE_COLORS.unknown; const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
return theme === 'dark' && set.dark ? set.dark : set.light;
}; };
// OAuth 排除相关方法 // OAuth 排除相关方法
@@ -441,13 +476,14 @@ export function AuthFilesPage() {
{existingTypes.map((type) => { {existingTypes.map((type) => {
const isActive = filter === type; const isActive = filter === type;
const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type); const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type);
const activeTextColor = theme === 'dark' ? '#111827' : '#fff';
return ( return (
<button <button
key={type} key={type}
className={`${styles.filterTag} ${isActive ? styles.filterTagActive : ''}`} className={`${styles.filterTag} ${isActive ? styles.filterTagActive : ''}`}
style={{ style={{
backgroundColor: isActive ? color.text : color.bg, backgroundColor: isActive ? color.text : color.bg,
color: isActive ? '#fff' : color.text, color: isActive ? activeTextColor : color.text,
borderColor: color.text borderColor: color.text
}} }}
onClick={() => { onClick={() => {
@@ -474,7 +510,11 @@ export function AuthFilesPage() {
<div className={styles.cardHeader}> <div className={styles.cardHeader}>
<span <span
className={styles.typeBadge} className={styles.typeBadge}
style={{ backgroundColor: typeColor.bg, color: typeColor.text }} style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
}}
> >
{getTypeLabel(item.type || 'unknown')} {getTypeLabel(item.type || 'unknown')}
</span> </span>
@@ -504,6 +544,7 @@ export function AuthFilesPage() {
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => showDetails(item)} onClick={() => showDetails(item)}
className={styles.iconButton}
disabled={disableControls} disabled={disableControls}
> >
<i className={styles.actionIcon}></i> <i className={styles.actionIcon}></i>
@@ -512,6 +553,7 @@ export function AuthFilesPage() {
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => handleDownload(item.name)} onClick={() => handleDownload(item.name)}
className={styles.iconButton}
disabled={disableControls} disabled={disableControls}
> >
<i className={styles.actionIcon}></i> <i className={styles.actionIcon}></i>
@@ -520,10 +562,14 @@ export function AuthFilesPage() {
variant="danger" variant="danger"
size="sm" size="sm"
onClick={() => handleDelete(item.name)} onClick={() => handleDelete(item.name)}
loading={deleting === item.name} className={styles.iconButton}
disabled={disableControls} disabled={disableControls || deleting === item.name}
> >
{deleting === item.name ? (
<LoadingSpinner size={14} />
) : (
<i className={styles.actionIcon}>🗑</i> <i className={styles.actionIcon}>🗑</i>
)}
</Button> </Button>
</> </>
)} )}

View File

@@ -23,43 +23,65 @@
gap: $spacing-lg; gap: $spacing-lg;
} }
.searchBar {
display: flex;
align-items: center;
gap: $spacing-sm;
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.searchInputWrapper { .searchInputWrapper {
flex: 1; flex: 1;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; 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 { .searchInput {
flex: 1; flex: 1;
padding-right: 80px !important; border-radius: $radius-full !important;
padding-right: 132px !important;
} }
.searchCount { .searchCount {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
background: var(--bg-secondary); background: var(--bg-secondary);
padding: 2px 8px; padding: 2px 8px;
border-radius: $radius-sm; border-radius: $radius-full;
pointer-events: none; pointer-events: none;
white-space: nowrap; 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 { .searchActions {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -67,7 +89,10 @@
button { button {
min-width: 32px; 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: 1px solid var(--border-color);
border-radius: $radius-lg; border-radius: $radius-lg;
overflow: hidden; 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 // CodeMirror theme overrides
:global { :global {
@@ -117,6 +158,7 @@
.cm-scroller { .cm-scroller {
overflow: auto; overflow: auto;
padding-top: calc(var(--floating-controls-height, 0px) + #{$spacing-md});
} }
.cm-gutters { .cm-gutters {

View File

@@ -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 { useTranslation } from 'react-i18next';
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml'; import { yaml } from '@codemirror/lang-yaml';
@@ -26,7 +26,10 @@ export function ConfigPage() {
// Search state // Search state
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<{ current: number; total: number }>({ current: 0, total: 0 }); const [searchResults, setSearchResults] = useState<{ current: number; total: number }>({ current: 0, total: 0 });
const [lastSearchedQuery, setLastSearchedQuery] = useState('');
const editorRef = useRef<ReactCodeMirrorRef>(null); const editorRef = useRef<ReactCodeMirrorRef>(null);
const floatingControlsRef = useRef<HTMLDivElement>(null);
const editorWrapperRef = useRef<HTMLDivElement>(null);
const disableControls = connectionStatus !== 'connected'; const disableControls = connectionStatus !== 'connected';
@@ -92,7 +95,8 @@ export function ConfigPage() {
} }
// Find current match based on cursor position // 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; let currentIndex = 0;
if (direction === 'next') { if (direction === 'next') {
@@ -134,27 +138,60 @@ export function ConfigPage() {
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value); setSearchQuery(value);
if (value) { // Do not auto-search on each keystroke. Clear previous results when query changes.
performSearch(value); if (!value) {
setSearchResults({ current: 0, total: 0 });
setLastSearchedQuery('');
} else { } else {
setSearchResults({ current: 0, total: 0 }); 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) => { const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
performSearch(searchQuery, e.shiftKey ? 'prev' : 'next'); executeSearch(e.shiftKey ? 'prev' : 'next');
} }
}, [searchQuery, performSearch]); }, [executeSearch]);
const handlePrevMatch = useCallback(() => { const handlePrevMatch = useCallback(() => {
performSearch(searchQuery, 'prev'); if (!lastSearchedQuery) return;
}, [searchQuery, performSearch]); performSearch(lastSearchedQuery, 'prev');
}, [lastSearchedQuery, performSearch]);
const handleNextMatch = useCallback(() => { const handleNextMatch = useCallback(() => {
performSearch(searchQuery, 'next'); if (!lastSearchedQuery) return;
}, [searchQuery, performSearch]); 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 // CodeMirror extensions
const extensions = useMemo(() => [ const extensions = useMemo(() => [
@@ -188,31 +225,62 @@ export function ConfigPage() {
<Card> <Card>
<div className={styles.content}> <div className={styles.content}>
{/* Search bar */} {/* Editor */}
<div className={styles.searchBar}> {error && <div className="error-box">{error}</div>}
<div className={styles.editorWrapper} ref={editorWrapperRef}>
{/* Floating search controls */}
<div className={styles.floatingControls} ref={floatingControlsRef}>
<div className={styles.searchInputWrapper}> <div className={styles.searchInputWrapper}>
<Input <Input
value={searchQuery} value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)} onChange={(e) => handleSearchChange(e.target.value)}
onKeyDown={handleSearchKeyDown} onKeyDown={handleSearchKeyDown}
placeholder={t('config_management.search_placeholder', { defaultValue: '搜索配置内容... (Enter 下一个, Shift+Enter 上一个)' })} placeholder={t('config_management.search_placeholder', {
defaultValue: '输入关键字后点击右侧搜索按钮(或 Enter进行搜索'
})}
disabled={disableControls || loading} disabled={disableControls || loading}
className={styles.searchInput} className={styles.searchInput}
/> rightElement={
{searchQuery && ( <div className={styles.searchRight}>
{searchQuery && lastSearchedQuery === searchQuery && (
<span className={styles.searchCount}> <span className={styles.searchCount}>
{searchResults.total > 0 {searchResults.total > 0
? `${searchResults.current} / ${searchResults.total}` ? `${searchResults.current} / ${searchResults.total}`
: t('config_management.search_no_results', { defaultValue: '无结果' })} : t('config_management.search_no_results', { defaultValue: '无结果' })}
</span> </span>
)} )}
<button
type="button"
className={styles.searchButton}
onClick={() => executeSearch('next')}
disabled={!searchQuery || disableControls || loading}
title={t('config_management.search_button', { defaultValue: '搜索' })}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
</div>
}
/>
</div> </div>
<div className={styles.searchActions}> <div className={styles.searchActions}>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={handlePrevMatch} onClick={handlePrevMatch}
disabled={!searchQuery || searchResults.total === 0} disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
title={t('config_management.search_prev', { defaultValue: '上一个' })} title={t('config_management.search_prev', { defaultValue: '上一个' })}
> >
@@ -221,17 +289,13 @@ export function ConfigPage() {
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={handleNextMatch} onClick={handleNextMatch}
disabled={!searchQuery || searchResults.total === 0} disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
title={t('config_management.search_next', { defaultValue: '下一个' })} title={t('config_management.search_next', { defaultValue: '下一个' })}
> >
</Button> </Button>
</div> </div>
</div> </div>
{/* Editor */}
{error && <div className="error-box">{error}</div>}
<div className={styles.editorWrapper}>
<CodeMirror <CodeMirror
ref={editorRef} ref={editorRef}
value={content} value={content}

View File

@@ -170,6 +170,28 @@ textarea {
max-width: 360px; max-width: 360px;
} }
@keyframes notification-enter {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes notification-exit {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100%);
}
}
.notification { .notification {
padding: $spacing-md; padding: $spacing-md;
border-radius: $radius-md; border-radius: $radius-md;
@@ -182,6 +204,15 @@ textarea {
align-items: center; align-items: center;
gap: $spacing-sm; gap: $spacing-sm;
// Animation states
&.entering {
animation: notification-enter 0.3s ease-out forwards;
}
&.exiting {
animation: notification-exit 0.3s ease-in forwards;
}
&.success { &.success {
border-color: rgba(16, 185, 129, 0.4); border-color: rgba(16, 185, 129, 0.4);
} }
@@ -202,6 +233,11 @@ textarea {
border: none; border: none;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
transition: color 0.15s ease;
&:hover {
color: var(--text-primary);
}
} }
} }