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:
CanisMinor 2024-05-13 17:41:53 +08:00 committed by GitHub
parent b7913660bf
commit 91f72942e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 1258 additions and 294 deletions

View file

@ -10,9 +10,8 @@ coverage
# test
jest*
_test_
__test__
*.test.ts
*.test.tsx
# umi
.umi

View file

@ -1,6 +1,8 @@
{
"login": "تسجيل الدخول",
"loginOrSignup": "تسجيل الدخول / التسجيل",
"profile": "الملف الشخصي",
"security": "الأمان",
"signout": "تسجيل الخروج",
"signup": "التسجيل"
}

View file

@ -169,6 +169,7 @@
"userPanel": {
"anonymousNickName": "مستخدم مجهول",
"billing": "إدارة الفواتير",
"data": "تخزين البيانات",
"defaultNickname": "مستخدم النسخة المجتمعية",
"discord": "الدعم المجتمعي",
"docs": "وثائق الاستخدام",

View file

@ -1,6 +1,8 @@
{
"login": "Вход",
"loginOrSignup": "Вход / Регистрация",
"profile": "Профил",
"security": "Сигурност",
"signout": "Изход",
"signup": "Регистрация"
}

View file

@ -169,6 +169,7 @@
"userPanel": {
"anonymousNickName": "Анонимен потребител",
"billing": "Управление на сметките",
"data": "Съхранение на данни",
"defaultNickname": "Потребител на общността",
"discord": "Поддръжка на общността",
"docs": "Документация",

View file

@ -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": {

View file

@ -1,6 +1,8 @@
{
"login": "Anmelden",
"loginOrSignup": "Anmelden / Registrieren",
"profile": "Profil",
"security": "Sicherheit",
"signout": "Abmelden",
"signup": "Registrieren"
}

View file

@ -169,6 +169,7 @@
"userPanel": {
"anonymousNickName": "Anonymer Benutzer",
"billing": "Abrechnung verwalten",
"data": "Daten speichern",
"defaultNickname": "Community User",
"discord": "Community-Support",
"docs": "Dokumentation",

View file

@ -1,6 +1,8 @@
{
"login": "Login",
"loginOrSignup": "Log in / Sign up",
"profile": "Profile",
"security": "Security",
"signout": "Sign out",
"signup": "Sign up"
}

View file

@ -169,6 +169,7 @@
"userPanel": {
"anonymousNickName": "Anonymous User",
"billing": "Billing Management",
"data": "Data Storage",
"defaultNickname": "Community User",
"discord": "Community Support",
"docs": "Documentation",

View file

@ -1,6 +1,8 @@
{
"login": "Iniciar sesión",
"loginOrSignup": "Iniciar sesión / Registrarse",
"profile": "Perfil",
"security": "Seguridad",
"signout": "Cerrar sesión",
"signup": "Registrarse"
}

View file

@ -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",

View file

@ -1,6 +1,8 @@
{
"login": "Connexion",
"loginOrSignup": "Connexion / Inscription",
"profile": "Profil",
"security": "Sécurité",
"signout": "Déconnexion",
"signup": "Inscription"
}

View file

@ -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",

View file

@ -1,6 +1,8 @@
{
"login": "Accedi",
"loginOrSignup": "Accedi / Registrati",
"profile": "Profilo",
"security": "Sicurezza",
"signout": "Esci",
"signup": "Registrati"
}

View file

@ -169,6 +169,7 @@
"userPanel": {
"anonymousNickName": "Utente Anonimo",
"billing": "Gestione fatturazione",
"data": "Archiviazione dati",
"defaultNickname": "Utente Community",
"discord": "Supporto della community",
"docs": "Documentazione",

View file

@ -1,6 +1,8 @@
{
"login": "ログイン",
"loginOrSignup": "ログイン / 登録",
"profile": "プロフィール",
"security": "セキュリティ",
"signout": "ログアウト",
"signup": "サインアップ"
}

View file

@ -169,6 +169,7 @@
"userPanel": {
"anonymousNickName": "匿名ユーザー",
"billing": "請求管理",
"data": "データストレージ",
"defaultNickname": "コミュニティユーザー",
"discord": "コミュニティサポート",
"docs": "使用文書",

View file

@ -1,6 +1,8 @@
{
"login": "로그인",
"loginOrSignup": "로그인 / 가입",
"profile": "프로필",
"security": "보안",
"signout": "로그아웃",
"signup": "가입"
}

View file

@ -169,6 +169,7 @@
"userPanel": {
"anonymousNickName": "익명 사용자",
"billing": "결제 관리",
"data": "데이터 저장",
"defaultNickname": "커뮤니티 사용자",
"discord": "커뮤니티 지원",
"docs": "사용 설명서",

View file

@ -1,6 +1,8 @@
{
"login": "Inloggen",
"loginOrSignup": "Inloggen / Registreren",
"profile": "Profiel",
"security": "Veiligheid",
"signout": "Uitloggen",
"signup": "Registreren"
}

View file

@ -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"
}
}

View file

@ -1,6 +1,8 @@
{
"login": "Zaloguj się",
"loginOrSignup": "Zaloguj się / Zarejestruj się",
"profile": "Profil użytkownika",
"security": "Bezpieczeństwo",
"signout": "Wyloguj",
"signup": "Zarejestruj się"
}

View file

@ -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",

View file

@ -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": {

View file

@ -1,6 +1,8 @@
{
"login": "Entrar",
"loginOrSignup": "Entrar / Registrar",
"profile": "Perfil",
"security": "Segurança",
"signout": "Sair",
"signup": "Cadastre-se"
}

View file

@ -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",

View file

@ -1,6 +1,8 @@
{
"login": "Войти",
"loginOrSignup": "Войти / Зарегистрироваться",
"profile": "Профиль",
"security": "Безопасность",
"signout": "Выйти",
"signup": "Зарегистрироваться"
}

View file

@ -169,6 +169,7 @@
"userPanel": {
"anonymousNickName": "Анонимный пользователь",
"billing": "Управление счетами",
"data": "Хранилище данных",
"defaultNickname": "Пользователь сообщества",
"discord": "Поддержка сообщества",
"docs": "Документация",

View file

@ -1,9 +1,9 @@
{
"clerkAuth": {
"loginSuccess": {
"action": "继续会话",
"desc": "{{greeting}},很高兴能够继续为你服务。让我们接着刚刚的话题聊下去吧",
"title": "欢迎回来, {{nickName}}"
"action": "Продолжить разговор",
"desc": "{{greeting}}, рады снова быть к вашим услугам. Давайте продолжим нашу беседу",
"title": "Добро пожаловать обратно, {{nickName}}"
}
},
"error": {

View file

@ -1,6 +1,8 @@
{
"login": "Giriş Yap",
"loginOrSignup": "Giriş Yap / Kayıt Ol",
"profile": "Profil",
"security": "Güvenlik",
"signout": ıkış Yap",
"signup": "Kaydol"
}

View file

@ -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",

View file

@ -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ý"
}

View file

@ -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",

View file

@ -1,6 +1,8 @@
{
"login": "登录",
"loginOrSignup": "登录 / 注册",
"profile": "个人资料",
"security": "安全",
"signout": "退出登录",
"signup": "注册"
}

View file

@ -169,6 +169,7 @@
"userPanel": {
"anonymousNickName": "匿名用户",
"billing": "账单管理",
"data": "数据存储",
"defaultNickname": "社区版用户",
"discord": "社区支持",
"docs": "使用文档",

View file

@ -1,6 +1,8 @@
{
"login": "登入",
"loginOrSignup": "登入 / 註冊",
"profile": "個人檔案",
"security": "安全",
"signout": "登出",
"signup": "註冊"
}

View file

@ -169,6 +169,7 @@
"userPanel": {
"anonymousNickName": "匿名使用者",
"billing": "帳單管理",
"data": "資料儲存",
"defaultNickname": "社群版使用者",
"discord": "社區支援",
"docs": "使用文件",

View file

@ -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;

View file

@ -1,14 +0,0 @@
import { UserProfile } from '@clerk/nextjs';
import PageTitle from './PageTitle';
const Page = () => {
return (
<>
<PageTitle />
<UserProfile />
</>
);
};
export default Page;

View file

@ -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();
});
});

View 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);
});
});
});

View 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;

View 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;

View 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;
};

View file

@ -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;

View 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;

View 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;

View 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;

View file

@ -0,0 +1,5 @@
import SkeletonLoading from '@/components/SkeletonLoading';
export default () => {
return <SkeletonLoading paragraph={{ rows: 8 }} />;
};

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -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;
};

View 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;

View 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;

View 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;

View file

@ -0,0 +1,5 @@
import SkeletonLoading from '@/components/SkeletonLoading';
export default () => {
return <SkeletonLoading paragraph={{ rows: 8 }} />;
};

View 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;

View 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;

View 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;

View 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)),
}));
};

View 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;

View file

@ -0,0 +1,5 @@
import SkeletonLoading from '@/components/SkeletonLoading';
export default () => {
return <SkeletonLoading paragraph={{ rows: 8 }} />;
};

View 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;

View file

@ -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}
</>
);
});

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View file

@ -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}
/>

View file

@ -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'),
},

View file

@ -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}

View file

@ -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};
`,
);

View file

@ -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>
);
});

View file

@ -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'}

View file

@ -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>

View file

@ -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'}

View file

@ -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 };
};
};

View file

@ -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();
});

View 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);
});
});
});

View file

@ -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;

View file

@ -1,6 +1,8 @@
export default {
login: '登录',
loginOrSignup: '登录 / 注册',
profile: '个人资料',
security: '安全',
signout: '退出登录',
signup: '注册',
};

View file

@ -163,6 +163,7 @@ export default {
userPanel: {
anonymousNickName: '匿名用户',
billing: '账单管理',
data: '数据存储',
defaultNickname: '社区版用户',
discord: '社区支持',
docs: '使用文档',

View file

@ -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,
};