From 7c0a2280a47bb6fd7a3faac16dec91e4ab5e0c05 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Fri, 12 Dec 2025 19:10:09 +0800 Subject: [PATCH] feat: implement versioning system by extracting version from environment, git tags, or package.json, and display app version in MainLayout; enhance ConfigPage with search functionality and CodeMirror integration for YAML editing --- src/components/layout/MainLayout.tsx | 3 + src/i18n/locales/en.json | 6 +- src/i18n/locales/zh-CN.json | 6 +- src/pages/ConfigPage.module.scss | 114 +++++++++++ src/pages/ConfigPage.tsx | 282 +++++++++++++++++++++++---- src/types/style.d.ts | 3 + vite.config.ts | 35 ++++ 7 files changed, 406 insertions(+), 43 deletions(-) diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 456325e..107cee1 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -421,6 +421,9 @@ export function MainLayout() { {t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')} + + {t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')} + {t('footer.build_date')}:{' '} {serverBuildDate ? new Date(serverBuildDate).toLocaleString(i18n.language) : t('system_info.version_unknown')} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9c8f351..bf907a7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -550,7 +550,11 @@ "status_save_failed": "Save failed", "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" + "editor_placeholder": "key: value", + "search_placeholder": "Search config... (Enter for next, Shift+Enter for previous)", + "search_no_results": "No results", + "search_prev": "Previous", + "search_next": "Next" }, "system_info": { "title": "Management Center Info", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 80b22a8..1188501 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -550,7 +550,11 @@ "status_save_failed": "保存失败", "save_success": "配置已保存", "error_yaml_not_supported": "服务器未返回 YAML 格式,请确认 /config.yaml 接口可用", - "editor_placeholder": "key: value" + "editor_placeholder": "key: value", + "search_placeholder": "搜索配置内容... (Enter 下一个, Shift+Enter 上一个)", + "search_no_results": "无结果", + "search_prev": "上一个", + "search_next": "下一个" }, "system_info": { "title": "管理中心信息", diff --git a/src/pages/ConfigPage.module.scss b/src/pages/ConfigPage.module.scss index 7a776c9..5da1f8c 100644 --- a/src/pages/ConfigPage.module.scss +++ b/src/pages/ConfigPage.module.scss @@ -1,3 +1,5 @@ +@use '../styles/mixins' as *; + .container { width: 100%; } @@ -21,6 +23,54 @@ 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; +} + +.searchInput { + flex: 1; + padding-right: 80px !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; + pointer-events: none; + white-space: nowrap; +} + +.searchActions { + display: flex; + gap: 4px; + flex-shrink: 0; + + button { + min-width: 32px; + padding: 0 8px; + } +} + .controls { display: flex; justify-content: space-between; @@ -56,10 +106,74 @@ border: 1px solid var(--border-color); border-radius: $radius-lg; overflow: hidden; + + // CodeMirror theme overrides + :global { + .cm-editor { + height: 100%; + font-size: 14px; + font-family: 'Consolas', 'Monaco', 'Menlo', monospace; + } + + .cm-scroller { + overflow: auto; + } + + .cm-gutters { + border-right: 1px solid var(--border-color); + background: var(--bg-secondary); + } + + .cm-lineNumbers .cm-gutterElement { + padding: 0 8px 0 12px; + min-width: 40px; + color: var(--text-muted); + } + + .cm-activeLine { + background: var(--bg-hover); + } + + .cm-activeLineGutter { + background: var(--bg-hover); + } + + .cm-selectionMatch { + background: rgba(255, 193, 7, 0.3); + } + + .cm-searchMatch { + background: rgba(255, 193, 7, 0.4); + outline: 1px solid rgba(255, 193, 7, 0.6); + } + + .cm-searchMatch-selected { + background: rgba(255, 152, 0, 0.5); + } + + // Dark theme adjustments + [data-theme='dark'] & { + .cm-gutters { + background: var(--bg-tertiary); + } + + .cm-selectionMatch { + background: rgba(255, 193, 7, 0.2); + } + } + } } .actions { display: flex; gap: $spacing-sm; justify-content: flex-end; + + @include mobile { + justify-content: stretch; + + button { + flex: 1; + } + } } diff --git a/src/pages/ConfigPage.tsx b/src/pages/ConfigPage.tsx index 9a9b642..217e634 100644 --- a/src/pages/ConfigPage.tsx +++ b/src/pages/ConfigPage.tsx @@ -1,14 +1,21 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; +import { yaml } from '@codemirror/lang-yaml'; +import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import { keymap } from '@codemirror/view'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; -import { useNotificationStore, useAuthStore } from '@/stores'; +import { Input } from '@/components/ui/Input'; +import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores'; import { configFileApi } from '@/services/api/configFile'; +import styles from './ConfigPage.module.scss'; export function ConfigPage() { const { t } = useTranslation(); const { showNotification } = useNotificationStore(); const connectionStatus = useAuthStore((state) => state.connectionStatus); + const theme = useThemeStore((state) => state.theme); const [content, setContent] = useState(''); const [loading, setLoading] = useState(true); @@ -16,71 +23,264 @@ export function ConfigPage() { const [error, setError] = useState(''); const [dirty, setDirty] = useState(false); + // Search state + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState<{ current: number; total: number }>({ current: 0, total: 0 }); + const editorRef = useRef(null); + const disableControls = connectionStatus !== 'connected'; - const loadConfig = async () => { + const loadConfig = useCallback(async () => { setLoading(true); setError(''); try { const data = await configFileApi.fetchConfigYaml(); setContent(data); setDirty(false); - } catch (err: any) { - setError(err?.message || t('notification.refresh_failed')); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('notification.refresh_failed'); + setError(message); } finally { setLoading(false); } - }; + }, [t]); useEffect(() => { loadConfig(); - }, []); + }, [loadConfig]); const handleSave = async () => { setSaving(true); try { await configFileApi.saveConfigYaml(content); setDirty(false); - showNotification(t('notification.saved_success'), 'success'); - } catch (err: any) { - showNotification(`${t('notification.save_failed')}: ${err?.message || ''}`, 'error'); + showNotification(t('config_management.save_success'), 'success'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : ''; + showNotification(`${t('notification.save_failed')}: ${message}`, 'error'); } finally { setSaving(false); } }; - return ( - - - - + const handleChange = useCallback((value: string) => { + setContent(value); + setDirty(true); + }, []); + + // Search functionality + const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => { + if (!query || !editorRef.current?.view) return; + + const view = editorRef.current.view; + const doc = view.state.doc.toString(); + const matches: number[] = []; + const lowerQuery = query.toLowerCase(); + const lowerDoc = doc.toLowerCase(); + + let pos = 0; + while (pos < lowerDoc.length) { + const index = lowerDoc.indexOf(lowerQuery, pos); + if (index === -1) break; + matches.push(index); + pos = index + 1; + } + + if (matches.length === 0) { + setSearchResults({ current: 0, total: 0 }); + return; + } + + // Find current match based on cursor position + const cursorPos = view.state.selection.main.head; + let currentIndex = 0; + + if (direction === 'next') { + // Find next match after cursor + for (let i = 0; i < matches.length; i++) { + if (matches[i] > cursorPos) { + currentIndex = i; + break; + } + // If no match after cursor, wrap to first + if (i === matches.length - 1) { + currentIndex = 0; + } } - > - {error &&
{error}
} -
- -