mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
✨ feat: add user profile page (#2433)
* ✨ feat: Add profile * 🔧 chore: Update i18n * ✅ test: Fix test * ✅ test: Add tests * 🐛 fix: Fix enableClerk * 🔧 chore: Fix .eslintignore * 🐛 fix: Fix review problem * 💄 style: Fix UserInfo padding
This commit is contained in:
parent
b7913660bf
commit
91f72942e4
90 changed files with 1258 additions and 294 deletions
|
|
@ -10,9 +10,8 @@ coverage
|
|||
|
||||
# test
|
||||
jest*
|
||||
_test_
|
||||
__test__
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
|
||||
# umi
|
||||
.umi
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "تسجيل الدخول",
|
||||
"loginOrSignup": "تسجيل الدخول / التسجيل",
|
||||
"profile": "الملف الشخصي",
|
||||
"security": "الأمان",
|
||||
"signout": "تسجيل الخروج",
|
||||
"signup": "التسجيل"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "مستخدم مجهول",
|
||||
"billing": "إدارة الفواتير",
|
||||
"data": "تخزين البيانات",
|
||||
"defaultNickname": "مستخدم النسخة المجتمعية",
|
||||
"discord": "الدعم المجتمعي",
|
||||
"docs": "وثائق الاستخدام",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "Вход",
|
||||
"loginOrSignup": "Вход / Регистрация",
|
||||
"profile": "Профил",
|
||||
"security": "Сигурност",
|
||||
"signout": "Изход",
|
||||
"signup": "Регистрация"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "Анонимен потребител",
|
||||
"billing": "Управление на сметките",
|
||||
"data": "Съхранение на данни",
|
||||
"defaultNickname": "Потребител на общността",
|
||||
"discord": "Поддръжка на общността",
|
||||
"docs": "Документация",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"clerkAuth": {
|
||||
"loginSuccess": {
|
||||
"action": "继续会话",
|
||||
"desc": "{{greeting}},很高兴能够继续为你服务。让我们接着刚刚的话题聊下去吧",
|
||||
"title": "欢迎回来, {{nickName}}"
|
||||
"action": "Continue Session",
|
||||
"desc": "{{greeting}}, I'm glad to continue serving you. Let's pick up where we left off.",
|
||||
"title": "Welcome back, {{nickName}}"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "Anmelden",
|
||||
"loginOrSignup": "Anmelden / Registrieren",
|
||||
"profile": "Profil",
|
||||
"security": "Sicherheit",
|
||||
"signout": "Abmelden",
|
||||
"signup": "Registrieren"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "Anonymer Benutzer",
|
||||
"billing": "Abrechnung verwalten",
|
||||
"data": "Daten speichern",
|
||||
"defaultNickname": "Community User",
|
||||
"discord": "Community-Support",
|
||||
"docs": "Dokumentation",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "Login",
|
||||
"loginOrSignup": "Log in / Sign up",
|
||||
"profile": "Profile",
|
||||
"security": "Security",
|
||||
"signout": "Sign out",
|
||||
"signup": "Sign up"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "Anonymous User",
|
||||
"billing": "Billing Management",
|
||||
"data": "Data Storage",
|
||||
"defaultNickname": "Community User",
|
||||
"discord": "Community Support",
|
||||
"docs": "Documentation",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "Iniciar sesión",
|
||||
"loginOrSignup": "Iniciar sesión / Registrarse",
|
||||
"profile": "Perfil",
|
||||
"security": "Seguridad",
|
||||
"signout": "Cerrar sesión",
|
||||
"signup": "Registrarse"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "Usuario Anónimo",
|
||||
"billing": "Gestión de facturación",
|
||||
"data": "Almacenamiento de datos",
|
||||
"defaultNickname": "Usuario de la comunidad",
|
||||
"discord": "Soporte de la comunidad",
|
||||
"docs": "Documentación de uso",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "Connexion",
|
||||
"loginOrSignup": "Connexion / Inscription",
|
||||
"profile": "Profil",
|
||||
"security": "Sécurité",
|
||||
"signout": "Déconnexion",
|
||||
"signup": "Inscription"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "Utilisateur anonyme",
|
||||
"billing": "Gestion de la facturation",
|
||||
"data": "Stockage des données",
|
||||
"defaultNickname": "Utilisateur de la version communautaire",
|
||||
"discord": "Support de la communauté",
|
||||
"docs": "Documentation d'utilisation",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "Accedi",
|
||||
"loginOrSignup": "Accedi / Registrati",
|
||||
"profile": "Profilo",
|
||||
"security": "Sicurezza",
|
||||
"signout": "Esci",
|
||||
"signup": "Registrati"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "Utente Anonimo",
|
||||
"billing": "Gestione fatturazione",
|
||||
"data": "Archiviazione dati",
|
||||
"defaultNickname": "Utente Community",
|
||||
"discord": "Supporto della community",
|
||||
"docs": "Documentazione",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "ログイン",
|
||||
"loginOrSignup": "ログイン / 登録",
|
||||
"profile": "プロフィール",
|
||||
"security": "セキュリティ",
|
||||
"signout": "ログアウト",
|
||||
"signup": "サインアップ"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "匿名ユーザー",
|
||||
"billing": "請求管理",
|
||||
"data": "データストレージ",
|
||||
"defaultNickname": "コミュニティユーザー",
|
||||
"discord": "コミュニティサポート",
|
||||
"docs": "使用文書",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "로그인",
|
||||
"loginOrSignup": "로그인 / 가입",
|
||||
"profile": "프로필",
|
||||
"security": "보안",
|
||||
"signout": "로그아웃",
|
||||
"signup": "가입"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "익명 사용자",
|
||||
"billing": "결제 관리",
|
||||
"data": "데이터 저장",
|
||||
"defaultNickname": "커뮤니티 사용자",
|
||||
"discord": "커뮤니티 지원",
|
||||
"docs": "사용 설명서",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "Inloggen",
|
||||
"loginOrSignup": "Inloggen / Registreren",
|
||||
"profile": "Profiel",
|
||||
"security": "Veiligheid",
|
||||
"signout": "Uitloggen",
|
||||
"signup": "Registreren"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,52 +104,52 @@
|
|||
"pin": "Vastzetten",
|
||||
"pinOff": "Vastzetten uitschakelen",
|
||||
"privacy": "Privacybeleid",
|
||||
"regenerate": "重新生成",
|
||||
"rename": "重命名",
|
||||
"reset": "重置",
|
||||
"retry": "重试",
|
||||
"send": "发送",
|
||||
"setting": "设置",
|
||||
"share": "分享",
|
||||
"stop": "停止",
|
||||
"regenerate": "Opnieuw genereren",
|
||||
"rename": "Naam wijzigen",
|
||||
"reset": "Resetten",
|
||||
"retry": "Opnieuw proberen",
|
||||
"send": "Verzenden",
|
||||
"setting": "Instelling",
|
||||
"share": "Delen",
|
||||
"stop": "Stoppen",
|
||||
"sync": {
|
||||
"actions": {
|
||||
"settings": "同步设置",
|
||||
"sync": "立即同步"
|
||||
"settings": "Synchronisatie-instellingen",
|
||||
"sync": "Nu synchroniseren"
|
||||
},
|
||||
"awareness": {
|
||||
"current": "当前设备"
|
||||
"current": "Huidig apparaat"
|
||||
},
|
||||
"channel": "频道",
|
||||
"channel": "Kanaal",
|
||||
"disabled": {
|
||||
"actions": {
|
||||
"enable": "开启云端同步",
|
||||
"settings": "配置同步参数"
|
||||
"enable": "Cloudsynchronisatie inschakelen",
|
||||
"settings": "Synchronisatie-instellingen configureren"
|
||||
},
|
||||
"desc": "当前会话数据仅存储于此浏览器中。如果你需要在多个设备间同步数据,请配置并开启云端同步。",
|
||||
"title": "数据同步未开启"
|
||||
"desc": "De huidige gespreksgegevens worden alleen opgeslagen in deze browser. Als u gegevens wilt synchroniseren tussen meerdere apparaten, configureer en schakel dan cloudsynchronisatie in.",
|
||||
"title": "Gegevenssynchronisatie is uitgeschakeld"
|
||||
},
|
||||
"enabled": {
|
||||
"title": "数据同步"
|
||||
"title": "Gegevenssynchronisatie"
|
||||
},
|
||||
"status": {
|
||||
"connecting": "连接中",
|
||||
"disabled": "同步未开启",
|
||||
"ready": "已连接",
|
||||
"synced": "已同步",
|
||||
"syncing": "同步中",
|
||||
"unconnected": "连接失败"
|
||||
"connecting": "Verbinding maken",
|
||||
"disabled": "Synchronisatie is uitgeschakeld",
|
||||
"ready": "Verbonden",
|
||||
"synced": "Gesynchroniseerd",
|
||||
"syncing": "Synchroniseren",
|
||||
"unconnected": "Verbinding mislukt"
|
||||
},
|
||||
"title": "同步状态",
|
||||
"title": "Synchronisatiestatus",
|
||||
"unconnected": {
|
||||
"tip": "信令服务器连接失败,将无法建立点对点通信频道,请检查网络后重试"
|
||||
"tip": "Verbindingsfout met de signaleringsserver. Er kan geen point-to-point-communicatiekanaal worden opgezet. Controleer het netwerk en probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"chat": "会话",
|
||||
"market": "发现",
|
||||
"me": "我",
|
||||
"setting": "设置"
|
||||
"chat": "Chat",
|
||||
"market": "Ontdekken",
|
||||
"me": "Ik",
|
||||
"setting": "Instellingen"
|
||||
},
|
||||
"telemetry": {
|
||||
"allow": "Toestaan",
|
||||
|
|
@ -158,28 +158,29 @@
|
|||
"learnMore": "Meer informatie",
|
||||
"title": "Help LobeChat verbeteren"
|
||||
},
|
||||
"temp": "临时",
|
||||
"terms": "Algemene voorwaarden",
|
||||
"updateAgent": "更新助理信息",
|
||||
"temp": "tijdelijk",
|
||||
"terms": "algemene voorwaarden",
|
||||
"updateAgent": "update assistent",
|
||||
"upgradeVersion": {
|
||||
"action": "升级",
|
||||
"hasNew": "有可用更新",
|
||||
"newVersion": "有新版本可用:{{version}}"
|
||||
"action": "upgraden",
|
||||
"hasNew": "nieuwe versie beschikbaar",
|
||||
"newVersion": "nieuwe versie beschikbaar: {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"anonymousNickName": "Anonieme gebruiker",
|
||||
"billing": "账单管理",
|
||||
"defaultNickname": "Standaardgebruiker",
|
||||
"discord": "社区支持",
|
||||
"docs": "使用文档",
|
||||
"email": "邮件支持",
|
||||
"feedback": "反馈与建议",
|
||||
"help": "帮助中心",
|
||||
"moveGuide": "De instellingenknop is hierheen verplaatst",
|
||||
"plans": "订阅方案",
|
||||
"preview": "Voorbeeld",
|
||||
"profile": "账户管理",
|
||||
"setting": "应用设置",
|
||||
"usages": "用量统计"
|
||||
"anonymousNickName": "anonieme gebruiker",
|
||||
"billing": "facturatie",
|
||||
"data": "gegevensopslag",
|
||||
"defaultNickname": "communitygebruiker",
|
||||
"discord": "communityondersteuning",
|
||||
"docs": "gebruiksaanwijzing",
|
||||
"email": "e-mailondersteuning",
|
||||
"feedback": "feedback en suggesties",
|
||||
"help": "helpcentrum",
|
||||
"moveGuide": "instellingen verplaatst naar hier",
|
||||
"plans": "abonnementen",
|
||||
"preview": "voorbeeldversie",
|
||||
"profile": "accountbeheer",
|
||||
"setting": "app-instellingen",
|
||||
"usages": "gebruiksstatistieken"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "Zaloguj się",
|
||||
"loginOrSignup": "Zaloguj się / Zarejestruj się",
|
||||
"profile": "Profil użytkownika",
|
||||
"security": "Bezpieczeństwo",
|
||||
"signout": "Wyloguj",
|
||||
"signup": "Zarejestruj się"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "Użytkownik Anonimowy",
|
||||
"billing": "Zarządzanie rachunkami",
|
||||
"data": "Przechowywanie danych",
|
||||
"defaultNickname": "Użytkownik Wersji Społecznościowej",
|
||||
"discord": "Wsparcie społeczności",
|
||||
"docs": "Dokumentacja",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"clerkAuth": {
|
||||
"loginSuccess": {
|
||||
"action": "继续会话",
|
||||
"desc": "{{greeting}},很高兴能够继续为你服务。让我们接着刚刚的话题聊下去吧",
|
||||
"title": "欢迎回来, {{nickName}}"
|
||||
"action": "Continue session",
|
||||
"desc": "{{greeting}}, it's great to continue serving you. Let's continue our previous conversation.",
|
||||
"title": "Welcome back, {{nickName}}"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "Entrar",
|
||||
"loginOrSignup": "Entrar / Registrar",
|
||||
"profile": "Perfil",
|
||||
"security": "Segurança",
|
||||
"signout": "Sair",
|
||||
"signup": "Cadastre-se"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "Usuário Anônimo",
|
||||
"billing": "Gerenciamento de faturas",
|
||||
"data": "Armazenamento de dados",
|
||||
"defaultNickname": "Usuário da Comunidade",
|
||||
"discord": "Suporte da Comunidade",
|
||||
"docs": "Documentação",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "Войти",
|
||||
"loginOrSignup": "Войти / Зарегистрироваться",
|
||||
"profile": "Профиль",
|
||||
"security": "Безопасность",
|
||||
"signout": "Выйти",
|
||||
"signup": "Зарегистрироваться"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "Анонимный пользователь",
|
||||
"billing": "Управление счетами",
|
||||
"data": "Хранилище данных",
|
||||
"defaultNickname": "Пользователь сообщества",
|
||||
"discord": "Поддержка сообщества",
|
||||
"docs": "Документация",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"clerkAuth": {
|
||||
"loginSuccess": {
|
||||
"action": "继续会话",
|
||||
"desc": "{{greeting}},很高兴能够继续为你服务。让我们接着刚刚的话题聊下去吧",
|
||||
"title": "欢迎回来, {{nickName}}"
|
||||
"action": "Продолжить разговор",
|
||||
"desc": "{{greeting}}, рады снова быть к вашим услугам. Давайте продолжим нашу беседу",
|
||||
"title": "Добро пожаловать обратно, {{nickName}}"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "Giriş Yap",
|
||||
"loginOrSignup": "Giriş Yap / Kayıt Ol",
|
||||
"profile": "Profil",
|
||||
"security": "Güvenlik",
|
||||
"signout": "Çıkış Yap",
|
||||
"signup": "Kaydol"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "Anonim Kullanıcı",
|
||||
"billing": "Fatura Yönetimi",
|
||||
"data": "Veri Depolama",
|
||||
"defaultNickname": "Topluluk Kullanıcısı",
|
||||
"discord": "Topluluk Destek",
|
||||
"docs": "Belgeler",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "Đăng nhập",
|
||||
"loginOrSignup": "Đăng nhập / Đăng ký",
|
||||
"profile": "Hồ sơ cá nhân",
|
||||
"security": "Bảo mật",
|
||||
"signout": "Đăng xuất",
|
||||
"signup": "Đăng ký"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "Người dùng ẩn danh",
|
||||
"billing": "Quản lý hóa đơn",
|
||||
"data": "Lưu trữ dữ liệu",
|
||||
"defaultNickname": "Người dùng phiên bản cộng đồng",
|
||||
"discord": "Hỗ trợ cộng đồng",
|
||||
"docs": "Tài liệu sử dụng",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "登录",
|
||||
"loginOrSignup": "登录 / 注册",
|
||||
"profile": "个人资料",
|
||||
"security": "安全",
|
||||
"signout": "退出登录",
|
||||
"signup": "注册"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "匿名用户",
|
||||
"billing": "账单管理",
|
||||
"data": "数据存储",
|
||||
"defaultNickname": "社区版用户",
|
||||
"discord": "社区支持",
|
||||
"docs": "使用文档",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"login": "登入",
|
||||
"loginOrSignup": "登入 / 註冊",
|
||||
"profile": "個人檔案",
|
||||
"security": "安全",
|
||||
"signout": "登出",
|
||||
"signup": "註冊"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"userPanel": {
|
||||
"anonymousNickName": "匿名使用者",
|
||||
"billing": "帳單管理",
|
||||
"data": "資料儲存",
|
||||
"defaultNickname": "社群版使用者",
|
||||
"discord": "社區支援",
|
||||
"docs": "使用文件",
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import PageTitle from '@/components/PageTitle';
|
||||
|
||||
const Title = memo(() => {
|
||||
const { t } = useTranslation('auth');
|
||||
|
||||
return <PageTitle title={t('signup')} />;
|
||||
});
|
||||
export default Title;
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { UserProfile } from '@clerk/nextjs';
|
||||
|
||||
import PageTitle from './PageTitle';
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<>
|
||||
<PageTitle />
|
||||
<UserProfile />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { act, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
||||
import UserBanner from '../features/UserBanner';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/User/UserInfo', () => ({
|
||||
default: vi.fn(() => <div>Mocked UserInfo</div>),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/User/DataStatistics', () => ({
|
||||
default: vi.fn(() => <div>Mocked DataStatistics</div>),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/User/UserLoginOrSignup', () => ({
|
||||
default: vi.fn(() => <div>Mocked UserLoginOrSignup</div>),
|
||||
}));
|
||||
|
||||
// 定义一个变量来存储 enableAuth 的值
|
||||
let enableAuth = true;
|
||||
|
||||
// 模拟 @/const/auth 模块
|
||||
vi.mock('@/const/auth', () => ({
|
||||
get enableAuth() {
|
||||
return enableAuth;
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
enableAuth = true;
|
||||
});
|
||||
|
||||
describe('UserBanner', () => {
|
||||
it('should render UserInfo and DataStatistics when auth is disabled', () => {
|
||||
act(() => {
|
||||
useUserStore.setState({ isSignedIn: false });
|
||||
});
|
||||
enableAuth = false;
|
||||
|
||||
render(<UserBanner />);
|
||||
|
||||
expect(screen.getByText('Mocked UserInfo')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mocked DataStatistics')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mocked UserLoginOrSignup')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render UserInfo and DataStatistics when user is logged in with auth enabled', () => {
|
||||
act(() => {
|
||||
useUserStore.setState({ isSignedIn: true });
|
||||
});
|
||||
enableAuth = true;
|
||||
|
||||
render(<UserBanner />);
|
||||
|
||||
expect(screen.getByText('Mocked UserInfo')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mocked DataStatistics')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mocked UserLoginOrSignup')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render UserLoginOrSignup when user is not logged in with auth enabled', () => {
|
||||
act(() => {
|
||||
useUserStore.setState({ isSignedIn: false });
|
||||
});
|
||||
enableAuth = true;
|
||||
|
||||
render(<UserBanner />);
|
||||
|
||||
expect(screen.getByText('Mocked UserLoginOrSignup')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mocked UserInfo')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Mocked DataStatistics')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
116
src/app/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx
Normal file
116
src/app/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
||||
import { useCategory } from '../features/useCategory';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: vi.fn(() => ({
|
||||
t: vi.fn((key) => key),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../settings/features/useCategory', () => ({
|
||||
useCategory: vi.fn(() => [{ key: 'extraSetting', label: 'Extra Setting' }]),
|
||||
}));
|
||||
|
||||
// 定义一个变量来存储 enableAuth 的值
|
||||
let enableAuth = true;
|
||||
let enableClerk = true;
|
||||
// 模拟 @/const/auth 模块
|
||||
vi.mock('@/const/auth', () => ({
|
||||
get enableAuth() {
|
||||
return enableAuth;
|
||||
},
|
||||
get enableClerk() {
|
||||
return enableClerk;
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
enableAuth = true;
|
||||
enableClerk = true;
|
||||
});
|
||||
|
||||
describe('useCategory', () => {
|
||||
it('should return correct items when the user is logged in with authentication', () => {
|
||||
act(() => {
|
||||
useUserStore.setState({ isSignedIn: true });
|
||||
});
|
||||
enableAuth = true;
|
||||
enableClerk = false;
|
||||
|
||||
const { result } = renderHook(() => useCategory());
|
||||
|
||||
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 === 'discord')).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());
|
||||
|
||||
act(() => {
|
||||
const items = result.current;
|
||||
expect(items.some((item) => item.key === 'profile')).toBe(true);
|
||||
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 === 'discord')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct items when the user is not logged in', () => {
|
||||
act(() => {
|
||||
useUserStore.setState({ isSignedIn: false });
|
||||
});
|
||||
enableAuth = true;
|
||||
|
||||
const { result } = renderHook(() => useCategory());
|
||||
|
||||
act(() => {
|
||||
const items = result.current;
|
||||
expect(items.some((item) => item.key === 'profile')).toBe(false);
|
||||
expect(items.some((item) => item.key === 'setting')).toBe(false);
|
||||
expect(items.some((item) => item.key === 'data')).toBe(false);
|
||||
expect(items.some((item) => item.key === 'docs')).toBe(true);
|
||||
expect(items.some((item) => item.key === 'feedback')).toBe(true);
|
||||
expect(items.some((item) => item.key === 'discord')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle settings for non-authenticated users', () => {
|
||||
act(() => {
|
||||
useUserStore.setState({ isSignedIn: false });
|
||||
});
|
||||
enableAuth = false;
|
||||
|
||||
const { result } = renderHook(() => useCategory());
|
||||
|
||||
act(() => {
|
||||
const items = result.current;
|
||||
expect(items.some((item) => item.key === 'extraSetting')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
15
src/app/(main)/(mobile)/me/(home)/features/Category.tsx
Normal file
15
src/app/(main)/(mobile)/me/(home)/features/Category.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
|
||||
import Cell from '@/components/Cell';
|
||||
|
||||
import { useCategory } from './useCategory';
|
||||
|
||||
const Category = memo(() => {
|
||||
const items = useCategory();
|
||||
|
||||
return items?.map((item, index) => <Cell key={item.key || index} {...item} />);
|
||||
});
|
||||
|
||||
export default Category;
|
||||
37
src/app/(main)/(mobile)/me/(home)/features/UserBanner.tsx
Normal file
37
src/app/(main)/(mobile)/me/(home)/features/UserBanner.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { memo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { enableAuth } from '@/const/auth';
|
||||
import DataStatistics from '@/features/User/DataStatistics';
|
||||
import UserInfo from '@/features/User/UserInfo';
|
||||
import UserLoginOrSignup from '@/features/User/UserLoginOrSignup';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { authSelectors } from '@/store/user/selectors';
|
||||
|
||||
const UserBanner = memo(() => {
|
||||
const router = useRouter();
|
||||
const isLoginWithAuth = useUserStore(authSelectors.isLoginWithAuth);
|
||||
|
||||
return (
|
||||
<Flexbox gap={12} paddingBlock={8}>
|
||||
{!enableAuth ? (
|
||||
<>
|
||||
<UserInfo />
|
||||
<DataStatistics paddingInline={12} />
|
||||
</>
|
||||
) : isLoginWithAuth ? (
|
||||
<>
|
||||
<UserInfo onClick={() => router.push('/me/profile')} />
|
||||
<DataStatistics paddingInline={12} />
|
||||
</>
|
||||
) : (
|
||||
<UserLoginOrSignup onClick={() => router.push('/login')} />
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default UserBanner;
|
||||
95
src/app/(main)/(mobile)/me/(home)/features/useCategory.tsx
Normal file
95
src/app/(main)/(mobile)/me/(home)/features/useCategory.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { DiscordIcon } from '@lobehub/ui';
|
||||
import { Book, CircleUserRound, Database, Feather, Settings2 } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CellProps } from '@/components/Cell';
|
||||
import { enableAuth } from '@/const/auth';
|
||||
import { DISCORD, DOCUMENTS, FEEDBACK } from '@/const/url';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { authSelectors } from '@/store/user/slices/auth/selectors';
|
||||
|
||||
import { useCategory as useSettingsCategory } from '../../settings/features/useCategory';
|
||||
|
||||
export const useCategory = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation(['common', 'setting', 'auth']);
|
||||
const [isLogin, isLoginWithAuth, isLoginWithClerk] = useUserStore((s) => [
|
||||
authSelectors.isLogin(s),
|
||||
authSelectors.isLoginWithAuth(s),
|
||||
authSelectors.isLoginWithClerk(s),
|
||||
]);
|
||||
|
||||
const profile: CellProps[] = [
|
||||
{
|
||||
icon: CircleUserRound,
|
||||
key: 'profile',
|
||||
label: t('userPanel.profile'),
|
||||
onClick: () => router.push('/me/profile'),
|
||||
},
|
||||
];
|
||||
|
||||
const settings: CellProps[] = [
|
||||
{
|
||||
icon: Settings2,
|
||||
key: 'setting',
|
||||
label: t('userPanel.setting'),
|
||||
onClick: () => router.push('/me/settings'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
];
|
||||
|
||||
const settingsWithoutAuth = [
|
||||
...useSettingsCategory(),
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
];
|
||||
|
||||
const data: CellProps[] = [
|
||||
{
|
||||
icon: Database,
|
||||
key: 'data',
|
||||
label: t('userPanel.data'),
|
||||
onClick: () => router.push('/me/data'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
];
|
||||
|
||||
const helps: CellProps[] = [
|
||||
{
|
||||
icon: Book,
|
||||
key: 'docs',
|
||||
label: t('document'),
|
||||
onClick: () => window.open(DOCUMENTS, '__blank'),
|
||||
},
|
||||
{
|
||||
icon: Feather,
|
||||
key: 'feedback',
|
||||
label: t('feedback'),
|
||||
onClick: () => window.open(FEEDBACK, '__blank'),
|
||||
},
|
||||
{
|
||||
icon: DiscordIcon,
|
||||
key: 'discord',
|
||||
label: 'Discord',
|
||||
onClick: () => window.open(DISCORD, '__blank'),
|
||||
},
|
||||
];
|
||||
|
||||
const mainItems = [
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
...(isLoginWithClerk ? profile : []),
|
||||
...(enableAuth ? (isLoginWithAuth ? settings : []) : settingsWithoutAuth),
|
||||
...(isLogin ? data : []),
|
||||
...helps,
|
||||
].filter(Boolean) as CellProps[];
|
||||
|
||||
return mainItems;
|
||||
};
|
||||
|
|
@ -2,13 +2,10 @@ import { redirect } from 'next/navigation';
|
|||
import { Center } from 'react-layout-kit';
|
||||
|
||||
import BrandWatermark from '@/components/BrandWatermark';
|
||||
import Divider from '@/components/Cell/Divider';
|
||||
import DataStatistics from '@/features/User/DataStatistics';
|
||||
import UserInfo from '@/features/User/UserInfo';
|
||||
import { isMobileDevice } from '@/utils/responsive';
|
||||
|
||||
import Cate from './features/Cate';
|
||||
import ExtraCate from './features/ExtraCate';
|
||||
import Category from './features/Category';
|
||||
import UserBanner from './features/UserBanner';
|
||||
|
||||
const Page = () => {
|
||||
const mobile = isMobileDevice();
|
||||
|
|
@ -17,11 +14,8 @@ const Page = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<UserInfo />
|
||||
<DataStatistics paddingInline={12} style={{ paddingBottom: 6 }} />
|
||||
<Divider />
|
||||
<Cate />
|
||||
<ExtraCate />
|
||||
<UserBanner />
|
||||
<Category />
|
||||
<Center padding={16}>
|
||||
<BrandWatermark />
|
||||
</Center>
|
||||
|
|
@ -29,4 +23,6 @@ const Page = () => {
|
|||
);
|
||||
};
|
||||
|
||||
Page.displayName = 'Me';
|
||||
|
||||
export default Page;
|
||||
48
src/app/(main)/(mobile)/me/data/features/Category.tsx
Normal file
48
src/app/(main)/(mobile)/me/data/features/Category.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Cell, { CellProps } from '@/components/Cell';
|
||||
import DataImporter from '@/features/DataImporter';
|
||||
import { configService } from '@/services/config';
|
||||
|
||||
const Category = memo(() => {
|
||||
const { t } = useTranslation('common');
|
||||
const items: CellProps[] = [
|
||||
{
|
||||
key: 'allAgent',
|
||||
label: t('exportType.allAgent'),
|
||||
onClick: configService.exportAgents,
|
||||
},
|
||||
{
|
||||
key: 'allAgentWithMessage',
|
||||
label: t('exportType.allAgentWithMessage'),
|
||||
onClick: configService.exportSessions,
|
||||
},
|
||||
{
|
||||
key: 'globalSetting',
|
||||
label: t('exportType.globalSetting'),
|
||||
onClick: configService.exportSettings,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'all',
|
||||
label: t('exportType.all'),
|
||||
onClick: configService.exportAll,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'import',
|
||||
label: <DataImporter>{t('import')}</DataImporter>,
|
||||
},
|
||||
];
|
||||
|
||||
return items?.map((item, index) => <Cell key={item.key || index} {...item} />);
|
||||
});
|
||||
|
||||
export default Category;
|
||||
33
src/app/(main)/(mobile)/me/data/features/Header.tsx
Normal file
33
src/app/(main)/(mobile)/me/data/features/Header.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use client';
|
||||
|
||||
import { MobileNavBar, MobileNavBarTitle } from '@lobehub/ui';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { mobileHeaderSticky } from '@/styles/mobileHeader';
|
||||
|
||||
const Header = memo(() => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const router = useRouter();
|
||||
return (
|
||||
<MobileNavBar
|
||||
center={
|
||||
<MobileNavBarTitle
|
||||
title={
|
||||
<Flexbox align={'center'} gap={4} horizontal>
|
||||
{t('userPanel.data')}
|
||||
</Flexbox>
|
||||
}
|
||||
/>
|
||||
}
|
||||
onBackClick={() => router.push('/me')}
|
||||
showBackButton
|
||||
style={mobileHeaderSticky}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default Header;
|
||||
13
src/app/(main)/(mobile)/me/data/layout.tsx
Normal file
13
src/app/(main)/(mobile)/me/data/layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import MobileContentLayout from '@/components/server/MobileNavLayout';
|
||||
|
||||
import Header from './features/Header';
|
||||
|
||||
const Layout = ({ children }: PropsWithChildren) => {
|
||||
return <MobileContentLayout header={<Header />}>{children}</MobileContentLayout>;
|
||||
};
|
||||
|
||||
Layout.displayName = 'MeDataLayout';
|
||||
|
||||
export default Layout;
|
||||
5
src/app/(main)/(mobile)/me/data/loading.tsx
Normal file
5
src/app/(main)/(mobile)/me/data/loading.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import SkeletonLoading from '@/components/SkeletonLoading';
|
||||
|
||||
export default () => {
|
||||
return <SkeletonLoading paragraph={{ rows: 8 }} />;
|
||||
};
|
||||
17
src/app/(main)/(mobile)/me/data/page.tsx
Normal file
17
src/app/(main)/(mobile)/me/data/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { isMobileDevice } from '@/utils/responsive';
|
||||
|
||||
import Category from './features/Category';
|
||||
|
||||
const Page = () => {
|
||||
const mobile = isMobileDevice();
|
||||
|
||||
if (!mobile) return redirect('/chat');
|
||||
|
||||
return <Category />;
|
||||
};
|
||||
|
||||
Page.displayName = 'MeData';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { memo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
import { useCategory } from '@/app/(main)/settings/hooks/useCategory';
|
||||
import Cell from '@/components/Cell';
|
||||
import Divider from '@/components/Cell/Divider';
|
||||
|
||||
const SettingCate = memo(() => {
|
||||
const settingItems = useCategory({ mobile: true });
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Flexbox width={'100%'}>
|
||||
{settingItems?.map(({ key, icon, label, type }: any, index) => {
|
||||
if (type === 'divider') return <Divider key={index} />;
|
||||
return (
|
||||
<Cell
|
||||
icon={icon}
|
||||
key={key}
|
||||
label={label}
|
||||
onClick={() => router.push(urlJoin('/settings', key))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default SettingCate;
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import Cell from '@/components/Cell';
|
||||
import Divider from '@/components/Cell/Divider';
|
||||
|
||||
import { useExtraCate } from './useExtraCate';
|
||||
|
||||
const ExtraCate = memo(() => {
|
||||
const mainItems = useExtraCate();
|
||||
|
||||
return (
|
||||
<Flexbox width={'100%'}>
|
||||
{mainItems?.map(({ key, icon, label, type, onClick }: any, index) => {
|
||||
if (type === 'divider') return <Divider key={index} />;
|
||||
return <Cell icon={icon} key={key} label={label} onClick={onClick} />;
|
||||
})}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default ExtraCate;
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { DiscordIcon, Icon } from '@lobehub/ui';
|
||||
import { Book, Feather, HardDriveDownload, HardDriveUpload } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { type MenuProps } from '@/components/Menu';
|
||||
import { DISCORD, DOCUMENTS, FEEDBACK } from '@/const/url';
|
||||
import DataImporter from '@/features/DataImporter';
|
||||
import { configService } from '@/services/config';
|
||||
|
||||
export const useExtraCate = () => {
|
||||
const { t } = useTranslation(['common', 'setting']);
|
||||
|
||||
const iconSize = { fontSize: 20 };
|
||||
|
||||
const exports: MenuProps['items'] = [
|
||||
{
|
||||
icon: <Icon icon={HardDriveUpload} size={iconSize} />,
|
||||
key: 'import',
|
||||
label: <DataImporter>{t('import')}</DataImporter>,
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={HardDriveDownload} size={iconSize} />,
|
||||
key: 'export',
|
||||
label: t('export'),
|
||||
onClick: configService.exportAll,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
];
|
||||
|
||||
const helps: MenuProps['items'] = [
|
||||
{
|
||||
icon: <Icon icon={Book} size={iconSize} />,
|
||||
key: 'docs',
|
||||
label: t('document'),
|
||||
onClick: () => window.open(DOCUMENTS, '__blank'),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={Feather} size={iconSize} />,
|
||||
key: 'feedback',
|
||||
label: t('feedback'),
|
||||
onClick: () => window.open(FEEDBACK, '__blank'),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={DiscordIcon} size={iconSize} />,
|
||||
key: 'discord',
|
||||
label: 'Discord',
|
||||
onClick: () => window.open(DISCORD, '__blank'),
|
||||
},
|
||||
];
|
||||
|
||||
const mainItems = [
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
...exports,
|
||||
...helps,
|
||||
].filter(Boolean) as MenuProps['items'];
|
||||
|
||||
return mainItems;
|
||||
};
|
||||
45
src/app/(main)/(mobile)/me/profile/features/Category.tsx
Normal file
45
src/app/(main)/(mobile)/me/profile/features/Category.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
'use client';
|
||||
|
||||
import { 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 { useUserStore } from '@/store/user';
|
||||
|
||||
const Category = memo(() => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation('auth');
|
||||
const signOut = useUserStore((s) => s.logout);
|
||||
const items: CellProps[] = [
|
||||
{
|
||||
icon: UserCircle,
|
||||
key: 'profile',
|
||||
label: t('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');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return items?.map((item, index) => <Cell key={item.key || index} {...item} />);
|
||||
});
|
||||
|
||||
export default Category;
|
||||
33
src/app/(main)/(mobile)/me/profile/features/Header.tsx
Normal file
33
src/app/(main)/(mobile)/me/profile/features/Header.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use client';
|
||||
|
||||
import { MobileNavBar, MobileNavBarTitle } from '@lobehub/ui';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { mobileHeaderSticky } from '@/styles/mobileHeader';
|
||||
|
||||
const Header = memo(() => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const router = useRouter();
|
||||
return (
|
||||
<MobileNavBar
|
||||
center={
|
||||
<MobileNavBarTitle
|
||||
title={
|
||||
<Flexbox align={'center'} gap={4} horizontal>
|
||||
{t('userPanel.profile')}
|
||||
</Flexbox>
|
||||
}
|
||||
/>
|
||||
}
|
||||
onBackClick={() => router.push('/me')}
|
||||
showBackButton
|
||||
style={mobileHeaderSticky}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default Header;
|
||||
16
src/app/(main)/(mobile)/me/profile/layout.tsx
Normal file
16
src/app/(main)/(mobile)/me/profile/layout.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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>;
|
||||
};
|
||||
|
||||
Layout.displayName = 'MeProfileLayout';
|
||||
|
||||
export default Layout;
|
||||
5
src/app/(main)/(mobile)/me/profile/loading.tsx
Normal file
5
src/app/(main)/(mobile)/me/profile/loading.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import SkeletonLoading from '@/components/SkeletonLoading';
|
||||
|
||||
export default () => {
|
||||
return <SkeletonLoading paragraph={{ rows: 8 }} />;
|
||||
};
|
||||
17
src/app/(main)/(mobile)/me/profile/page.tsx
Normal file
17
src/app/(main)/(mobile)/me/profile/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { isMobileDevice } from '@/utils/responsive';
|
||||
|
||||
import Category from './features/Category';
|
||||
|
||||
const Page = () => {
|
||||
const mobile = isMobileDevice();
|
||||
|
||||
if (!mobile) return redirect('/profile');
|
||||
|
||||
return <Category />;
|
||||
};
|
||||
|
||||
Page.displayName = 'MeProfile';
|
||||
|
||||
export default Page;
|
||||
15
src/app/(main)/(mobile)/me/settings/features/Category.tsx
Normal file
15
src/app/(main)/(mobile)/me/settings/features/Category.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
|
||||
import Cell from '@/components/Cell';
|
||||
|
||||
import { useCategory } from './useCategory';
|
||||
|
||||
const Category = memo(() => {
|
||||
const items = useCategory();
|
||||
|
||||
return items?.map((item, index) => <Cell key={item.key || index} {...item} />);
|
||||
});
|
||||
|
||||
export default Category;
|
||||
33
src/app/(main)/(mobile)/me/settings/features/Header.tsx
Normal file
33
src/app/(main)/(mobile)/me/settings/features/Header.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use client';
|
||||
|
||||
import { MobileNavBar, MobileNavBarTitle } from '@lobehub/ui';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { mobileHeaderSticky } from '@/styles/mobileHeader';
|
||||
|
||||
const Header = memo(() => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const router = useRouter();
|
||||
return (
|
||||
<MobileNavBar
|
||||
center={
|
||||
<MobileNavBarTitle
|
||||
title={
|
||||
<Flexbox align={'center'} gap={4} horizontal>
|
||||
{t('userPanel.setting')}
|
||||
</Flexbox>
|
||||
}
|
||||
/>
|
||||
}
|
||||
onBackClick={() => router.push('/me')}
|
||||
showBackButton
|
||||
style={mobileHeaderSticky}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default Header;
|
||||
57
src/app/(main)/(mobile)/me/settings/features/useCategory.tsx
Normal file
57
src/app/(main)/(mobile)/me/settings/features/useCategory.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Tag } from 'antd';
|
||||
import { Bot, Brain, Cloudy, Info, Mic2, Settings2 } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
import { CellProps } from '@/components/Cell';
|
||||
import { SettingsTabs } from '@/store/global/initialState';
|
||||
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
|
||||
export const useCategory = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation('setting');
|
||||
const { enableWebrtc, showLLM } = useServerConfigStore(featureFlagsSelectors);
|
||||
|
||||
const items: CellProps[] = [
|
||||
{
|
||||
icon: Settings2,
|
||||
key: SettingsTabs.Common,
|
||||
label: t('tab.common'),
|
||||
},
|
||||
enableWebrtc && {
|
||||
icon: Cloudy,
|
||||
key: SettingsTabs.Sync,
|
||||
label: (
|
||||
<Flexbox align={'center'} gap={8} horizontal>
|
||||
{t('tab.sync')}
|
||||
<Tag bordered={false} color={'warning'}>
|
||||
{t('tab.experiment')}
|
||||
</Tag>
|
||||
</Flexbox>
|
||||
),
|
||||
},
|
||||
showLLM && {
|
||||
icon: Brain,
|
||||
key: SettingsTabs.LLM,
|
||||
label: t('tab.llm'),
|
||||
},
|
||||
{ icon: Mic2, key: SettingsTabs.TTS, label: t('tab.tts') },
|
||||
{
|
||||
icon: Bot,
|
||||
key: SettingsTabs.Agent,
|
||||
label: t('tab.agent'),
|
||||
},
|
||||
{
|
||||
icon: Info,
|
||||
key: SettingsTabs.About,
|
||||
label: t('tab.about'),
|
||||
},
|
||||
].filter(Boolean) as CellProps[];
|
||||
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
onClick: () => router.push(urlJoin('/settings', item.key as SettingsTabs)),
|
||||
}));
|
||||
};
|
||||
13
src/app/(main)/(mobile)/me/settings/layout.tsx
Normal file
13
src/app/(main)/(mobile)/me/settings/layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import MobileContentLayout from '@/components/server/MobileNavLayout';
|
||||
|
||||
import Header from './features/Header';
|
||||
|
||||
const Layout = ({ children }: PropsWithChildren) => {
|
||||
return <MobileContentLayout header={<Header />}>{children}</MobileContentLayout>;
|
||||
};
|
||||
|
||||
Layout.displayName = 'MeSettingsLayout';
|
||||
|
||||
export default Layout;
|
||||
5
src/app/(main)/(mobile)/me/settings/loading.tsx
Normal file
5
src/app/(main)/(mobile)/me/settings/loading.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import SkeletonLoading from '@/components/SkeletonLoading';
|
||||
|
||||
export default () => {
|
||||
return <SkeletonLoading paragraph={{ rows: 8 }} />;
|
||||
};
|
||||
17
src/app/(main)/(mobile)/me/settings/page.tsx
Normal file
17
src/app/(main)/(mobile)/me/settings/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { isMobileDevice } from '@/utils/responsive';
|
||||
|
||||
import Category from './features/Category';
|
||||
|
||||
const Page = () => {
|
||||
const mobile = isMobileDevice();
|
||||
|
||||
if (!mobile) return redirect('/settings/common');
|
||||
|
||||
return <Category />;
|
||||
};
|
||||
|
||||
Page.displayName = 'MeSettings';
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,24 +1,25 @@
|
|||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import qs from 'query-string';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useQuery } from '@/hooks/useQuery';
|
||||
|
||||
import { LayoutProps } from './type';
|
||||
|
||||
const MOBILE_IGNORE_NAV_ROUTES = ['/settings/', '/chat/'];
|
||||
const MOBILE_NAV_ROUTES = new Set(['/chat', '/market', '/me']);
|
||||
|
||||
const Layout = memo(({ children, nav }: LayoutProps) => {
|
||||
const { showMobileWorkspace } = useQuery();
|
||||
const pathname = usePathname();
|
||||
const hideNav =
|
||||
showMobileWorkspace || MOBILE_IGNORE_NAV_ROUTES.some((path) => pathname.startsWith(path));
|
||||
const { url } = qs.parseUrl(pathname);
|
||||
const showNav = !showMobileWorkspace && MOBILE_NAV_ROUTES.has(url);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{!hideNav && nav}
|
||||
{showNav && nav}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
74
src/app/(main)/profile/[[...slugs]]/Client.tsx
Normal file
74
src/app/(main)/profile/[[...slugs]]/Client.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
'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-right: 0;
|
||||
padding: 24px 12px 16px;
|
||||
|
||||
background: ${token.colorBgContainer};
|
||||
border-right: 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<{
|
||||
[k in keyof ElementsConfig]: any;
|
||||
}>,
|
||||
);
|
||||
|
||||
const Client = memo<{ mobile?: boolean }>(({ mobile }) => {
|
||||
const { styles } = useStyles(mobile);
|
||||
|
||||
return (
|
||||
<UserProfile
|
||||
appearance={{
|
||||
elements: styles,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default Client;
|
||||
18
src/app/(main)/profile/[[...slugs]]/page.tsx
Normal file
18
src/app/(main)/profile/[[...slugs]]/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { translation } from '@/server/translation';
|
||||
import { isMobileDevice } from '@/utils/responsive';
|
||||
|
||||
import Client from './Client';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const { t } = await translation('common');
|
||||
return {
|
||||
title: t('userButton.profile'),
|
||||
};
|
||||
};
|
||||
|
||||
const Page = () => {
|
||||
const mobile = isMobileDevice();
|
||||
return <Client mobile={mobile} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
26
src/app/(main)/profile/_layout/Mobile/Header.tsx
Normal file
26
src/app/(main)/profile/_layout/Mobile/Header.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
'use client';
|
||||
|
||||
import { MobileNavBar, MobileNavBarTitle } from '@lobehub/ui';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { mobileHeaderSticky } from '@/styles/mobileHeader';
|
||||
|
||||
const Header = memo(() => {
|
||||
const { t } = useTranslation('auth');
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const isSecurity = pathname.startsWith('/prifile/security');
|
||||
return (
|
||||
<MobileNavBar
|
||||
center={<MobileNavBarTitle title={t(isSecurity ? 'security' : 'profile')} />}
|
||||
onBackClick={() => router.push('/me/profile')}
|
||||
showBackButton
|
||||
style={mobileHeaderSticky}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default Header;
|
||||
16
src/app/(main)/profile/_layout/Mobile/index.tsx
Normal file
16
src/app/(main)/profile/_layout/Mobile/index.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import Header from './Header';
|
||||
|
||||
const Layout = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Layout.displayName = 'ProfileMobileLayout';
|
||||
|
||||
export default Layout;
|
||||
20
src/app/(main)/profile/layout.tsx
Normal file
20
src/app/(main)/profile/layout.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { notFound } from 'next/navigation';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { enableClerk } from '@/const/auth';
|
||||
import { isMobileDevice } from '@/utils/responsive';
|
||||
|
||||
import MobileLayout from './_layout/Mobile';
|
||||
|
||||
const Layout = ({ children }: PropsWithChildren) => {
|
||||
if (!enableClerk) return notFound();
|
||||
|
||||
const mobile = isMobileDevice();
|
||||
if (mobile) return <MobileLayout>{children}</MobileLayout>;
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
Layout.displayName = 'ProfileLayout';
|
||||
|
||||
export default Layout;
|
||||
23
src/app/(main)/profile/loading.tsx
Normal file
23
src/app/(main)/profile/loading.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import SkeletonLoading from '@/components/SkeletonLoading';
|
||||
import { isMobileDevice } from '@/utils/responsive';
|
||||
|
||||
const Loading = () => {
|
||||
const mobile = 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;
|
||||
|
|
@ -7,6 +7,7 @@ import { memo } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { enableAuth } from '@/const/auth';
|
||||
import { useActiveSettingsKey } from '@/hooks/useActiveSettingsKey';
|
||||
import { SettingsTabs } from '@/store/global/initialState';
|
||||
import { mobileHeaderSticky } from '@/styles/mobileHeader';
|
||||
|
|
@ -32,7 +33,7 @@ const Header = memo(() => {
|
|||
}
|
||||
/>
|
||||
}
|
||||
onBackClick={() => router.push('/me')}
|
||||
onBackClick={() => router.push(enableAuth ? '/me/settings' : '/me')}
|
||||
showBackButton
|
||||
style={mobileHeaderSticky}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -9,26 +9,20 @@ import type { MenuProps } from '@/components/Menu';
|
|||
import { SettingsTabs } from '@/store/global/initialState';
|
||||
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
|
||||
interface UseCategoryOptions {
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
export const useCategory = ({ mobile }: UseCategoryOptions = {}) => {
|
||||
export const useCategory = () => {
|
||||
const { t } = useTranslation('setting');
|
||||
const { enableWebrtc, showLLM } = useServerConfigStore(featureFlagsSelectors);
|
||||
|
||||
const iconSize = mobile ? { fontSize: 20 } : undefined;
|
||||
|
||||
const cateItems: MenuProps['items'] = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
icon: <Icon icon={Settings2} size={iconSize} />,
|
||||
icon: <Icon icon={Settings2} />,
|
||||
key: SettingsTabs.Common,
|
||||
label: t('tab.common'),
|
||||
},
|
||||
enableWebrtc && {
|
||||
icon: <Icon icon={Cloudy} size={iconSize} />,
|
||||
icon: <Icon icon={Cloudy} />,
|
||||
key: SettingsTabs.Sync,
|
||||
label: (
|
||||
<Flexbox align={'center'} gap={8} horizontal>
|
||||
|
|
@ -40,18 +34,18 @@ export const useCategory = ({ mobile }: UseCategoryOptions = {}) => {
|
|||
),
|
||||
},
|
||||
showLLM && {
|
||||
icon: <Icon icon={Brain} size={iconSize} />,
|
||||
icon: <Icon icon={Brain} />,
|
||||
key: SettingsTabs.LLM,
|
||||
label: t('tab.llm'),
|
||||
},
|
||||
{ icon: <Icon icon={Mic2} size={iconSize} />, key: SettingsTabs.TTS, label: t('tab.tts') },
|
||||
{ icon: <Icon icon={Mic2} />, key: SettingsTabs.TTS, label: t('tab.tts') },
|
||||
{
|
||||
icon: <Icon icon={Bot} size={iconSize} />,
|
||||
icon: <Icon icon={Bot} />,
|
||||
key: SettingsTabs.Agent,
|
||||
label: t('tab.agent'),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={Info} size={iconSize} />,
|
||||
icon: <Icon icon={Info} />,
|
||||
key: SettingsTabs.About,
|
||||
label: t('tab.about'),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
'use client';
|
||||
|
||||
import { Modal } from '@lobehub/ui';
|
||||
import { useTheme } from 'antd-style';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PropsWithChildren, memo, useState } from 'react';
|
||||
|
||||
const SessionSettingsModal = memo<PropsWithChildren>(({ children }) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
|
@ -18,6 +20,7 @@ const SessionSettingsModal = memo<PropsWithChildren>(({ children }) => {
|
|||
open={open}
|
||||
styles={{
|
||||
body: { display: 'flex', minHeight: 'min(75vh, 750px)', overflow: 'hidden', padding: 0 },
|
||||
content: { border: 'none', boxShadow: `0 0 0 1px ${theme.colorBorderSecondary}` },
|
||||
}}
|
||||
title={false}
|
||||
width={1024}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
'use client';
|
||||
|
||||
import { createStyles } from 'antd-style';
|
||||
import { rgba } from 'polished';
|
||||
import { memo } from 'react';
|
||||
|
||||
const useStyles = createStyles(
|
||||
({ css, token }) => css`
|
||||
({ css, token, isDarkMode }) => css`
|
||||
flex: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: ${token.colorFillTertiary};
|
||||
background: ${isDarkMode ? rgba(token.colorFillTertiary, 0.04) : token.colorFillTertiary};
|
||||
`,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,42 +1,52 @@
|
|||
import { Icon, List } from '@lobehub/ui';
|
||||
import { Icon, IconProps } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { ReactNode, memo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
const { Item } = List;
|
||||
import Divider from './Divider';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
container: css`
|
||||
position: relative;
|
||||
|
||||
gap: 12px;
|
||||
|
||||
padding: 16px !important;
|
||||
|
||||
background: ${token.colorBgLayout};
|
||||
font-size: 15px;
|
||||
border-radius: 0;
|
||||
|
||||
&:active {
|
||||
background: ${token.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
export interface CellProps {
|
||||
icon: ReactNode;
|
||||
label: string | ReactNode;
|
||||
icon?: IconProps['icon'];
|
||||
key?: string | number;
|
||||
label?: string | ReactNode;
|
||||
onClick?: () => void;
|
||||
type?: 'divider';
|
||||
}
|
||||
|
||||
const Cell = memo<CellProps>(({ label, icon, onClick }) => {
|
||||
const { cx, styles } = useStyles();
|
||||
const Cell = memo<CellProps>(({ label, icon, onClick, type }) => {
|
||||
const { cx, styles, theme } = useStyles();
|
||||
|
||||
if (type === 'divider') return <Divider />;
|
||||
|
||||
return (
|
||||
<Item
|
||||
active={false}
|
||||
avatar={icon}
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
className={cx(styles.container)}
|
||||
gap={12}
|
||||
horizontal
|
||||
justify={'space-between'}
|
||||
onClick={onClick}
|
||||
title={label as string}
|
||||
padding={16}
|
||||
>
|
||||
<Icon icon={ChevronRight} size={{ fontSize: 16 }} />
|
||||
</Item>
|
||||
<Flexbox align={'center'} gap={12} horizontal>
|
||||
{icon && <Icon color={theme.colorPrimaryBorder} icon={icon} size={{ fontSize: 20 }} />}
|
||||
{label}
|
||||
</Flexbox>
|
||||
<Icon color={theme.colorBorder} icon={ChevronRight} size={{ fontSize: 16 }} />
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import useSWR from 'swr';
|
|||
import { messageService } from '@/services/message';
|
||||
import { sessionService } from '@/services/session';
|
||||
import { topicService } from '@/services/topic';
|
||||
import { useServerConfigStore } from '@/store/serverConfig';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
card: css`
|
||||
|
|
@ -53,6 +54,7 @@ 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',
|
||||
|
|
@ -111,7 +113,7 @@ const DataStatistics = memo<Omit<FlexboxProps, 'children'>>(({ style, ...rest })
|
|||
<Flexbox
|
||||
align={'center'}
|
||||
className={styles.card}
|
||||
flex={showBadge ? 2 : 1}
|
||||
flex={showBadge && !mobile ? 2 : 1}
|
||||
gap={4}
|
||||
horizontal
|
||||
justify={'space-between'}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { memo } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import UserInfo from '@/features/User/UserInfo';
|
||||
import UserInfo from './UserInfo';
|
||||
|
||||
const UserLoginOrSignup = memo<{ onClick: () => void }>(({ onClick }) => {
|
||||
const { t } = useTranslation('auth');
|
||||
|
|
@ -11,7 +11,7 @@ const UserLoginOrSignup = memo<{ onClick: () => void }>(({ onClick }) => {
|
|||
return (
|
||||
<>
|
||||
<UserInfo />
|
||||
<Flexbox paddingBlock={'0 12px'} paddingInline={16} width={'100%'}>
|
||||
<Flexbox paddingBlock={12} paddingInline={16} width={'100%'}>
|
||||
<Button block onClick={onClick} type={'primary'}>
|
||||
{t('loginOrSignup')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -45,13 +45,19 @@ const PanelContent = memo<{ closePopover: () => void }>(({ closePopover }) => {
|
|||
return (
|
||||
<Flexbox gap={2} style={{ minWidth: 300 }}>
|
||||
{!enableAuth ? (
|
||||
<UserInfo />
|
||||
<>
|
||||
<UserInfo />
|
||||
<DataStatistics />
|
||||
</>
|
||||
) : isLoginWithAuth ? (
|
||||
<UserInfo onClick={handleOpenProfile} />
|
||||
<>
|
||||
<UserInfo onClick={handleOpenProfile} />
|
||||
<DataStatistics />
|
||||
</>
|
||||
) : (
|
||||
<UserLoginOrSignup onClick={handleSignIn} />
|
||||
)}
|
||||
<DataStatistics />
|
||||
|
||||
<Menu items={mainItems} onClick={closePopover} />
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,21 @@ export const useMenu = () => {
|
|||
const hasNewVersion = useNewVersion();
|
||||
const openSettings = useOpenSettings();
|
||||
const { t } = useTranslation(['common', 'setting', 'auth']);
|
||||
const isSignedIn = useUserStore(authSelectors.isLoginWithAuth);
|
||||
const [isLogin, isLoginWithAuth, isLoginWithClerk, openUserProfile] = useUserStore((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(),
|
||||
},
|
||||
];
|
||||
|
||||
const settings: MenuProps['items'] = [
|
||||
{
|
||||
|
|
@ -82,7 +96,7 @@ export const useMenu = () => {
|
|||
},
|
||||
];
|
||||
|
||||
const exports: MenuProps['items'] = [
|
||||
const data: MenuProps['items'] = [
|
||||
{
|
||||
icon: <Icon icon={HardDriveUpload} />,
|
||||
key: 'import',
|
||||
|
|
@ -123,22 +137,6 @@ export const useMenu = () => {
|
|||
},
|
||||
];
|
||||
|
||||
const openUserProfile = useUserStore((s) => s.openUserProfile);
|
||||
|
||||
const planAndBilling: MenuProps['items'] = [
|
||||
{
|
||||
icon: <Icon icon={CircleUserRound} />,
|
||||
key: 'profile',
|
||||
label: t('userPanel.profile'),
|
||||
onClick: () => {
|
||||
openUserProfile();
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
];
|
||||
|
||||
const helps: MenuProps['items'] = [
|
||||
{
|
||||
icon: <Icon icon={DiscordIcon} />,
|
||||
|
|
@ -192,19 +190,21 @@ export const useMenu = () => {
|
|||
{
|
||||
type: 'divider',
|
||||
},
|
||||
...settings,
|
||||
...(isSignedIn ? planAndBilling : []),
|
||||
...exports,
|
||||
...(isLoginWithClerk ? profile : []),
|
||||
...(isLogin ? settings : []),
|
||||
...(isLogin ? data : []),
|
||||
...helps,
|
||||
].filter(Boolean) as MenuProps['items'];
|
||||
|
||||
const logoutItems: MenuProps['items'] = [
|
||||
{
|
||||
icon: <Icon icon={LogOut} />,
|
||||
key: 'logout',
|
||||
label: <span>{t('signout', { ns: 'auth' })}</span>,
|
||||
},
|
||||
];
|
||||
const logoutItems: MenuProps['items'] = isLoginWithAuth
|
||||
? [
|
||||
{
|
||||
icon: <Icon icon={LogOut} />,
|
||||
key: 'logout',
|
||||
label: <span>{t('signout', { ns: 'auth' })}</span>,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return { logoutItems, mainItems };
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ vi.mock('../UserLoginOrSignup', () => ({
|
|||
)),
|
||||
}));
|
||||
|
||||
vi.mock('../DataStatistics', () => ({
|
||||
default: vi.fn(() => <div>Mocked DataStatistics</div>),
|
||||
}));
|
||||
|
||||
// 定义一个变量来存储 enableAuth 的值
|
||||
let enableAuth = true;
|
||||
|
||||
|
|
@ -83,6 +87,7 @@ describe('PanelContent', () => {
|
|||
render(<PanelContent closePopover={closePopover} />);
|
||||
|
||||
expect(screen.getByText('Mocked UserInfo')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mocked DataStatistics')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mocked SignInBlock')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -94,6 +99,7 @@ describe('PanelContent', () => {
|
|||
render(<PanelContent closePopover={closePopover} />);
|
||||
|
||||
expect(screen.getByText('Mocked SignInBlock')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mocked DataStatistics')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Mocked UserInfo')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -127,6 +133,7 @@ describe('PanelContent', () => {
|
|||
render(<PanelContent closePopover={closePopover} />);
|
||||
|
||||
expect(screen.getByText('Mocked UserInfo')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mocked DataStatistics')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mocked SignInBlock')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
|
|||
142
src/features/User/__tests__/useMenu.test.tsx
Normal file
142
src/features/User/__tests__/useMenu.test.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
||||
import { useMenu } from '../UserPanel/useMenu';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('next/link', () => ({
|
||||
default: vi.fn(({ children }) => <div>{children}</div>),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useQueryRoute', () => ({
|
||||
useQueryRoute: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useInterceptingRoutes', () => ({
|
||||
useOpenSettings: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/DataImporter', () => ({
|
||||
default: vi.fn(({ children }) => <div>{children}</div>),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: vi.fn(() => ({
|
||||
t: vi.fn((key) => key),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/config', () => ({
|
||||
configService: {
|
||||
exportAgents: vi.fn(),
|
||||
exportAll: vi.fn(),
|
||||
exportSessions: vi.fn(),
|
||||
exportSettings: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./useNewVersion', () => ({
|
||||
useNewVersion: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// 定义一个变量来存储 enableAuth 的值
|
||||
let enableAuth = true;
|
||||
let enableClerk = true;
|
||||
// 模拟 @/const/auth 模块
|
||||
vi.mock('@/const/auth', () => ({
|
||||
get enableAuth() {
|
||||
return enableAuth;
|
||||
},
|
||||
get enableClerk() {
|
||||
return enableClerk;
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
enableAuth = true;
|
||||
enableClerk = true;
|
||||
});
|
||||
|
||||
describe('useMenu', () => {
|
||||
it('should provide correct menu items when user is logged in with auth', () => {
|
||||
act(() => {
|
||||
useUserStore.setState({ isSignedIn: true });
|
||||
});
|
||||
enableAuth = true;
|
||||
enableClerk = false;
|
||||
|
||||
const { result } = renderHook(() => useMenu());
|
||||
|
||||
act(() => {
|
||||
const { mainItems, logoutItems } = result.current;
|
||||
expect(mainItems?.some((item) => item?.key === 'profile')).toBe(false);
|
||||
expect(mainItems?.some((item) => item?.key === 'setting')).toBe(true);
|
||||
expect(mainItems?.some((item) => item?.key === 'import')).toBe(true);
|
||||
expect(mainItems?.some((item) => item?.key === 'export')).toBe(true);
|
||||
expect(mainItems?.some((item) => item?.key === 'discord')).toBe(true);
|
||||
expect(logoutItems.some((item) => item?.key === 'logout')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should provide correct menu items when user is logged in with Clerk', () => {
|
||||
act(() => {
|
||||
useUserStore.setState({ isSignedIn: true });
|
||||
});
|
||||
enableAuth = true;
|
||||
enableClerk = true;
|
||||
|
||||
const { result } = renderHook(() => useMenu());
|
||||
|
||||
act(() => {
|
||||
const { mainItems, logoutItems } = result.current;
|
||||
expect(mainItems?.some((item) => item?.key === 'profile')).toBe(true);
|
||||
expect(mainItems?.some((item) => item?.key === 'setting')).toBe(true);
|
||||
expect(mainItems?.some((item) => item?.key === 'import')).toBe(true);
|
||||
expect(mainItems?.some((item) => item?.key === 'export')).toBe(true);
|
||||
expect(mainItems?.some((item) => item?.key === 'discord')).toBe(true);
|
||||
expect(logoutItems.some((item) => item?.key === 'logout')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should provide correct menu items when user is logged in without auth', () => {
|
||||
act(() => {
|
||||
useUserStore.setState({ isSignedIn: false });
|
||||
});
|
||||
enableAuth = false;
|
||||
|
||||
const { result } = renderHook(() => useMenu());
|
||||
|
||||
act(() => {
|
||||
const { mainItems, logoutItems } = result.current;
|
||||
expect(mainItems?.some((item) => item?.key === 'profile')).toBe(false);
|
||||
expect(mainItems?.some((item) => item?.key === 'setting')).toBe(true);
|
||||
expect(mainItems?.some((item) => item?.key === 'import')).toBe(true);
|
||||
expect(mainItems?.some((item) => item?.key === 'export')).toBe(true);
|
||||
expect(mainItems?.some((item) => item?.key === 'discord')).toBe(true);
|
||||
expect(logoutItems.some((item) => item?.key === 'logout')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should provide correct menu items when user is not logged in', () => {
|
||||
act(() => {
|
||||
useUserStore.setState({ isSignedIn: false });
|
||||
});
|
||||
enableAuth = true;
|
||||
|
||||
const { result } = renderHook(() => useMenu());
|
||||
|
||||
act(() => {
|
||||
const { mainItems, logoutItems } = result.current;
|
||||
expect(mainItems?.some((item) => item?.key === 'profile')).toBe(false);
|
||||
expect(mainItems?.some((item) => item?.key === 'setting')).toBe(false);
|
||||
expect(mainItems?.some((item) => item?.key === 'import')).toBe(false);
|
||||
expect(mainItems?.some((item) => item?.key === 'export')).toBe(false);
|
||||
expect(mainItems?.some((item) => item?.key === 'discord')).toBe(true);
|
||||
expect(logoutItems.some((item) => item?.key === 'logout')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -7,13 +7,14 @@ import { createStyles, useThemeMode } from 'antd-style';
|
|||
const prefixCls = 'cl';
|
||||
|
||||
export const useStyles = createStyles(
|
||||
({ css, token }) =>
|
||||
({ css, token, isDarkMode }) =>
|
||||
({
|
||||
avatarBox: css`
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
`,
|
||||
cardBox: css`
|
||||
background: ${token.colorBgContainer};
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
box-shadow: 0 0 0 1px ${token.colorBorderSecondary};
|
||||
`,
|
||||
|
|
@ -42,7 +43,7 @@ export const useStyles = createStyles(
|
|||
}
|
||||
`,
|
||||
navbar: css`
|
||||
background: ${token.colorBgContainer};
|
||||
background: ${isDarkMode ? token.colorBgContainer : token.colorFillTertiary};
|
||||
`,
|
||||
navbarButton: css`
|
||||
line-height: 2;
|
||||
|
|
@ -68,10 +69,10 @@ export const useStyles = createStyles(
|
|||
}
|
||||
`,
|
||||
scrollBox: css`
|
||||
background: ${token.colorBgLayout};
|
||||
background: ${isDarkMode ? token.colorFillQuaternary : token.colorBgElevated};
|
||||
border: unset;
|
||||
border-radius: unset;
|
||||
box-shadow: 0 1px 0 1px ${token.colorSplit};
|
||||
box-shadow: 0 1px 0 1px ${token.colorFillTertiary};
|
||||
`,
|
||||
socialButtons: css`
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
export default {
|
||||
login: '登录',
|
||||
loginOrSignup: '登录 / 注册',
|
||||
profile: '个人资料',
|
||||
security: '安全',
|
||||
signout: '退出登录',
|
||||
signup: '注册',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ export default {
|
|||
userPanel: {
|
||||
anonymousNickName: '匿名用户',
|
||||
billing: '账单管理',
|
||||
data: '数据存储',
|
||||
defaultNickname: '社区版用户',
|
||||
discord: '社区支持',
|
||||
docs: '使用文档',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { t } from 'i18next';
|
||||
|
||||
import { enableAuth } from '@/const/auth';
|
||||
import { enableAuth, enableClerk } from '@/const/auth';
|
||||
import { UserStore } from '@/store/user';
|
||||
import { LobeUser } from '@/types/user';
|
||||
|
||||
|
|
@ -43,4 +43,5 @@ const isLogin = (s: UserStore) => {
|
|||
export const authSelectors = {
|
||||
isLogin,
|
||||
isLoginWithAuth: (s: UserStore) => s.isSignedIn,
|
||||
isLoginWithClerk: (s: UserStore) => s.isSignedIn && enableClerk,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue