feat: add User Stats and Refactor Profile (#5149)

* ♻️ refactor: Refactor Profile

*  test: Fix test

*  feat: Update nextAuth profile

* ♻️ refactor: Refactor hook

* 💄 style: Update style

*  test: Fix test

* 💄 style: Update sats services

* ♻️ refactor: Server service arrow function

*  feat: Add Rank

* ♻️ refactor: rebase

* ♻️ refactor: rebase

*  feat: Add heatmap

* ♻️ refactor: refactor to support upload user profile in next-auth

* 💄 style: Update Stats style

* 📝 docs: Update i18n

* Revert "♻️ refactor: refactor to support upload user profile in next-auth"

This reverts commit d479ec375efef7671d7751524a014c040657a7d7.

*  feat: Add Welcome

* 💄 style: Update stats style

* 💄 style: Update stats style

*  feat: Add Share Modal

* 🔧 chore: Update i18n

* upgrade

* upgrade

* 💄 style: Update Share style

*  test: Fix test

* 💄 style: Update Modal Loading

*  feat: Add Model Usage Rank

* add test and fix some

* 🐛 fix: Fix Valid Date

* 🐛 fix: Fix test

*  test: Fix rank order

* 🐛 fix: Fix mobile

* 💄 style: Update BrandLoading

* 💄 style: Update moible AiHeatmaps style

* 💄 style: Add Inbox

* 📝 docs: Add Changelog

* 📝 docs: Fix typo

*  test: Fix test

* 📝 docs: Update Changelog

* fix

---------

Co-authored-by: arvinxx <arvinx@foxmail.com>
This commit is contained in:
CanisMinor
2025-01-03 23:17:07 +08:00
committed by GitHub
parent 301a945251
commit cbc219cd07
137 changed files with 5058 additions and 725 deletions
+1
View File
@@ -7,6 +7,7 @@
"https://github.com/user-attachments/assets/29508dda-2382-430f-bc81-fb23f02149f8": "/blog/assets/29b13dc042e3b839ad8865354afe2fac.webp",
"https://github.com/user-attachments/assets/2a4116a7-15ad-43e5-b801-cc62d8da2012": "/blog/assets/37d85fdfccff9ed56e9c6827faee01c7.webp",
"https://github.com/user-attachments/assets/385eaca6-daea-484a-9bea-ba7270b4753d": "/blog/assets/d6129350de510a62fe87b2d2f0fb9477.webp",
"https://github.com/user-attachments/assets/3d80e0f5-d32a-4412-85b2-e709731460a0": "/blog/assets2d409f43b58953ad5396c6beab8a0719.webp",
"https://github.com/user-attachments/assets/484f28f4-017c-4ed7-948b-4a8d51f0b63a": "/blog/assets/5bbb4b421d6df63780b3c7a05f5a102d.webp",
"https://github.com/user-attachments/assets/533f7a5e-8a93-4a57-a62f-8233897d72b5": "/blog/assets/9498087e85f27e692716a63cb3b58d79.webp",
"https://github.com/user-attachments/assets/6069332b-8e15-4d3c-8a77-479e8bc09c23": "/blog/assets/603fefbb944bc6761ebdab5956fc0084.webp",
@@ -0,0 +1,27 @@
---
title: LobeChat Supports User Data Statistics and Activity Sharing
description: >-
LobeChat now supports multi-dimensional user data statistics and activity
sharing
---
# User Data Statistics and Activity Sharing 💯
Want to know about your activity performance on LobeChat?
Now, you can comprehensively understand your AI data through the statistics feature, and even generate personal activity sharing images to share your LobeChat activity with friends.
## 📊 Data Statistics
- **Statistics**: Number of Assistants / Topics / Messages / Total Word Count
- **Rankings**:
- Model Usage Rate `Top 10`
- Assistant Usage Rate `Top 10`
- Topic Content Volume `Top 10`
- **Heat Map**: Activity distribution over the past year
- **User Activity Sharing**: Generate personal activity sharing images
## 👉 How to Use
1. Requires `PgLite` or `Database` mode
2. Click on your profile picture to enter "Account" - "Data Statistics" page
@@ -0,0 +1,26 @@
---
title: LobeChat 支持用户数据统计与活跃度分享
description: LobeChat 现已支持多维度用户数据统计与活跃度分享
---
# 用户数据统计与活跃度分享 💯
想要了解自己在 LobeChat 上的活跃度表现吗?
现在,您可以通过数据统计功能,全方位了解自己的 AI 数据,还可以生成个人活跃度分享图片,与好友分享您在 LobeChat 上的活跃度。
## 📊 数据统计
- **数据统计**: 助手数 / 话题数 / 消息数 / 累计字数
- **排行版**:
- 模型使用率 `Top 10`
- 助手使用率 `Top 10`
- 话题内容量 `Top 10`
- **热力图**: 过去一年内的活跃度分布
- **用户活跃度分享**: 生成个人活跃度分享图片
## 👉 使用方式
1. 需要使用 `PgLite` 或 `数据库` 模式
2. 点击个人头像进入「账户管理」-「数据统计」页面
+5
View File
@@ -2,6 +2,11 @@
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
"cloud": [],
"community": [
{
"image": "https://github.com/user-attachments/assets/3d80e0f5-d32a-4412-85b2-e709731460a0",
"id": "2025-01-03-user-profile",
"date": "2025-01-03"
},
{
"image": "https://github.com/user-attachments/assets/2048b4c2-4a56-4029-acf9-71e35ff08652",
"id": "2024-11-27-forkable-chat",
@@ -1,6 +1,8 @@
---
title: Configure Wechat Authentication Service in LobeChat
description: Learn how to configure Wechat authentication service in LobeChat, including creating a new Wechat App, setting permissions, and environment variables.
description: >-
Learn how to configure Wechat authentication service in LobeChat, including
creating a new Wechat App, setting permissions, and environment variables.
tags:
- Wechat Authentication
- Wechat App
@@ -2,8 +2,8 @@
title: 在 LobeChat 中配置微信身份验证服务
description: 学习如何在 LobeChat 中配置微信身份验证服务,包括创建新的微信网站应用、设置权限和环境变量。
tags:
-微信身份验证
-微信网站应用
- 微信身份验证
- 微信网站应用
- 环境变量配置
- 单点登录
- LobeChat
+76 -4
View File
@@ -1,8 +1,80 @@
{
"date": {
"prevMonth": "الشهر الماضي",
"recent30Days": "آخر 30 يومًا"
},
"header": {
"desc": "إدارة معلومات حسابك.",
"title": "الحساب"
},
"heatmaps": {
"legend": {
"less": "غير نشط",
"more": "نشط"
},
"months": {
"apr": "أبريل",
"aug": "أغسطس",
"dec": "ديسمبر",
"feb": "فبراير",
"jan": "يناير",
"jul": "يوليو",
"jun": "يونيو",
"mar": "مارس",
"may": "مايو",
"nov": "نوفمبر",
"oct": "أكتوبر",
"sep": "سبتمبر"
},
"tooltip": "{{date}} أرسل {{count}} رسائل في ذلك اليوم",
"totalCount": "إجمالي {{count}} رسائل أرسلت في العام الماضي"
},
"login": "تسجيل الدخول",
"loginOrSignup": "تسجيل الدخول / التسجيل",
"profile": "الملف الشخصي",
"security": "الأمان",
"loginOrSignup": "تسجيل الدخول / الاشتراك",
"profile": {
"avatar": "الصورة الشخصية",
"email": "عنوان البريد الإلكتروني",
"username": "اسم المستخدم"
},
"signout": "تسجيل الخروج",
"signup": "التسجيل"
"signup": "الاشتراك",
"stats": {
"aiheatmaps": "مؤشر النشاط",
"assistants": "المساعدون",
"assistantsRank": {
"left": "المساعد",
"right": "المواضيع",
"title": "ترتيب استخدام المساعد"
},
"createdAt": "تاريخ التسجيل",
"days": "أيام",
"empty": {
"desc": "يرجى تجميع المزيد من بيانات الدردشة للعرض",
"title": "لا توجد بيانات"
},
"lastYearActivity": "النشاط في العام الماضي",
"messages": "رسائل",
"modelsRank": {
"left": "النموذج",
"right": "الرسائل",
"title": "ترتيب استخدام النموذج"
},
"share": {
"title": "مؤشر نشاط الذكاء الاصطناعي الخاص بي"
},
"topics": "المواضيع",
"topicsRank": {
"left": "الموضوع",
"right": "الرسائل",
"title": "ترتيب محتوى الموضوع"
},
"updatedAt": "تاريخ التحديث",
"welcome": "{{username}}، هذا هو يومك <span>{{days}}</span> مع {{appName}}",
"words": "كلمات"
},
"tab": {
"profile": "الملف الشخصي",
"security": "الأمان",
"stats": "الإحصائيات"
}
}
+75 -3
View File
@@ -1,8 +1,80 @@
{
"date": {
"prevMonth": "Миналия месец",
"recent30Days": "Последните 30 дни"
},
"header": {
"desc": "Управлявайте информацията за вашия акаунт.",
"title": "Акаунт"
},
"heatmaps": {
"legend": {
"less": "Неактивен",
"more": "Активен"
},
"months": {
"apr": "Апр",
"aug": "Авг",
"dec": "Дек",
"feb": "Фев",
"jan": "Ян",
"jul": "Юл",
"jun": "Юн",
"mar": "Мар",
"may": "Май",
"nov": "Ное",
"oct": "Окт",
"sep": "Сеп"
},
"tooltip": "{{date}} изпратил(а) {{count}} съобщения този ден",
"totalCount": "Общо {{count}} съобщения изпратени през последната година"
},
"login": "Вход",
"loginOrSignup": "Вход / Регистрация",
"profile": "Профил",
"security": "Сигурност",
"profile": {
"avatar": "Аватар",
"email": "Имейл адрес",
"username": "Потребителско име"
},
"signout": "Изход",
"signup": "Регистрация"
"signup": "Регистрация",
"stats": {
"aiheatmaps": "Индекс на активност",
"assistants": "Асистенти",
"assistantsRank": {
"left": "Асистент",
"right": "Тематики",
"title": "Ранг на използване на асистенти"
},
"createdAt": "Регистриран на",
"days": "дни",
"empty": {
"desc": "Моля, натрупайте повече данни от чат, за да видите",
"title": "Няма данни"
},
"lastYearActivity": "активност през последната година",
"messages": "Съобщения",
"modelsRank": {
"left": "Модел",
"right": "Съобщения",
"title": "Ранг на използване на модели"
},
"share": {
"title": "Моят индекс на активност с ИИ"
},
"topics": "Тематики",
"topicsRank": {
"left": "Тематика",
"right": "Съобщения",
"title": "Ранг на съдържание на тематики"
},
"updatedAt": "Актуализиран на",
"welcome": "{{username}}, това е вашият <span>{{days}}</span> ден с {{appName}}",
"words": "Думи"
},
"tab": {
"profile": "Профил",
"security": "Сигурност",
"stats": "Статистика"
}
}
+78 -6
View File
@@ -1,8 +1,80 @@
{
"login": "Anmelden",
"loginOrSignup": "Anmelden / Registrieren",
"profile": "Profil",
"security": "Sicherheit",
"signout": "Abmelden",
"signup": "Registrieren"
"date": {
"prevMonth": "Letzter Monat",
"recent30Days": "Letzte 30 Tage"
},
"header": {
"desc": "Verwalten Sie Ihre Kontoinformationen.",
"title": "Konto"
},
"heatmaps": {
"legend": {
"less": "Inaktiv",
"more": "Aktiv"
},
"months": {
"apr": "Apr",
"aug": "Aug",
"dec": "Dez",
"feb": "Feb",
"jan": "Jan",
"jul": "Jul",
"jun": "Jun",
"mar": "Mär",
"may": "Mai",
"nov": "Nov",
"oct": "Okt",
"sep": "Sep"
},
"tooltip": "{{date}} hat {{count}} Nachrichten an diesem Tag gesendet",
"totalCount": "Insgesamt wurden {{count}} Nachrichten im letzten Jahr gesendet"
},
"login": "Einloggen",
"loginOrSignup": "Einloggen / Registrieren",
"profile": {
"avatar": "Avatar",
"email": "E-Mail-Adresse",
"username": "Benutzername"
},
"signout": "Ausloggen",
"signup": "Registrieren",
"stats": {
"aiheatmaps": "Aktivitätsindex",
"assistants": "Assistenten",
"assistantsRank": {
"left": "Assistent",
"right": "Themen",
"title": "Rang der Assistentennutzung"
},
"createdAt": "Registriert am",
"days": "Tage",
"empty": {
"desc": "Bitte sammeln Sie mehr Chatdaten, um sie anzuzeigen",
"title": "Keine Daten"
},
"lastYearActivity": "Aktivität im letzten Jahr",
"messages": "Nachrichten",
"modelsRank": {
"left": "Modell",
"right": "Nachrichten",
"title": "Rang der Modellenutzung"
},
"share": {
"title": "Mein AI Aktivitätsindex"
},
"topics": "Themen",
"topicsRank": {
"left": "Thema",
"right": "Nachrichten",
"title": "Rang des Themeninhalts"
},
"updatedAt": "Aktualisiert am",
"welcome": "{{username}}, dies ist Ihr <span>{{days}}</span> Tag mit {{appName}}",
"words": "Wörter"
},
"tab": {
"profile": "Profil",
"security": "Sicherheit",
"stats": "Statistiken"
}
}
+78 -6
View File
@@ -1,8 +1,80 @@
{
"login": "Login",
"loginOrSignup": "Login / Sign up",
"profile": "Profile",
"security": "Security",
"signout": "Sign out",
"signup": "Sign up"
"date": {
"prevMonth": "Last Month",
"recent30Days": "Last 30 Days"
},
"header": {
"desc": "Manage your account information.",
"title": "Account"
},
"heatmaps": {
"legend": {
"less": "Inactive",
"more": "Active"
},
"months": {
"apr": "Apr",
"aug": "Aug",
"dec": "Dec",
"feb": "Feb",
"jan": "Jan",
"jul": "Jul",
"jun": "Jun",
"mar": "Mar",
"may": "May",
"nov": "Nov",
"oct": "Oct",
"sep": "Sep"
},
"tooltip": "{{date}} sent {{count}} messages that day",
"totalCount": "A total of {{count}} messages sent in the past year"
},
"login": "Log In",
"loginOrSignup": "Log In / Sign Up",
"profile": {
"avatar": "Avatar",
"email": "Email Address",
"username": "Username"
},
"signout": "Log Out",
"signup": "Sign Up",
"stats": {
"aiheatmaps": "Activity Index",
"assistants": "Assistants",
"assistantsRank": {
"left": "Assistant",
"right": "Topics",
"title": "Assistant Usage Rank"
},
"createdAt": "Registered at",
"days": "days",
"empty": {
"desc": "Please accumulate more chat data to view",
"title": "No Data"
},
"lastYearActivity": "Activity in the past year",
"messages": "Messages",
"modelsRank": {
"left": "Model",
"right": "Messages",
"title": "Model Usage Rank"
},
"share": {
"title": "My AI Activity Index"
},
"topics": "Topics",
"topicsRank": {
"left": "Topic",
"right": "Messages",
"title": "Topic Content Rank"
},
"updatedAt": "Updated at",
"welcome": "{{username}}, this is your <span>{{days}}</span> day with {{appName}}",
"words": "Total Words"
},
"tab": {
"profile": "Profile",
"security": "Security",
"stats": "Statistics"
}
}
+75 -3
View File
@@ -1,8 +1,80 @@
{
"date": {
"prevMonth": "Último mes",
"recent30Days": "Últimos 30 días"
},
"header": {
"desc": "Gestiona la información de tu cuenta.",
"title": "Cuenta"
},
"heatmaps": {
"legend": {
"less": "Inactivo",
"more": "Activo"
},
"months": {
"apr": "Abr",
"aug": "Ago",
"dec": "Dic",
"feb": "Feb",
"jan": "Ene",
"jul": "Jul",
"jun": "Jun",
"mar": "Mar",
"may": "May",
"nov": "Nov",
"oct": "Oct",
"sep": "Sep"
},
"tooltip": "{{date}} envió {{count}} mensajes ese día",
"totalCount": "Un total de {{count}} mensajes enviados en el último año"
},
"login": "Iniciar sesión",
"loginOrSignup": "Iniciar sesión / Registrarse",
"profile": "Perfil",
"security": "Seguridad",
"profile": {
"avatar": "Avatar",
"email": "Dirección de correo electrónico",
"username": "Nombre de usuario"
},
"signout": "Cerrar sesión",
"signup": "Registrarse"
"signup": "Registrarse",
"stats": {
"aiheatmaps": "Índice de Actividad",
"assistants": "Asistentes",
"assistantsRank": {
"left": "Asistente",
"right": "Temas",
"title": "Clasificación de Uso de Asistentes"
},
"createdAt": "Registrado en",
"days": "días",
"empty": {
"desc": "Por favor, acumula más datos de chat para ver",
"title": "Sin datos"
},
"lastYearActivity": "actividad en el último año",
"messages": "Mensajes",
"modelsRank": {
"left": "Modelo",
"right": "Mensajes",
"title": "Clasificación de Uso de Modelos"
},
"share": {
"title": "Mi Índice de Actividad AI"
},
"topics": "Temas",
"topicsRank": {
"left": "Tema",
"right": "Mensajes",
"title": "Clasificación de Contenido de Temas"
},
"updatedAt": "Actualizado en",
"welcome": "{{username}}, este es tu <span>{{days}}</span> día con {{appName}}",
"words": "Palabras"
},
"tab": {
"profile": "Perfil",
"security": "Seguridad",
"stats": "Estadísticas"
}
}
+77 -5
View File
@@ -1,8 +1,80 @@
{
"date": {
"prevMonth": "ماه گذشته",
"recent30Days": "۳۰ روز گذشته"
},
"header": {
"desc": "اطلاعات حساب کاربری خود را مدیریت کنید.",
"title": "حساب کاربری"
},
"heatmaps": {
"legend": {
"less": "غیرفعال",
"more": "فعال"
},
"months": {
"apr": "آوریل",
"aug": "اوت",
"dec": "دسامبر",
"feb": "فوریه",
"jan": "ژانویه",
"jul": "ژوئیه",
"jun": "ژوئن",
"mar": "مارس",
"may": "مه",
"nov": "نوامبر",
"oct": "اکتبر",
"sep": "سپتامبر"
},
"tooltip": "{{date}} در آن روز {{count}} پیام ارسال کرد",
"totalCount": "در مجموع {{count}} پیام در سال گذشته ارسال شده است"
},
"login": "ورود",
"loginOrSignup": "ورود / ثبتنام",
"profile": "پروفایل",
"security": "امنیت",
"signout": "خروج از حساب",
"signup": "ثبت‌نام"
"loginOrSignup": "ورود / ثبت نام",
"profile": {
"avatar": "آواتار",
"email": "آدرس ایمیل",
"username": "نام کاربری"
},
"signout": "خروج",
"signup": "ثبت نام",
"stats": {
"aiheatmaps": "شاخص فعالیت",
"assistants": "دستیاران",
"assistantsRank": {
"left": "دستیار",
"right": "موضوعات",
"title": "رتبه استفاده از دستیار"
},
"createdAt": "تاریخ ثبت نام",
"days": "روز",
"empty": {
"desc": "لطفاً داده‌های چت بیشتری جمع‌آوری کنید تا مشاهده کنید",
"title": "داده‌ای وجود ندارد"
},
"lastYearActivity": "فعالیت در سال گذشته",
"messages": "پیام‌ها",
"modelsRank": {
"left": "مدل",
"right": "پیام‌ها",
"title": "رتبه استفاده از مدل"
},
"share": {
"title": "شاخص فعالیت هوش مصنوعی من"
},
"topics": "موضوعات",
"topicsRank": {
"left": "موضوع",
"right": "پیام‌ها",
"title": "رتبه محتوای موضوع"
},
"updatedAt": "تاریخ به‌روزرسانی",
"welcome": "{{username}}، این {{days}} روز شما با {{appName}} است",
"words": "کلمات"
},
"tab": {
"profile": "پروفایل",
"security": "امنیت",
"stats": "آمار"
}
}
+78 -6
View File
@@ -1,8 +1,80 @@
{
"login": "Connexion",
"loginOrSignup": "Connexion / Inscription",
"profile": "Profil",
"security": "Sécurité",
"signout": "Déconnexion",
"signup": "Inscription"
"date": {
"prevMonth": "Le mois dernier",
"recent30Days": "Les 30 derniers jours"
},
"header": {
"desc": "Gérez les informations de votre compte.",
"title": "Compte"
},
"heatmaps": {
"legend": {
"less": "Inactif",
"more": "Actif"
},
"months": {
"apr": "Avr",
"aug": "Août",
"dec": "Déc",
"feb": "Fév",
"jan": "Jan",
"jul": "Juil",
"jun": "Juin",
"mar": "Mar",
"may": "Mai",
"nov": "Nov",
"oct": "Oct",
"sep": "Sep"
},
"tooltip": "{{date}} a envoyé {{count}} messages ce jour-là",
"totalCount": "Un total de {{count}} messages envoyés au cours de l'année dernière"
},
"login": "Se connecter",
"loginOrSignup": "Se connecter / S'inscrire",
"profile": {
"avatar": "Avatar",
"email": "Adresse e-mail",
"username": "Nom d'utilisateur"
},
"signout": "Se déconnecter",
"signup": "S'inscrire",
"stats": {
"aiheatmaps": "Indice d'activité",
"assistants": "Assistants",
"assistantsRank": {
"left": "Assistant",
"right": "Sujets",
"title": "Classement d'utilisation des assistants"
},
"createdAt": "Inscrit le",
"days": "jours",
"empty": {
"desc": "Veuillez accumuler plus de données de chat pour voir",
"title": "Aucune donnée"
},
"lastYearActivity": "activité au cours de l'année dernière",
"messages": "Messages",
"modelsRank": {
"left": "Modèle",
"right": "Messages",
"title": "Classement d'utilisation des modèles"
},
"share": {
"title": "Mon indice d'activité IA"
},
"topics": "Sujets",
"topicsRank": {
"left": "Sujet",
"right": "Messages",
"title": "Classement du contenu des sujets"
},
"updatedAt": "Mis à jour le",
"welcome": "{{username}}, c'est votre <span>{{days}}</span> jour avec {{appName}}",
"words": "Mots"
},
"tab": {
"profile": "Profil",
"security": "Sécurité",
"stats": "Statistiques"
}
}
+76 -4
View File
@@ -1,8 +1,80 @@
{
"date": {
"prevMonth": "Mese Scorso",
"recent30Days": "Ultimi 30 Giorni"
},
"header": {
"desc": "Gestisci le informazioni del tuo account.",
"title": "Account"
},
"heatmaps": {
"legend": {
"less": "Inattivo",
"more": "Attivo"
},
"months": {
"apr": "Apr",
"aug": "Ago",
"dec": "Dic",
"feb": "Feb",
"jan": "Gen",
"jul": "Lug",
"jun": "Giu",
"mar": "Mar",
"may": "Mag",
"nov": "Nov",
"oct": "Ott",
"sep": "Set"
},
"tooltip": "{{date}} ha inviato {{count}} messaggi quel giorno",
"totalCount": "Un totale di {{count}} messaggi inviati nell'ultimo anno"
},
"login": "Accedi",
"loginOrSignup": "Accedi / Registrati",
"profile": "Profilo",
"security": "Sicurezza",
"signout": "Esci",
"signup": "Registrati"
"profile": {
"avatar": "Avatar",
"email": "Indirizzo Email",
"username": "Nome Utente"
},
"signout": "Disconnetti",
"signup": "Registrati",
"stats": {
"aiheatmaps": "Indice di Attività",
"assistants": "Assistenti",
"assistantsRank": {
"left": "Assistente",
"right": "Argomenti",
"title": "Classifica Utilizzo Assistente"
},
"createdAt": "Registrato il",
"days": "giorni",
"empty": {
"desc": "Accumula più dati di chat per visualizzare",
"title": "Nessun Dato"
},
"lastYearActivity": "attività nell'ultimo anno",
"messages": "Messaggi",
"modelsRank": {
"left": "Modello",
"right": "Messaggi",
"title": "Classifica Utilizzo Modello"
},
"share": {
"title": "Il Mio Indice di Attività AI"
},
"topics": "Argomenti",
"topicsRank": {
"left": "Argomento",
"right": "Messaggi",
"title": "Classifica Contenuti Argomento"
},
"updatedAt": "Aggiornato il",
"welcome": "{{username}}, questo è il tuo <span>{{days}}</span> giorno con {{appName}}",
"words": "Parole"
},
"tab": {
"profile": "Profilo",
"security": "Sicurezza",
"stats": "Statistiche"
}
}
+76 -4
View File
@@ -1,8 +1,80 @@
{
"date": {
"prevMonth": "先月",
"recent30Days": "過去30日間"
},
"header": {
"desc": "アカウント情報を管理します。",
"title": "アカウント"
},
"heatmaps": {
"legend": {
"less": "非アクティブ",
"more": "アクティブ"
},
"months": {
"apr": "4月",
"aug": "8月",
"dec": "12月",
"feb": "2月",
"jan": "1月",
"jul": "7月",
"jun": "6月",
"mar": "3月",
"may": "5月",
"nov": "11月",
"oct": "10月",
"sep": "9月"
},
"tooltip": "{{date}} に {{count}} 件のメッセージを送信しました",
"totalCount": "過去1年間に送信されたメッセージは合計で {{count}} 件です"
},
"login": "ログイン",
"loginOrSignup": "ログイン / 登録",
"profile": "プロフィール",
"security": "セキュリティ",
"loginOrSignup": "ログイン / サインアップ",
"profile": {
"avatar": "アバター",
"email": "メールアドレス",
"username": "ユーザー名"
},
"signout": "ログアウト",
"signup": "サインアップ"
"signup": "サインアップ",
"stats": {
"aiheatmaps": "アクティビティインデックス",
"assistants": "アシスタント",
"assistantsRank": {
"left": "アシスタント",
"right": "トピック",
"title": "アシスタント使用ランク"
},
"createdAt": "登録日",
"days": "日",
"empty": {
"desc": "チャットデータをもっと蓄積してください",
"title": "データなし"
},
"lastYearActivity": "過去1年間のアクティビティ",
"messages": "メッセージ",
"modelsRank": {
"left": "モデル",
"right": "メッセージ",
"title": "モデル使用ランク"
},
"share": {
"title": "私のAIアクティビティインデックス"
},
"topics": "トピック",
"topicsRank": {
"left": "トピック",
"right": "メッセージ",
"title": "トピックコンテンツランク"
},
"updatedAt": "更新日",
"welcome": "{{username}}さん、これはあなたの <span>{{days}}</span> 日目の {{appName}} です",
"words": "単語"
},
"tab": {
"profile": "プロフィール",
"security": "セキュリティ",
"stats": "統計"
}
}
+75 -3
View File
@@ -1,8 +1,80 @@
{
"date": {
"prevMonth": "지난 달",
"recent30Days": "최근 30일"
},
"header": {
"desc": "계정 정보를 관리하세요.",
"title": "계정"
},
"heatmaps": {
"legend": {
"less": "비활성",
"more": "활성"
},
"months": {
"apr": "4월",
"aug": "8월",
"dec": "12월",
"feb": "2월",
"jan": "1월",
"jul": "7월",
"jun": "6월",
"mar": "3월",
"may": "5월",
"nov": "11월",
"oct": "10월",
"sep": "9월"
},
"tooltip": "{{date}}에 {{count}}개의 메시지를 보냈습니다.",
"totalCount": "지난 1년 동안 총 {{count}}개의 메시지가 전송되었습니다."
},
"login": "로그인",
"loginOrSignup": "로그인 / 가입",
"profile": "프로필",
"security": "보안",
"profile": {
"avatar": "아바타",
"email": "이메일 주소",
"username": "사용자 이름"
},
"signout": "로그아웃",
"signup": "가입"
"signup": "가입",
"stats": {
"aiheatmaps": "활동 지수",
"assistants": "어시스턴트",
"assistantsRank": {
"left": "어시스턴트",
"right": "주제",
"title": "어시스턴트 사용 순위"
},
"createdAt": "등록일",
"days": "일",
"empty": {
"desc": "더 많은 채팅 데이터를 축적하여 보세요.",
"title": "데이터 없음"
},
"lastYearActivity": "지난 1년간의 활동",
"messages": "메시지",
"modelsRank": {
"left": "모델",
"right": "메시지",
"title": "모델 사용 순위"
},
"share": {
"title": "내 AI 활동 지수"
},
"topics": "주제",
"topicsRank": {
"left": "주제",
"right": "메시지",
"title": "주제 내용 순위"
},
"updatedAt": "업데이트 일",
"welcome": "{{username}}, {{appName}}와 함께한 <span>{{days}}</span>일입니다.",
"words": "단어"
},
"tab": {
"profile": "프로필",
"security": "보안",
"stats": "통계"
}
}
+76 -4
View File
@@ -1,8 +1,80 @@
{
"date": {
"prevMonth": "Vorige maand",
"recent30Days": "Laatste 30 dagen"
},
"header": {
"desc": "Beheer uw accountinformatie.",
"title": "Account"
},
"heatmaps": {
"legend": {
"less": "Inactief",
"more": "Actief"
},
"months": {
"apr": "Apr",
"aug": "Aug",
"dec": "Dec",
"feb": "Feb",
"jan": "Jan",
"jul": "Jul",
"jun": "Jun",
"mar": "Mar",
"may": "Mei",
"nov": "Nov",
"oct": "Okt",
"sep": "Sep"
},
"tooltip": "{{date}} heeft {{count}} berichten op die dag verzonden",
"totalCount": "In totaal zijn er {{count}} berichten verzonden in het afgelopen jaar"
},
"login": "Inloggen",
"loginOrSignup": "Inloggen / Registreren",
"profile": "Profiel",
"security": "Veiligheid",
"loginOrSignup": "Inloggen / Aanmelden",
"profile": {
"avatar": "Avatar",
"email": "E-mailadres",
"username": "Gebruikersnaam"
},
"signout": "Uitloggen",
"signup": "Registreren"
"signup": "Aanmelden",
"stats": {
"aiheatmaps": "Activiteitsindex",
"assistants": "Assistenten",
"assistantsRank": {
"left": "Assistent",
"right": "Onderwerpen",
"title": "Ranglijst Assistentgebruik"
},
"createdAt": "Geregistreerd op",
"days": "dagen",
"empty": {
"desc": "Verzamel meer chatgegevens om te bekijken",
"title": "Geen gegevens"
},
"lastYearActivity": "activiteit in het afgelopen jaar",
"messages": "Berichten",
"modelsRank": {
"left": "Model",
"right": "Berichten",
"title": "Ranglijst Modelgebruik"
},
"share": {
"title": "Mijn AI Activiteitsindex"
},
"topics": "Onderwerpen",
"topicsRank": {
"left": "Onderwerp",
"right": "Berichten",
"title": "Ranglijst Onderwerpinhoud"
},
"updatedAt": "Bijgewerkt op",
"welcome": "{{username}}, dit is uw <span>{{days}}</span> dag met {{appName}}",
"words": "Woorden"
},
"tab": {
"profile": "Profiel",
"security": "Beveiliging",
"stats": "Statistieken"
}
}
+76 -4
View File
@@ -1,8 +1,80 @@
{
"date": {
"prevMonth": "Poprzedni miesiąc",
"recent30Days": "Ostatnie 30 dni"
},
"header": {
"desc": "Zarządzaj informacjami o swoim koncie.",
"title": "Konto"
},
"heatmaps": {
"legend": {
"less": "Nieaktywny",
"more": "Aktywny"
},
"months": {
"apr": "Kwi",
"aug": "Sie",
"dec": "Gru",
"feb": "Lut",
"jan": "Sty",
"jul": "Lip",
"jun": "Cze",
"mar": "Mar",
"may": "Maj",
"nov": "Lis",
"oct": "Paź",
"sep": "Wrz"
},
"tooltip": "{{date}} wysłał {{count}} wiadomości tego dnia",
"totalCount": "Łącznie {{count}} wiadomości wysłanych w ciągu ostatniego roku"
},
"login": "Zaloguj się",
"loginOrSignup": "Zaloguj się / Zarejestruj się",
"profile": "Profil użytkownika",
"security": "Bezpieczeństwo",
"signout": "Wyloguj",
"signup": "Zarejestruj się"
"profile": {
"avatar": "Awatar",
"email": "Adres e-mail",
"username": "Nazwa użytkownika"
},
"signout": "Wyloguj się",
"signup": "Zarejestruj się",
"stats": {
"aiheatmaps": "Indeks Aktywności",
"assistants": "Asystenci",
"assistantsRank": {
"left": "Asystent",
"right": "Tematy",
"title": "Ranking Użycia Asystentów"
},
"createdAt": "Zarejestrowano",
"days": "dni",
"empty": {
"desc": "Proszę zgromadzić więcej danych czatu, aby wyświetlić",
"title": "Brak danych"
},
"lastYearActivity": "aktywność w ciągu ostatniego roku",
"messages": "Wiadomości",
"modelsRank": {
"left": "Model",
"right": "Wiadomości",
"title": "Ranking Użycia Modeli"
},
"share": {
"title": "Mój Indeks Aktywności AI"
},
"topics": "Tematy",
"topicsRank": {
"left": "Temat",
"right": "Wiadomości",
"title": "Ranking Treści Tematów"
},
"updatedAt": "Zaktualizowano",
"welcome": "{{username}}, to twój <span>{{days}}</span> dzień z {{appName}}",
"words": "Słowa"
},
"tab": {
"profile": "Profil",
"security": "Bezpieczeństwo",
"stats": "Statystyki"
}
}
+76 -4
View File
@@ -1,8 +1,80 @@
{
"date": {
"prevMonth": "Último Mês",
"recent30Days": "Últimos 30 Dias"
},
"header": {
"desc": "Gerencie as informações da sua conta.",
"title": "Conta"
},
"heatmaps": {
"legend": {
"less": "Inativo",
"more": "Ativo"
},
"months": {
"apr": "Abr",
"aug": "Ago",
"dec": "Dez",
"feb": "Fev",
"jan": "Jan",
"jul": "Jul",
"jun": "Jun",
"mar": "Mar",
"may": "Mai",
"nov": "Nov",
"oct": "Out",
"sep": "Set"
},
"tooltip": "{{date}} enviou {{count}} mensagens naquele dia",
"totalCount": "Um total de {{count}} mensagens enviadas no último ano"
},
"login": "Entrar",
"loginOrSignup": "Entrar / Registrar",
"profile": "Perfil",
"security": "Segurança",
"loginOrSignup": "Entrar / Cadastrar",
"profile": {
"avatar": "Avatar",
"email": "Endereço de E-mail",
"username": "Nome de Usuário"
},
"signout": "Sair",
"signup": "Cadastre-se"
"signup": "Cadastrar",
"stats": {
"aiheatmaps": "Índice de Atividade",
"assistants": "Assistentes",
"assistantsRank": {
"left": "Assistente",
"right": "Tópicos",
"title": "Ranking de Uso do Assistente"
},
"createdAt": "Registrado em",
"days": "dias",
"empty": {
"desc": "Por favor, acumule mais dados de chat para visualizar",
"title": "Sem Dados"
},
"lastYearActivity": "atividade no último ano",
"messages": "Mensagens",
"modelsRank": {
"left": "Modelo",
"right": "Mensagens",
"title": "Ranking de Uso do Modelo"
},
"share": {
"title": "Meu Índice de Atividade de IA"
},
"topics": "Tópicos",
"topicsRank": {
"left": "Tópico",
"right": "Mensagens",
"title": "Ranking de Conteúdo do Tópico"
},
"updatedAt": "Atualizado em",
"welcome": "{{username}}, este é seu <span>{{days}}</span> dia com {{appName}}",
"words": "Palavras"
},
"tab": {
"profile": "Perfil",
"security": "Segurança",
"stats": "Estatísticas"
}
}
+75 -3
View File
@@ -1,8 +1,80 @@
{
"date": {
"prevMonth": "Прошлый месяц",
"recent30Days": "Последние 30 дней"
},
"header": {
"desc": "Управляйте информацией о своей учетной записи.",
"title": "Учетная запись"
},
"heatmaps": {
"legend": {
"less": "Неактивный",
"more": "Активный"
},
"months": {
"apr": "Апр",
"aug": "Авг",
"dec": "Дек",
"feb": "Фев",
"jan": "Янв",
"jul": "Июл",
"jun": "Июн",
"mar": "Мар",
"may": "Май",
"nov": "Ноя",
"oct": "Окт",
"sep": "Сен"
},
"tooltip": "{{date}} отправил {{count}} сообщений в этот день",
"totalCount": "Всего {{count}} сообщений отправлено за последний год"
},
"login": "Войти",
"loginOrSignup": "Войти / Зарегистрироваться",
"profile": "Профиль",
"security": "Безопасность",
"profile": {
"avatar": "Аватар",
"email": "Электронная почта",
"username": "Имя пользователя"
},
"signout": "Выйти",
"signup": "Зарегистрироваться"
"signup": "Зарегистрироваться",
"stats": {
"aiheatmaps": "Индекс активности",
"assistants": "Ассистенты",
"assistantsRank": {
"left": "Ассистент",
"right": "Темы",
"title": "Рейтинг использования ассистентов"
},
"createdAt": "Зарегистрирован",
"days": "дней",
"empty": {
"desc": "Пожалуйста, накопите больше данных чата для просмотра",
"title": "Нет данных"
},
"lastYearActivity": "активность за последний год",
"messages": "Сообщения",
"modelsRank": {
"left": "Модель",
"right": "Сообщения",
"title": "Рейтинг использования моделей"
},
"share": {
"title": "Мой индекс активности ИИ"
},
"topics": "Темы",
"topicsRank": {
"left": "Тема",
"right": "Сообщения",
"title": "Рейтинг содержания тем"
},
"updatedAt": "Обновлено",
"welcome": "{{username}}, это ваш <span>{{days}}</span> день с {{appName}}",
"words": "Слова"
},
"tab": {
"profile": "Профиль",
"security": "Безопасность",
"stats": "Статистика"
}
}
+74 -3
View File
@@ -1,8 +1,79 @@
{
"date": {
"prevMonth": "Geçen Ay",
"recent30Days": "Son 30 Gün"
},
"header": {
"desc": "Hesap bilgilerinizi yönetin.",
"title": "Hesap"
},
"heatmaps": {
"legend": {
"less": "Pasif",
"more": "Aktif"
},
"months": {
"apr": "Nis",
"aug": "Ağu",
"dec": "Ara",
"feb": "Şub",
"jan": "Oca",
"jul": "Tem",
"jun": "Haz",
"mar": "Mar",
"may": "May",
"nov": "Kas",
"oct": "Eki",
"sep": "Eyl"
},
"tooltip": "{{date}} tarihinde {{count}} mesaj gönderildi",
"totalCount": "Geçen yıl toplam {{count}} mesaj gönderildi"
},
"login": "Giriş Yap",
"loginOrSignup": "Giriş Yap / Kayıt Ol",
"profile": "Profil",
"security": "Güvenlik",
"profile": {
"avatar": "Avatar",
"email": "E-posta Adresi",
"username": "Kullanıcı Adı"
},
"signout": "Çıkış Yap",
"signup": "Kaydol"
"signup": "Kayıt Ol",
"stats": {
"aiheatmaps": "Aktivite İndeksi",
"assistants": "Asistanlar",
"assistantsRank": {
"left": "Asistan",
"right": "Konu",
"title": "Asistan Kullanım Sıralaması"
},
"createdAt": "Kayıtlı olduğu tarih",
"days": "gün",
"empty": {
"desc": "Görüntülemek için daha fazla sohbet verisi biriktirin",
"title": "Veri Yok"
},
"lastYearActivity": "geçen yılki aktivite",
"messages": "Mesajlar",
"modelsRank": {
"left": "Model",
"right": "Mesajlar",
"title": "Model Kullanım Sıralaması"
},
"share": {
"title": "AI Aktivite İndeksim"
},
"topics": "Konu",
"topicsRank": {
"left": "Konu",
"right": "Mesajlar",
"title": "Konu İçerik Sıralaması"
},
"updatedAt": "Güncellenme tarihi",
"welcome": "{{username}}, bu {{appName}} ile geçirdiğin <span>{{days}}</span> gün."
},
"tab": {
"profile": "Profil",
"security": "Güvenlik",
"stats": "İstatistikler"
}
}
+75 -3
View File
@@ -1,8 +1,80 @@
{
"date": {
"prevMonth": "Tháng trước",
"recent30Days": "30 ngày qua"
},
"header": {
"desc": "Quản lý thông tin tài khoản của bạn.",
"title": "Tài khoản"
},
"heatmaps": {
"legend": {
"less": "Không hoạt động",
"more": "Hoạt động"
},
"months": {
"apr": "Th4",
"aug": "Th8",
"dec": "Th12",
"feb": "Th2",
"jan": "Th1",
"jul": "Th7",
"jun": "Th6",
"mar": "Th3",
"may": "Th5",
"nov": "Th11",
"oct": "Th10",
"sep": "Th9"
},
"tooltip": "{{date}} đã gửi {{count}} tin nhắn trong ngày đó",
"totalCount": "Tổng cộng {{count}} tin nhắn đã gửi trong năm qua"
},
"login": "Đăng nhập",
"loginOrSignup": "Đăng nhập / Đăng ký",
"profile": "Hồ sơ cá nhân",
"security": "Bảo mật",
"profile": {
"avatar": "Ảnh đại diện",
"email": "Địa chỉ email",
"username": "Tên người dùng"
},
"signout": "Đăng xuất",
"signup": "Đăng ký"
"signup": "Đăng ký",
"stats": {
"aiheatmaps": "Chỉ số hoạt động",
"assistants": "Trợ lý",
"assistantsRank": {
"left": "Trợ lý",
"right": "Chủ đề",
"title": "Xếp hạng sử dụng trợ lý"
},
"createdAt": "Đăng ký vào",
"days": "ngày",
"empty": {
"desc": "Vui lòng tích lũy thêm dữ liệu trò chuyện để xem",
"title": "Không có dữ liệu"
},
"lastYearActivity": "hoạt động trong năm qua",
"messages": "Tin nhắn",
"modelsRank": {
"left": "Mô hình",
"right": "Tin nhắn",
"title": "Xếp hạng sử dụng mô hình"
},
"share": {
"title": "Chỉ số hoạt động AI của tôi"
},
"topics": "Chủ đề",
"topicsRank": {
"left": "Chủ đề",
"right": "Tin nhắn",
"title": "Xếp hạng nội dung chủ đề"
},
"updatedAt": "Cập nhật vào",
"welcome": "{{username}}, đây là ngày <span>{{days}}</span> của bạn với {{appName}}",
"words": "Từ"
},
"tab": {
"profile": "Hồ sơ",
"security": "Bảo mật",
"stats": "Thống kê"
}
}
+75 -3
View File
@@ -1,8 +1,80 @@
{
"date": {
"prevMonth": "上个月",
"recent30Days": "最近30天"
},
"header": {
"desc": "管理您的账户信息。",
"title": "账户"
},
"heatmaps": {
"legend": {
"less": "不活跃",
"more": "活跃"
},
"months": {
"apr": "四月",
"aug": "八月",
"dec": "十二月",
"feb": "二月",
"jan": "一月",
"jul": "七月",
"jun": "六月",
"mar": "三月",
"may": "五月",
"nov": "十一月",
"oct": "十月",
"sep": "九月"
},
"tooltip": "{{date}} 当日发送 {{count}} 条消息",
"totalCount": "过去一年共发送 {{count}} 条消息"
},
"login": "登录",
"loginOrSignup": "登录 / 注册",
"profile": "个人资料",
"security": "安全",
"profile": {
"avatar": "头像",
"email": "电子邮件地址",
"username": "用户名"
},
"signout": "退出登录",
"signup": "注册"
"signup": "注册",
"stats": {
"aiheatmaps": "AI 指数",
"assistants": "助手数",
"assistantsRank": {
"left": "助手名称",
"right": "话题数",
"title": "助手使用率"
},
"createdAt": "用户创建于",
"days": "天",
"empty": {
"desc": "请积累更多聊天数据后查看",
"title": "暂无数据"
},
"lastYearActivity": "过去一年活跃度",
"messages": "消息数",
"modelsRank": {
"left": "模型名称",
"right": "消息数",
"title": "模型使用率"
},
"share": {
"title": "我的 AI 活跃指数"
},
"topics": "话题数",
"topicsRank": {
"left": "话题名称",
"right": "消息数",
"title": "话题内容量"
},
"updatedAt": "数据更新至",
"welcome": "{{username}}, 这是你和 {{appName}} 相伴的第 <span>{{days}}</span> 天",
"words": "累计字数"
},
"tab": {
"profile": "个人资料",
"security": "安全",
"stats": "数据统计"
}
}
+75 -3
View File
@@ -1,8 +1,80 @@
{
"date": {
"prevMonth": "上個月",
"recent30Days": "最近30天"
},
"header": {
"desc": "管理您的帳戶資訊。",
"title": "帳戶"
},
"heatmaps": {
"legend": {
"less": "不活躍",
"more": "活躍"
},
"months": {
"apr": "四月",
"aug": "八月",
"dec": "十二月",
"feb": "二月",
"jan": "一月",
"jul": "七月",
"jun": "六月",
"mar": "三月",
"may": "五月",
"nov": "十一月",
"oct": "十月",
"sep": "九月"
},
"tooltip": "{{date}} 當日發送 {{count}} 條消息",
"totalCount": "過去一年共發送 {{count}} 條消息"
},
"login": "登入",
"loginOrSignup": "登入 / 註冊",
"profile": "個人檔案",
"security": "安全",
"profile": {
"avatar": "頭像",
"email": "電子郵件地址",
"username": "用戶名"
},
"signout": "登出",
"signup": "註冊"
"signup": "註冊",
"stats": {
"aiheatmaps": "AI 指數",
"assistants": "助手數",
"assistantsRank": {
"left": "助手名稱",
"right": "話題數",
"title": "助手使用率"
},
"createdAt": "用戶創建於",
"days": "天",
"empty": {
"desc": "請累積更多聊天數據後查看",
"title": "暫無數據"
},
"lastYearActivity": "過去一年活躍度",
"messages": "消息數",
"modelsRank": {
"left": "模型名稱",
"right": "消息數",
"title": "模型使用率"
},
"share": {
"title": "我的 AI 活躍指數"
},
"topics": "話題數",
"topicsRank": {
"left": "話題名稱",
"right": "消息數",
"title": "話題內容量"
},
"updatedAt": "數據更新至",
"welcome": "{{username}}, 這是你和 {{appName}} 相伴的第 <span>{{days}}</span> 天",
"words": "總字數"
},
"tab": {
"profile": "個人資料",
"security": "安全",
"stats": "數據統計"
}
}
+3 -2
View File
@@ -125,11 +125,12 @@
"@icons-pack/react-simple-icons": "9.6.0",
"@khmyznikov/pwa-install": "^0.3.9",
"@langchain/community": "^0.3.0",
"@lobehub/charts": "^1.9.12",
"@lobehub/chat-plugin-sdk": "^1.32.4",
"@lobehub/chat-plugins-gateway": "^1.9.0",
"@lobehub/icons": "^1.56.0",
"@lobehub/tts": "^1.25.1",
"@lobehub/ui": "^1.155.8",
"@lobehub/ui": "^1.156.3",
"@neondatabase/serverless": "^0.10.1",
"@next/third-parties": "^15.0.0",
"@react-spring/web": "^9.7.5",
@@ -176,7 +177,7 @@
"mammoth": "^1.8.0",
"modern-screenshot": "^4.4.39",
"nanoid": "^5.0.7",
"next": "^15.1.2",
"next": "^15.1.3",
"next-auth": "beta",
"next-mdx-remote": "^4.4.1",
"nextjs-toploader": "^3.7.15",
@@ -24,6 +24,10 @@ vi.mock('@/features/User/UserLoginOrSignup/Community', () => ({
default: vi.fn(() => <div>Mocked UserLoginOrSignup</div>),
}));
vi.mock('@/const/version', () => ({
isDeprecatedEdition: false,
}));
// 定义一个变量来存储 enableAuth 的值
let enableAuth = true;
let enableClerk = false;
@@ -57,26 +57,6 @@ describe('useCategory', () => {
const { result } = renderHook(() => useCategory(), { wrapper });
act(() => {
const items = result.current;
expect(items.some((item) => item.key === 'profile')).toBe(false);
expect(items.some((item) => item.key === 'setting')).toBe(true);
expect(items.some((item) => item.key === 'data')).toBe(true);
expect(items.some((item) => item.key === 'docs')).toBe(true);
expect(items.some((item) => item.key === 'feedback')).toBe(true);
expect(items.some((item) => item.key === 'changelog')).toBe(true);
});
});
it('should return correct items when the user is logged in with Clerk', () => {
act(() => {
useUserStore.setState({ isSignedIn: true });
});
enableAuth = true;
enableClerk = true;
const { result } = renderHook(() => useCategory(), { wrapper });
act(() => {
const items = result.current;
expect(items.some((item) => item.key === 'profile')).toBe(true);
@@ -88,31 +68,6 @@ describe('useCategory', () => {
});
});
it('should return correct items when the user is logged in with NextAuth', () => {
act(() => {
useUserStore.setState({
isSignedIn: true,
enableAuth: () => true,
enabledNextAuth: true,
});
});
enableClerk = false;
const { result } = renderHook(() => useCategory(), { wrapper });
act(() => {
const items = result.current;
// Should not render profile for NextAuth, it's Clerk only
expect(items.some((item) => item.key === 'profile')).toBe(false);
expect(items.some((item) => item.key === 'setting')).toBe(true);
expect(items.some((item) => item.key === 'data')).toBe(true);
expect(items.some((item) => item.key === 'docs')).toBe(true);
expect(items.some((item) => item.key === 'feedback')).toBe(true);
expect(items.some((item) => item.key === 'changelog')).toBe(true);
expect(items.some((item) => item.key === 'nextauthSignout')).toBe(true);
});
});
it('should return correct items when the user is not logged in', () => {
act(() => {
useUserStore.setState({ isSignedIn: false, enableAuth: () => true });
@@ -128,7 +83,6 @@ describe('useCategory', () => {
expect(items.some((item) => item.key === 'docs')).toBe(true);
expect(items.some((item) => item.key === 'feedback')).toBe(true);
expect(items.some((item) => item.key === 'changelog')).toBe(true);
expect(items.some((item) => item.key === 'nextauthSignout')).toBe(false);
});
});
@@ -1,9 +1,11 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { isDeprecatedEdition } from '@/const/version';
import DataStatistics from '@/features/User/DataStatistics';
import UserInfo from '@/features/User/UserInfo';
import UserLoginOrSignup from '@/features/User/UserLoginOrSignup/Community';
@@ -21,21 +23,16 @@ const UserBanner = memo(() => {
return (
<Flexbox gap={12} paddingBlock={8}>
{!enableAuth ? (
{!enableAuth || (enableAuth && isLoginWithAuth) ? (
<>
<UserInfo />
<DataStatistics paddingInline={12} />
</>
) : isLoginWithAuth ? (
<>
<UserInfo
onClick={() => {
// Profile page only works with Clerk
if (enabledNextAuth) return;
router.push('/me/profile');
}}
/>
<DataStatistics paddingInline={12} />
<Link href={'/profile'} style={{ color: 'inherit' }}>
<UserInfo />
</Link>
{!isDeprecatedEdition && (
<Link href={'/profile/stats'} style={{ color: 'inherit' }}>
<DataStatistics paddingInline={12} />
</Link>
)}
</>
) : (
<UserLoginOrSignup
@@ -6,7 +6,6 @@ import {
Download,
Feather,
FileClockIcon,
LogOut,
Settings2,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
@@ -28,15 +27,11 @@ export const useCategory = () => {
const { canInstall, install } = usePWAInstall();
const { t } = useTranslation(['common', 'setting', 'auth']);
const { showCloudPromotion, hideDocs } = useServerConfigStore(featureFlagsSelectors);
const [isLogin, isLoginWithAuth, isLoginWithClerk, enableAuth, signOut, isLoginWithNextAuth] =
useUserStore((s) => [
authSelectors.isLogin(s),
authSelectors.isLoginWithAuth(s),
authSelectors.isLoginWithClerk(s),
authSelectors.enabledAuth(s),
s.logout,
authSelectors.isLoginWithNextAuth(s),
]);
const [isLogin, isLoginWithAuth, enableAuth] = useUserStore((s) => [
authSelectors.isLogin(s),
authSelectors.isLoginWithAuth(s),
authSelectors.enabledAuth(s),
]);
const profile: CellProps[] = [
{
@@ -121,20 +116,11 @@ export const useCategory = () => {
},
].filter(Boolean) as CellProps[];
const nextAuthSignOut: CellProps[] = [
{
icon: LogOut,
key: 'nextauthSignout',
label: t('auth:signout'),
onClick: signOut,
},
];
const mainItems = [
{
type: 'divider',
},
...(isLoginWithClerk ? profile : []),
...(!enableAuth || (enableAuth && isLoginWithAuth) ? profile : []),
...(enableAuth ? (isLoginWithAuth ? settings : []) : settingsWithoutAuth),
/* ↓ cloud slot ↓ */
@@ -142,7 +128,6 @@ export const useCategory = () => {
...(canInstall ? pwa : []),
...(isLogin && !isServerMode ? data : []),
...(!hideDocs ? helps : []),
...(enableAuth && isLoginWithNextAuth ? nextAuthSignOut : []),
].filter(Boolean) as CellProps[];
return mainItems;
@@ -1,43 +1,60 @@
'use client';
import { LogOut, ShieldCheck, UserCircle } from 'lucide-react';
import { ChartColumnBigIcon, LogOut, ShieldCheck, UserCircle } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Cell, { CellProps } from '@/components/Cell';
import { isDeprecatedEdition } from '@/const/version';
import { ProfileTabs } from '@/store/global/initialState';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/slices/auth/selectors';
const Category = memo(() => {
const [isLogin, enableAuth, isLoginWithClerk, signOut] = useUserStore((s) => [
authSelectors.isLogin(s),
authSelectors.enabledAuth(s),
authSelectors.isLoginWithClerk(s),
s.logout,
]);
const router = useRouter();
const { t } = useTranslation('auth');
const signOut = useUserStore((s) => s.logout);
const items: CellProps[] = [
{
icon: UserCircle,
key: 'profile',
label: t('profile'),
key: ProfileTabs.Profile,
label: t('tab.profile'),
onClick: () => router.push('/profile'),
},
{
icon: ShieldCheck,
key: 'security',
label: t('security'),
onClick: () => router.push('/profile/security'),
},
{
type: 'divider',
},
{
icon: LogOut,
key: 'logout',
label: t('signout', { ns: 'auth' }),
onClick: () => {
signOut();
router.push('/login');
enableAuth &&
isLoginWithClerk && {
icon: ShieldCheck,
key: ProfileTabs.Security,
label: t('tab.security'),
onClick: () => router.push('/profile/security'),
},
!isDeprecatedEdition && {
icon: ChartColumnBigIcon,
key: ProfileTabs.Stats,
label: t('tab.stats'),
onClick: () => router.push('/profile/stats'),
},
];
enableAuth &&
isLogin && {
type: 'divider',
},
enableAuth &&
isLogin && {
icon: LogOut,
key: 'logout',
label: t('signout', { ns: 'auth' }),
onClick: () => {
signOut();
router.push('/login');
},
},
].filter(Boolean) as CellProps[];
return items?.map((item, index) => <Cell key={item.key || index} {...item} />);
});
@@ -1,13 +1,10 @@
import { notFound } from 'next/navigation';
import { PropsWithChildren } from 'react';
import MobileContentLayout from '@/components/server/MobileNavLayout';
import { enableClerk } from '@/const/auth';
import Header from './features/Header';
const Layout = ({ children }: PropsWithChildren) => {
if (!enableClerk) return notFound();
return <MobileContentLayout header={<Header />}>{children}</MobileContentLayout>;
};
+3 -3
View File
@@ -7,10 +7,10 @@ import { isMobileDevice } from '@/utils/server/responsive';
import Category from './features/Category';
export const generateMetadata = async () => {
const { t } = await translation('clerk');
const { t } = await translation('auth');
return metadataModule.generate({
description: t('userProfile.navbar.title'),
title: t('userProfile.navbar.description'),
description: t('header.desc'),
title: t('header.title'),
url: '/me/profile',
});
};
+2 -2
View File
@@ -1,3 +1,3 @@
import CircleLoading from '@/components/Loading/BrandTextLoading';
import Loading from '@/components/Loading/BrandTextLoading';
export default () => <CircleLoading />;
export default () => <Loading />;
+2 -8
View File
@@ -1,9 +1,3 @@
import { Center } from 'react-layout-kit';
import Loading from '@/components/Loading/BrandTextLoading';
import CircleLoading from '@/components/Loading/BrandTextLoading';
export default () => (
<Center height={'90vh'} width={'100%'}>
<CircleLoading />
</Center>
);
export default () => <Loading />;
+2 -2
View File
@@ -1,3 +1,3 @@
import CircleLoading from '@/components/Loading/BrandTextLoading';
import Loading from '@/components/Loading/BrandTextLoading';
export default () => <CircleLoading />;
export default () => <Loading />;
+53
View File
@@ -0,0 +1,53 @@
'use client';
import { Form, type ItemGroup } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FORM_STYLE } from '@/const/layoutTokens';
import AvatarWithUpload from '@/features/AvatarWithUpload';
import UserAvatar from '@/features/User/UserAvatar';
import { useUserStore } from '@/store/user';
import { authSelectors, userProfileSelectors } from '@/store/user/selectors';
type SettingItemGroup = ItemGroup;
const Client = memo<{ mobile?: boolean }>(() => {
const [isLoginWithNextAuth] = useUserStore((s) => [authSelectors.isLoginWithNextAuth(s)]);
const [enableAuth, nickname, username, userProfile] = useUserStore((s) => [
s.enableAuth(),
userProfileSelectors.nickName(s),
userProfileSelectors.username(s),
userProfileSelectors.userProfile(s),
]);
const [form] = Form.useForm();
const { t } = useTranslation('auth');
const profile: SettingItemGroup = {
children: [
{
children: enableAuth && isLoginWithNextAuth ? <UserAvatar /> : <AvatarWithUpload />,
label: t('profile.avatar'),
minWidth: undefined,
},
{
children: nickname || username,
label: t('profile.username'),
minWidth: undefined,
},
{
children: userProfile?.email || '--',
hidden: !isLoginWithNextAuth || !userProfile?.email,
label: t('profile.email'),
minWidth: undefined,
},
],
title: t('tab.profile'),
};
return (
<Form form={form} items={[profile]} itemsType={'group'} variant={'pure'} {...FORM_STYLE} />
);
});
export default Client;
@@ -0,0 +1,38 @@
import { Skeleton } from 'antd';
import dynamic from 'next/dynamic';
import { enableClerk } from '@/const/auth';
import { metadataModule } from '@/server/metadata';
import { translation } from '@/server/translation';
import { isMobileDevice } from '@/utils/server/responsive';
import Client from '../Client';
// 为了兼容 ClerkProfile 需要使用 [[...slug]]
const ClerkProfile = dynamic(() => import('../../features/ClerkProfile'), {
loading: () => (
<div style={{ flex: 1 }}>
<Skeleton paragraph={{ rows: 8 }} title={false} />
</div>
),
});
export const generateMetadata = async () => {
const { t } = await translation('auth');
return metadataModule.generate({
description: t('header.desc'),
title: t('tab.profile'),
url: '/profile',
});
};
const Page = async () => {
const mobile = await isMobileDevice();
if (enableClerk) return <ClerkProfile mobile={mobile} />;
return <Client mobile={mobile} />;
};
export default Page;
@@ -0,0 +1,9 @@
import CategoryContent from './features/CategoryContent';
const Category = () => {
return <CategoryContent />;
};
Category.displayName = 'SettingCategory';
export default Category;
@@ -0,0 +1,38 @@
'use client';
import { memo } from 'react';
import urlJoin from 'url-join';
import Menu from '@/components/Menu';
import { useActiveSettingsKey } from '@/hooks/useActiveTabKey';
import { useQuery } from '@/hooks/useQuery';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { ProfileTabs, SettingsTabs } from '@/store/global/initialState';
import { useCategory } from '../../hooks/useCategory';
const CategoryContent = memo<{ modal?: boolean }>(({ modal }) => {
const activeTab = useActiveSettingsKey();
const { tab = SettingsTabs.Common } = useQuery();
const cateItems = useCategory();
const router = useQueryRoute();
return (
<Menu
items={cateItems}
onClick={({ key }) => {
const activeKey = key === ProfileTabs.Profile ? '/' : key;
if (modal) {
router.replace('/profile/modal', { query: { tab: activeKey } });
} else {
router.push(urlJoin('/profile', activeKey));
}
}}
selectable
selectedKeys={[modal ? tab : (activeTab as any)]}
variant={'compact'}
/>
);
});
export default CategoryContent;
@@ -1,76 +0,0 @@
'use client';
import { UserProfile } from '@clerk/nextjs';
import { ElementsConfig } from '@clerk/types';
import { createStyles } from 'antd-style';
import { memo } from 'react';
export const useStyles = createStyles(
({ css, token, cx }, mobile: boolean) =>
({
cardBox: css`
width: 100%;
max-width: unset;
height: 100%;
border: unset;
border-radius: unset;
box-shadow: unset;
`,
footer: cx(
mobile &&
css`
display: none;
`,
),
navbar: css`
flex: none;
width: 280px;
max-width: unset;
margin-inline-end: 0;
padding-block: 24px 16px;
padding-inline: 12px;
background: ${token.colorBgContainer};
border-inline-end: 1px solid ${token.colorSplit};
`,
navbarMobileMenuRow: cx(
mobile &&
css`
display: none;
`,
),
pageScrollBox: css`
align-self: center;
width: 100%;
max-width: 1024px;
`,
rootBox: css`
width: 100%;
height: 100%;
`,
scrollBox: css`
background: ${token.colorBgLayout};
border: unset;
border-radius: unset;
`,
}) as Partial<{
// eslint-disable-next-line unused-imports/no-unused-vars
[k in keyof ElementsConfig]: any;
}>,
);
const Client = memo<{ mobile?: boolean }>(({ mobile }) => {
const { styles } = useStyles(mobile);
return (
<UserProfile
appearance={{
elements: styles,
}}
/>
);
});
export default Client;
@@ -0,0 +1,85 @@
'use client';
import { ActionIcon, ChatHeader, ChatHeaderTitle } from '@lobehub/ui';
import { Drawer, type DrawerProps } from 'antd';
import { createStyles } from 'antd-style';
import { Menu } from 'lucide-react';
import { ReactNode, memo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import BrandWatermark from '@/components/BrandWatermark';
const useStyles = createStyles(({ token, css }) => ({
container: css`
position: relative;
flex: none;
height: 54px;
background: ${token.colorBgContainer};
`,
title: css`
font-size: 18px;
font-weight: 700;
line-height: 1.2;
`,
}));
interface HeaderProps extends Pick<DrawerProps, 'getContainer'> {
children: ReactNode;
title: ReactNode;
}
const Header = memo<HeaderProps>(({ children, getContainer, title }) => {
const [open, setOpen] = useState(false);
const { styles, theme } = useStyles();
return (
<>
<ChatHeader
className={styles.container}
left={
<ChatHeaderTitle
title={
<Flexbox align={'center'} className={styles.title} gap={4} horizontal>
<ActionIcon
color={theme.colorText}
icon={Menu}
onClick={() => setOpen(true)}
size={{ blockSize: 32, fontSize: 18 }}
/>
{title}
</Flexbox>
}
/>
}
/>
<Drawer
bodyStyle={{
display: 'flex',
flexDirection: 'column',
gap: 20,
justifyContent: 'space-between',
padding: 16,
}}
getContainer={getContainer}
headerStyle={{ display: 'none' }}
maskStyle={{ background: 'transparent' }}
onClick={() => setOpen(false)}
onClose={() => setOpen(false)}
open={open}
placement={'left'}
rootStyle={{ position: 'absolute' }}
style={{
background: theme.colorBgContainer,
borderRight: `1px solid ${theme.colorSplit}`,
}}
width={260}
zIndex={10}
>
{children}
<BrandWatermark paddingInline={12} />
</Drawer>
</>
);
});
export default Header;
@@ -0,0 +1,42 @@
'use client';
import { createStyles } from 'antd-style';
import { useTranslation } from 'react-i18next';
import { Flexbox, FlexboxProps } from 'react-layout-kit';
import BrandWatermark from '@/components/BrandWatermark';
import PanelTitle from '@/components/PanelTitle';
const useStyles = createStyles(({ token, css }) => ({
container: css`
padding-block: 0 16px;
padding-inline: 12px;
background: ${token.colorBgContainer};
border-inline-end: 1px solid ${token.colorBorder};
`,
}));
interface SidebarLayoutProps extends FlexboxProps {
desc?: string;
title?: string;
}
const SidebarLayout = ({ children, className, title, desc, ...rest }: SidebarLayoutProps) => {
const { cx, styles } = useStyles();
const { t } = useTranslation('auth');
return (
<Flexbox
className={cx(styles.container, className)}
flex={'none'}
gap={20}
width={280}
{...rest}
>
<PanelTitle desc={desc || t('header.desc')} title={title || t('header.title')} />
{children}
<BrandWatermark paddingInline={12} />
</Flexbox>
);
};
export default SidebarLayout;
@@ -0,0 +1,48 @@
'use client';
import { useResponsive } from 'antd-style';
import { memo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import InitClientDB from '@/features/InitClientDB';
import Footer from '@/features/Setting/Footer';
import SettingContainer from '@/features/Setting/SettingContainer';
import { useActiveProfileKey } from '@/hooks/useActiveTabKey';
import { LayoutProps } from '../type';
import Header from './Header';
import SideBar from './SideBar';
const Layout = memo<LayoutProps>(({ children, category }) => {
const ref = useRef<any>(null);
const { md = true } = useResponsive();
const { t } = useTranslation('auth');
const activeKey = useActiveProfileKey();
return (
<>
<Flexbox
height={'100%'}
horizontal={md}
ref={ref}
style={{ position: 'relative' }}
width={'100%'}
>
{md ? (
<SideBar>{category}</SideBar>
) : (
<Header getContainer={() => ref.current} title={<>{t(`tab.${activeKey}`)}</>}>
{category}
</Header>
)}
<SettingContainer addonAfter={<Footer />}>{children}</SettingContainer>
</Flexbox>
<InitClientDB />
</>
);
});
Layout.displayName = 'DesktopProfileLayout';
export default Layout;
@@ -1,22 +1,40 @@
'use client';
import { MobileNavBar, MobileNavBarTitle } from '@lobehub/ui';
import { usePathname, useRouter } from 'next/navigation';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useActiveProfileKey } from '@/hooks/useActiveTabKey';
import { mobileHeaderSticky } from '@/styles/mobileHeader';
import ShareButton from '../../stats/features/ShareButton';
const Header = memo(() => {
const { t } = useTranslation('auth');
const router = useRouter();
const pathname = usePathname();
const isSecurity = pathname.startsWith('/prifile/security');
const activeSettingsKey = useActiveProfileKey();
const isStats = activeSettingsKey === 'stats';
const handleBackClick = () => {
router.push('/me/profile');
};
return (
<MobileNavBar
center={<MobileNavBarTitle title={t(isSecurity ? 'security' : 'profile')} />}
onBackClick={() => router.push('/me/profile')}
center={
<MobileNavBarTitle
title={
<Flexbox align={'center'} gap={8} horizontal>
<span style={{ lineHeight: 1.2 }}> {t(`tab.${activeSettingsKey}`)}</span>
</Flexbox>
}
/>
}
onBackClick={handleBackClick}
right={isStats ? <ShareButton mobile /> : undefined}
showBackButton
style={mobileHeaderSticky}
/>
@@ -1,16 +1,23 @@
import { PropsWithChildren } from 'react';
import MobileContentLayout from '@/components/server/MobileNavLayout';
import InitClientDB from '@/features/InitClientDB';
import Footer from '@/features/Setting/Footer';
import { LayoutProps } from '../type';
import Header from './Header';
const Layout = ({ children }: PropsWithChildren) => {
const Layout = ({ children }: LayoutProps) => {
return (
<>
<Header />
{children}
<MobileContentLayout header={<Header />}>
{children}
<div style={{ flex: 1 }} />
<Footer />
</MobileContentLayout>
<InitClientDB />
</>
);
};
Layout.displayName = 'ProfileMobileLayout';
Layout.displayName = 'MobileProfileLayout';
export default Layout;
+6
View File
@@ -0,0 +1,6 @@
import { ReactNode } from 'react';
export interface LayoutProps {
category: ReactNode;
children: ReactNode;
}
+5
View File
@@ -0,0 +1,5 @@
'use client';
import dynamic from 'next/dynamic';
export default dynamic(() => import('@/components/Error'));
@@ -0,0 +1,72 @@
'use client';
import { UserProfile } from '@clerk/nextjs';
import { ElementsConfig } from '@clerk/types';
import { createStyles } from 'antd-style';
import { memo } from 'react';
export const useStyles = createStyles(
({ css, responsive, token }) =>
({
cardBox: css`
width: 100%;
min-width: 100%;
background: transparent;
`,
footer: css`
display: none !important;
`,
headerTitle: css`
${responsive.mobile} {
margin: 0;
padding: 16px;
font-size: 14px;
font-weight: 400;
line-height: 24px;
opacity: 0.5;
}
`,
navbar: css`
display: none !important;
`,
navbarMobileMenuRow: css`
display: none !important;
`,
pageScrollBox: css`
padding: 0;
`,
profileSection: css`
${responsive.mobile} {
padding-inline: 16px;
background: ${token.colorBgContainer};
}
`,
rootBox: css`
width: 100%;
height: 100%;
`,
scrollBox: css`
background: transparent;
`,
}) as Partial<{
// eslint-disable-next-line unused-imports/no-unused-vars
[k in keyof ElementsConfig]: any;
}>,
);
const Client = memo<{ mobile?: boolean }>(({ mobile }) => {
const { styles } = useStyles(mobile);
return (
<UserProfile
appearance={{
elements: styles,
}}
path={'/profile'}
/>
);
});
export default Client;
@@ -0,0 +1,51 @@
import { Icon } from '@lobehub/ui';
import { ChartColumnBigIcon, ShieldCheck, UserCircle } from 'lucide-react';
import Link from 'next/link';
import { useTranslation } from 'react-i18next';
import type { MenuProps } from '@/components/Menu';
import { isDeprecatedEdition } from '@/const/version';
import { ProfileTabs } from '@/store/global/initialState';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/slices/auth/selectors';
export const useCategory = () => {
const { t } = useTranslation('auth');
const [enableAuth, isLoginWithClerk] = useUserStore((s) => [
authSelectors.enabledAuth(s),
authSelectors.isLoginWithClerk(s),
]);
const cateItems: MenuProps['items'] = [
{
icon: <Icon icon={UserCircle} />,
key: ProfileTabs.Profile,
label: (
<Link href={'/profile'} onClick={(e) => e.preventDefault()}>
{t('tab.profile')}
</Link>
),
},
enableAuth &&
isLoginWithClerk && {
icon: <Icon icon={ShieldCheck} />,
key: ProfileTabs.Security,
label: (
<Link href={'/profile/security'} onClick={(e) => e.preventDefault()}>
{t('tab.security')}
</Link>
),
},
!isDeprecatedEdition && {
icon: <Icon icon={ChartColumnBigIcon} />,
key: ProfileTabs.Stats,
label: (
<Link href={'/profile/stats'} onClick={(e) => e.preventDefault()}>
{t('tab.stats')}
</Link>
),
},
].filter(Boolean) as MenuProps['items'];
return cateItems;
};
+7 -17
View File
@@ -1,21 +1,11 @@
import { notFound } from 'next/navigation';
import { PropsWithChildren } from 'react';
import ServerLayout from '@/components/server/ServerLayout';
import { enableClerk } from '@/const/auth';
import { isMobileDevice } from '@/utils/server/responsive';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
import { LayoutProps } from './_layout/type';
import MobileLayout from './_layout/Mobile';
const ProfileLayout = ServerLayout<LayoutProps>({ Desktop, Mobile });
const Layout = async ({ children }: PropsWithChildren) => {
if (!enableClerk) return notFound();
ProfileLayout.displayName = 'ProfileLayout';
const mobile = await isMobileDevice();
if (mobile) return <MobileLayout>{children}</MobileLayout>;
return children;
};
Layout.displayName = 'ProfileLayout';
export default Layout;
export default ProfileLayout;
+2 -22
View File
@@ -1,23 +1,3 @@
import { Flexbox } from 'react-layout-kit';
import Loading from '@/components/Loading/BrandTextLoading';
import SkeletonLoading from '@/components/Loading/SkeletonLoading';
import { isMobileDevice } from '@/utils/server/responsive';
const Loading = async () => {
const mobile = await isMobileDevice();
if (mobile) return <SkeletonLoading paragraph={{ rows: 8 }} />;
return (
<Flexbox horizontal style={{ position: 'relative' }} width={'100%'}>
<Flexbox padding={24} width={256}>
<SkeletonLoading paragraph={{ rows: 8 }} />;
</Flexbox>
<Flexbox align={'center'} flex={1}>
<Flexbox padding={24} style={{ maxWidth: 1024 }} width={'100%'}>
<SkeletonLoading paragraph={{ rows: 8 }} />;
</Flexbox>
</Flexbox>
</Flexbox>
);
};
export default Loading;
export default () => <Loading />;
+3
View File
@@ -0,0 +1,3 @@
import dynamic from 'next/dynamic';
export default dynamic(() => import('@/components/404'));
+34
View File
@@ -0,0 +1,34 @@
import { Skeleton } from 'antd';
import dynamic from 'next/dynamic';
import { notFound } from 'next/navigation';
import { enableClerk } from '@/const/auth';
import { metadataModule } from '@/server/metadata';
import { translation } from '@/server/translation';
import { isMobileDevice } from '@/utils/server/responsive';
const ClerkProfile = dynamic(() => import('../features/ClerkProfile'), {
loading: () => (
<div style={{ flex: 1 }}>
<Skeleton paragraph={{ rows: 8 }} title={false} />
</div>
),
});
export const generateMetadata = async () => {
const { t } = await translation('auth');
return metadataModule.generate({
description: t('header.desc'),
title: t('tab.security'),
url: '/profile/security',
});
};
const Page = async () => {
if (!enableClerk) return notFound();
const mobile = await isMobileDevice();
return <ClerkProfile mobile={mobile} />;
};
export default Page;
+52
View File
@@ -0,0 +1,52 @@
'use client';
import { FormGroup, Grid } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { FORM_STYLE } from '@/const/layoutTokens';
import AiHeatmaps from './features/AiHeatmaps';
import AssistantsRank from './features/AssistantsRank';
import ModelsRank from './features/ModelsRank';
import ShareButton from './features/ShareButton';
import TopicsRank from './features/TopicsRank';
import TotalAssistants from './features/TotalAssistants';
import TotalMessages from './features/TotalMessages';
import TotalTopics from './features/TotalTopics';
import TotalWords from './features/TotalWords';
import Welcome from './features/Welcome';
const Client = memo<{ mobile?: boolean }>(({ mobile }) => {
const { t } = useTranslation('auth');
return (
<Flexbox gap={mobile ? 0 : 24}>
{mobile ? (
<Welcome mobile />
) : (
<Flexbox align={'flex-start'} gap={16} horizontal justify={'space-between'}>
<Welcome />
<ShareButton />
</Flexbox>
)}
<FormGroup style={FORM_STYLE.style} title={t('tab.stats')} variant={'pure'}>
<Grid maxItemWidth={150} paddingBlock={16} rows={4}>
<TotalAssistants mobile={mobile} />
<TotalTopics mobile={mobile} />
<TotalMessages mobile={mobile} />
<TotalWords />
</Grid>
</FormGroup>
<AiHeatmaps mobile={mobile} />
<Grid gap={mobile ? 0 : 48} rows={3}>
<ModelsRank />
<AssistantsRank />
<TopicsRank />
</Grid>
</Flexbox>
);
});
export default Client;
@@ -0,0 +1,130 @@
import { Heatmaps, HeatmapsProps } from '@lobehub/charts';
import { FormGroup, Icon } from '@lobehub/ui';
import { Tag } from 'antd';
import { useTheme } from 'antd-style';
import { FlameIcon } from 'lucide-react';
import { readableColor } from 'polished';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { FORM_STYLE } from '@/const/layoutTokens';
import { useClientDataSWR } from '@/libs/swr';
import { messageService } from '@/services/message';
const AiHeatmaps = memo<Omit<HeatmapsProps, 'data'> & { inShare?: boolean; mobile?: boolean }>(
({ inShare, mobile, ...rest }) => {
const { t } = useTranslation('auth');
const theme = useTheme();
const { data, isLoading } = useClientDataSWR('stats-heatmaps', async () =>
messageService.getHeatmaps(),
);
const days = data?.filter((item) => item.level > 0).length || '--';
const hotDays = data?.filter((item) => item.level >= 3).length || '--';
const content = (
<Heatmaps
blockMargin={mobile ? 3 : undefined}
blockRadius={mobile ? 2 : undefined}
blockSize={mobile ? 6 : 14}
data={data || []}
labels={{
legend: {
less: t('heatmaps.legend.less'),
more: t('heatmaps.legend.more'),
},
months: [
t('heatmaps.months.jan'),
t('heatmaps.months.feb'),
t('heatmaps.months.mar'),
t('heatmaps.months.apr'),
t('heatmaps.months.may'),
t('heatmaps.months.jun'),
t('heatmaps.months.jul'),
t('heatmaps.months.aug'),
t('heatmaps.months.sep'),
t('heatmaps.months.oct'),
t('heatmaps.months.nov'),
t('heatmaps.months.dec'),
],
tooltip: t('heatmaps.tooltip'),
totalCount: t('heatmaps.totalCount'),
}}
loading={isLoading || !data}
maxLevel={4}
{...rest}
/>
);
const fillColor = readableColor(theme.gold);
const tags = (
<Flexbox
gap={4}
horizontal
style={{
alignSelf: 'center',
flex: 'none',
zoom: 0.9,
}}
>
<Tag
bordered={false}
style={{
background: theme.colorText,
color: theme.colorBgLayout,
fontWeight: 500,
margin: 0,
}}
>
{[days, t('stats.days')].join(' ')}
</Tag>
<Tag
bordered={false}
color={'gold'}
icon={<Icon color={fillColor} fill={fillColor} icon={FlameIcon} />}
style={{
background: theme.gold,
color: fillColor,
fontWeight: 500,
margin: 0,
}}
>
{[hotDays, t('stats.days')].join(' ')}
</Tag>
</Flexbox>
);
if (inShare) {
return (
<Flexbox gap={4}>
<Flexbox align={'baseline'} gap={4} horizontal justify={'space-between'}>
<div
style={{
color: theme.colorTextDescription,
fontSize: 12,
}}
>
{t('stats.lastYearActivity')}
</div>
{tags}
</Flexbox>
{content}
</Flexbox>
);
}
return (
<FormGroup
extra={tags}
style={FORM_STYLE.style}
title={t('stats.lastYearActivity')}
variant={'pure'}
>
<Flexbox paddingBlock={24}>{content}</Flexbox>
</FormGroup>
);
},
);
export default AiHeatmaps;
@@ -0,0 +1,115 @@
import { BarList } from '@lobehub/charts';
import { ActionIcon, Avatar, FormGroup, Modal } from '@lobehub/ui';
import { MaximizeIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import qs from 'query-string';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { FORM_STYLE } from '@/const/layoutTokens';
import { DEFAULT_AVATAR } from '@/const/meta';
import { INBOX_SESSION_ID } from '@/const/session';
import { useClientDataSWR } from '@/libs/swr';
import { sessionService } from '@/services/session';
import { SessionRankItem } from '@/types/session';
export const AssistantsRank = memo(() => {
const [open, setOpen] = useState(false);
const { t } = useTranslation(['auth', 'chat']);
const router = useRouter();
const { data, isLoading } = useClientDataSWR('rank-sessions', async () =>
sessionService.rankSessions(),
);
const showExtra = Boolean(data && data?.length > 5);
const mapData = (item: SessionRankItem) => {
const link = qs.stringifyUrl({
query: {
session: item.id,
},
url: '/chat',
});
return {
icon: (
<Avatar
alt={item.title || t('defaultAgent', { ns: 'chat' })}
avatar={item.avatar || DEFAULT_AVATAR}
background={item.backgroundColor || undefined}
size={28}
style={{
backdropFilter: 'blur(8px)',
}}
/>
),
link,
name: (
<Link href={link} style={{ color: 'inherit' }}>
{item.title
? item.id === INBOX_SESSION_ID
? t('inbox.title', { ns: 'chat' })
: item.title
: t('defaultAgent', { ns: 'chat' })}
</Link>
),
value: item.count,
};
};
return (
<>
<FormGroup
extra={
showExtra && (
<ActionIcon
icon={MaximizeIcon}
onClick={() => setOpen(true)}
size={{ blockSize: 28, fontSize: 20 }}
/>
)
}
style={FORM_STYLE.style}
title={t('stats.assistantsRank.title')}
variant={'pure'}
>
<Flexbox paddingBlock={16}>
<BarList
data={data?.slice(0, 5).map((item) => mapData(item)) || []}
height={220}
leftLabel={t('stats.assistantsRank.left')}
loading={isLoading || !data}
noDataText={{
desc: t('stats.empty.desc'),
title: t('stats.empty.title'),
}}
onValueChange={(item) => router.push(item.link)}
rightLabel={t('stats.assistantsRank.right')}
/>
</Flexbox>
</FormGroup>
{showExtra && (
<Modal
footer={null}
loading={isLoading || !data}
onCancel={() => setOpen(false)}
open={open}
title={t('stats.assistantsRank.title')}
>
<BarList
data={data?.map((item) => mapData(item)) || []}
height={340}
leftLabel={t('stats.assistantsRank.left')}
loading={isLoading || !data}
onValueChange={(item) => router.push(item.link)}
rightLabel={t('stats.assistantsRank.right')}
/>
</Modal>
)}
</>
);
});
export default AssistantsRank;
@@ -0,0 +1,84 @@
import { BarList } from '@lobehub/charts';
import { ModelIcon } from '@lobehub/icons';
import { ActionIcon, FormGroup, Modal } from '@lobehub/ui';
import { MaximizeIcon } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { FORM_STYLE } from '@/const/layoutTokens';
import { useClientDataSWR } from '@/libs/swr';
import { messageService } from '@/services/message';
import { ModelRankItem } from '@/types/message';
export const TopicsRank = memo(() => {
const [open, setOpen] = useState(false);
const { t } = useTranslation('auth');
const { data, isLoading } = useClientDataSWR('rank-models', async () =>
messageService.rankModels(),
);
const showExtra = Boolean(data && data?.length > 5);
const mapData = (item: ModelRankItem) => {
return {
icon: <ModelIcon model={item.id as string} size={24} />,
id: item.id,
name: item.id,
value: item.count,
};
};
return (
<>
<FormGroup
extra={
showExtra ? (
<ActionIcon
icon={MaximizeIcon}
onClick={() => setOpen(true)}
size={{ blockSize: 28, fontSize: 20 }}
/>
) : undefined
}
style={FORM_STYLE.style}
title={t('stats.modelsRank.title')}
variant={'pure'}
>
<Flexbox horizontal paddingBlock={16}>
<BarList
data={data?.slice(0, 5).map((item) => mapData(item)) || []}
height={220}
leftLabel={t('stats.modelsRank.left')}
loading={isLoading || !data}
noDataText={{
desc: t('stats.empty.desc'),
title: t('stats.empty.title'),
}}
rightLabel={t('stats.modelsRank.right')}
/>
</Flexbox>
</FormGroup>
{showExtra && (
<Modal
footer={null}
loading={isLoading || !data}
onCancel={() => setOpen(false)}
open={open}
title={t('stats.modelsRank.title')}
>
<BarList
data={data?.map((item) => mapData(item)) || []}
height={340}
leftLabel={t('stats.assistantsRank.left')}
loading={isLoading || !data}
rightLabel={t('stats.assistantsRank.right')}
/>
</Modal>
)}
</>
);
});
export default TopicsRank;
@@ -0,0 +1,159 @@
import { Github } from '@lobehub/icons';
import { Grid } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import { ProductLogo } from '@/components/Branding';
import { OFFICIAL_URL, imageUrl } from '@/const/url';
import { isServerMode } from '@/const/version';
import UserAvatar from '@/features/User/UserAvatar';
import TotalMessages from '..//TotalMessages';
import TotalWords from '..//TotalWords';
import AiHeatmaps from '../AiHeatmaps';
const useStyles = createStyles(({ css, token, stylish, cx, responsive }) => ({
avatar: css`
box-sizing: content-box;
background: ${token.colorText};
border: 4px solid ${token.colorBgLayout};
`,
background: css`
position: relative;
width: 100%;
padding: 24px;
background-color: ${token.colorBgLayout};
background-image: url(${imageUrl('screenshot_background.webp')});
background-position: center;
background-size: 120% 120%;
`,
container: css`
position: relative;
overflow: hidden;
width: 100%;
background: ${token.colorBgLayout};
border: 1px solid ${token.colorBorder};
border-radius: ${token.borderRadiusLG * 2}px;
box-shadow: ${token.boxShadow};
`,
decs: css`
font-size: 12px;
color: ${token.colorTextDescription};
`,
footer: css`
font-size: 12px;
color: ${token.colorTextDescription};
`,
heatmaps: css`
.legend-month,
footer {
display: none;
}
`,
preview: cx(
stylish.noScrollbar,
css`
overflow: hidden scroll;
width: 100%;
max-height: 70dvh;
background: ${token.colorBgLayout};
border: 1px solid ${token.colorBorder};
border-radius: ${token.borderRadiusLG}px;
* {
pointer-events: none;
::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
}
${responsive.mobile} {
max-height: 40dvh;
}
`,
),
title: css`
font-size: 24px;
font-weight: bold;
text-align: center;
`,
}));
const Preview = memo(() => {
const { styles } = useStyles();
const { t } = useTranslation('auth');
const isOfficial = !isServerMode && OFFICIAL_URL.includes(location.host);
return (
<div className={styles.preview}>
<div className={styles.background} id={'preview'}>
<Center className={styles.container} gap={12} padding={24}>
<ProductLogo size={24} type={'text'} />
<div className={styles.title}>{t('stats.share.title')}</div>
<Flexbox align={'center'} horizontal>
<UserAvatar
className={styles.avatar}
size={48}
style={{
marginRight: -12,
zIndex: 2,
}}
/>
<Center
className={styles.avatar}
height={48}
style={{
borderRadius: '50%',
zIndex: 1,
}}
width={48}
>
<ProductLogo size={40} />
</Center>
</Flexbox>
<Flexbox gap={12} paddingBlock={12} width={'100%'}>
<AiHeatmaps
blockMargin={2}
blockRadius={1}
blockSize={4.5}
className={styles.heatmaps}
inShare
style={{
marginTop: -12,
}}
width={'100%'}
/>
<Grid gap={8} maxItemWidth={100} rows={2} width={'100%'}>
<TotalMessages inShare />
<TotalWords inShare />
</Grid>
</Flexbox>
<div className={styles.footer}>
{isOfficial ? (
OFFICIAL_URL
) : (
<Flexbox align={'center'} gap={8} horizontal>
<Github size={16} />
<span>lobehub/lobe-chat</span>
</Flexbox>
)}
</div>
</Center>
</div>
</div>
);
});
export default Preview;
@@ -0,0 +1,87 @@
'use client';
import { type FormItemProps, FormModal, FormModalProps } from '@lobehub/ui';
import { Segmented, Skeleton } from 'antd';
import { createStyles } from 'antd-style';
import dynamic from 'next/dynamic';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ImageType, imageTypeOptions, useScreenshot } from '@/hooks/useScreenshot';
const Preview = dynamic(() => import('./Preview'), {
loading: () => (
<Skeleton.Button
active
block
size={'large'}
style={{
height: 400,
width: '100%',
}}
/>
),
});
const useStyles = createStyles(({ css, prefixCls }) => ({
preview: css`
.${prefixCls}-form-item-label {
display: none;
}
`,
}));
type FieldType = {
imageType: ImageType;
};
const DEFAULT_FIELD_VALUE: FieldType = {
imageType: ImageType.JPG,
};
const ShareModal = memo<FormModalProps & { mobile?: boolean }>(({ open, onCancel, mobile }) => {
const { t } = useTranslation(['chat', 'common']);
const [fieldValue, setFieldValue] = useState<FieldType>(DEFAULT_FIELD_VALUE);
const { styles } = useStyles();
const { loading, onDownload } = useScreenshot({
imageType: fieldValue.imageType,
title: 'stats',
width: mobile ? 440 : undefined,
});
const items: FormItemProps[] = [
{
children: <Preview />,
className: styles.preview,
divider: false,
minWidth: '100%',
},
{
children: <Segmented options={imageTypeOptions} />,
divider: false,
label: t('shareModal.imageType'),
minWidth: undefined,
name: 'imageType',
},
];
return (
<FormModal
allowFullscreen
footer={null}
initialValues={DEFAULT_FIELD_VALUE}
items={items}
itemsType={'flat'}
onCancel={onCancel}
onFinish={onDownload}
onValuesChange={(_, v) => setFieldValue(v)}
open={open}
submitLoading={loading}
submitText={t('shareModal.download')}
title={t('share', { ns: 'common' })}
width={480}
/>
);
});
export default ShareModal;
@@ -0,0 +1,39 @@
import { useTheme } from 'antd-style';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
interface TotalCardProps {
count: string | number;
title: string;
}
const TotalCard = memo<TotalCardProps>(({ title, count }) => {
const theme = useTheme();
return (
<Flexbox
padding={12}
style={{
background: theme.isDarkMode ? theme.colorFillTertiary : theme.colorFillQuaternary,
borderRadius: theme.borderRadiusLG,
}}
>
<div
style={{
fontSize: 13,
}}
>
{title}
</div>
<div
style={{
fontSize: 20,
fontWeight: 'bold',
}}
>
{count}
</div>
</Flexbox>
);
});
export default TotalCard;
@@ -0,0 +1,26 @@
'use client';
import { ActionIcon } from '@lobehub/ui';
import { Share2Icon } from 'lucide-react';
import { memo, useState } from 'react';
import { DESKTOP_HEADER_ICON_SIZE, MOBILE_HEADER_ICON_SIZE } from '@/const/layoutTokens';
import ShareModal from './ShareModal';
const ShareButton = memo<{ mobile?: boolean }>(({ mobile }) => {
const [open, setOpen] = useState(false);
return (
<>
<ActionIcon
icon={Share2Icon}
onClick={() => setOpen(true)}
size={mobile ? MOBILE_HEADER_ICON_SIZE : DESKTOP_HEADER_ICON_SIZE}
/>
<ShareModal mobile={mobile} onCancel={() => setOpen(false)} open={open} />
</>
);
});
export default ShareButton;
@@ -0,0 +1,30 @@
import { Icon } from '@lobehub/ui';
import { useTheme } from 'antd-style';
import { Loader2, LucideIcon } from 'lucide-react';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
const TimeLabel = memo<{
date?: string;
icon: LucideIcon;
title: string;
}>(({ date, icon, title }) => {
const theme = useTheme();
return (
<Flexbox
align={'center'}
gap={4}
horizontal
style={{
color: theme.colorTextDescription,
fontSize: 12,
}}
>
<Icon icon={icon} />
{title}:{' '}
{date ? <span style={{ fontWeight: 'bold' }}>{date}</span> : <Icon icon={Loader2} spin />}
</Flexbox>
);
});
export default TimeLabel;
@@ -0,0 +1,103 @@
import { BarList } from '@lobehub/charts';
import { ActionIcon, FormGroup, Icon, Modal } from '@lobehub/ui';
import { useTheme } from 'antd-style';
import { MaximizeIcon, MessageSquareIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import qs from 'query-string';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { FORM_STYLE } from '@/const/layoutTokens';
import { useClientDataSWR } from '@/libs/swr';
import { topicService } from '@/services/topic';
import { TopicRankItem } from '@/types/topic';
export const TopicsRank = memo(() => {
const [open, setOpen] = useState(false);
const { t } = useTranslation('auth');
const theme = useTheme();
const router = useRouter();
const { data, isLoading } = useClientDataSWR('rank-topics', async () =>
topicService.rankTopics(),
);
const showExtra = Boolean(data && data?.length > 5);
const mapData = (item: TopicRankItem) => {
const link = qs.stringifyUrl({
query: {
session: item.sessionId,
topic: item.id,
},
url: '/chat',
});
return {
icon: (
<Icon color={theme.colorTextDescription} icon={MessageSquareIcon} size={{ fontSize: 16 }} />
),
link,
name: (
<Link href={link} style={{ color: 'inherit' }}>
{item.title}
</Link>
),
value: item.count,
};
};
return (
<>
<FormGroup
extra={
showExtra && (
<ActionIcon
icon={MaximizeIcon}
onClick={() => setOpen(true)}
size={{ blockSize: 28, fontSize: 20 }}
/>
)
}
style={FORM_STYLE.style}
title={t('stats.topicsRank.title')}
variant={'pure'}
>
<Flexbox paddingBlock={16}>
<BarList
data={data?.slice(0, 5).map((item) => mapData(item)) || []}
height={220}
leftLabel={t('stats.topicsRank.left')}
loading={isLoading || !data}
noDataText={{
desc: t('stats.empty.desc'),
title: t('stats.empty.title'),
}}
onValueChange={(item) => router.push(item.link)}
rightLabel={t('stats.topicsRank.right')}
/>
</Flexbox>
</FormGroup>
{showExtra && (
<Modal
footer={null}
loading={isLoading || !data}
onCancel={() => setOpen(false)}
open={open}
title={t('stats.topicsRank.title')}
>
<BarList
data={data?.map((item) => mapData(item)) || []}
height={340}
leftLabel={t('stats.assistantsRank.left')}
loading={isLoading || !data}
onValueChange={(item) => router.push(item.link)}
rightLabel={t('stats.assistantsRank.right')}
/>
</Modal>
)}
</>
);
});
export default TopicsRank;
@@ -0,0 +1,56 @@
import { useTheme } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Statistic from '@/components/Statistic';
import StatisticCard from '@/components/StatisticCard';
import TitleWithPercentage from '@/components/StatisticCard/TitleWithPercentage';
import { useClientDataSWR } from '@/libs/swr';
import { sessionService } from '@/services/session';
import { formatIntergerNumber } from '@/utils/format';
import { lastMonth } from '@/utils/time';
import TotalCard from './ShareButton/TotalCard';
const TotalMessages = memo<{ inShare?: boolean; mobile?: boolean }>(({ mobile, inShare }) => {
const { t } = useTranslation('auth');
const theme = useTheme();
const { data, isLoading } = useClientDataSWR('stats-sessions', async () => ({
count: await sessionService.countSessions(),
prevCount: await sessionService.countSessions({ endDate: lastMonth().format('YYYY-MM-DD') }),
}));
if (inShare)
return (
<TotalCard
count={formatIntergerNumber(data?.prevCount) || '--'}
title={t('stats.assistants')}
/>
);
return (
<StatisticCard
highlight={mobile ? undefined : theme.purple}
loading={isLoading || !data}
statistic={{
description: (
<Statistic
title={t('date.prevMonth')}
value={formatIntergerNumber(data?.prevCount) || '--'}
/>
),
precision: 0,
value: data?.count || '--',
}}
title={
<TitleWithPercentage
count={data?.count}
prvCount={data?.prevCount}
title={t('stats.assistants')}
/>
}
/>
);
});
export default TotalMessages;
@@ -0,0 +1,56 @@
import { useTheme } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Statistic from '@/components/Statistic';
import StatisticCard from '@/components/StatisticCard';
import TitleWithPercentage from '@/components/StatisticCard/TitleWithPercentage';
import { useClientDataSWR } from '@/libs/swr';
import { messageService } from '@/services/message';
import { formatIntergerNumber } from '@/utils/format';
import { lastMonth } from '@/utils/time';
import TotalCard from './ShareButton/TotalCard';
const TotalMessages = memo<{ inShare?: boolean; mobile?: boolean }>(({ inShare, mobile }) => {
const { t } = useTranslation('auth');
const theme = useTheme();
const { data, isLoading } = useClientDataSWR('stats-messages', async () => ({
count: await messageService.countMessages(),
prevCount: await messageService.countMessages({ endDate: lastMonth().format('YYYY-MM-DD') }),
}));
if (inShare)
return (
<TotalCard
count={formatIntergerNumber(data?.prevCount) || '--'}
title={t('stats.messages')}
/>
);
return (
<StatisticCard
highlight={mobile ? undefined : theme.yellow}
loading={isLoading || !data}
statistic={{
description: (
<Statistic
title={t('date.prevMonth')}
value={formatIntergerNumber(data?.prevCount) || '--'}
/>
),
precision: 0,
value: data?.count || '--',
}}
title={
<TitleWithPercentage
count={data?.count}
prvCount={data?.prevCount}
title={t('stats.messages')}
/>
}
/>
);
});
export default TotalMessages;
@@ -0,0 +1,53 @@
import { useTheme } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Statistic from '@/components/Statistic';
import StatisticCard from '@/components/StatisticCard';
import TitleWithPercentage from '@/components/StatisticCard/TitleWithPercentage';
import { useClientDataSWR } from '@/libs/swr';
import { topicService } from '@/services/topic';
import { formatIntergerNumber } from '@/utils/format';
import { lastMonth } from '@/utils/time';
import TotalCard from './ShareButton/TotalCard';
const TotalMessages = memo<{ inShare?: boolean; mobile?: boolean }>(({ inShare, mobile }) => {
const { t } = useTranslation('auth');
const theme = useTheme();
const { data, isLoading } = useClientDataSWR('stats-topics', async () => ({
count: await topicService.countTopics(),
prevCount: await topicService.countTopics({ endDate: lastMonth().format('YYYY-MM-DD') }),
}));
if (inShare)
return (
<TotalCard count={formatIntergerNumber(data?.prevCount) || '--'} title={t('stats.topics')} />
);
return (
<StatisticCard
highlight={mobile ? undefined : theme.gold}
loading={isLoading || !data}
statistic={{
description: (
<Statistic
title={t('date.prevMonth')}
value={formatIntergerNumber(data?.prevCount) || '--'}
/>
),
precision: 0,
value: data?.count || '--',
}}
title={
<TitleWithPercentage
count={data?.count}
prvCount={data?.prevCount}
title={t('stats.topics')}
/>
}
/>
);
});
export default TotalMessages;
@@ -0,0 +1,54 @@
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Statistic from '@/components/Statistic';
import StatisticCard from '@/components/StatisticCard';
import TitleWithPercentage from '@/components/StatisticCard/TitleWithPercentage';
import { useClientDataSWR } from '@/libs/swr';
import { messageService } from '@/services/message';
import { formatShortenNumber } from '@/utils/format';
import { lastMonth } from '@/utils/time';
import TotalCard from './ShareButton/TotalCard';
const TotalWords = memo<{ inShare?: boolean }>(({ inShare }) => {
const { t } = useTranslation('auth');
const { data, isLoading } = useClientDataSWR('stats-words', async () => ({
count: await messageService.countWords(),
prevCount: await messageService.countWords({ endDate: lastMonth().format('YYYY-MM-DD') }),
}));
if (inShare)
return (
<TotalCard count={formatShortenNumber(data?.prevCount) || '--'} title={t('stats.words')} />
);
return (
<StatisticCard
loading={isLoading || !data}
statistic={{
description: (
<Statistic
title={t('date.prevMonth')}
value={formatShortenNumber(data?.prevCount) || '--'}
/>
),
precision: 0,
style: {
fontWeight: 'bold',
},
value: formatShortenNumber(data?.count) || '--',
}}
title={
<TitleWithPercentage
count={data?.count}
prvCount={data?.prevCount}
title={t('stats.words')}
/>
}
/>
);
});
export default TotalWords;
@@ -0,0 +1,86 @@
import { FluentEmoji } from '@lobehub/ui';
import { Skeleton } from 'antd';
import { useTheme } from 'antd-style';
import { Clock3Icon, ClockArrowUp } from 'lucide-react';
import { memo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import TimeLabel from '@/app/(main)/profile/stats/features/TimeLabel';
import { BRANDING_NAME } from '@/const/branding';
import { useClientDataSWR } from '@/libs/swr';
import { userService } from '@/services/user';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/slices/auth/selectors';
import { formatIntergerNumber } from '@/utils/format';
const formatEnglishNumber = (number: number) => {
if (number === 1) return '1st';
if (number === 2) return '2nd';
if (number === 3) return '3rd';
return `${formatIntergerNumber(number)}th`;
};
const Welcome = memo<{ mobile?: boolean }>(({ mobile }) => {
const { t, i18n } = useTranslation('auth');
const theme = useTheme();
const [nickname, username] = useUserStore((s) => [
userProfileSelectors.nickName(s),
userProfileSelectors.username(s),
]);
const { data, isLoading } = useClientDataSWR('welcome', async () =>
userService.getUserRegistrationDuration(),
);
return (
<Flexbox gap={8} padding={mobile ? 16 : 0}>
<Flexbox
align={'center'}
gap={8}
horizontal
style={{
fontSize: mobile ? 16 : 20,
fontWeight: 500,
}}
>
<div>
<Trans
components={{
span:
isLoading || !data ? (
<Skeleton.Button active style={{ height: 24 }} />
) : (
<span style={{ fontWeight: 'bold' }} />
),
}}
i18nKey="stats.welcome"
ns={'auth'}
values={{
appName: BRANDING_NAME,
days:
i18n.language === 'en-US'
? formatEnglishNumber(Number(data?.duration || 1))
: formatIntergerNumber(Number(data?.duration || 1)),
username: nickname || username,
}}
/>
</div>
{!mobile && <FluentEmoji emoji={'🫶'} size={32} type={'anim'} />}
</Flexbox>
<Flexbox
gap={16}
horizontal
style={{
color: theme.colorTextDescription,
}}
wrap={'wrap'}
>
<TimeLabel date={data?.createdAt} icon={Clock3Icon} title={t('stats.createdAt')} />
<TimeLabel date={data?.updatedAt} icon={ClockArrowUp} title={t('stats.updatedAt')} />
</Flexbox>
</Flexbox>
);
});
export default Welcome;
@@ -5,17 +5,16 @@ import { isMobileDevice } from '@/utils/server/responsive';
import Client from './Client';
export const generateMetadata = async () => {
const { t } = await translation('clerk');
const { t } = await translation('auth');
return metadataModule.generate({
description: t('userProfile.navbar.title'),
title: t('userProfile.navbar.description'),
url: '/profile',
description: t('header.desc'),
title: t('tab.stats'),
url: '/profile/stats',
});
};
const Page = async () => {
const mobile = await isMobileDevice();
return <Client mobile={mobile} />;
};
@@ -3,7 +3,7 @@
import { createStyles } from 'antd-style';
import { Flexbox } from 'react-layout-kit';
import CircleLoading from '@/components/Loading/BrandTextLoading';
import Loading from '@/components/Loading/BrandTextLoading';
import { useKnowledgeBaseStore } from '@/store/knowledgeBase';
import DatasetDetail from './DatasetDetail';
@@ -34,7 +34,7 @@ const Dataset = ({ params }: Props) => {
const isEmpty = data?.length === 0;
return isLoading ? (
<CircleLoading />
<Loading />
) : isEmpty ? (
<EmptyGuide knowledgeBaseId={knowledgeBaseId} />
) : (
@@ -2,7 +2,7 @@
import { Flexbox } from 'react-layout-kit';
import CircleLoading from '@/components/Loading/BrandTextLoading';
import Loading from '@/components/Loading/BrandTextLoading';
import { useKnowledgeBaseStore } from '@/store/knowledgeBase';
import EmptyGuide from './EmptyGuide';
@@ -24,7 +24,7 @@ const Evaluation = ({ params }: Props) => {
const isEmpty = data?.length === 0;
return isLoading ? (
<CircleLoading />
<Loading />
) : isEmpty ? (
<EmptyGuide knowledgeBaseId={knowledgeBaseId} />
) : (
@@ -4,7 +4,7 @@ import { memo } from 'react';
import urlJoin from 'url-join';
import Menu from '@/components/Menu';
import { useActiveSettingsKey } from '@/hooks/useActiveSettingsKey';
import { useActiveSettingsKey } from '@/hooks/useActiveTabKey';
import { useQuery } from '@/hooks/useQuery';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { SettingsTabs } from '@/store/global/initialState';
@@ -9,7 +9,7 @@ import { Flexbox } from 'react-layout-kit';
import InitClientDB from '@/features/InitClientDB';
import Footer from '@/features/Setting/Footer';
import SettingContainer from '@/features/Setting/SettingContainer';
import { useActiveSettingsKey } from '@/hooks/useActiveSettingsKey';
import { useActiveSettingsKey } from '@/hooks/useActiveTabKey';
import { SettingsTabs } from '@/store/global/initialState';
import { LayoutProps } from '../type';
@@ -7,7 +7,7 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useActiveSettingsKey } from '@/hooks/useActiveSettingsKey';
import { useActiveSettingsKey } from '@/hooks/useActiveTabKey';
import { SettingsTabs } from '@/store/global/initialState';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/selectors';
@@ -1,4 +1,5 @@
import MobileContentLayout from '@/components/server/MobileNavLayout';
import InitClientDB from '@/features/InitClientDB';
import Footer from '@/features/Setting/Footer';
import { LayoutProps } from '../type';
@@ -9,6 +10,7 @@ const Layout = ({ children }: LayoutProps) => {
<MobileContentLayout header={<Header />}>
{children}
<Footer />
<InitClientDB />
</MobileContentLayout>
);
};
@@ -11,14 +11,9 @@ import { useTranslation } from 'react-i18next';
import { useSyncSettings } from '@/app/(main)/settings/hooks/useSyncSettings';
import { FORM_STYLE } from '@/const/layoutTokens';
import { imageUrl } from '@/const/url';
import AvatarWithUpload from '@/features/AvatarWithUpload';
import { Locales, localeOptions } from '@/locales/resources';
import { useUserStore } from '@/store/user';
import {
authSelectors,
settingsSelectors,
userGeneralSettingsSelectors,
} from '@/store/user/selectors';
import { settingsSelectors, userGeneralSettingsSelectors } from '@/store/user/selectors';
import { switchLang } from '@/utils/client/switchLang';
import { ThemeSwatchesNeutral, ThemeSwatchesPrimary } from './ThemeSwatches';
@@ -31,11 +26,7 @@ const Theme = memo(() => {
const [form] = Form.useForm();
const settings = useUserStore(settingsSelectors.currentSettings, isEqual);
const themeMode = useUserStore(userGeneralSettingsSelectors.currentThemeMode);
const [setThemeMode, setSettings, enableAuth] = useUserStore((s) => [
s.switchThemeMode,
s.setSettings,
authSelectors.enabledAuth(s),
]);
const [setThemeMode, setSettings] = useUserStore((s) => [s.switchThemeMode, s.setSettings]);
useSyncSettings(form);
@@ -46,12 +37,6 @@ const Theme = memo(() => {
const theme: SettingItemGroup = {
children: [
{
children: <AvatarWithUpload />,
hidden: enableAuth,
label: t('settingTheme.avatar.title'),
minWidth: undefined,
},
{
children: (
<SelectWithImg
+2 -2
View File
@@ -1,3 +1,3 @@
import FullLoading from '@/components/Loading/BrandTextLoading';
import Loading from '@/components/Loading/BrandTextLoading';
export default () => <FullLoading />;
export default () => <Loading />;
@@ -1,44 +0,0 @@
import { memo } from 'react';
const LobeChatText = memo<{ size?: number }>(({ size = '1em' }) => (
<svg
fill="currentColor"
fillRule="evenodd"
height={size}
style={{ flex: 'none', lineHeight: 1, opacity: 0.6 }}
viewBox="0 0 980 320"
xmlns="http://www.w3.org/2000/svg"
>
<title>LobeChat</title>
<path
className="animate-draw"
d="M696.999 135.08c-.455.472-.895.949-1.32 1.434V77h-34.98v162.8h35.42v-69.52c0-2.933.514-5.573 1.54-7.92a18.279 18.279 0 014.4-6.16c2.054-1.907 4.4-3.3 7.04-4.18 2.64-1.027 5.5-1.54 8.58-1.54 3.96-.147 7.26.513 9.9 1.98 2.64 1.467 4.62 3.74 5.94 6.82 1.32 3.08 1.98 6.967 1.98 11.66v68.86h35.42v-71.5c0-10.413-1.54-19.14-4.62-26.18-2.933-7.187-7.406-12.54-13.42-16.06-6.013-3.667-13.42-5.5-22.22-5.5-6.453 0-12.613 1.32-18.48 3.96-5.866 2.64-10.926 6.16-15.18 10.56zM15 240.035V87.172h39.24V205.75h66.192v34.285H15z"
/>
<path
className="animate-draw"
d="M183.731 242c-11.759 0-22.196-2.621-31.313-7.862-9.116-5.241-16.317-12.447-21.601-21.619-5.153-9.317-7.729-19.945-7.729-31.883 0-11.937 2.576-22.492 7.729-31.664 5.164-8.963 12.159-15.98 20.982-21.05l.619-.351c9.117-5.241 19.554-7.861 31.313-7.861s22.196 2.62 31.313 7.861c9.248 5.096 16.449 12.229 21.601 21.401 5.153 9.172 7.729 19.727 7.729 31.664 0 11.938-2.576 22.566-7.729 31.883-5.152 9.172-12.353 16.378-21.601 21.619-9.117 5.241-19.554 7.862-31.313 7.862zm0-32.975c4.36 0 8.191-1.092 11.494-3.275 3.436-2.184 6.144-5.387 8.126-9.609 1.982-4.367 2.973-9.536 2.973-15.505 0-5.968-.991-10.991-2.973-15.067-1.906-4.06-4.483-7.177-7.733-9.352l-.393-.257c-3.303-2.184-7.134-3.276-11.494-3.276-4.228 0-8.059 1.092-11.495 3.276-3.303 2.184-6.011 5.387-8.125 9.609-1.982 4.076-2.973 9.099-2.973 15.067 0 5.969.991 11.138 2.973 15.505 2.114 4.222 4.822 7.425 8.125 9.609 3.436 2.183 7.267 3.275 11.495 3.275z"
/>
<path
className="animate-draw"
d="M295.508 78l-.001 54.042a34.071 34.071 0 016.541-5.781c6.474-4.367 14.269-6.551 23.385-6.551 9.777 0 18.629 2.475 26.557 7.424 7.872 4.835 14.105 11.684 18.7 20.546l.325.637c4.756 9.026 7.135 19.799 7.135 32.319 0 12.666-2.379 23.585-7.135 32.757-4.624 9.026-10.966 16.087-19.025 21.182-7.928 4.95-16.78 7.425-26.557 7.425-9.644 0-17.704-2.184-24.178-6.551-2.825-1.946-5.336-4.355-7.532-7.226l.001 11.812h-35.87V78h37.654zm21.998 74.684c-4.228 0-8.059 1.092-11.494 3.276-3.303 2.184-6.012 5.387-8.126 9.609-1.982 4.076-2.972 9.099-2.972 15.067 0 5.969.99 11.138 2.972 15.505 2.114 4.222 4.823 7.425 8.126 9.609 3.435 2.183 7.266 3.275 11.494 3.275s7.994-1.092 11.297-3.275c3.435-2.184 6.143-5.387 8.125-9.609 2.114-4.367 3.171-9.536 3.171-15.505 0-5.968-1.057-10.991-3.171-15.067-1.906-4.06-4.483-7.177-7.732-9.352l-.393-.257c-3.303-2.184-7.069-3.276-11.297-3.276z"
/>
<path
className="animate-draw"
d="M422.841 191.337l.084.337a27.857 27.857 0 002.057 5.559c2.246 4.222 5.417 7.498 9.513 9.827 4.096 2.184 8.984 3.276 14.665 3.276 5.285 0 9.777-.801 13.477-2.403 3.579-1.632 7.1-4.025 10.564-7.182l.732-.679 19.818 22.711c-5.153 6.26-11.494 11.064-19.025 14.413-7.531 3.203-16.449 4.804-26.755 4.804-12.683 0-23.782-2.621-33.294-7.862-9.381-5.386-16.713-12.665-21.998-21.837-5.153-9.317-7.729-19.872-7.729-31.665 0-11.792 2.51-22.274 7.53-31.446 5.036-9.105 11.902-16.195 20.596-21.268l.61-.351c8.984-5.241 19.091-7.861 30.322-7.861 10.311 0 19.743 2.286 28.294 6.859l.64.347c8.72 4.659 15.656 11.574 20.809 20.746 5.153 9.172 7.729 20.309 7.729 33.411 0 1.294-.052 2.761-.156 4.4l-.042.623-.17 2.353c-.075 1.01-.151 1.973-.227 2.888h-78.044zm21.365-42.147c-4.492 0-8.456 1.092-11.891 3.276-3.303 2.184-5.879 5.314-7.729 9.39a26.04 26.04 0 00-1.117 2.79 30.164 30.164 0 00-1.121 4.499l-.058.354h43.96l-.015-.106c-.401-2.638-1.122-5.055-2.163-7.252l-.246-.503c-1.776-3.774-4.282-6.742-7.519-8.906l-.409-.266c-3.303-2.184-7.2-3.276-11.692-3.276z"
/>
<path
className="animate-draw"
d="M846.763 155.12c3.373 2.786 5.06 7.186 5.06 13.2v2.64h-17.38c-8.654 0-16.28.806-22.88 2.42-6.454 1.466-11.88 3.74-16.28 6.82-4.254 2.933-7.48 6.673-9.68 11.22-2.2 4.546-3.3 9.826-3.3 15.84 0 7.04 1.686 13.2 5.06 18.48 3.52 5.133 8.213 9.166 14.08 12.1 6.013 2.933 12.686 4.4 20.02 4.4 5.573 0 10.853-1.1 15.84-3.3 5.133-2.2 9.826-5.207 14.08-9.02l.44-.385v10.505h34.1V161.5c0-9.24-1.98-16.867-5.94-22.88-3.96-6.014-9.534-10.487-16.72-13.42-7.187-2.934-15.694-4.4-25.52-4.4-10.707 0-20.607 2.053-29.7 6.16-9.094 4.106-17.16 9.753-24.2 16.94l21.12 20.46c4.106-4.84 8.36-8.287 12.76-10.34 4.546-2.2 9.68-3.3 15.4-3.3 5.866 0 10.413 1.466 13.64 4.4zm5.06 52.071V193.18h-16.5c-3.08 0-5.794.293-8.14.88-2.347.44-4.327 1.246-5.94 2.42-1.614 1.026-2.86 2.273-3.74 3.74-.734 1.466-1.1 3.226-1.1 5.28 0 2.2.586 4.106 1.76 5.72 1.173 1.613 2.713 2.86 4.62 3.74 2.053.88 4.4 1.32 7.04 1.32 3.813 0 7.406-.587 10.78-1.76 3.52-1.32 6.6-3.154 9.24-5.5a29.32 29.32 0 001.98-1.829z"
/>
<path
className="animate-draw"
d="M592.64 242c-11.587 0-22.293-1.907-32.12-5.72-9.68-3.96-18.113-9.46-25.3-16.5-7.04-7.187-12.54-15.62-16.5-25.3-3.813-9.827-5.72-20.534-5.72-32.12 0-11.44 1.98-22 5.94-31.68 4.107-9.68 9.827-18.114 17.16-25.3 7.333-7.187 15.913-12.76 25.74-16.72 9.973-3.96 20.753-5.94 32.34-5.94 7.333 0 14.52.953 21.56 2.86 7.04 1.906 13.567 4.766 19.58 8.58 6.16 3.666 11.44 8.14 15.84 13.42l-23.1 26.18c-4.547-4.987-9.68-8.874-15.4-11.66-5.573-2.787-11.807-4.18-18.7-4.18-5.867 0-11.44 1.026-16.72 3.08-5.133 2.053-9.607 5.06-13.42 9.02-3.813 3.96-6.82 8.653-9.02 14.08-2.2 5.426-3.3 11.586-3.3 18.48 0 6.746 1.027 12.906 3.08 18.48 2.2 5.426 5.28 10.12 9.24 14.08 4.107 3.813 8.947 6.746 14.52 8.8 5.72 2.053 12.027 3.08 18.92 3.08 4.547 0 8.873-.587 12.98-1.76a48.746 48.746 0 0011.88-5.5c3.667-2.347 6.82-4.987 9.46-7.92l18.26 29.04c-3.52 3.96-8.36 7.553-14.52 10.78-6.013 3.226-12.76 5.793-20.24 7.7a95.814 95.814 0 01-22.44 2.64z"
/>
<path
className="animate-draw"
d="M942.281 202.6v-48.59h27.14V123.4h-27.14V92.8h-33.58v30.6h-16.79v30.61h16.79v48.36c0 26.526 13.263 39.79 39.79 39.79 9.66 0 17.327-1.227 23-3.68v-27.83c-4.753 3.066-10.197 4.6-16.33 4.6-3.833 0-6.977-1.074-9.43-3.22-2.3-2.147-3.45-5.29-3.45-9.43z"
/>
</svg>
));
export default LobeChatText;
@@ -1,6 +0,0 @@
import LobeChatText from './SVG';
import './style.css';
const LobeChatTextLoading = () => <LobeChatText size={40} />;
export default LobeChatTextLoading;
@@ -1,32 +0,0 @@
@keyframes draw {
0% {
stroke-dashoffset: 1000;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes fill {
30% {
fill-opacity: 0%;
}
100% {
fill-opacity: 100%;
}
}
.animate-draw {
fill: currentcolor;
fill-opacity: 0%;
stroke: currentcolor;
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
stroke-width: 1.5;
animation:
draw 2s cubic-bezier(0.4, 0, 0.2, 1) infinite,
fill 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@@ -1,16 +1,16 @@
import { BrandLoading, LobeChatText } from '@lobehub/ui/brand';
import { Center } from 'react-layout-kit';
import { isCustomBranding } from '@/const/version';
import CircleLoading from '../CircleLoading';
import LobeChatText from './LobeChatText';
export default () => {
if (isCustomBranding) return <CircleLoading />;
return (
<Center height={'100%'} width={'100%'}>
<LobeChatText />
<BrandLoading size={40} style={{ opacity: 0.6 }} text={LobeChatText} />
</Center>
);
};
+15
View File
@@ -0,0 +1,15 @@
import { useTheme } from 'antd-style';
import { ReactNode, memo } from 'react';
import { Flexbox } from 'react-layout-kit';
const Statistic = memo<{ title: ReactNode; value: ReactNode }>(({ value, title }) => {
const theme = useTheme();
return (
<Flexbox gap={4} horizontal style={{ color: theme.colorTextSecondary, fontSize: 12 }}>
<span style={{ fontWeight: 'bold' }}>{value}</span>
<span>{title}</span>
</Flexbox>
);
});
export default Statistic;
@@ -0,0 +1,80 @@
import { Tag, Typography } from 'antd';
import { useTheme } from 'antd-style';
import { CSSProperties, memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { calcGrowthPercentage } from './growthPercentage';
const { Title } = Typography;
interface TitleWithPercentageProps {
count?: number;
inverseColor?: boolean;
prvCount?: number;
title: string;
}
const TitleWithPercentage = memo<TitleWithPercentageProps>(
({ inverseColor, title, prvCount, count }) => {
const percentage = calcGrowthPercentage(count || 0, prvCount || 0);
const theme = useTheme();
const upStyle: CSSProperties = {
background: theme.colorSuccessBg,
borderColor: theme.colorSuccessBorder,
color: theme.colorSuccess,
};
const downStyle: CSSProperties = {
backgroundColor: theme.colorWarningBg,
borderColor: theme.colorWarningBorder,
color: theme.colorWarning,
};
return (
<Flexbox
align={'center'}
gap={8}
horizontal
justify={'flex-start'}
style={{
overflow: 'hidden',
position: 'inherit',
}}
>
<Title
ellipsis={{ rows: 1, tooltip: title }}
level={2}
style={{
fontSize: 'inherit',
fontWeight: 'inherit',
lineHeight: 'inherit',
margin: 0,
overflow: 'hidden',
}}
>
{title}
</Title>
{count && prvCount && percentage && percentage !== 0 ? (
<Tag
style={{
borderWidth: 0.5,
...(inverseColor
? percentage > 0
? downStyle
: upStyle
: percentage > 0
? upStyle
: downStyle),
}}
>
{percentage > 0 ? '+' : ''}
{percentage.toFixed(1)}%
</Tag>
) : null}
</Flexbox>
);
},
);
export default TitleWithPercentage;
@@ -0,0 +1,8 @@
export const calcGrowthPercentage = (currentCount: number, previousCount: number) => {
if (typeof currentCount !== 'number') return 0;
return previousCount !== 0
? ((currentCount - previousCount) / previousCount) * 100 // 计算增长百分比
: currentCount > 0
? 100
: 0;
};
+209
View File
@@ -0,0 +1,209 @@
import {
StatisticCard as AntdStatisticCard,
StatisticCardProps as AntdStatisticCardProps,
} from '@ant-design/pro-components';
import { Spin, Typography } from 'antd';
import { createStyles, useResponsive } from 'antd-style';
import { adjustHue } from 'polished';
import { memo } from 'react';
const { Title } = Typography;
const useStyles = createStyles(
({ isDarkMode, css, token, prefixCls, responsive }, highlight: string = '#000') => ({
card: css`
border: 1px solid ${isDarkMode ? token.colorFillTertiary : token.colorFillSecondary};
border-radius: ${token.borderRadiusLG}px;
${responsive.mobile} {
background: ${token.colorBgContainer};
border: none;
border-radius: 0;
}
`,
container: css`
${responsive.mobile} {
background: ${token.colorBgContainer};
border: none;
border-radius: 0;
}
.${prefixCls}-pro-card-title {
overflow: hidden;
${responsive.mobile} {
margin: 0;
font-size: 14px;
line-height: 16px !important;
}
}
.${prefixCls}-pro-card-body {
padding: 0;
.${prefixCls}-pro-statistic-card-content {
position: relative;
width: 100%;
padding-block-end: 16px;
padding-inline: 24px;
.${prefixCls}-pro-statistic-card-chart {
position: relative;
width: 100%;
}
}
.${prefixCls}-pro-statistic-card-footer {
overflow: hidden;
margin: 0;
padding: 0;
border-end-start-radius: ${token.borderRadiusLG}px;
border-end-end-radius: ${token.borderRadiusLG}px;
}
}
.${prefixCls}-pro-card-loading-content {
padding-block: 16px;
padding-inline: 24px;
}
.${prefixCls}-pro-card-header {
padding-block-start: 16px;
padding-inline: 24px;
.${prefixCls}-pro-card-title {
line-height: 32px;
}
+ .${prefixCls}-pro-card-body {
padding-block-start: 0;
}
${responsive.mobile} {
flex-wrap: wrap;
gap: 8px;
margin-block-end: 8px;
padding-block-start: 0;
padding-inline: 0;
}
}
.${prefixCls}-statistic-content-value-int, .${prefixCls}-statistic-content-value-decimal {
font-size: 24px;
font-weight: bold;
line-height: 1.2;
}
.${prefixCls}-pro-statistic-card-chart {
margin: 0;
}
.${prefixCls}-pro-statistic-card-content {
display: flex;
flex-direction: column;
gap: 16px;
${responsive.mobile} {
padding-block-end: 0 !important;
padding-inline: 0 !important;
}
}
.${prefixCls}-pro-statistic-card-content-horizontal {
flex-direction: row;
align-items: center;
.${prefixCls}-pro-statistic-card-chart {
align-self: center;
}
}
`,
highlight: css`
overflow: hidden;
&::before {
content: '';
position: absolute;
z-index: 0;
inset-block-end: -30%;
inset-inline-end: -30%;
transform: rotate(-15deg);
width: 66%;
height: 50%;
opacity: ${isDarkMode ? 1 : 0.33};
background-image: linear-gradient(
60deg,
${adjustHue(-30, highlight)} 20%,
${highlight} 80%
);
background-repeat: no-repeat;
background-position: center left;
background-size: contain;
filter: blur(32px);
border-radius: 50%;
}
> div {
z-index: 1;
}
`,
icon: css`
background: ${token.colorFillSecondary};
border-radius: ${token.borderRadius}px;
`,
pure: css`
background: transparent !important;
border: none !important;
`,
}),
);
interface StatisticCardProps extends AntdStatisticCardProps {
highlight?: string;
variant?: 'pure' | 'card';
}
const StatisticCard = memo<StatisticCardProps>(
({ title, className, highlight, variant, loading, extra, ...rest }) => {
const { cx, styles } = useStyles(highlight);
const { mobile } = useResponsive();
const isPure = variant === 'pure';
return (
<AntdStatisticCard
bordered={!mobile}
className={cx(
styles.container,
isPure ? styles.pure : styles.card,
highlight && styles.highlight,
className,
)}
extra={loading ? <Spin percent={'auto'} size={'small'} /> : extra}
title={
typeof title === 'string' ? (
<Title
ellipsis={{ rows: 1, tooltip: title }}
level={2}
style={{
fontSize: 'inherit',
fontWeight: 'inherit',
lineHeight: 'inherit',
margin: 0,
overflow: 'hidden',
}}
>
{title}
</Title>
) : (
title
)
}
{...rest}
/>
);
},
);
export default StatisticCard;
+3 -3
View File
@@ -9,9 +9,9 @@ import { INBOX_SESSION_ID } from './session';
export const UTM_SOURCE = 'chat_preview';
export const OFFICIAL_URL = 'https://lobechat.com/';
export const OFFICIAL_PREVIEW_URL = 'https://chat-preview.lobehub.com/';
export const OFFICIAL_SITE = 'https://lobehub.com/';
export const OFFICIAL_URL = 'https://lobechat.com';
export const OFFICIAL_PREVIEW_URL = 'https://chat-preview.lobehub.com';
export const OFFICIAL_SITE = 'https://lobehub.com';
export const OG_URL = '/og/cover.png?v=1';
@@ -1,3 +1,4 @@
import dayjs from 'dayjs';
import { eq } from 'drizzle-orm/expressions';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -1042,41 +1043,6 @@ describe('MessageModel', () => {
});
});
describe('countToday', () => {
it('should return the count of messages created today', async () => {
// 创建测试数据
await serverDB.insert(messages).values([
{
id: '1',
userId,
role: 'user',
content: 'message 1',
createdAt: new Date(),
},
{
id: '2',
userId,
role: 'user',
content: 'message 2',
createdAt: new Date(),
},
{
id: '3',
userId,
role: 'user',
content: 'message 3',
createdAt: new Date('2023-01-01'),
},
]);
// 调用 countToday 方法
const result = await messageModel.countToday();
// 断言结果
expect(result).toBe(2);
});
});
describe('findMessageQueriesById', () => {
it('should return undefined for non-existent message query', async () => {
const result = await messageModel.findMessageQueriesById('non-existent-id');
@@ -1211,4 +1177,349 @@ describe('MessageModel', () => {
expect(id2).toMatch(/^msg_/);
});
});
describe('countWords', () => {
it('should count total words of messages belonging to the user', async () => {
// 创建测试数据
await serverDB.insert(messages).values([
{ id: '1', userId, role: 'user', content: 'hello world' },
{ id: '2', userId, role: 'user', content: 'test message' },
{ id: '3', userId: '456', role: 'user', content: 'other user message' },
]);
// 调用 countWords 方法
const result = await messageModel.countWords();
// 断言结果 - 'hello world' + 'test message' = 23 characters
expect(result).toEqual(23);
});
it('should count words within date range', async () => {
// 创建测试数据
await serverDB.insert(messages).values([
{
id: '1',
userId,
role: 'user',
content: 'old message',
createdAt: new Date('2023-01-01'),
},
{
id: '2',
userId,
role: 'user',
content: 'new message',
createdAt: new Date('2023-06-01'),
},
]);
// 调用 countWords 方法,设置日期范围
const result = await messageModel.countWords({
range: ['2023-05-01', '2023-07-01'],
});
// 断言结果 - 只计算 'new message' = 11 characters
expect(result).toEqual(11);
});
it('should handle empty content', async () => {
// 创建测试数据
await serverDB.insert(messages).values([
{ id: '1', userId, role: 'user', content: '' },
{ id: '2', userId, role: 'user', content: null },
]);
// 调用 countWords 方法
const result = await messageModel.countWords();
// 断言结果
expect(result).toEqual(0);
});
});
describe('getHeatmaps', () => {
it('should return heatmap data for the last year', async () => {
const today = dayjs();
// 创建测试数据
await serverDB.insert(messages).values([
{
id: '1',
userId,
role: 'user',
content: 'message 1',
createdAt: today.subtract(2, 'day').toDate(),
},
{
id: '2',
userId,
role: 'user',
content: 'message 2',
createdAt: today.subtract(2, 'day').toDate(),
},
{
id: '3',
userId,
role: 'user',
content: 'message 3',
createdAt: today.subtract(1, 'day').toDate(),
},
]);
// 调用 getHeatmaps 方法
const result = await messageModel.getHeatmaps();
// 断言结果
expect(result.length).toBeGreaterThan(366);
expect(result.length).toBeLessThan(368);
// 检查两天前的数据
const twoDaysAgo = result.find(
(item) => item.date === today.subtract(2, 'day').format('YYYY-MM-DD'),
);
expect(twoDaysAgo?.count).toBe(2);
expect(twoDaysAgo?.level).toBe(0);
// 检查一天前的数据
const oneDayAgo = result.find(
(item) => item.date === today.subtract(1, 'day').format('YYYY-MM-DD'),
);
expect(oneDayAgo?.count).toBe(1);
expect(oneDayAgo?.level).toBe(0);
// 检查今天的数据
const todayData = result.find((item) => item.date === today.format('YYYY-MM-DD'));
expect(todayData?.count).toBe(0);
expect(todayData?.level).toBe(0);
});
it('should calculate correct levels based on message count', async () => {
const today = dayjs();
// 创建测试数据 - 不同数量的消息以测试不同的等级
await serverDB.insert(messages).values([
// 1 message - level 0
{
id: '1',
userId,
role: 'user',
content: 'message 1',
createdAt: today.subtract(4, 'day').toDate(),
},
// 6 messages - level 1
...Array(6)
.fill(0)
.map((_, i) => ({
id: `2-${i}`,
userId,
role: 'user',
content: `message 2-${i}`,
createdAt: today.subtract(3, 'day').toDate(),
})),
// 11 messages - level 2
...Array(11)
.fill(0)
.map((_, i) => ({
id: `3-${i}`,
userId,
role: 'user',
content: `message 3-${i}`,
createdAt: today.subtract(2, 'day').toDate(),
})),
// 16 messages - level 3
...Array(16)
.fill(0)
.map((_, i) => ({
id: `4-${i}`,
userId,
role: 'user',
content: `message 4-${i}`,
createdAt: today.subtract(1, 'day').toDate(),
})),
// 21 messages - level 4
...Array(21)
.fill(0)
.map((_, i) => ({
id: `5-${i}`,
userId,
role: 'user',
content: `message 5-${i}`,
createdAt: today.toDate(),
})),
]);
// 调用 getHeatmaps 方法
const result = await messageModel.getHeatmaps();
// 检查不同天数的等级
const fourDaysAgo = result.find(
(item) => item.date === today.subtract(4, 'day').format('YYYY-MM-DD'),
);
expect(fourDaysAgo?.count).toBe(1);
expect(fourDaysAgo?.level).toBe(0);
const threeDaysAgo = result.find(
(item) => item.date === today.subtract(3, 'day').format('YYYY-MM-DD'),
);
expect(threeDaysAgo?.count).toBe(6);
expect(threeDaysAgo?.level).toBe(1);
const twoDaysAgo = result.find(
(item) => item.date === today.subtract(2, 'day').format('YYYY-MM-DD'),
);
expect(twoDaysAgo?.count).toBe(11);
expect(twoDaysAgo?.level).toBe(2);
const oneDayAgo = result.find(
(item) => item.date === today.subtract(1, 'day').format('YYYY-MM-DD'),
);
expect(oneDayAgo?.count).toBe(16);
expect(oneDayAgo?.level).toBe(3);
const todayData = result.find((item) => item.date === today.format('YYYY-MM-DD'));
expect(todayData?.count).toBe(21);
expect(todayData?.level).toBe(4);
});
it('should handle empty data', async () => {
// 不创建任何消息数据
// 调用 getHeatmaps 方法
const result = await messageModel.getHeatmaps();
// 断言结果
expect(result.length).toBeGreaterThan(366);
expect(result.length).toBeLessThan(368);
// 检查所有数据的 count 和 level 是否为 0
result.forEach((item) => {
expect(item.count).toBe(0);
expect(item.level).toBe(0);
});
});
});
describe('rankModels', () => {
it('should rank models by usage count', async () => {
// 创建测试数据
await serverDB.insert(messages).values([
{ id: '1', userId, role: 'assistant', content: 'message 1', model: 'gpt-3.5' },
{ id: '2', userId, role: 'assistant', content: 'message 2', model: 'gpt-3.5' },
{ id: '3', userId, role: 'assistant', content: 'message 3', model: 'gpt-4' },
{ id: '4', userId: '456', role: 'assistant', content: 'message 4', model: 'gpt-3.5' }, // 其他用户的消息
]);
// 调用 rankModels 方法
const result = await messageModel.rankModels();
// 断言结果
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ id: 'gpt-3.5', count: 2 }); // 当前用户使用 gpt-3.5 两次
expect(result[1]).toEqual({ id: 'gpt-4', count: 1 }); // 当前用户使用 gpt-4 一次
});
it('should only count messages with model field', async () => {
// 创建测试数据,包括没有 model 字段的消息
await serverDB.insert(messages).values([
{ id: '1', userId, role: 'assistant', content: 'message 1', model: 'gpt-3.5' },
{ id: '2', userId, role: 'assistant', content: 'message 2', model: null },
{ id: '3', userId, role: 'user', content: 'message 3' }, // 用户消息通常没有 model
]);
// 调用 rankModels 方法
const result = await messageModel.rankModels();
// 断言结果
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ id: 'gpt-3.5', count: 1 });
});
it('should return empty array when no models are used', async () => {
// 创建测试数据,所有消息都没有 model
await serverDB.insert(messages).values([
{ id: '1', userId, role: 'user', content: 'message 1' },
{ id: '2', userId, role: 'assistant', content: 'message 2' },
]);
// 调用 rankModels 方法
const result = await messageModel.rankModels();
// 断言结果
expect(result).toHaveLength(0);
});
it('should order models by count in descending order', async () => {
// 创建测试数据,使用不同次数的模型
await serverDB.insert(messages).values([
{ id: '1', userId, role: 'assistant', content: 'message 1', model: 'gpt-4' },
{ id: '2', userId, role: 'assistant', content: 'message 2', model: 'gpt-3.5' },
{ id: '3', userId, role: 'assistant', content: 'message 3', model: 'gpt-3.5' },
{ id: '4', userId, role: 'assistant', content: 'message 4', model: 'claude' },
{ id: '5', userId, role: 'assistant', content: 'message 5', model: 'gpt-3.5' },
]);
// 调用 rankModels 方法
const result = await messageModel.rankModels();
// 断言结果
expect(result).toHaveLength(3);
expect(result[0]).toEqual({ id: 'gpt-3.5', count: 3 }); // 最多使用
expect(result[1]).toEqual({ id: 'claude', count: 1 });
expect(result[2]).toEqual({ id: 'gpt-4', count: 1 });
});
});
describe('hasMoreThanN', () => {
it('should return true when message count is greater than N', async () => {
// 创建测试数据
await serverDB.insert(messages).values([
{ id: '1', userId, role: 'user', content: 'message 1' },
{ id: '2', userId, role: 'user', content: 'message 2' },
{ id: '3', userId, role: 'user', content: 'message 3' },
]);
// 测试不同的 N 值
const result1 = await messageModel.hasMoreThanN(2); // 3 > 2
const result2 = await messageModel.hasMoreThanN(3); // 3 ≯ 3
const result3 = await messageModel.hasMoreThanN(4); // 3 ≯ 4
expect(result1).toBe(true);
expect(result2).toBe(false);
expect(result3).toBe(false);
});
it('should only count messages belonging to the user', async () => {
// 创建测试数据,包括其他用户的消息
await serverDB.insert(messages).values([
{ id: '1', userId, role: 'user', content: 'message 1' },
{ id: '2', userId, role: 'user', content: 'message 2' },
{ id: '3', userId: '456', role: 'user', content: 'message 3' }, // 其他用户的消息
]);
const result = await messageModel.hasMoreThanN(2);
expect(result).toBe(false); // 当前用户只有 2 条消息,不大于 2
});
it('should return false when no messages exist', async () => {
const result = await messageModel.hasMoreThanN(0);
expect(result).toBe(false);
});
it('should handle edge cases', async () => {
// 创建一条消息
await serverDB
.insert(messages)
.values([{ id: '1', userId, role: 'user', content: 'message 1' }]);
// 测试边界情况
const result1 = await messageModel.hasMoreThanN(0); // 1 > 0
const result2 = await messageModel.hasMoreThanN(1); // 1 ≯ 1
const result3 = await messageModel.hasMoreThanN(-1); // 1 > -1
expect(result1).toBe(true);
expect(result2).toBe(false);
expect(result3).toBe(true);
});
});
});
@@ -626,8 +626,6 @@ describe('SessionModel', () => {
});
});
// 在原有的 describe('SessionModel') 中添加以下测试套件
describe('createInbox', () => {
it('should create inbox session if not exists', async () => {
const inbox = await sessionModel.createInbox();
@@ -782,4 +780,189 @@ describe('SessionModel', () => {
});
});
});
describe('rank', () => {
it('should return ranked sessions based on topic count', async () => {
// Create test data
await serverDB.transaction(async (trx) => {
// Create sessions
await trx.insert(sessions).values([
{ id: '1', userId },
{ id: '2', userId },
{ id: '3', userId },
]);
// Create agents
await trx.insert(agents).values([
{ id: 'a1', userId, title: 'Agent 1', avatar: 'avatar1', backgroundColor: 'bg1' },
{ id: 'a2', userId, title: 'Agent 2', avatar: 'avatar2', backgroundColor: 'bg2' },
{ id: 'a3', userId, title: 'Agent 3', avatar: 'avatar3', backgroundColor: 'bg3' },
]);
// Link agents to sessions
await trx.insert(agentsToSessions).values([
{ sessionId: '1', agentId: 'a1' },
{ sessionId: '2', agentId: 'a2' },
{ sessionId: '3', agentId: 'a3' },
]);
// Create topics (different counts for ranking)
await trx.insert(topics).values([
{ id: 't1', sessionId: '1', userId },
{ id: 't2', sessionId: '1', userId },
{ id: 't3', sessionId: '1', userId }, // Session 1 has 3 topics
{ id: 't4', sessionId: '2', userId },
{ id: 't5', sessionId: '2', userId }, // Session 2 has 2 topics
{ id: 't6', sessionId: '3', userId }, // Session 3 has 1 topic
]);
});
// Get ranked sessions with default limit
const result = await sessionModel.rank();
// Verify results
expect(result).toHaveLength(3);
// Should be ordered by topic count (descending)
expect(result[0]).toMatchObject({
id: '1',
count: 3,
title: 'Agent 1',
avatar: 'avatar1',
backgroundColor: 'bg1',
});
expect(result[1]).toMatchObject({
id: '2',
count: 2,
title: 'Agent 2',
avatar: 'avatar2',
backgroundColor: 'bg2',
});
expect(result[2]).toMatchObject({
id: '3',
count: 1,
title: 'Agent 3',
avatar: 'avatar3',
backgroundColor: 'bg3',
});
});
it('should respect the limit parameter', async () => {
// Create test data
await serverDB.transaction(async (trx) => {
// Create sessions and related data
await trx.insert(sessions).values([
{ id: '1', userId },
{ id: '2', userId },
{ id: '3', userId },
]);
await trx.insert(agents).values([
{ id: 'a1', userId, title: 'Agent 1' },
{ id: 'a2', userId, title: 'Agent 2' },
{ id: 'a3', userId, title: 'Agent 3' },
]);
await trx.insert(agentsToSessions).values([
{ sessionId: '1', agentId: 'a1' },
{ sessionId: '2', agentId: 'a2' },
{ sessionId: '3', agentId: 'a3' },
]);
await trx.insert(topics).values([
{ id: 't1', sessionId: '1', userId },
{ id: 't2', sessionId: '1', userId },
{ id: 't3', sessionId: '2', userId },
{ id: 't4', sessionId: '3', userId },
]);
});
// Get ranked sessions with limit of 2
const result = await sessionModel.rank(2);
// Verify results
expect(result).toHaveLength(2);
expect(result[0].id).toBe('1'); // Most topics (2)
expect(result[1].id).toBe('2'); // Second most topics (1)
});
it('should handle sessions with no topics', async () => {
// Create test data
await serverDB.transaction(async (trx) => {
await trx.insert(sessions).values([
{ id: '1', userId },
{ id: '2', userId },
]);
await trx.insert(agents).values([
{ id: 'a1', userId, title: 'Agent 1' },
{ id: 'a2', userId, title: 'Agent 2' },
]);
await trx.insert(agentsToSessions).values([
{ sessionId: '1', agentId: 'a1' },
{ sessionId: '2', agentId: 'a2' },
]);
// No topics created
});
const result = await sessionModel.rank();
expect(result).toHaveLength(0);
});
});
describe('hasMoreThanN', () => {
it('should return true when session count is more than N', async () => {
// Create test data
await serverDB.insert(sessions).values([
{ id: '1', userId },
{ id: '2', userId },
{ id: '3', userId },
]);
const result = await sessionModel.hasMoreThanN(2);
expect(result).toBe(true);
});
it('should return false when session count is equal to N', async () => {
// Create test data
await serverDB.insert(sessions).values([
{ id: '1', userId },
{ id: '2', userId },
]);
const result = await sessionModel.hasMoreThanN(2);
expect(result).toBe(false);
});
it('should return false when session count is less than N', async () => {
// Create test data
await serverDB.insert(sessions).values([{ id: '1', userId }]);
const result = await sessionModel.hasMoreThanN(2);
expect(result).toBe(false);
});
it('should only count sessions for the current user', async () => {
// Create sessions for current user and another user
await serverDB.transaction(async (trx) => {
await trx.insert(users).values([{ id: 'other-user' }]);
await trx.insert(sessions).values([
{ id: '1', userId }, // Current user
{ id: '2', userId: 'other-user' }, // Other user
{ id: '3', userId: 'other-user' }, // Other user
]);
});
const result = await sessionModel.hasMoreThanN(1);
// Should return false as current user only has 1 session
expect(result).toBe(false);
});
it('should return false when no sessions exist', async () => {
const result = await sessionModel.hasMoreThanN(0);
expect(result).toBe(false);
});
});
});
@@ -620,4 +620,140 @@ describe('TopicModel', () => {
);
});
});
describe('rank', () => {
it('should return ranked topics based on message count', async () => {
// 创建测试数据
await serverDB.transaction(async (tx) => {
await tx.insert(topics).values([
{ id: 'topic1', title: 'Topic 1', sessionId, userId },
{ id: 'topic2', title: 'Topic 2', sessionId, userId },
{ id: 'topic3', title: 'Topic 3', sessionId, userId },
]);
// topic1 有 3 条消息
await tx.insert(messages).values([
{ id: 'msg1', role: 'user', topicId: 'topic1', userId },
{ id: 'msg2', role: 'assistant', topicId: 'topic1', userId },
{ id: 'msg3', role: 'user', topicId: 'topic1', userId },
]);
// topic2 有 2 条消息
await tx.insert(messages).values([
{ id: 'msg4', role: 'user', topicId: 'topic2', userId },
{ id: 'msg5', role: 'assistant', topicId: 'topic2', userId },
]);
// topic3 有 1 条消息
await tx.insert(messages).values([{ id: 'msg6', role: 'user', topicId: 'topic3', userId }]);
});
// 调用 rank 方法
const result = await topicModel.rank(2);
// 断言返回结果符合预期
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
id: 'topic1',
title: 'Topic 1',
count: 3,
sessionId,
});
expect(result[1]).toMatchObject({
id: 'topic2',
title: 'Topic 2',
count: 2,
sessionId,
});
});
it('should return empty array if no topics exist', async () => {
const result = await topicModel.rank();
expect(result).toHaveLength(0);
});
it('should respect the limit parameter', async () => {
// 创建测试数据
await serverDB.transaction(async (tx) => {
await tx.insert(topics).values([
{ id: 'topic1', title: 'Topic 1', sessionId, userId },
{ id: 'topic2', title: 'Topic 2', sessionId, userId },
]);
await tx.insert(messages).values([
{ id: 'msg1', role: 'user', topicId: 'topic1', userId },
{ id: 'msg2', role: 'user', topicId: 'topic2', userId },
]);
});
// 使用限制为 1 调用 rank 方法
const result = await topicModel.rank(1);
// 断言只返回一个结果
expect(result).toHaveLength(1);
});
});
describe('count with date filters', () => {
beforeEach(async () => {
// 创建测试数据
await serverDB.insert(topics).values([
{
id: 'topic1',
userId,
createdAt: new Date('2023-01-01'),
},
{
id: 'topic2',
userId,
createdAt: new Date('2023-02-01'),
},
{
id: 'topic3',
userId,
createdAt: new Date('2023-03-01'),
},
]);
});
it('should count topics with start date filter', async () => {
const result = await topicModel.count({
startDate: '2023-02-01',
});
expect(result).toBe(2); // should count topics from Feb 1st onwards
});
it('should count topics with end date filter', async () => {
const result = await topicModel.count({
endDate: '2023-02-01',
});
expect(result).toBe(2); // should count topics up to Feb 1st
});
it('should count topics within date range', async () => {
const result = await topicModel.count({
range: ['2023-01-15', '2023-02-15'],
});
expect(result).toBe(1); // should only count topic2
});
it('should return 0 if no topics match date filters', async () => {
const result = await topicModel.count({
range: ['2024-01-01', '2024-12-31'],
});
expect(result).toBe(0);
});
it('should handle invalid date filters gracefully', async () => {
const result = await topicModel.count({
startDate: 'invalid-date',
});
expect(result).toBe(3); // should return all topics if date is invalid
});
});
});
@@ -1,3 +1,4 @@
import dayjs from 'dayjs';
import { eq } from 'drizzle-orm/expressions';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -5,7 +6,6 @@ import { INBOX_SESSION_ID } from '@/const/session';
import { getTestDBInstance } from '@/database/server/core/dbForTest';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { UserGuide, UserPreference } from '@/types/user';
import { UserSettings } from '@/types/user/settings';
import { UserSettingsItem, userSettings, users } from '../../../schemas';
import { SessionModel } from '../session';
@@ -268,4 +268,143 @@ describe('UserModel', () => {
expect(result).toEqual({});
});
});
describe('getUserRegistrationDuration', () => {
it('should return default values when user not found', async () => {
const duration = await userModel.getUserRegistrationDuration();
const today = dayjs().format('YYYY-MM-DD');
expect(duration).toEqual({
createdAt: today,
duration: 1,
updatedAt: today,
});
});
it('should calculate correct duration for existing user', async () => {
// Mock the current date
const now = new Date('2024-01-15');
vi.setSystemTime(now);
const createdAt = new Date('2024-01-10'); // 5 days ago
await serverDB.insert(users).values({
id: userId,
createdAt,
});
const duration = await userModel.getUserRegistrationDuration();
expect(duration).toEqual({
createdAt: '2024-01-10',
duration: 6, // 5 days difference + 1
updatedAt: '2024-01-15',
});
vi.useRealTimers();
});
});
// 补充一些边界情况的测试
describe('edge cases', () => {
describe('updatePreference', () => {
it('should handle undefined preference', async () => {
await serverDB.insert(users).values({ id: userId });
const newPreference: Partial<UserPreference> = {
guide: { topic: true },
};
await userModel.updatePreference(newPreference);
const updatedUser = await serverDB.query.users.findFirst({
where: eq(users.id, userId),
});
expect(updatedUser?.preference).toMatchObject(newPreference);
});
it('should do nothing if user not found', async () => {
const nonExistentUserModel = new UserModel(serverDB, 'non-existent-id');
const result = await nonExistentUserModel.updatePreference({ guide: { topic: true } });
expect(result).toBeUndefined();
});
});
describe('updateGuide', () => {
it('should handle undefined guide', async () => {
await serverDB.insert(users).values({
id: userId,
preference: {} as UserPreference,
});
const newGuide: Partial<UserGuide> = {
topic: true,
};
await userModel.updateGuide(newGuide);
const updatedUser = await serverDB.query.users.findFirst({
where: eq(users.id, userId),
});
expect(updatedUser?.preference).toEqual({ guide: newGuide });
});
it('should do nothing if user not found', async () => {
const nonExistentUserModel = new UserModel(serverDB, 'non-existent-id');
const result = await nonExistentUserModel.updateGuide({ topic: true });
expect(result).toBeUndefined();
});
});
describe('createUser', () => {
it('should not create duplicate user with same id', async () => {
const params = {
id: userId,
username: 'existinguser',
email: 'existing@example.com',
};
// First creation
await UserModel.createUser(serverDB, params);
// Attempt to create with same ID but different details
await UserModel.createUser(serverDB, {
...params,
username: 'newuser',
email: 'new@example.com',
});
const user = await UserModel.findById(serverDB, userId);
expect(user?.username).toBe('existinguser');
expect(user?.email).toBe('existing@example.com');
});
});
describe('getUserState', () => {
it('should handle empty settings', async () => {
await serverDB.insert(users).values({
id: userId,
preference: {} as UserPreference,
isOnboarded: true,
});
const state = await userModel.getUserState(KeyVaultsGateKeeper.getUserKeyVaults);
expect(state).toMatchObject({
userId,
isOnboarded: true,
preference: {},
settings: {
defaultAgent: {},
general: {},
keyVaults: {},
languageModel: {},
systemAgent: {},
tool: {},
tts: {},
},
});
});
});
});
});
+118 -23
View File
@@ -1,7 +1,15 @@
import { count } from 'drizzle-orm';
import { and, asc, desc, eq, gte, inArray, isNull, like, lt } from 'drizzle-orm/expressions';
import type { HeatmapsProps } from '@lobehub/charts';
import dayjs from 'dayjs';
import { count, sql } from 'drizzle-orm';
import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, like } from 'drizzle-orm/expressions';
import { LobeChatDatabase } from '@/database/type';
import {
genEndDateWhere,
genRangeWhere,
genStartDateWhere,
genWhere,
} from '@/database/utils/genWhere';
import { idGenerator } from '@/database/utils/idGenerator';
import {
ChatFileItem,
@@ -9,8 +17,10 @@ import {
ChatTTS,
ChatToolPayload,
CreateMessageParams,
ModelRankItem,
} from '@/types/message';
import { merge } from '@/utils/merge';
import { today } from '@/utils/time';
import {
MessageItem,
@@ -265,39 +275,124 @@ export class MessageModel {
});
};
count = async (): Promise<number> => {
const result = await this.db
.select({
count: count(messages.id),
})
.from(messages)
.where(eq(messages.userId, this.userId));
return result[0].count;
};
countToday = async (): Promise<number> => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
count = async (params?: {
endDate?: string;
range?: [string, string];
startDate?: string;
}): Promise<number> => {
const result = await this.db
.select({
count: count(messages.id),
})
.from(messages)
.where(
and(
genWhere([
eq(messages.userId, this.userId),
gte(messages.createdAt, today),
lt(messages.createdAt, tomorrow),
),
params?.range
? genRangeWhere(params.range, messages.createdAt, (date) => date.toDate())
: undefined,
params?.endDate
? genEndDateWhere(params.endDate, messages.createdAt, (date) => date.toDate())
: undefined,
params?.startDate
? genStartDateWhere(params.startDate, messages.createdAt, (date) => date.toDate())
: undefined,
]),
);
return result[0].count;
};
countWords = async (params?: {
endDate?: string;
range?: [string, string];
startDate?: string;
}): Promise<number> => {
const result = await this.db
.select({
count: sql<string>`sum(length(${messages.content}))`.as('total_length'),
})
.from(messages)
.where(
genWhere([
eq(messages.userId, this.userId),
params?.range
? genRangeWhere(params.range, messages.createdAt, (date) => date.toDate())
: undefined,
params?.endDate
? genEndDateWhere(params.endDate, messages.createdAt, (date) => date.toDate())
: undefined,
params?.startDate
? genStartDateWhere(params.startDate, messages.createdAt, (date) => date.toDate())
: undefined,
]),
);
return Number(result[0].count);
};
rankModels = async (limit: number = 10): Promise<ModelRankItem[]> => {
return this.db
.select({
count: count(messages.id).as('count'),
id: messages.model,
})
.from(messages)
.where(and(eq(messages.userId, this.userId), isNotNull(messages.model)))
.having(({ count }) => gt(count, 0))
.groupBy(messages.model)
.orderBy(desc(sql`count`), asc(messages.model))
.limit(limit);
};
getHeatmaps = async (): Promise<HeatmapsProps['data']> => {
const startDate = today().subtract(1, 'year').startOf('day');
const endDate = today().endOf('day');
const result = await this.db
.select({
count: count(messages.id),
date: sql`DATE(${messages.createdAt})`.as('heatmaps_date'),
})
.from(messages)
.where(
genWhere([
eq(messages.userId, this.userId),
genRangeWhere(
[startDate.format('YYYY-MM-DD'), endDate.add(1, 'day').format('YYYY-MM-DD')],
messages.createdAt,
(date) => date.toDate(),
),
]),
)
.groupBy(sql`heatmaps_date`)
.orderBy(desc(sql`heatmaps_date`));
const heatmapData: HeatmapsProps['data'] = [];
let currentDate = startDate;
while (currentDate.isBefore(endDate) || currentDate.isSame(endDate, 'day')) {
const formattedDate = currentDate.format('YYYY-MM-DD');
const matchingResult = result.find(
(r) => r?.date && dayjs(r.date as string).format('YYYY-MM-DD') === formattedDate,
);
const count = matchingResult ? matchingResult.count : 0;
const levelCount = count > 0 ? Math.floor(count / 5) : 0;
const level = levelCount > 4 ? 4 : levelCount;
heatmapData.push({
count,
date: formattedDate,
level,
});
currentDate = currentDate.add(1, 'day');
}
return heatmapData;
};
hasMoreThanN = async (n: number): Promise<boolean> => {
const result = await this.db
.select({ id: messages.id })
+75 -4
View File
@@ -1,13 +1,20 @@
import { Column, count, sql } from 'drizzle-orm';
import { and, asc, desc, eq, inArray, like, not, or } from 'drizzle-orm/expressions';
import { and, asc, desc, eq, gt, inArray, isNull, like, not, or } from 'drizzle-orm/expressions';
import { appEnv } from '@/config/app';
import { DEFAULT_INBOX_AVATAR } from '@/const/meta';
import { INBOX_SESSION_ID } from '@/const/session';
import { DEFAULT_AGENT_CONFIG } from '@/const/settings';
import { LobeChatDatabase } from '@/database/type';
import {
genEndDateWhere,
genRangeWhere,
genStartDateWhere,
genWhere,
} from '@/database/utils/genWhere';
import { idGenerator } from '@/database/utils/idGenerator';
import { parseAgentConfig } from '@/server/globalConfig/parseDefaultAgent';
import { ChatSessionList, LobeAgentSession } from '@/types/session';
import { ChatSessionList, LobeAgentSession, SessionRankItem } from '@/types/session';
import { merge } from '@/utils/merge';
import {
@@ -19,6 +26,7 @@ import {
agentsToSessions,
sessionGroups,
sessions,
topics,
} from '../../schemas';
export class SessionModel {
@@ -84,17 +92,80 @@ export class SessionModel {
return { ...result, agent: (result?.agentsToSessions?.[0] as any)?.agent } as any;
};
count = async (): Promise<number> => {
count = async (params?: {
endDate?: string;
range?: [string, string];
startDate?: string;
}): Promise<number> => {
const result = await this.db
.select({
count: count(sessions.id),
})
.from(sessions)
.where(eq(sessions.userId, this.userId));
.where(
genWhere([
eq(sessions.userId, this.userId),
params?.range
? genRangeWhere(params.range, sessions.createdAt, (date) => date.toDate())
: undefined,
params?.endDate
? genEndDateWhere(params.endDate, sessions.createdAt, (date) => date.toDate())
: undefined,
params?.startDate
? genStartDateWhere(params.startDate, sessions.createdAt, (date) => date.toDate())
: undefined,
]),
);
return result[0].count;
};
_rank = async (limit: number = 10): Promise<SessionRankItem[]> => {
return this.db
.select({
avatar: agents.avatar,
backgroundColor: agents.backgroundColor,
count: count(topics.id).as('count'),
id: sessions.id,
title: agents.title,
})
.from(sessions)
.leftJoin(topics, eq(sessions.id, topics.sessionId))
.leftJoin(agentsToSessions, eq(sessions.id, agentsToSessions.sessionId))
.leftJoin(agents, eq(agentsToSessions.agentId, agents.id))
.groupBy(sessions.id, agentsToSessions.agentId, agents.id)
.having(({ count }) => gt(count, 0))
.orderBy(desc(sql`count`))
.limit(limit);
};
// TODO: 未来将 Inbox id 入库后可以直接使用 _rank 方法
rank = async (limit: number = 10): Promise<SessionRankItem[]> => {
const inboxResult = await this.db
.select({
count: count(topics.id).as('count'),
})
.from(topics)
.where(isNull(topics.sessionId));
const inboxCount = inboxResult[0].count;
if (!inboxCount || inboxCount === 0) return this._rank(limit);
const result = await this._rank(limit ? limit - 1 : undefined);
return [
{
avatar: DEFAULT_INBOX_AVATAR,
backgroundColor: null,
count: inboxCount,
id: INBOX_SESSION_ID,
title: 'inbox.title',
},
...result,
].sort((a, b) => b.count - a.count);
};
hasMoreThanN = async (n: number): Promise<boolean> => {
const result = await this.db
.select({ id: sessions.id })
+43 -3
View File
@@ -1,8 +1,15 @@
import { Column, count, sql } from 'drizzle-orm';
import { and, desc, eq, exists, inArray, isNull, like, or } from 'drizzle-orm/expressions';
import { and, desc, eq, exists, gt, inArray, isNull, like, or } from 'drizzle-orm/expressions';
import { LobeChatDatabase } from '@/database/type';
import {
genEndDateWhere,
genRangeWhere,
genStartDateWhere,
genWhere,
} from '@/database/utils/genWhere';
import { idGenerator } from '@/database/utils/idGenerator';
import { TopicRankItem } from '@/types/topic';
import { NewMessage, TopicItem, messages, topics } from '../../schemas';
@@ -92,17 +99,50 @@ export class TopicModel {
});
};
count = async (): Promise<number> => {
count = async (params?: {
endDate?: string;
range?: [string, string];
startDate?: string;
}): Promise<number> => {
const result = await this.db
.select({
count: count(topics.id),
})
.from(topics)
.where(eq(topics.userId, this.userId));
.where(
genWhere([
eq(topics.userId, this.userId),
params?.range
? genRangeWhere(params.range, topics.createdAt, (date) => date.toDate())
: undefined,
params?.endDate
? genEndDateWhere(params.endDate, topics.createdAt, (date) => date.toDate())
: undefined,
params?.startDate
? genStartDateWhere(params.startDate, topics.createdAt, (date) => date.toDate())
: undefined,
]),
);
return result[0].count;
};
rank = async (limit: number = 10): Promise<TopicRankItem[]> => {
return this.db
.select({
count: count(messages.id).as('count'),
id: topics.id,
sessionId: topics.sessionId,
title: topics.title,
})
.from(topics)
.leftJoin(messages, eq(topics.id, messages.topicId))
.groupBy(topics.id)
.orderBy(desc(sql`count`))
.having(({ count }) => gt(count, 0))
.limit(limit);
};
// **************** Create *************** //
create = async (
+22
View File
@@ -1,4 +1,5 @@
import { TRPCError } from '@trpc/server';
import dayjs from 'dayjs';
import { eq } from 'drizzle-orm/expressions';
import { DeepPartial } from 'utility-types';
@@ -6,6 +7,7 @@ import { LobeChatDatabase } from '@/database/type';
import { UserGuide, UserPreference } from '@/types/user';
import { UserKeyVaults, UserSettings } from '@/types/user/settings';
import { merge } from '@/utils/merge';
import { today } from '@/utils/time';
import { NewUser, UserItem, UserSettingsItem, userSettings, users } from '../../schemas';
import { SessionModel } from './session';
@@ -30,6 +32,26 @@ export class UserModel {
this.db = db;
}
getUserRegistrationDuration = async (): Promise<{
createdAt: string;
duration: number;
updatedAt: string;
}> => {
const user = await this.db.query.users.findFirst({ where: eq(users.id, this.userId) });
if (!user)
return {
createdAt: today().format('YYYY-MM-DD'),
duration: 1,
updatedAt: today().format('YYYY-MM-DD'),
};
return {
createdAt: dayjs(user.createdAt).format('YYYY-MM-DD'),
duration: dayjs().diff(dayjs(user.createdAt), 'day') + 1,
updatedAt: today().format('YYYY-MM-DD'),
};
};
getUserState = async (decryptor: DecryptUserKeyVaults) => {
const result = await this.db
.select({
+39
View File
@@ -0,0 +1,39 @@
import dayjs, { Dayjs } from 'dayjs';
import { SQL } from 'drizzle-orm';
import { and, gte, lte } from 'drizzle-orm/expressions';
export const genWhere = (sqls: (SQL<any> | undefined)[]): SQL<any> | undefined => {
const where = sqls.filter(Boolean);
if (where.length > 1) return and(...where);
return where[0];
};
export const genStartDateWhere = (
date: string | undefined,
key: any,
format: (date: Dayjs) => any,
): SQL | undefined => {
if (!date || !dayjs(date).isValid()) return;
return gte(key, format(dayjs(new Date(date))));
};
export const genEndDateWhere = (
date: string | undefined,
key: any,
format: (date: Dayjs) => any,
): SQL | undefined => {
if (!date || !dayjs(date).isValid()) return;
return lte(key, format(dayjs(new Date(date)).add(1, 'day')));
};
export const genRangeWhere = (
range: [string, string] | undefined,
key: any,
format: (date: Dayjs) => any,
): SQL | undefined => {
if (!range) return;
if (!dayjs(range[0]).isValid() && !dayjs(range[1]).isValid()) return;
if (!dayjs(range[0]).isValid()) return genEndDateWhere(range[1], key, format);
if (!dayjs(range[1]).isValid()) return genStartDateWhere(range[0], key, format);
return and(genStartDateWhere(range[0], key, format), genEndDateWhere(range[1], key, format));
};
+11 -24
View File
@@ -1,34 +1,17 @@
import { Form, type FormItemProps } from '@lobehub/ui';
import { Button, Segmented, SegmentedProps, Switch } from 'antd';
import { Button, Segmented, Switch } from 'antd';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { FORM_STYLE } from '@/const/layoutTokens';
import { useIsMobile } from '@/hooks/useIsMobile';
import { ImageType, imageTypeOptions, useScreenshot } from '@/hooks/useScreenshot';
import { useSessionStore } from '@/store/session';
import { sessionMetaSelectors } from '@/store/session/selectors';
import Preview from './Preview';
import { FieldType, ImageType } from './type';
import { useScreenshot } from './useScreenshot';
export const imageTypeOptions: SegmentedProps['options'] = [
{
label: 'JPG',
value: ImageType.JPG,
},
{
label: 'PNG',
value: ImageType.PNG,
},
{
label: 'SVG',
value: ImageType.SVG,
},
{
label: 'WEBP',
value: ImageType.WEBP,
},
];
import { FieldType } from './type';
const DEFAULT_FIELD_VALUE: FieldType = {
imageType: ImageType.JPG,
@@ -39,9 +22,13 @@ const DEFAULT_FIELD_VALUE: FieldType = {
};
const ShareImage = memo(() => {
const currentAgentTitle = useSessionStore(sessionMetaSelectors.currentAgentTitle);
const [fieldValue, setFieldValue] = useState<FieldType>(DEFAULT_FIELD_VALUE);
const { t } = useTranslation('chat');
const { loading, onDownload, title } = useScreenshot(fieldValue.imageType);
const { t } = useTranslation(['chat', 'common']);
const { loading, onDownload, title } = useScreenshot({
imageType: fieldValue.imageType,
title: currentAgentTitle,
});
const settings: FormItemProps[] = [
{
+1 -6
View File
@@ -1,9 +1,4 @@
export enum ImageType {
JPG = 'jpg',
PNG = 'png',
SVG = 'svg',
WEBP = 'webp',
}
import { ImageType } from '@/hooks/useScreenshot';
export type FieldType = {
imageType: ImageType;
+21 -14
View File
@@ -3,17 +3,18 @@
import { Icon, Tooltip } from '@lobehub/ui';
import { Badge } from 'antd';
import { createStyles } from 'antd-style';
import { isNumber } from 'lodash-es';
import { isNumber, isUndefined } from 'lodash-es';
import { LoaderCircle } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox, FlexboxProps } from 'react-layout-kit';
import useSWR from 'swr';
import { useClientDataSWR } from '@/libs/swr';
import { messageService } from '@/services/message';
import { sessionService } from '@/services/session';
import { topicService } from '@/services/topic';
import { useServerConfigStore } from '@/store/serverConfig';
import { today } from '@/utils/time';
const useStyles = createStyles(({ css, token }) => ({
card: css`
@@ -21,6 +22,10 @@ const useStyles = createStyles(({ css, token }) => ({
padding-inline: 8px;
background: ${token.colorFillTertiary};
border-radius: ${token.borderRadius}px;
&:hover {
background: ${token.colorFillSecondary};
}
`,
count: css`
font-size: 16px;
@@ -57,21 +62,23 @@ const formatNumber = (num: any) => {
const DataStatistics = memo<Omit<FlexboxProps, 'children'>>(({ style, ...rest }) => {
const mobile = useServerConfigStore((s) => s.isMobile);
// sessions
const { data: sessions, isLoading: sessionsLoading } = useSWR(
'count-sessions',
sessionService.countSessions,
const { data: sessions, isLoading: sessionsLoading } = useClientDataSWR('count-sessions', () =>
sessionService.countSessions(),
);
// topics
const { data: topics, isLoading: topicsLoading } = useSWR(
'count-topics',
topicService.countTopics,
const { data: topics, isLoading: topicsLoading } = useClientDataSWR('count-topics', () =>
topicService.countTopics(),
);
// messages
const { data: messages, isLoading: messagesLoading } = useSWR(
const { data: { messages, messagesToday } = {}, isLoading: messagesLoading } = useClientDataSWR(
'count-messages',
messageService.countMessages,
async () => ({
messages: await messageService.countMessages(),
messagesToday: await messageService.countMessages({
startDate: today().format('YYYY-MM-DD'),
}),
}),
);
const { data: messagesToday } = useSWR('today-messages', messageService.countTodayMessages);
const { styles, theme } = useStyles();
const { t } = useTranslation('common');
@@ -80,17 +87,17 @@ const DataStatistics = memo<Omit<FlexboxProps, 'children'>>(({ style, ...rest })
const items = [
{
count: sessionsLoading ? loading : sessions,
count: sessionsLoading || isUndefined(sessions) ? loading : sessions,
key: 'sessions',
title: t('dataStatistics.sessions'),
},
{
count: topicsLoading ? loading : topics,
count: topicsLoading || isUndefined(topics) ? loading : topics,
key: 'topics',
title: t('dataStatistics.topics'),
},
{
count: messagesLoading ? loading : messages,
count: messagesLoading || isUndefined(messages) ? loading : messages,
countToady: messagesToday,
key: 'messages',
title: t('dataStatistics.messages'),
+12 -16
View File
@@ -1,9 +1,11 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import BrandWatermark from '@/components/BrandWatermark';
import Menu from '@/components/Menu';
import { isDeprecatedEdition } from '@/const/version';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/selectors';
@@ -17,21 +19,14 @@ import { useMenu } from './useMenu';
const PanelContent = memo<{ closePopover: () => void }>(({ closePopover }) => {
const router = useRouter();
const isLoginWithAuth = useUserStore(authSelectors.isLoginWithAuth);
const [openSignIn, signOut, openUserProfile, enableAuth, enabledNextAuth] = useUserStore((s) => [
const [openSignIn, signOut, enableAuth, enabledNextAuth] = useUserStore((s) => [
s.openLogin,
s.logout,
s.openUserProfile,
s.enableAuth(),
s.enabledNextAuth,
]);
const { mainItems, logoutItems } = useMenu();
const handleOpenProfile = () => {
if (!enableAuth) return;
openUserProfile();
closePopover();
};
const handleSignIn = () => {
openSignIn();
closePopover();
@@ -47,15 +42,16 @@ const PanelContent = memo<{ closePopover: () => void }>(({ closePopover }) => {
return (
<Flexbox gap={2} style={{ minWidth: 300 }}>
{!enableAuth ? (
{!enableAuth || (enableAuth && isLoginWithAuth) ? (
<>
<UserInfo />
<DataStatistics />
</>
) : isLoginWithAuth ? (
<>
<UserInfo onClick={handleOpenProfile} />
<DataStatistics />
<Link href={'/profile'} style={{ color: 'inherit' }}>
<UserInfo />
</Link>
{!isDeprecatedEdition && (
<Link href={'/profile/stats'} style={{ color: 'inherit' }}>
<DataStatistics />
</Link>
)}
</>
) : (
<UserLoginOrSignup onClick={handleSignIn} />
+4 -6
View File
@@ -68,19 +68,17 @@ export const useMenu = () => {
const hasNewVersion = useNewVersion();
const { t } = useTranslation(['common', 'setting', 'auth']);
const { showCloudPromotion, hideDocs } = useServerConfigStore(featureFlagsSelectors);
const [isLogin, isLoginWithAuth, isLoginWithClerk, openUserProfile] = useUserStore((s) => [
const [enableAuth, isLogin, isLoginWithAuth] = useUserStore((s) => [
authSelectors.enabledAuth(s),
authSelectors.isLogin(s),
authSelectors.isLoginWithAuth(s),
authSelectors.isLoginWithClerk(s),
s.openUserProfile,
]);
const profile: MenuProps['items'] = [
{
icon: <Icon icon={CircleUserRound} />,
key: 'profile',
label: t('userPanel.profile'),
onClick: () => openUserProfile(),
label: <Link href={'/profile'}>{t('userPanel.profile')}</Link>,
},
];
@@ -227,7 +225,7 @@ export const useMenu = () => {
{
type: 'divider',
},
...(isLoginWithClerk ? profile : []),
...(!enableAuth || (enableAuth && isLoginWithAuth) ? profile : []),
...(isLogin ? settings : []),
/* ↓ cloud slot ↓ */

Some files were not shown because too many files have changed in this diff Show More