diff --git a/src/components/common/NotificationContainer.tsx b/src/components/common/NotificationContainer.tsx index e731a66..d5f2239 100644 --- a/src/components/common/NotificationContainer.tsx +++ b/src/components/common/NotificationContainer.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useNotificationStore } from '@/stores'; +import { IconX } from '@/components/ui/icons'; import type { Notification } from '@/types'; interface AnimatedNotification extends Notification { @@ -83,8 +84,8 @@ export function NotificationContainer() { className={`notification ${notification.type} ${notification.isExiting ? 'exiting' : 'entering'}`} >
{notification.message}
- ))} diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 107cee1..a45a19d 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -2,97 +2,31 @@ import { ReactNode, SVGProps, useCallback, useEffect, useLayoutEffect, useRef, u import { NavLink, Outlet } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; +import { + IconBot, + IconChartLine, + IconFileText, + IconInfo, + IconKey, + IconScrollText, + IconSettings, + IconShield, + IconSlidersHorizontal +} from '@/components/ui/icons'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores'; import { versionApi } from '@/services/api'; -const iconProps: SVGProps = { - width: 18, - height: 18, - viewBox: '0 0 20 20', - fill: 'none', - stroke: 'currentColor', - strokeWidth: 1.5, - strokeLinecap: 'round', - strokeLinejoin: 'round', - 'aria-hidden': 'true', - focusable: 'false' -}; - const sidebarIcons: Record = { - settings: ( - - - - - - - - - ), - apiKeys: ( - - - - - - - ), - aiProviders: ( - - - - - - - - - ), - authFiles: ( - - - - - - ), - oauth: ( - - - - - - - ), - usage: ( - - - - - ), - config: ( - - - - - - ), - logs: ( - - - - - - - - - ), - system: ( - - - - - - ) + settings: , + apiKeys: , + aiProviders: , + authFiles: , + oauth: , + usage: , + config: , + logs: , + system: }; // Header action icons - smaller size for header buttons diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx index a3be192..20c8316 100644 --- a/src/components/ui/EmptyState.tsx +++ b/src/components/ui/EmptyState.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import { IconInbox } from './icons'; interface EmptyStateProps { title: string; @@ -11,7 +12,7 @@ export function EmptyState({ title, description, action }: EmptyStateProps) {
{title}
diff --git a/src/components/ui/HeaderInputList.tsx b/src/components/ui/HeaderInputList.tsx index e0fc618..f85db15 100644 --- a/src/components/ui/HeaderInputList.tsx +++ b/src/components/ui/HeaderInputList.tsx @@ -1,5 +1,6 @@ import { Fragment } from 'react'; import { Button } from './Button'; +import { IconX } from './icons'; import type { HeaderEntry } from '@/utils/headers'; interface HeaderInputListProps { @@ -60,8 +61,10 @@ export function HeaderInputList({ size="sm" onClick={() => removeEntry(index)} disabled={disabled || currentEntries.length <= 1} + title="Remove" + aria-label="Remove" > - ✕ +
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index e17205d..de9f7e8 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -1,4 +1,5 @@ import type { PropsWithChildren, ReactNode } from 'react'; +import { IconX } from './icons'; interface ModalProps { open: boolean; @@ -23,7 +24,7 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
{title}
{children}
diff --git a/src/components/ui/ModelInputList.tsx b/src/components/ui/ModelInputList.tsx index 560a0f1..e4340ac 100644 --- a/src/components/ui/ModelInputList.tsx +++ b/src/components/ui/ModelInputList.tsx @@ -1,5 +1,6 @@ import { Fragment } from 'react'; import { Button } from './Button'; +import { IconX } from './icons'; import type { ModelAlias } from '@/types'; interface ModelEntry { @@ -88,8 +89,10 @@ export function ModelInputList({ size="sm" onClick={() => removeEntry(index)} disabled={disabled || currentEntries.length <= 1} + title="Remove" + aria-label="Remove" > - ✕ +
diff --git a/src/components/ui/icons.tsx b/src/components/ui/icons.tsx new file mode 100644 index 0000000..7423128 --- /dev/null +++ b/src/components/ui/icons.tsx @@ -0,0 +1,260 @@ +import type { SVGProps } from 'react'; + +// Inline SVG icons (Lucide, ISC). We embed paths to keep the WebUI single-file/offline friendly. +// Source: https://github.com/lucide-icons/lucide (via lucide-static). + +export interface IconProps extends SVGProps { + size?: number; +} + +const baseSvgProps: SVGProps = { + xmlns: 'http://www.w3.org/2000/svg', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + 'aria-hidden': 'true', + focusable: 'false' +}; + +export function IconSlidersHorizontal({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + + + + + ); +} + +export function IconKey({ size = 20, ...props }: IconProps) { + return ( + + + + + + ); +} + +export function IconBot({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + + ); +} + +export function IconFileText({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + ); +} + +export function IconShield({ size = 20, ...props }: IconProps) { + return ( + + + + ); +} + +export function IconChartLine({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconSettings({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconScrollText({ size = 20, ...props }: IconProps) { + return ( + + + + + + + ); +} + +export function IconInfo({ size = 20, ...props }: IconProps) { + return ( + + + + + + ); +} + +export function IconDownload({ size = 20, ...props }: IconProps) { + return ( + + + + + + ); +} + +export function IconTrash2({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + ); +} + +export function IconChevronUp({ size = 20, ...props }: IconProps) { + return ( + + + + ); +} + +export function IconChevronDown({ size = 20, ...props }: IconProps) { + return ( + + + + ); +} + +export function IconSearch({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconX({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconCheck({ size = 20, ...props }: IconProps) { + return ( + + + + ); +} + +export function IconEye({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconEyeOff({ size = 20, ...props }: IconProps) { + return ( + + + + + + + ); +} + +export function IconInbox({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconSatellite({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + ); +} + +export function IconDiamond({ size = 20, ...props }: IconProps) { + return ( + + + + ); +} + +export function IconTimer({ size = 20, ...props }: IconProps) { + return ( + + + + + + ); +} + +export function IconTrendingUp({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconDollarSign({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + diff --git a/src/pages/AiProvidersPage.module.scss b/src/pages/AiProvidersPage.module.scss index c0bfb8d..b249407 100644 --- a/src/pages/AiProvidersPage.module.scss +++ b/src/pages/AiProvidersPage.module.scss @@ -274,6 +274,10 @@ border-radius: 10px; font-size: 10px; font-weight: 600; + + svg { + display: block; + } } .apiKeyEntryStatSuccess { diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index b02f3d7..ef51f48 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -7,6 +7,7 @@ import { Modal } from '@/components/ui/Modal'; import { EmptyState } from '@/components/ui/EmptyState'; import { HeaderInputList } from '@/components/ui/HeaderInputList'; import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/ui/ModelInputList'; +import { IconCheck, IconX } from '@/components/ui/icons'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import { modelsApi, providersApi, usageApi } from '@/services/api'; import type { @@ -1038,10 +1039,10 @@ export function AiProvidersPage() { )}
- ✓ {entryStats.success} + {entryStats.success} - ✗ {entryStats.failure} + {entryStats.failure}
diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 81d05f3..6a6f4d9 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -251,8 +251,7 @@ } .actionIcon { - font-style: normal; - font-size: 14px; + display: block; } .virtualBadge { diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index d9cb0d2..77ee440 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -6,6 +6,7 @@ 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 { IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons'; import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores'; import { authFilesApi, usageApi } from '@/services/api'; import { apiClient } from '@/services/api/client'; @@ -545,30 +546,33 @@ export function AuthFilesPage() { size="sm" onClick={() => showDetails(item)} className={styles.iconButton} + title={t('common.info', { defaultValue: '关于' })} disabled={disableControls} > - + diff --git a/src/pages/ConfigPage.tsx b/src/pages/ConfigPage.tsx index 4caedbc..5aefe62 100644 --- a/src/pages/ConfigPage.tsx +++ b/src/pages/ConfigPage.tsx @@ -7,6 +7,7 @@ import { keymap } from '@codemirror/view'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; +import { IconChevronDown, IconChevronUp, IconSearch } from '@/components/ui/icons'; import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores'; import { configFileApi } from '@/services/api/configFile'; import styles from './ConfigPage.module.scss'; @@ -256,20 +257,7 @@ export function ConfigPage() { disabled={!searchQuery || disableControls || loading} title={t('config_management.search_button', { defaultValue: '搜索' })} > - + } @@ -283,7 +271,7 @@ export function ConfigPage() { disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0} title={t('config_management.search_prev', { defaultValue: '上一个' })} > - ↑ + diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index f3af89b..b66e6a8 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -3,6 +3,7 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; +import { IconEye, IconEyeOff } from '@/components/ui/icons'; import { useAuthStore, useNotificationStore } from '@/stores'; import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection'; @@ -121,8 +122,18 @@ export function LoginPage() { type="button" className="btn btn-ghost btn-sm" onClick={() => setShowKey((prev) => !prev)} + aria-label={ + showKey + ? t('login.hide_key', { defaultValue: '隐藏密钥' }) + : t('login.show_key', { defaultValue: '显示密钥' }) + } + title={ + showKey + ? t('login.hide_key', { defaultValue: '隐藏密钥' }) + : t('login.show_key', { defaultValue: '显示密钥' }) + } > - {showKey ? '🙈' : '👁️'} + {showKey ? : } } /> diff --git a/src/pages/UsagePage.module.scss b/src/pages/UsagePage.module.scss index 80d0de7..9803269 100644 --- a/src/pages/UsagePage.module.scss +++ b/src/pages/UsagePage.module.scss @@ -111,6 +111,10 @@ font-size: 13px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08); flex-shrink: 0; + + svg { + display: block; + } } .statHeader { diff --git a/src/pages/UsagePage.tsx b/src/pages/UsagePage.tsx index caca78a..3686f2c 100644 --- a/src/pages/UsagePage.tsx +++ b/src/pages/UsagePage.tsx @@ -15,6 +15,7 @@ import { Line } from 'react-chartjs-2'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; +import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons'; import { usageApi } from '@/services/api/usage'; import { formatTokensInMillions, @@ -319,7 +320,7 @@ export function UsagePage() { { key: 'requests', label: t('usage_stats.total_requests'), - icon: '🛰️', + icon: , accent: '#2563eb', value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(), meta: ( @@ -339,7 +340,7 @@ export function UsagePage() { { key: 'tokens', label: t('usage_stats.total_tokens'), - icon: '💠', + icon: , accent: '#8b5cf6', value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0), meta: ( @@ -357,7 +358,7 @@ export function UsagePage() { { key: 'rpm', label: t('usage_stats.rpm_30m'), - icon: '⏱️', + icon: , accent: '#22c55e', value: loading ? '-' : formatPerMinuteValue(rateStats.rpm), meta: ( @@ -370,7 +371,7 @@ export function UsagePage() { { key: 'tpm', label: t('usage_stats.tpm_30m'), - icon: '📈', + icon: , accent: '#f97316', value: loading ? '-' : formatPerMinuteValue(rateStats.tpm), meta: ( @@ -383,7 +384,7 @@ export function UsagePage() { { key: 'cost', label: t('usage_stats.total_cost'), - icon: '💰', + icon: , accent: '#f59e0b', value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--', meta: ( diff --git a/src/styles/components.scss b/src/styles/components.scss index dbdb288..1dbd3f2 100644 --- a/src/styles/components.scss +++ b/src/styles/components.scss @@ -229,14 +229,26 @@ textarea { } .close-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; background: transparent; border: none; color: var(--text-secondary); + border-radius: $radius-md; cursor: pointer; - transition: color 0.15s ease; + transition: color 0.15s ease, background-color 0.15s ease; + + svg { + display: block; + } &:hover { color: var(--text-primary); + background: var(--bg-secondary); } } } @@ -353,12 +365,27 @@ textarea { } .modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; background: transparent; border: none; color: var(--text-secondary); - font-size: 20px; cursor: pointer; - padding: 4px; + border-radius: $radius-md; + transition: color 0.15s ease, background-color 0.15s ease; + + svg { + display: block; + } + + &:hover { + color: var(--text-primary); + background: var(--bg-secondary); + } } } @@ -401,6 +428,10 @@ textarea { display: grid; place-items: center; color: var(--text-secondary); + + svg { + display: block; + } } .empty-title {