From 7e4e87bda5bff966a6307bd6d5914d5be7f3a4bf Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 28 Mar 2026 06:23:18 +0800 Subject: [PATCH] feat(DashboardPage): add dynamic greetings and time-based messages --- src/i18n/locales/en.json | 12 +- src/i18n/locales/ru.json | 12 +- src/i18n/locales/zh-CN.json | 12 +- src/pages/DashboardPage.module.scss | 537 +++++++++++++++++++--------- src/pages/DashboardPage.tsx | 197 ++++++---- 5 files changed, 526 insertions(+), 244 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e3c9293..a584ae9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -140,7 +140,17 @@ "edit_settings": "Edit Settings", "routing_strategy": "Routing Strategy", "available_models": "Available Models", - "available_models_desc": "Total models from all providers" + "available_models_desc": "Total models from all providers", + "welcome_back": "Welcome Back", + "greeting_morning": "Good Morning", + "greeting_afternoon": "Good Afternoon", + "greeting_evening": "Good Evening", + "greeting_night": "Good Night", + "caring_morning": "A fresh start — let's make today count.", + "caring_afternoon": "Steady progress — you're doing great.", + "caring_evening": "Wrapping up nicely — almost there.", + "caring_night": "Burning the midnight oil? Don't forget to rest.", + "system_overview": "System Overview" }, "basic_settings": { "title": "Basic Settings", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 4b2bf27..450688b 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -140,7 +140,17 @@ "edit_settings": "Изменить настройки", "routing_strategy": "Стратегия маршрутизации", "available_models": "Доступные модели", - "available_models_desc": "Всего моделей от всех провайдеров" + "available_models_desc": "Всего моделей от всех провайдеров", + "welcome_back": "С возвращением", + "greeting_morning": "Доброе утро", + "greeting_afternoon": "Добрый день", + "greeting_evening": "Добрый вечер", + "greeting_night": "Доброй ночи", + "caring_morning": "Новый день — начнём продуктивно.", + "caring_afternoon": "Уверенный прогресс — отличная работа.", + "caring_evening": "День подходит к концу — финальный рывок.", + "caring_night": "Поздняя работа? Не забудьте отдохнуть.", + "system_overview": "Обзор системы" }, "basic_settings": { "title": "Основные настройки", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 725f26b..bb2ce2b 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -140,7 +140,17 @@ "edit_settings": "编辑设置", "routing_strategy": "路由策略", "available_models": "可用模型", - "available_models_desc": "所有提供商的模型总数" + "available_models_desc": "所有提供商的模型总数", + "welcome_back": "欢迎回来", + "greeting_morning": "早上好", + "greeting_afternoon": "下午好", + "greeting_evening": "晚上好", + "greeting_night": "夜深了", + "caring_morning": "新的一天,准备大展身手吧。", + "caring_afternoon": "稳步推进中,继续加油。", + "caring_evening": "今天辛苦了,收尾工作做好哦。", + "caring_night": "夜深了,别忘了早些休息。", + "system_overview": "系统概览" }, "basic_settings": { "title": "基础设置", diff --git a/src/pages/DashboardPage.module.scss b/src/pages/DashboardPage.module.scss index 1ccf601..cfd8500 100644 --- a/src/pages/DashboardPage.module.scss +++ b/src/pages/DashboardPage.module.scss @@ -1,59 +1,234 @@ @use 'sass:color'; @use '../styles/variables.scss' as *; +// ─── Container ────────────────────────────────────────── + .dashboard { display: flex; flex-direction: column; - gap: $spacing-lg; + gap: $spacing-xl; max-width: 1000px; margin: 0 auto; + position: relative; } -.header { - margin-bottom: $spacing-sm; +// ─── Decorative Background Orbs ───────────────────────── + +.backgroundOrbs { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; + overflow: hidden; } -.title { - font-size: 26px; - font-weight: 800; +.orb1 { + position: absolute; + width: 420px; + height: 420px; + border-radius: 50%; + background: radial-gradient( + circle, + color-mix(in srgb, var(--primary-color) 8%, transparent), + transparent 70% + ); + top: -140px; + right: -80px; + animation: orbFloat 22s ease-in-out infinite alternate; +} + +.orb2 { + position: absolute; + width: 320px; + height: 320px; + border-radius: 50%; + background: radial-gradient( + circle, + color-mix(in srgb, var(--success-color) 6%, transparent), + transparent 70% + ); + bottom: 18%; + left: -100px; + animation: orbFloat 28s ease-in-out infinite alternate-reverse; +} + +@keyframes orbFloat { + 0% { + transform: translate(0, 0) scale(1); + } + 100% { + transform: translate(30px, -20px) scale(1.1); + } +} + +// ─── Hero Welcome Section ─────────────────────────────── + +.hero { + position: relative; + z-index: 1; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: $spacing-lg; + padding: $spacing-2xl $spacing-xl; + border-radius: $radius-lg; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--bg-primary) 92%, transparent), + color-mix(in srgb, var(--bg-secondary) 80%, transparent) + ); + border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + overflow: hidden; + animation: heroEnter 0.6s ease-out both; + + @media (max-width: $breakpoint-mobile) { + flex-direction: column; + align-items: flex-start; + padding: $spacing-xl $spacing-lg; + } +} + +// Large watermark text behind hero content +.heroWatermark { + position: absolute; + top: 50%; + left: $spacing-xl; + transform: translateY(-50%); + font-size: clamp(64px, 12vw, 120px); + font-weight: 900; + line-height: 1; + letter-spacing: -0.04em; + text-transform: uppercase; color: var(--text-primary); - margin: 0; - line-height: 1.4; + opacity: 0.04; + white-space: nowrap; + pointer-events: none; + user-select: none; + animation: watermarkEnter 0.8s ease-out 0.1s both; + + @media (max-width: $breakpoint-mobile) { + font-size: clamp(48px, 14vw, 80px); + left: $spacing-lg; + } } -.subtitle { +@keyframes watermarkEnter { + from { + opacity: 0; + transform: translateY(-50%) translateX(-20px); + } + to { + opacity: 0.04; + transform: translateY(-50%) translateX(0); + } +} + +.heroContent { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: $spacing-xs; +} + +.heroGreeting { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--primary-color); + animation: fadeSlideUp 0.5s ease-out 0.1s both; +} + +.heroTitle { + margin: 0; + font-size: clamp(32px, 5vw, 48px); + font-weight: 800; + line-height: 1.1; + letter-spacing: -0.03em; + color: var(--text-primary); + animation: fadeSlideUp 0.5s ease-out 0.2s both; +} + +.heroCaring { + margin: $spacing-xs 0 0; font-size: 15px; color: var(--text-secondary); - margin: $spacing-xs 0 0 0; + line-height: 1.5; + animation: fadeSlideUp 0.5s ease-out 0.3s both; } -.connectionCard { - display: flex; - align-items: center; - justify-content: space-between; - gap: $spacing-md; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: $radius-lg; - padding: $spacing-md $spacing-lg; - flex-wrap: wrap; -} +// ─── Hero Meta (right side) ───────────────────────────── -.connectionStatus { +.heroMeta { + position: relative; + z-index: 1; display: flex; - align-items: center; + flex-direction: column; + align-items: flex-end; gap: $spacing-sm; + flex-shrink: 0; + animation: fadeSlideUp 0.5s ease-out 0.35s both; + + @media (max-width: $breakpoint-mobile) { + align-items: flex-start; + flex-direction: row; + flex-wrap: wrap; + gap: $spacing-sm; + } +} + +.dateTimeBlock { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + + @media (max-width: $breakpoint-mobile) { + align-items: flex-start; + } +} + +.time { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + line-height: 1.2; +} + +.date { + font-size: 12px; + color: var(--text-secondary); +} + +// ─── Connection Pill ──────────────────────────────────── + +.connectionPill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border-radius: $radius-full; + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + font-size: 12px; } .statusDot { - width: 10px; - height: 10px; + width: 8px; + height: 8px; border-radius: 50%; background: $gray-400; + flex-shrink: 0; &.connected { background: $success-color; - box-shadow: 0 0 8px rgba($success-color, 0.5); + box-shadow: 0 0 6px rgba($success-color, 0.5); } &.connecting { @@ -66,54 +241,41 @@ } } -@keyframes pulse { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} - -.statusText { +.pillText { font-weight: 600; color: var(--text-primary); } -.connectionInfo { - display: flex; - align-items: center; - gap: $spacing-md; - flex-wrap: wrap; -} - -.serverUrl { - font-family: $font-mono; - font-size: 13px; - color: var(--text-secondary); - background: var(--bg-primary); - padding: 4px 10px; - border-radius: $radius-md; - border: 1px solid var(--border-color); -} - -.serverVersion { - font-size: 13px; - font-weight: 600; - color: var(--primary-color); - background: rgba($primary-color, 0.1); - padding: 4px 10px; - border-radius: $radius-full; -} - .buildDate { - font-size: 12px; - color: var(--text-secondary); + font-size: 11px; + color: var(--text-tertiary); + text-align: right; + + @media (max-width: $breakpoint-mobile) { + text-align: left; + } } -.statsGrid { +// ─── Bento Stats Grid ─────────────────────────────────── + +.statsSection { + position: relative; + z-index: 1; +} + +.sectionHeading { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-tertiary); + margin: 0 0 $spacing-md; +} + +.bentoGrid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 1fr); + grid-template-rows: auto auto; gap: $spacing-md; @media (max-width: 900px) { @@ -125,150 +287,175 @@ } } -.statCard { +.bentoCard { display: flex; - align-items: center; + flex-direction: column; gap: $spacing-md; padding: $spacing-lg; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: $radius-lg; text-decoration: none; - transition: all $transition-fast; + transition: + border-color $transition-fast, + box-shadow $transition-fast, + transform $transition-fast; + animation: cardEnter 0.4s ease-out both; &:hover { border-color: var(--primary-color); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); + transform: translateY(-3px); } } -.statIcon { +// First card spans 2 rows — the "hero stat" +.bentoLarge { + grid-row: 1 / 3; + justify-content: center; + background: linear-gradient( + 160deg, + color-mix(in srgb, var(--primary-color) 6%, var(--bg-primary)), + var(--bg-primary) + ); + + .bentoValue { + font-size: 44px; + } + + .bentoIcon { + width: 56px; + height: 56px; + } + + @media (max-width: 900px) { + grid-row: auto; + + .bentoValue { + font-size: 32px; + } + + .bentoIcon { + width: 44px; + height: 44px; + } + } +} + +.bentoIcon { display: flex; align-items: center; justify-content: center; - width: 48px; - height: 48px; + width: 44px; + height: 44px; border-radius: $radius-md; - background: var(--bg-secondary); + background: color-mix(in srgb, var(--primary-color) 10%, var(--bg-secondary)); color: var(--primary-color); + transition: background $transition-fast; } -.statContent { +.bentoCard:hover .bentoIcon { + background: color-mix(in srgb, var(--primary-color) 16%, var(--bg-secondary)); +} + +.bentoContent { display: flex; flex-direction: column; gap: 2px; } -.statValue { - font-size: 24px; +.bentoValue { + font-size: 28px; font-weight: 800; color: var(--text-primary); + font-variant-numeric: tabular-nums; + line-height: 1.2; } -.statLabel { +.bentoLabel { font-size: 13px; color: var(--text-secondary); } -.statSublabel { +.bentoSublabel { font-size: 11px; color: var(--text-secondary); - opacity: 0.8; + opacity: 0.7; margin-top: 2px; } -.section { +// ─── Config Pills Section ─────────────────────────────── + +.configSection { + position: relative; + z-index: 1; display: flex; flex-direction: column; gap: $spacing-md; + animation: cardEnter 0.4s ease-out 0.5s both; } -.sectionTitle { - font-size: 16px; - font-weight: 700; - color: var(--text-primary); - margin: 0; -} - -.actionsGrid { +.configPillGrid { display: flex; flex-wrap: wrap; gap: $spacing-sm; - - a { - text-decoration: none; - } } -.actionButton { +.configPill { display: inline-flex; align-items: center; gap: $spacing-sm; - - // Button 内部的 span 需要 flex 对齐图标和文字 - > span { - display: inline-flex; - align-items: center; - gap: $spacing-sm; - } - - svg { - flex-shrink: 0; - } -} - -.configGrid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: $spacing-sm; -} - -.configItem { - display: flex; - align-items: center; - justify-content: space-between; - gap: $spacing-sm; - padding: $spacing-sm $spacing-md; + padding: 6px 14px; background: var(--bg-secondary); border: 1px solid var(--border-color); + border-radius: $radius-full; + font-size: 13px; + transition: border-color $transition-fast; + + &:hover { + border-color: var(--border-hover); + } +} + +.configPillWide { + flex-basis: 100%; border-radius: $radius-md; } -.configLabel { - font-size: 13px; +.configPillLabel { color: var(--text-secondary); + white-space: nowrap; } -.configValue { - font-size: 13px; +.configPillValue { font-weight: 600; color: var(--text-primary); - &.enabled { + &.on { color: $success-color; } - &.disabled { + &.off { color: var(--text-secondary); } } -.configValueMono { +.configPillMono { font-size: 12px; font-family: $font-mono; color: var(--text-secondary); word-break: break-all; } +// Config badges (routing strategy) .configBadge { display: inline-flex; align-items: center; justify-content: center; - padding: 4px 10px; + padding: 2px 8px; border-radius: $radius-full; border: 1px solid var(--border-color); - font-size: 12px; + font-size: 11px; font-weight: 600; line-height: 1.2; max-width: 100%; @@ -294,50 +481,7 @@ background: var(--bg-primary); } -.configItemFull { - grid-column: 1 / -1; -} - -// Usage stats section -.usageGrid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: $spacing-sm; -} - -.usageCard { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: $spacing-md; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: $radius-md; - text-align: center; -} - -.usageValue { - font-size: 22px; - font-weight: 800; - color: var(--primary-color); -} - -.usageLabel { - font-size: 12px; - color: var(--text-secondary); - margin-top: 4px; -} - -.usageLoading, -.usageEmpty { - padding: $spacing-lg; - text-align: center; - color: var(--text-secondary); - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: $radius-md; -} +// ─── View More Link ───────────────────────────────────── .viewMoreLink { display: inline-flex; @@ -346,8 +490,55 @@ color: var(--primary-color); text-decoration: none; margin-top: $spacing-xs; + transition: color $transition-fast; &:hover { text-decoration: underline; + color: var(--primary-hover); + } +} + +// ─── Animations ───────────────────────────────────────── + +@keyframes heroEnter { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeSlideUp { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes cardEnter { + from { + opacity: 0; + transform: translateY(16px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; } } diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index ca20098..30cf036 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -27,6 +27,16 @@ interface ProviderStats { openai: number | null; } +type TimeOfDay = 'morning' | 'afternoon' | 'evening' | 'night'; + +function getTimeOfDay(): TimeOfDay { + const hour = new Date().getHours(); + if (hour >= 5 && hour < 12) return 'morning'; + if (hour >= 12 && hour < 17) return 'afternoon'; + if (hour >= 17 && hour < 21) return 'evening'; + return 'night'; +} + export function DashboardPage() { const { t, i18n } = useTranslation(); const connectionStatus = useAuthStore((state) => state.connectionStatus); @@ -56,12 +66,25 @@ export function DashboardPage() { const [loading, setLoading] = useState(true); + // Time-of-day state for dynamic greeting + const [timeOfDay, setTimeOfDay] = useState(getTimeOfDay); + const [currentTime, setCurrentTime] = useState(() => new Date()); + const apiKeysCache = useRef([]); useEffect(() => { apiKeysCache.current = []; }, [apiBase, config?.apiKeys]); + // Update time every 60 seconds + useEffect(() => { + const id = setInterval(() => { + setTimeOfDay(getTimeOfDay()); + setCurrentTime(new Date()); + }, 60_000); + return () => clearInterval(id); + }, []); + const normalizeApiKeyList = (input: unknown): string[] => { if (!Array.isArray(input)) return []; const seen = new Set(); @@ -237,113 +260,151 @@ export function DashboardPage() { ? styles.configBadgeFillFirst : styles.configBadgeUnknown; + // Derived time-based values + const greetingKey = `dashboard.greeting_${timeOfDay}`; + const caringKey = `dashboard.caring_${timeOfDay}`; + + const formattedDate = currentTime.toLocaleDateString(i18n.language, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + + const formattedTime = currentTime.toLocaleTimeString(i18n.language, { + hour: '2-digit', + minute: '2-digit' + }); + return (
-
-

{t('dashboard.title')}

-

{t('dashboard.subtitle')}

+ {/* Decorative background orbs */} +