mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-02 19:00:49 +08:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": "下一个"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user