mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 12:10:16 +00:00
✨ 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:
@@ -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. 点击个人头像进入「账户管理」-「数据统计」页面
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import CircleLoading from '@/components/Loading/BrandTextLoading';
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
|
||||
export default () => <CircleLoading />;
|
||||
export default () => <Loading />;
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import CircleLoading from '@/components/Loading/BrandTextLoading';
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
|
||||
export default () => <CircleLoading />;
|
||||
export default () => <Loading />;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export interface LayoutProps {
|
||||
category: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
export default dynamic(() => import('@/components/404'));
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+4
-5
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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,9 +1,4 @@
|
||||
export enum ImageType {
|
||||
JPG = 'jpg',
|
||||
PNG = 'png',
|
||||
SVG = 'svg',
|
||||
WEBP = 'webp',
|
||||
}
|
||||
import { ImageType } from '@/hooks/useScreenshot';
|
||||
|
||||
export type FieldType = {
|
||||
imageType: ImageType;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user