mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-01-09 07:32:05 +08:00
💄 style: add user panel and refactor the next-auth (#2349)
* ♻️ refactor: refactor the next-auth * ✨ feat: Add User Panel * 💄 style: Update User Avatar on mobile * 🚸 style: fix data importer hot zone * 🚸 style: add migration guide * 🎨 chore: clean code * ✅ test: add test * 🌐 chore: update locale * 💄 style: improve style * ✅ test: fix test * 💄 style: improve locale switch * ♻️ refactor: use middleware redirect instead of page --------- Co-authored-by: canisminor1990 <i@canisminor.cc>
This commit is contained in:
parent
6026481f29
commit
5cecee096e
@ -157,5 +157,19 @@
|
||||
"action": "ترقية",
|
||||
"hasNew": "يوجد تحديث متاح",
|
||||
"newVersion": "هناك إصدار جديد متاح: {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "إدارة الفواتير",
|
||||
"defaultNickname": "مستخدم النسخة المجتمعية",
|
||||
"discord": "الدعم المجتمعي",
|
||||
"docs": "وثائق الاستخدام",
|
||||
"email": "الدعم عبر البريد الإلكتروني",
|
||||
"feedback": "تقديم ملاحظات واقتراحات",
|
||||
"help": "مركز المساعدة",
|
||||
"moveGuide": "تم نقل زر الإعدادات إلى هنا",
|
||||
"plans": "خطط الاشتراك",
|
||||
"profile": "إدارة الحساب",
|
||||
"setting": "إعدادات التطبيق",
|
||||
"usages": "إحصاءات الاستخدام"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "Надстрой",
|
||||
"hasNew": "Налична е нова актуализация",
|
||||
"newVersion": "Налична е нова версия: {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "Управление на сметките",
|
||||
"defaultNickname": "Потребител на общността",
|
||||
"discord": "Поддръжка на общността",
|
||||
"docs": "Документация",
|
||||
"email": "Поддръжка по имейл",
|
||||
"feedback": "Обратна връзка и предложения",
|
||||
"help": "Център за помощ",
|
||||
"moveGuide": "Бутонът за настройки е преместен тук",
|
||||
"plans": "Планове за абонамент",
|
||||
"profile": "Управление на профила",
|
||||
"setting": "Настройки на приложението",
|
||||
"usages": "Статистика за използване"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "Aktualisieren",
|
||||
"hasNew": "Neue Version verfügbar",
|
||||
"newVersion": "Neue Version verfügbar: {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "Abrechnung verwalten",
|
||||
"defaultNickname": "Community User",
|
||||
"discord": "Community-Support",
|
||||
"docs": "Dokumentation",
|
||||
"email": "E-Mail-Support",
|
||||
"feedback": "Feedback und Vorschläge",
|
||||
"help": "Hilfezentrum",
|
||||
"moveGuide": "Die Einstellungen wurden hierher verschoben.",
|
||||
"plans": "Abonnementpläne",
|
||||
"profile": "Kontoverwaltung",
|
||||
"setting": "App-Einstellungen",
|
||||
"usages": "Nutzungsstatistiken"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "Upgrade",
|
||||
"hasNew": "New update available",
|
||||
"newVersion": "New version available: {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "Billing Management",
|
||||
"defaultNickname": "Community User",
|
||||
"discord": "Community Support",
|
||||
"docs": "Documentation",
|
||||
"email": "Email Support",
|
||||
"feedback": "Feedback and Suggestions",
|
||||
"help": "Help Center",
|
||||
"moveGuide": "The settings button has been moved here",
|
||||
"plans": "Subscription Plans",
|
||||
"profile": "Account Management",
|
||||
"setting": "App Settings",
|
||||
"usages": "Usage Statistics"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "Actualizar",
|
||||
"hasNew": "Hay una nueva actualización disponible",
|
||||
"newVersion": "Nueva versión disponible: {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "Gestión de facturación",
|
||||
"defaultNickname": "Usuario de la comunidad",
|
||||
"discord": "Soporte de la comunidad",
|
||||
"docs": "Documentación de uso",
|
||||
"email": "Soporte por correo electrónico",
|
||||
"feedback": "Comentarios y sugerencias",
|
||||
"help": "Centro de ayuda",
|
||||
"moveGuide": "El botón de configuración se ha movido aquí",
|
||||
"plans": "Planes de suscripción",
|
||||
"profile": "Gestión de cuenta",
|
||||
"setting": "Configuración de la aplicación",
|
||||
"usages": "Estadísticas de uso"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "Mettre à jour",
|
||||
"hasNew": "Nouvelle mise à jour disponible",
|
||||
"newVersion": "Nouvelle version disponible : {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "Gestion de la facturation",
|
||||
"defaultNickname": "Utilisateur de la version communautaire",
|
||||
"discord": "Support de la communauté",
|
||||
"docs": "Documentation d'utilisation",
|
||||
"email": "Support par e-mail",
|
||||
"feedback": "Retours et suggestions",
|
||||
"help": "Centre d'aide",
|
||||
"moveGuide": "Le bouton de configuration a été déplacé ici",
|
||||
"plans": "Forfaits d'abonnement",
|
||||
"profile": "Gestion du compte",
|
||||
"setting": "Paramètres de l'application",
|
||||
"usages": "Statistiques d'utilisation"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "Aggiorna",
|
||||
"hasNew": "Nuovo aggiornamento disponibile",
|
||||
"newVersion": "Nuova versione disponibile: {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "Gestione fatturazione",
|
||||
"defaultNickname": "Utente Community",
|
||||
"discord": "Supporto della community",
|
||||
"docs": "Documentazione",
|
||||
"email": "Supporto via email",
|
||||
"feedback": "Feedback e suggerimenti",
|
||||
"help": "Centro assistenza",
|
||||
"moveGuide": "Il pulsante delle impostazioni è stato spostato qui",
|
||||
"plans": "Piani di abbonamento",
|
||||
"profile": "Gestione account",
|
||||
"setting": "Impostazioni app",
|
||||
"usages": "Statistiche di utilizzo"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "アップグレード",
|
||||
"hasNew": "利用可能な更新があります",
|
||||
"newVersion": "新しいバージョンが利用可能です:{{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "請求管理",
|
||||
"defaultNickname": "コミュニティユーザー",
|
||||
"discord": "コミュニティサポート",
|
||||
"docs": "使用文書",
|
||||
"email": "メールサポート",
|
||||
"feedback": "フィードバックと提案",
|
||||
"help": "ヘルプセンター",
|
||||
"moveGuide": "設定ボタンがこちらに移動しました",
|
||||
"plans": "サブスクリプションプラン",
|
||||
"profile": "アカウント管理",
|
||||
"setting": "アプリ設定",
|
||||
"usages": "利用量統計"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "업그레이드",
|
||||
"hasNew": "사용 가능한 업데이트가 있습니다",
|
||||
"newVersion": "새 버전 사용 가능: {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "결제 관리",
|
||||
"defaultNickname": "커뮤니티 사용자",
|
||||
"discord": "커뮤니티 지원",
|
||||
"docs": "사용 설명서",
|
||||
"email": "이메일 지원",
|
||||
"feedback": "피드백 및 제안",
|
||||
"help": "도움말 센터",
|
||||
"moveGuide": "설정 버튼을 여기로 이동했습니다",
|
||||
"plans": "요금제",
|
||||
"profile": "계정 관리",
|
||||
"setting": "앱 설정",
|
||||
"usages": "사용량 통계"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "升级",
|
||||
"hasNew": "有可用更新",
|
||||
"newVersion": "有新版本可用:{{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "账单管理",
|
||||
"defaultNickname": "Standaardgebruiker",
|
||||
"discord": "社区支持",
|
||||
"docs": "使用文档",
|
||||
"email": "邮件支持",
|
||||
"feedback": "反馈与建议",
|
||||
"help": "帮助中心",
|
||||
"moveGuide": "De instellingenknop is hierheen verplaatst",
|
||||
"plans": "订阅方案",
|
||||
"profile": "账户管理",
|
||||
"setting": "应用设置",
|
||||
"usages": "用量统计"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "Aktualizuj",
|
||||
"hasNew": "Dostępna jest nowa aktualizacja",
|
||||
"newVersion": "Dostępna jest nowa wersja: {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "Zarządzanie rachunkami",
|
||||
"defaultNickname": "Użytkownik Wersji Społecznościowej",
|
||||
"discord": "Wsparcie społeczności",
|
||||
"docs": "Dokumentacja",
|
||||
"email": "Wsparcie mailowe",
|
||||
"feedback": "Opinie i sugestie",
|
||||
"help": "Centrum pomocy",
|
||||
"moveGuide": "Przenieś przycisk ustawień tutaj",
|
||||
"plans": "Plan abonamentu",
|
||||
"profile": "Zarządzanie kontem",
|
||||
"setting": "Ustawienia aplikacji",
|
||||
"usages": "Statystyki użycia"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "Atualizar",
|
||||
"hasNew": "Nova atualização disponível",
|
||||
"newVersion": "Nova versão disponível: {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "Gerenciamento de faturas",
|
||||
"defaultNickname": "Usuário da Comunidade",
|
||||
"discord": "Suporte da Comunidade",
|
||||
"docs": "Documentação",
|
||||
"email": "Suporte por E-mail",
|
||||
"feedback": "Feedback e Sugestões",
|
||||
"help": "Central de Ajuda",
|
||||
"moveGuide": "O botão de configurações foi movido para cá",
|
||||
"plans": "Planos de Assinatura",
|
||||
"profile": "Gerenciamento de Conta",
|
||||
"setting": "Configurações do Aplicativo",
|
||||
"usages": "Estatísticas de Uso"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "обновить",
|
||||
"hasNew": "Доступно обновление",
|
||||
"newVersion": "Доступна новая версия: {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "Управление счетами",
|
||||
"defaultNickname": "Пользователь сообщества",
|
||||
"discord": "Поддержка сообщества",
|
||||
"docs": "Документация",
|
||||
"email": "Поддержка по электронной почте",
|
||||
"feedback": "Обратная связь и предложения",
|
||||
"help": "Центр помощи",
|
||||
"moveGuide": "Кнопка настроек перемещена сюда",
|
||||
"plans": "Планы подписки",
|
||||
"profile": "Управление аккаунтом",
|
||||
"setting": "Настройки приложения",
|
||||
"usages": "Статистика использования"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "Güncelle",
|
||||
"hasNew": "Yeni güncelleme mevcut",
|
||||
"newVersion": "Yeni sürüm mevcut: {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "Fatura Yönetimi",
|
||||
"defaultNickname": "Topluluk Kullanıcısı",
|
||||
"discord": "Topluluk Destek",
|
||||
"docs": "Belgeler",
|
||||
"email": "E-posta Destek",
|
||||
"feedback": "Geribildirim ve Öneriler",
|
||||
"help": "Yardım Merkezi",
|
||||
"moveGuide": "Ayarlar düğmesini buraya taşıyın",
|
||||
"plans": "Planlar",
|
||||
"profile": "Hesap Yönetimi",
|
||||
"setting": "Uygulama Ayarları",
|
||||
"usages": "Kullanım İstatistikleri"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "Nâng cấp",
|
||||
"hasNew": "Có bản cập nhật mới",
|
||||
"newVersion": "Có phiên bản mới: {{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "Quản lý hóa đơn",
|
||||
"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",
|
||||
"email": "Hỗ trợ qua email",
|
||||
"feedback": "Phản hồi và đề xuất",
|
||||
"help": "Trung tâm trợ giúp",
|
||||
"moveGuide": "Đã di chuyển nút cài đặt đến đây",
|
||||
"plans": "Kế hoạch đăng ký",
|
||||
"profile": "Quản lý tài khoản",
|
||||
"setting": "Cài đặt ứng dụng",
|
||||
"usages": "Thống kê sử dụng"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "升级",
|
||||
"hasNew": "有可用更新",
|
||||
"newVersion": "有新版本可用:{{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "账单管理",
|
||||
"defaultNickname": "社区版用户",
|
||||
"discord": "社区支持",
|
||||
"docs": "使用文档",
|
||||
"email": "邮件支持",
|
||||
"feedback": "反馈与建议",
|
||||
"help": "帮助中心",
|
||||
"moveGuide": "设置按钮搬到这里啦",
|
||||
"plans": "订阅方案",
|
||||
"profile": "账户管理",
|
||||
"setting": "应用设置",
|
||||
"usages": "用量统计"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,5 +157,19 @@
|
||||
"action": "升級",
|
||||
"hasNew": "有可用更新",
|
||||
"newVersion": "有新版本可用:{{version}}"
|
||||
},
|
||||
"userPanel": {
|
||||
"billing": "帳單管理",
|
||||
"defaultNickname": "社群版使用者",
|
||||
"discord": "社區支援",
|
||||
"docs": "使用文件",
|
||||
"email": "郵件支援",
|
||||
"feedback": "反饋與建議",
|
||||
"help": "幫助中心",
|
||||
"moveGuide": "設置按鈕搬到這裡啦",
|
||||
"plans": "訂閱方案",
|
||||
"profile": "帳戶管理",
|
||||
"setting": "應用設定",
|
||||
"usages": "用量統計"
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +30,13 @@ const nextConfig = {
|
||||
|
||||
output: buildWithDocker ? 'standalone' : undefined,
|
||||
|
||||
redirects: async () => [
|
||||
{
|
||||
source: '/settings',
|
||||
permanent: true,
|
||||
destination: '/settings/common',
|
||||
},
|
||||
],
|
||||
rewrites: async () => [
|
||||
// due to google api not work correct in some countries
|
||||
// we need a proxy to bypass the restriction
|
||||
|
||||
@ -2,7 +2,7 @@ import { redirect } from 'next/navigation';
|
||||
import { Center } from 'react-layout-kit';
|
||||
|
||||
import BrandWatermark from '@/components/BrandWatermark';
|
||||
import Avatar from '@/features/AvatarWithUpload';
|
||||
import UserAvatar from '@/features/User/UserAvatar';
|
||||
import { isMobileDevice } from '@/utils/responsive';
|
||||
|
||||
import AvatarBanner, { AVATAR_SIZE } from './features/AvatarBanner';
|
||||
@ -17,7 +17,7 @@ const Page = () => {
|
||||
return (
|
||||
<>
|
||||
<AvatarBanner>
|
||||
<Avatar size={AVATAR_SIZE} />
|
||||
<UserAvatar size={AVATAR_SIZE} />
|
||||
</AvatarBanner>
|
||||
<Cate />
|
||||
<ExtraCate />
|
||||
|
||||
55
src/app/(main)/@nav/_layout/Desktop/Avatar.test.tsx
Normal file
55
src/app/(main)/@nav/_layout/Desktop/Avatar.test.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
||||
import Avatar from './Avatar';
|
||||
|
||||
// Mock UserAvatar and UserPanel components
|
||||
vi.mock('@/features/User/UserAvatar', () => ({
|
||||
default: vi.fn(() => <div>Mocked UserAvatar</div>),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/User/UserPanel', () => ({
|
||||
default: vi.fn(({ children }) => <div>Mocked UserPanel {children}</div>),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Avatar', () => {
|
||||
it('should render UserAvatar and UserPanel when hideSettingsMoveGuide is true', () => {
|
||||
render(<Avatar />);
|
||||
|
||||
expect(screen.getByText('Mocked UserPanel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mocked UserAvatar')).toBeInTheDocument();
|
||||
expect(screen.queryByText('userPanel.moveGuide')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Tooltip with guide content when hideSettingsMoveGuide is false', () => {
|
||||
act(() => {
|
||||
useUserStore.getState().updateGuideState({ moveSettingsToAvatar: false });
|
||||
});
|
||||
|
||||
render(<Avatar />);
|
||||
|
||||
expect(screen.getByText('userPanel.moveGuide')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mocked UserPanel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mocked UserAvatar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call updateGuideState when close icon is clicked in Tooltip', () => {
|
||||
const updateGuideStateMock = vi.fn();
|
||||
act(() => {
|
||||
useUserStore.getState().updateGuideState({ moveSettingsToAvatar: false });
|
||||
useUserStore.setState({ updateGuideState: updateGuideStateMock });
|
||||
});
|
||||
|
||||
render(<Avatar />);
|
||||
|
||||
fireEvent.click(screen.getByRole('close-guide'));
|
||||
|
||||
expect(updateGuideStateMock).toHaveBeenCalledWith({ moveSettingsToAvatar: true });
|
||||
});
|
||||
});
|
||||
@ -1,9 +1,51 @@
|
||||
import { ActionIcon } from '@lobehub/ui';
|
||||
import { Tooltip } from 'antd';
|
||||
import { LucideX } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import AvatarWithUpload from '@/features/AvatarWithUpload';
|
||||
import UserAvatar from '@/features/User/UserAvatar';
|
||||
import UserPanel from '@/features/User/UserPanel';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { preferenceSelectors } from '@/store/user/selectors';
|
||||
|
||||
const Avatar = memo(() => {
|
||||
return <AvatarWithUpload id={'avatar'} />;
|
||||
const { t } = useTranslation('common');
|
||||
const hideSettingsMoveGuide = useUserStore(preferenceSelectors.hideSettingsMoveGuide);
|
||||
const updateGuideState = useUserStore((s) => s.updateGuideState);
|
||||
const content = (
|
||||
<UserPanel>
|
||||
<UserAvatar clickable />
|
||||
</UserPanel>
|
||||
);
|
||||
|
||||
return hideSettingsMoveGuide ? (
|
||||
content
|
||||
) : (
|
||||
<Tooltip
|
||||
color={'blue'}
|
||||
open
|
||||
placement={'right'}
|
||||
prefixCls={'guide'}
|
||||
title={
|
||||
<Flexbox align={'center'} gap={8} horizontal>
|
||||
<div style={{ lineHeight: '22px' }}>{t('userPanel.moveGuide')}</div>
|
||||
<ActionIcon
|
||||
icon={LucideX}
|
||||
onClick={() => {
|
||||
updateGuideState({ moveSettingsToAvatar: true });
|
||||
}}
|
||||
role={'close-guide'}
|
||||
size={'small'}
|
||||
style={{ color: 'inherit' }}
|
||||
/>
|
||||
</Flexbox>
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
Avatar.displayName = 'Avatar';
|
||||
|
||||
@ -1,123 +1,14 @@
|
||||
import { ActionIcon, DiscordIcon, Icon } from '@lobehub/ui';
|
||||
import { Badge, ConfigProvider, Dropdown, MenuProps } from 'antd';
|
||||
import {
|
||||
Book,
|
||||
Feather,
|
||||
FileClock,
|
||||
Github,
|
||||
HardDriveDownload,
|
||||
HardDriveUpload,
|
||||
Heart,
|
||||
Settings,
|
||||
Settings2,
|
||||
} from 'lucide-react';
|
||||
import { ActionIcon } from '@lobehub/ui';
|
||||
import { Book, Github } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { ABOUT, CHANGELOG, DISCORD, DOCUMENTS, FEEDBACK, GITHUB } from '@/const/url';
|
||||
import DataImporter from '@/features/DataImporter';
|
||||
import { configService } from '@/services/config';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import { SidebarTabKey } from '@/store/global/initialState';
|
||||
import { DOCUMENTS, GITHUB } from '@/const/url';
|
||||
|
||||
export interface BottomActionProps {
|
||||
tab?: SidebarTabKey;
|
||||
}
|
||||
|
||||
const BottomActions = memo<BottomActionProps>(({ tab }) => {
|
||||
const router = useRouter();
|
||||
const BottomActions = memo(() => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const [hasNewVersion, useCheckLatestVersion] = useGlobalStore((s) => [
|
||||
s.hasNewVersion,
|
||||
s.useCheckLatestVersion,
|
||||
]);
|
||||
|
||||
useCheckLatestVersion();
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
icon: <Icon icon={HardDriveUpload} />,
|
||||
key: 'import',
|
||||
label: <DataImporter>{t('import')}</DataImporter>,
|
||||
},
|
||||
{
|
||||
children: [
|
||||
{
|
||||
key: 'allAgent',
|
||||
label: <div>{t('exportType.allAgent')}</div>,
|
||||
onClick: configService.exportAgents,
|
||||
},
|
||||
{
|
||||
key: 'allAgentWithMessage',
|
||||
label: <div>{t('exportType.allAgentWithMessage')}</div>,
|
||||
onClick: configService.exportSessions,
|
||||
},
|
||||
{
|
||||
key: 'globalSetting',
|
||||
label: <div>{t('exportType.globalSetting')}</div>,
|
||||
onClick: configService.exportSettings,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'all',
|
||||
label: <div>{t('exportType.all')}</div>,
|
||||
onClick: configService.exportAll,
|
||||
},
|
||||
],
|
||||
icon: <Icon icon={HardDriveDownload} />,
|
||||
key: 'export',
|
||||
label: t('export'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={Feather} />,
|
||||
key: 'feedback',
|
||||
label: t('feedback'),
|
||||
onClick: () => window.open(FEEDBACK, '__blank'),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={FileClock} />,
|
||||
key: 'changelog',
|
||||
label: t('changelog'),
|
||||
onClick: () => window.open(CHANGELOG, '__blank'),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={DiscordIcon} />,
|
||||
key: 'wiki',
|
||||
label: 'Discord',
|
||||
onClick: () => window.open(DISCORD, '__blank'),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={Heart} />,
|
||||
key: 'about',
|
||||
label: t('about'),
|
||||
onClick: () => window.open(ABOUT, '__blank'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={Settings} />,
|
||||
key: 'setting',
|
||||
label: (
|
||||
<Flexbox align={'center'} distribution={'space-between'} gap={8} horizontal>
|
||||
{t('setting')} {hasNewVersion && <Badge count={t('upgradeVersion.hasNew')} />}
|
||||
</Flexbox>
|
||||
),
|
||||
onClick: () => {
|
||||
router.push('/settings/common');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link aria-label={'GitHub'} href={GITHUB} target={'_blank'}>
|
||||
@ -126,19 +17,6 @@ const BottomActions = memo<BottomActionProps>(({ tab }) => {
|
||||
<Link aria-label={t('document')} href={DOCUMENTS} target={'_blank'}>
|
||||
<ActionIcon icon={Book} placement={'right'} title={t('document')} />
|
||||
</Link>
|
||||
<Dropdown arrow={false} menu={{ items }} trigger={['click']}>
|
||||
{hasNewVersion ? (
|
||||
<Flexbox>
|
||||
<ConfigProvider theme={{ components: { Badge: { dotSize: 8 } } }}>
|
||||
<Badge dot offset={[-4, 4]}>
|
||||
<ActionIcon active={tab === SidebarTabKey.Setting} icon={Settings2} />
|
||||
</Badge>
|
||||
</ConfigProvider>
|
||||
</Flexbox>
|
||||
) : (
|
||||
<ActionIcon active={tab === SidebarTabKey.Setting} icon={Settings2} />
|
||||
)}
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@ -14,7 +14,7 @@ const Nav = memo(() => {
|
||||
return (
|
||||
<SideNav
|
||||
avatar={<Avatar />}
|
||||
bottomActions={<BottomActions tab={sidebarKey} />}
|
||||
bottomActions={<BottomActions />}
|
||||
style={{ height: '100%', zIndex: 100 }}
|
||||
topActions={<TopActions tab={sidebarKey} />}
|
||||
/>
|
||||
|
||||
@ -10,13 +10,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useSyncSettings } from '@/app/(main)/settings/hooks/useSyncSettings';
|
||||
import { FORM_STYLE } from '@/const/layoutTokens';
|
||||
import { DEFAULT_SETTINGS } from '@/const/settings';
|
||||
import { useOAuthSession } from '@/hooks/useOAuthSession';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { useFileStore } from '@/store/file';
|
||||
import { useSessionStore } from '@/store/session';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { settingsSelectors } from '@/store/user/selectors';
|
||||
import { settingsSelectors, userProfileSelectors } from '@/store/user/selectors';
|
||||
|
||||
type SettingItemGroup = ItemGroup;
|
||||
|
||||
@ -29,7 +28,8 @@ const Common = memo<SettingsCommonProps>(({ showAccessCodeConfig, showOAuthLogin
|
||||
const { t } = useTranslation('setting');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { user, isOAuthLoggedIn } = useOAuthSession();
|
||||
const isSignedIn = useUserStore((s) => s.isSignedIn);
|
||||
const user = useUserStore(userProfileSelectors.userProfile, isEqual);
|
||||
|
||||
const [clearSessions, clearSessionGroups] = useSessionStore((s) => [
|
||||
s.clearSessions,
|
||||
@ -110,18 +110,18 @@ const Common = memo<SettingsCommonProps>(({ showAccessCodeConfig, showOAuthLogin
|
||||
name: 'password',
|
||||
},
|
||||
{
|
||||
children: isOAuthLoggedIn ? (
|
||||
children: isSignedIn ? (
|
||||
<Button onClick={handleSignOut}>{t('settingSystem.oauth.signout.action')}</Button>
|
||||
) : (
|
||||
<Button onClick={handleSignIn} type="primary">
|
||||
{t('settingSystem.oauth.signin.action')}
|
||||
</Button>
|
||||
),
|
||||
desc: isOAuthLoggedIn
|
||||
desc: isSignedIn
|
||||
? `${user?.email} ${t('settingSystem.oauth.info.desc')}`
|
||||
: t('settingSystem.oauth.signin.desc'),
|
||||
hidden: !showOAuthLogin,
|
||||
label: isOAuthLoggedIn
|
||||
label: isSignedIn
|
||||
? t('settingSystem.oauth.info.title')
|
||||
: t('settingSystem.oauth.signin.title'),
|
||||
minWidth: undefined,
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
const Page = () => {
|
||||
return redirect('/settings/common');
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@ -1,49 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { Upload } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import NextImage from 'next/image';
|
||||
import { CSSProperties, memo, useCallback } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
|
||||
import { DEFAULT_USER_AVATAR_URL } from '@/const/meta';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userProfileSelectors } from '@/store/user/selectors';
|
||||
import { imageToBase64 } from '@/utils/imageToBase64';
|
||||
import { createUploadImageHandler } from '@/utils/uploadFIle';
|
||||
|
||||
const useStyle = createStyles(
|
||||
({ css, token }) => css`
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
scale 400ms ${token.motionEaseOut},
|
||||
box-shadow 100ms ${token.motionEaseOut};
|
||||
import UserAvatar, { type UserAvatarProps } from '../User/UserAvatar';
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 3px ${token.colorText};
|
||||
}
|
||||
|
||||
&:active {
|
||||
scale: 0.8;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
interface AvatarWithUploadProps {
|
||||
interface AvatarWithUploadProps extends UserAvatarProps {
|
||||
compressSize?: number;
|
||||
id?: string;
|
||||
size?: number;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const AvatarWithUpload = memo<AvatarWithUploadProps>(
|
||||
({ size = 40, compressSize = 256, style, id }) => {
|
||||
const { styles } = useStyle();
|
||||
const [avatar, updateAvatar] = useUserStore((s) => [
|
||||
userProfileSelectors.userAvatar(s),
|
||||
s.updateAvatar,
|
||||
]);
|
||||
({ size = 40, compressSize = 256, ...rest }) => {
|
||||
const updateAvatar = useUserStore((s) => s.updateAvatar);
|
||||
|
||||
const handleUploadAvatar = useCallback(
|
||||
createUploadImageHandler((avatar) => {
|
||||
@ -58,17 +30,9 @@ const AvatarWithUpload = memo<AvatarWithUploadProps>(
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles} id={id} style={{ maxHeight: size, maxWidth: size, ...style }}>
|
||||
<Upload beforeUpload={handleUploadAvatar} itemRender={() => void 0} maxCount={1}>
|
||||
<NextImage
|
||||
alt={avatar ? 'userAvatar' : 'LobeChat'}
|
||||
height={size}
|
||||
src={!!avatar ? avatar : DEFAULT_USER_AVATAR_URL}
|
||||
unoptimized
|
||||
width={size}
|
||||
/>
|
||||
</Upload>
|
||||
</div>
|
||||
<Upload beforeUpload={handleUploadAvatar} itemRender={() => void 0} maxCount={1}>
|
||||
<UserAvatar clickable size={size} {...rest} />
|
||||
</Upload>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -14,6 +14,15 @@ const useStyles = createStyles(({ css, token }) => {
|
||||
const size = 28;
|
||||
|
||||
return {
|
||||
children: css`
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
`,
|
||||
|
||||
loader: css`
|
||||
transform: translateX(-${size * 2}px);
|
||||
|
||||
@ -231,7 +240,8 @@ const DataImporter = memo<DataImporterProps>(({ children, onFinishImport }) => {
|
||||
maxCount={1}
|
||||
showUploadList={false}
|
||||
>
|
||||
{children}
|
||||
{/* a very hackable solution: add a pseudo before to have a large hot zone */}
|
||||
<div className={styles.children}>{children}</div>
|
||||
</Upload>
|
||||
</>
|
||||
);
|
||||
|
||||
67
src/features/User/UserAvatar.tsx
Normal file
67
src/features/User/UserAvatar.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { Avatar, type AvatarProps } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { DEFAULT_USER_AVATAR_URL } from '@/const/meta';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userProfileSelectors } from '@/store/user/selectors';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
clickable: css`
|
||||
position: relative;
|
||||
transition: all 200ms ease-out 0s;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
|
||||
position: absolute;
|
||||
transform: skewX(-45deg) translateX(-400%);
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
box-sizing: border-box;
|
||||
width: 25%;
|
||||
height: 100%;
|
||||
|
||||
background: rgba(255, 255, 255, 50%);
|
||||
|
||||
transition: all 200ms ease-out 0s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 2px ${token.colorPrimary};
|
||||
|
||||
&::before {
|
||||
transform: skewX(-45deg) translateX(400%);
|
||||
}
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
export interface UserAvatarProps extends AvatarProps {
|
||||
clickable?: boolean;
|
||||
}
|
||||
|
||||
const UserAvatar = memo<UserAvatarProps>(
|
||||
({ size = 40, background, clickable, className, ...rest }) => {
|
||||
const { styles, cx } = useStyles();
|
||||
const avatar = useUserStore(userProfileSelectors.userAvatar);
|
||||
return (
|
||||
<Avatar
|
||||
alt={avatar ? 'UserAvatar' : 'LobeChat'}
|
||||
avatar={avatar || DEFAULT_USER_AVATAR_URL}
|
||||
background={avatar ? background : undefined}
|
||||
className={cx(clickable && styles.clickable, className)}
|
||||
size={size}
|
||||
unoptimized
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
UserAvatar.displayName = 'UserAvatar';
|
||||
|
||||
export default UserAvatar;
|
||||
41
src/features/User/UserInfo.tsx
Normal file
41
src/features/User/UserInfo.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import UserAvatar from './UserAvatar';
|
||||
|
||||
const DEFAULT_USERNAME = 'LobeChat Community Edition';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
nickname: css`
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
`,
|
||||
username: css`
|
||||
line-height: 1;
|
||||
color: ${token.colorTextDescription};
|
||||
`,
|
||||
}));
|
||||
|
||||
const UserInfo = memo<{ onClick?: () => void }>(({ onClick }) => {
|
||||
const { t } = useTranslation('common');
|
||||
const { styles, theme } = useStyles();
|
||||
|
||||
const DEFAULT_NICKNAME = t('userPanel.defaultNickname');
|
||||
|
||||
return (
|
||||
<Flexbox align={'center'} gap={12} horizontal paddingBlock={12} paddingInline={16}>
|
||||
<UserAvatar background={theme.colorFill} onClick={onClick} size={48} />
|
||||
<Flexbox flex={1} gap={6}>
|
||||
<div className={styles.nickname}>{DEFAULT_NICKNAME}</div>
|
||||
<div className={styles.username}>{DEFAULT_USERNAME}</div>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default UserInfo;
|
||||
57
src/features/User/UserPanel/LangButton.tsx
Normal file
57
src/features/User/UserPanel/LangButton.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { ActionIcon } from '@lobehub/ui';
|
||||
import { Popover } from 'antd';
|
||||
import { useTheme } from 'antd-style';
|
||||
import { Languages } from 'lucide-react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Menu, { type MenuProps } from '@/components/Menu';
|
||||
import { localeOptions } from '@/locales/resources';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { settingsSelectors } from '@/store/user/selectors';
|
||||
|
||||
const LangButton = memo(() => {
|
||||
const theme = useTheme();
|
||||
const [language, switchLocale] = useUserStore((s) => [
|
||||
settingsSelectors.currentSettings(s).language,
|
||||
s.switchLocale,
|
||||
]);
|
||||
|
||||
const { t } = useTranslation('setting');
|
||||
|
||||
const items: MenuProps['items'] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'auto',
|
||||
label: t('settingTheme.lang.autoMode'),
|
||||
onClick: () => switchLocale('auto'),
|
||||
},
|
||||
...localeOptions.map((item) => ({
|
||||
key: item.value,
|
||||
label: item.label,
|
||||
onClick: () => switchLocale(item.value),
|
||||
})),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
arrow={false}
|
||||
content={<Menu items={items} selectable selectedKeys={[language]} />}
|
||||
overlayInnerStyle={{
|
||||
padding: 0,
|
||||
}}
|
||||
placement={'right'}
|
||||
trigger={['click', 'hover']}
|
||||
>
|
||||
<ActionIcon
|
||||
icon={Languages}
|
||||
size={{ blockSize: 32, fontSize: 16 }}
|
||||
style={{ border: `1px solid ${theme.colorFillSecondary}` }}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
export default LangButton;
|
||||
35
src/features/User/UserPanel/Popover.tsx
Normal file
35
src/features/User/UserPanel/Popover.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { memo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import BrandWatermark from '@/components/BrandWatermark';
|
||||
import Menu from '@/components/Menu';
|
||||
|
||||
import UserInfo from '../UserInfo';
|
||||
import LangButton from './LangButton';
|
||||
import ThemeButton from './ThemeButton';
|
||||
import { useMenu } from './useMenu';
|
||||
|
||||
const PopoverContent = memo<{ closePopover: () => void }>(({ closePopover }) => {
|
||||
const { mainItems } = useMenu();
|
||||
|
||||
return (
|
||||
<Flexbox gap={2} style={{ minWidth: 300 }}>
|
||||
<UserInfo />
|
||||
<Menu items={mainItems} onClick={closePopover} />
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
horizontal
|
||||
justify={'space-between'}
|
||||
style={{ padding: '6px 6px 6px 16px' }}
|
||||
>
|
||||
<BrandWatermark />
|
||||
<Flexbox align={'center'} flex={'none'} gap={6} horizontal>
|
||||
<LangButton />
|
||||
<ThemeButton />
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default PopoverContent;
|
||||
70
src/features/User/UserPanel/ThemeButton.tsx
Normal file
70
src/features/User/UserPanel/ThemeButton.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { ActionIcon, Icon } from '@lobehub/ui';
|
||||
import { Popover } from 'antd';
|
||||
import { useTheme } from 'antd-style';
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Menu, { type MenuProps } from '@/components/Menu';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { settingsSelectors } from '@/store/user/selectors';
|
||||
|
||||
const themeIcons = {
|
||||
auto: Monitor,
|
||||
dark: Moon,
|
||||
light: Sun,
|
||||
};
|
||||
|
||||
const ThemeButton = memo(() => {
|
||||
const theme = useTheme();
|
||||
const [themeMode, switchThemeMode] = useUserStore((s) => [
|
||||
settingsSelectors.currentSettings(s).themeMode,
|
||||
s.switchThemeMode,
|
||||
]);
|
||||
|
||||
const { t } = useTranslation('setting');
|
||||
|
||||
const items: MenuProps['items'] = useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: <Icon icon={themeIcons.auto} />,
|
||||
key: 'auto',
|
||||
label: t('settingTheme.themeMode.auto'),
|
||||
onClick: () => switchThemeMode('auto'),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={themeIcons.light} />,
|
||||
key: 'light',
|
||||
label: t('settingTheme.themeMode.light'),
|
||||
onClick: () => switchThemeMode('light'),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={themeIcons.dark} />,
|
||||
key: 'dark',
|
||||
label: t('settingTheme.themeMode.dark'),
|
||||
onClick: () => switchThemeMode('dark'),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
arrow={false}
|
||||
content={<Menu items={items} selectable selectedKeys={[themeMode]} />}
|
||||
overlayInnerStyle={{
|
||||
padding: 0,
|
||||
}}
|
||||
placement={'right'}
|
||||
trigger={['click', 'hover']}
|
||||
>
|
||||
<ActionIcon
|
||||
icon={themeIcons[themeMode]}
|
||||
size={{ blockSize: 32, fontSize: 16 }}
|
||||
style={{ border: `1px solid ${theme.colorFillSecondary}` }}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
export default ThemeButton;
|
||||
35
src/features/User/UserPanel/UserInfo.tsx
Normal file
35
src/features/User/UserPanel/UserInfo.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import UserAvatar from '@/features/User/UserAvatar';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
nickname: css`
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
`,
|
||||
username: css`
|
||||
line-height: 1;
|
||||
color: ${token.colorTextDescription};
|
||||
`,
|
||||
}));
|
||||
|
||||
// TODO
|
||||
|
||||
const UserInfo = memo<{ onClick?: () => void }>(({ onClick }) => {
|
||||
const { styles, theme } = useStyles();
|
||||
|
||||
return (
|
||||
<Flexbox align={'center'} gap={12} horizontal paddingBlock={12} paddingInline={16}>
|
||||
<UserAvatar background={theme.colorFill} onClick={onClick} size={48} />
|
||||
<Flexbox flex={1} gap={6}>
|
||||
<div className={styles.nickname}>{'社区版用户'}</div>
|
||||
<div className={styles.username}> {'Community Edition'}</div>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default UserInfo;
|
||||
62
src/features/User/UserPanel/index.tsx
Normal file
62
src/features/User/UserPanel/index.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { Badge, ConfigProvider, Popover } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { PropsWithChildren, memo, useCallback, useState } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import PopoverContent from './Popover';
|
||||
import { useNewVersion } from './useNewVersion';
|
||||
|
||||
const useStyles = createStyles(({ css }) => ({
|
||||
popover: css`
|
||||
top: 8px !important;
|
||||
left: 8px !important;
|
||||
`,
|
||||
}));
|
||||
|
||||
const UserPanel = memo<PropsWithChildren>(({ children }) => {
|
||||
const hasNewVersion = useNewVersion();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { styles } = useStyles();
|
||||
|
||||
const AvatarBadge = useCallback(
|
||||
({ children: badgeChildren, showBadge }: PropsWithChildren<{ showBadge?: boolean }>) => {
|
||||
if (!showBadge) return badgeChildren;
|
||||
|
||||
return (
|
||||
<Flexbox>
|
||||
<ConfigProvider theme={{ components: { Badge: { dotSize: 8 } } }}>
|
||||
<Badge dot offset={[-4, 4]}>
|
||||
{badgeChildren}
|
||||
</Badge>
|
||||
</ConfigProvider>
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<AvatarBadge showBadge={hasNewVersion}>
|
||||
<Popover
|
||||
arrow={false}
|
||||
content={<PopoverContent closePopover={() => setOpen(false)} />}
|
||||
onOpenChange={setOpen}
|
||||
open={open}
|
||||
overlayInnerStyle={{
|
||||
padding: 0,
|
||||
}}
|
||||
placement={'topRight'}
|
||||
rootClassName={styles.popover}
|
||||
trigger={['click']}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
</AvatarBadge>
|
||||
);
|
||||
});
|
||||
|
||||
UserPanel.displayName = 'UserPanel';
|
||||
|
||||
export default UserPanel;
|
||||
158
src/features/User/UserPanel/useMenu.tsx
Normal file
158
src/features/User/UserPanel/useMenu.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import { DiscordIcon, Icon } from '@lobehub/ui';
|
||||
import { Badge } from 'antd';
|
||||
import {
|
||||
Book,
|
||||
Feather,
|
||||
HardDriveDownload,
|
||||
HardDriveUpload,
|
||||
LifeBuoy,
|
||||
Mail,
|
||||
Settings2,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { PropsWithChildren, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { type MenuProps } from '@/components/Menu';
|
||||
import { DISCORD, DOCUMENTS, EMAIL_SUPPORT, GITHUB_ISSUES } from '@/const/url';
|
||||
import DataImporter from '@/features/DataImporter';
|
||||
import { configService } from '@/services/config';
|
||||
|
||||
import { useNewVersion } from './useNewVersion';
|
||||
|
||||
export const useMenu = () => {
|
||||
const hasNewVersion = useNewVersion();
|
||||
const { t } = useTranslation(['common', 'setting']);
|
||||
|
||||
const NewVersionBadge = useCallback(
|
||||
({ children, showBadge }: PropsWithChildren & { showBadge?: boolean }) => {
|
||||
if (!showBadge) return children;
|
||||
return (
|
||||
<Flexbox align={'center'} distribution={'space-between'} gap={8} horizontal width={'100%'}>
|
||||
<span>{children}</span>
|
||||
<Badge count={t('upgradeVersion.hasNew')} />
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const exports: MenuProps['items'] = [
|
||||
{
|
||||
icon: <Icon icon={HardDriveUpload} />,
|
||||
key: 'import',
|
||||
label: <DataImporter>{t('import')}</DataImporter>,
|
||||
},
|
||||
{
|
||||
children: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
],
|
||||
icon: <Icon icon={HardDriveDownload} />,
|
||||
key: 'export',
|
||||
label: t('export'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
];
|
||||
|
||||
const settings: MenuProps['items'] = [
|
||||
{
|
||||
icon: <Icon icon={Settings2} />,
|
||||
key: 'setting',
|
||||
label: (
|
||||
<Link href={'/settings'}>
|
||||
<NewVersionBadge showBadge={hasNewVersion}>{t('userPanel.setting')}</NewVersionBadge>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
];
|
||||
|
||||
const helps: MenuProps['items'] = [
|
||||
{
|
||||
icon: <Icon icon={DiscordIcon} />,
|
||||
key: 'discord',
|
||||
label: (
|
||||
<Link href={DISCORD} target={'_blank'}>
|
||||
{t('userPanel.discord')}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
children: [
|
||||
{
|
||||
icon: <Icon icon={Book} />,
|
||||
key: 'docs',
|
||||
label: (
|
||||
<Link href={DOCUMENTS} target={'_blank'}>
|
||||
{t('userPanel.docs')}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={Feather} />,
|
||||
key: 'feedback',
|
||||
label: (
|
||||
<Link href={GITHUB_ISSUES} target={'_blank'}>
|
||||
{t('userPanel.feedback')}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={Mail} />,
|
||||
key: 'email',
|
||||
label: (
|
||||
<Link href={`mailto:${EMAIL_SUPPORT}`} target={'_blank'}>
|
||||
{t('userPanel.email')}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
],
|
||||
icon: <Icon icon={LifeBuoy} />,
|
||||
key: 'help',
|
||||
label: t('userPanel.help'),
|
||||
},
|
||||
];
|
||||
|
||||
const mainItems = [
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
...settings,
|
||||
...exports,
|
||||
...helps,
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
].filter(Boolean) as MenuProps['items'];
|
||||
|
||||
return {
|
||||
mainItems,
|
||||
};
|
||||
};
|
||||
12
src/features/User/UserPanel/useNewVersion.tsx
Normal file
12
src/features/User/UserPanel/useNewVersion.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
||||
export const useNewVersion = () => {
|
||||
const [hasNewVersion, useCheckLatestVersion] = useGlobalStore((s) => [
|
||||
s.hasNewVersion,
|
||||
s.useCheckLatestVersion,
|
||||
]);
|
||||
|
||||
useCheckLatestVersion();
|
||||
|
||||
return hasNewVersion;
|
||||
};
|
||||
35
src/layout/AuthProvider/NextAuth/UserUpdater.tsx
Normal file
35
src/layout/AuthProvider/NextAuth/UserUpdater.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { memo } from 'react';
|
||||
import { createStoreUpdater } from 'zustand-utils';
|
||||
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { LobeUser } from '@/types/user';
|
||||
|
||||
// update the user data into the context
|
||||
const UserUpdater = memo(() => {
|
||||
const { data: session, status } = useSession();
|
||||
const isSignedIn = (status === 'authenticated' && session && !!session.user) || false;
|
||||
|
||||
const nextUser = session?.user;
|
||||
const useStoreUpdater = createStoreUpdater(useUserStore);
|
||||
|
||||
const lobeUser = {
|
||||
avatar: nextUser?.image,
|
||||
email: nextUser?.email,
|
||||
fullName: nextUser?.name,
|
||||
id: nextUser?.id,
|
||||
} as LobeUser;
|
||||
|
||||
useStoreUpdater('isLoaded', true);
|
||||
useStoreUpdater('user', lobeUser);
|
||||
useStoreUpdater('isSignedIn', isSignedIn);
|
||||
|
||||
useStoreUpdater('nextSession', session);
|
||||
useStoreUpdater('nextUser', nextUser);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
export default UserUpdater;
|
||||
@ -3,8 +3,15 @@ import { PropsWithChildren } from 'react';
|
||||
|
||||
import { API_ENDPOINTS } from '@/services/_url';
|
||||
|
||||
import UserUpdater from './UserUpdater';
|
||||
|
||||
const NextAuth = ({ children }: PropsWithChildren) => {
|
||||
return <SessionProvider basePath={API_ENDPOINTS.oauth}>{children}</SessionProvider>;
|
||||
return (
|
||||
<SessionProvider basePath={API_ENDPOINTS.oauth}>
|
||||
{children}
|
||||
<UserUpdater />
|
||||
</SessionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextAuth;
|
||||
|
||||
@ -152,4 +152,18 @@ export default {
|
||||
hasNew: '有可用更新',
|
||||
newVersion: '有新版本可用:{{version}}',
|
||||
},
|
||||
userPanel: {
|
||||
billing: '账单管理',
|
||||
defaultNickname: '社区版用户',
|
||||
discord: '社区支持',
|
||||
docs: '使用文档',
|
||||
email: '邮件支持',
|
||||
feedback: '反馈与建议',
|
||||
help: '帮助中心',
|
||||
moveGuide: '设置按钮搬到这里啦',
|
||||
plans: '订阅方案',
|
||||
profile: '账户管理',
|
||||
setting: '应用设置',
|
||||
usages: '用量统计',
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,18 +1,16 @@
|
||||
export interface LobeUser {
|
||||
avatar?: string;
|
||||
firstName?: string | null;
|
||||
fullName?: string | null;
|
||||
id: string;
|
||||
latestName?: string | null;
|
||||
username?: string | null;
|
||||
}
|
||||
import { Session, User } from '@auth/core/types';
|
||||
|
||||
import { LobeUser } from '@/types/user';
|
||||
|
||||
export interface UserAuthState {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
avatar?: string;
|
||||
isLoaded?: boolean;
|
||||
isSignedIn?: boolean;
|
||||
nextSession?: Session;
|
||||
nextUser?: User;
|
||||
user?: LobeUser;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { UserStore } from '@/store/user';
|
||||
import { LobeUser } from '@/types/user';
|
||||
|
||||
export const userProfileSelectors = {
|
||||
userAvatar: (s: UserStore): string => s.avatar || '',
|
||||
userAvatar: (s: UserStore): string => s.user?.avatar || s.avatar || '',
|
||||
userId: (s: UserStore) => s.userId,
|
||||
userProfile: (s: UserStore): LobeUser | null | undefined => s.user,
|
||||
username: (s: UserStore): string | null | undefined => s.user?.username,
|
||||
};
|
||||
|
||||
@ -2,10 +2,9 @@ import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { withSWR } from '~test-utils';
|
||||
|
||||
import { globalService } from '@/services/global';
|
||||
import { useUserStore } from '@/store/user';
|
||||
|
||||
import { type Guide } from './initialState';
|
||||
import { DEFAULT_PREFERENCE, type Guide, UserPreference } from './initialState';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@ -25,7 +24,7 @@ describe('createPreferenceSlice', () => {
|
||||
result.current.updateGuideState(guide);
|
||||
});
|
||||
|
||||
expect(result.current.preference.guide).toEqual(guide);
|
||||
expect(result.current.preference.guide!.topic).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -58,5 +57,44 @@ describe('createPreferenceSlice', () => {
|
||||
expect(result.current.isPreferenceInit).toBeTruthy();
|
||||
});
|
||||
});
|
||||
it('should return default preference when local storage is empty', async () => {
|
||||
const { result } = renderHook(() => useUserStore());
|
||||
|
||||
vi.spyOn(result.current.preferenceStorage, 'getFromLocalStorage').mockResolvedValueOnce(
|
||||
{} as any,
|
||||
);
|
||||
|
||||
renderHook(() => result.current.useInitPreference(), {
|
||||
wrapper: withSWR,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.preference).toEqual(DEFAULT_PREFERENCE);
|
||||
expect(result.current.isPreferenceInit).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return saved preference when local storage has data', async () => {
|
||||
const { result } = renderHook(() => useUserStore());
|
||||
const savedPreference: UserPreference = {
|
||||
...DEFAULT_PREFERENCE,
|
||||
hideSyncAlert: true,
|
||||
guide: { topic: false, moveSettingsToAvatar: true },
|
||||
};
|
||||
|
||||
vi.spyOn(result.current.preferenceStorage, 'getFromLocalStorage').mockResolvedValueOnce(
|
||||
savedPreference,
|
||||
);
|
||||
|
||||
const { result: prefernce } = renderHook(() => result.current.useInitPreference(), {
|
||||
wrapper: withSWR,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(prefernce.current.data).toEqual(savedPreference);
|
||||
expect(result.current.isPreferenceInit).toBeTruthy();
|
||||
expect(result.current.preference).toEqual(savedPreference);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,7 +6,7 @@ import type { UserStore } from '@/store/user';
|
||||
import { merge } from '@/utils/merge';
|
||||
import { setNamespace } from '@/utils/storeDebug';
|
||||
|
||||
import type { Guide, UserPreference } from './initialState';
|
||||
import { DEFAULT_PREFERENCE, Guide, UserPreference } from './initialState';
|
||||
|
||||
const n = setNamespace('preference');
|
||||
|
||||
@ -41,7 +41,13 @@ export const createPreferenceSlice: StateCreator<
|
||||
() => get().preferenceStorage.getFromLocalStorage(),
|
||||
{
|
||||
onSuccess: (preference) => {
|
||||
set({ isPreferenceInit: true, preference }, false, n('initPreference'));
|
||||
const isEmpty = Object.keys(preference).length === 0;
|
||||
|
||||
set(
|
||||
{ isPreferenceInit: true, preference: isEmpty ? DEFAULT_PREFERENCE : preference },
|
||||
false,
|
||||
n('initPreference'),
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { AsyncLocalStorage } from '@/utils/localStorage';
|
||||
|
||||
export interface Guide {
|
||||
/**
|
||||
* Move the settings button to the avatar dropdown
|
||||
*/
|
||||
moveSettingsToAvatar?: boolean;
|
||||
|
||||
// Topic 引导
|
||||
topic?: boolean;
|
||||
}
|
||||
@ -24,12 +29,16 @@ export interface UserPreferenceState {
|
||||
preferenceStorage: AsyncLocalStorage<UserPreference>;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCE: UserPreference = {
|
||||
guide: {
|
||||
moveSettingsToAvatar: true,
|
||||
},
|
||||
telemetry: null,
|
||||
useCmdEnterToSend: false,
|
||||
};
|
||||
|
||||
export const initialPreferenceState: UserPreferenceState = {
|
||||
isPreferenceInit: false,
|
||||
preference: {
|
||||
guide: {},
|
||||
telemetry: null,
|
||||
useCmdEnterToSend: false,
|
||||
},
|
||||
preference: DEFAULT_PREFERENCE,
|
||||
preferenceStorage: new AsyncLocalStorage('LOBE_PREFERENCE'),
|
||||
};
|
||||
|
||||
82
src/store/user/slices/preference/selectors.test.ts
Normal file
82
src/store/user/slices/preference/selectors.test.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { UserStore } from '@/store/user';
|
||||
|
||||
import { initialPreferenceState } from './initialState';
|
||||
import { preferenceSelectors } from './selectors';
|
||||
|
||||
describe('preferenceSelectors', () => {
|
||||
let store: UserStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = {
|
||||
...initialPreferenceState,
|
||||
} as unknown as UserStore;
|
||||
});
|
||||
|
||||
describe('useCmdEnterToSend', () => {
|
||||
it('should return the value of useCmdEnterToSend preference', () => {
|
||||
store.preference.useCmdEnterToSend = true;
|
||||
expect(preferenceSelectors.useCmdEnterToSend(store)).toBe(true);
|
||||
|
||||
store.preference.useCmdEnterToSend = false;
|
||||
expect(preferenceSelectors.useCmdEnterToSend(store)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if useCmdEnterToSend preference is undefined', () => {
|
||||
store.preference.useCmdEnterToSend = undefined;
|
||||
expect(preferenceSelectors.useCmdEnterToSend(store)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('userAllowTrace', () => {
|
||||
it('should return the value of telemetry preference', () => {
|
||||
store.preference.telemetry = true;
|
||||
expect(preferenceSelectors.userAllowTrace(store)).toBe(true);
|
||||
|
||||
store.preference.telemetry = false;
|
||||
expect(preferenceSelectors.userAllowTrace(store)).toBe(false);
|
||||
|
||||
store.preference.telemetry = null;
|
||||
expect(preferenceSelectors.userAllowTrace(store)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hideSyncAlert', () => {
|
||||
it('should return the value of hideSyncAlert preference', () => {
|
||||
store.preference.hideSyncAlert = true;
|
||||
expect(preferenceSelectors.hideSyncAlert(store)).toBe(true);
|
||||
|
||||
store.preference.hideSyncAlert = false;
|
||||
expect(preferenceSelectors.hideSyncAlert(store)).toBe(false);
|
||||
|
||||
store.preference.hideSyncAlert = undefined;
|
||||
expect(preferenceSelectors.hideSyncAlert(store)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hideSettingsMoveGuide', () => {
|
||||
it('should return the value of moveSettingsToAvatar guide preference', () => {
|
||||
store.preference.guide = { moveSettingsToAvatar: true };
|
||||
expect(preferenceSelectors.hideSettingsMoveGuide(store)).toBe(true);
|
||||
|
||||
store.preference.guide = { moveSettingsToAvatar: false };
|
||||
expect(preferenceSelectors.hideSettingsMoveGuide(store)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined if guide preference is undefined', () => {
|
||||
store.preference.guide = undefined;
|
||||
expect(preferenceSelectors.hideSettingsMoveGuide(store)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPreferenceInit', () => {
|
||||
it('should return the value of isPreferenceInit state', () => {
|
||||
store.isPreferenceInit = true;
|
||||
expect(preferenceSelectors.isPreferenceInit(store)).toBe(true);
|
||||
|
||||
store.isPreferenceInit = false;
|
||||
expect(preferenceSelectors.isPreferenceInit(store)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -5,9 +5,13 @@ const useCmdEnterToSend = (s: UserStore): boolean => s.preference.useCmdEnterToS
|
||||
const userAllowTrace = (s: UserStore) => s.preference.telemetry;
|
||||
|
||||
const hideSyncAlert = (s: UserStore) => s.preference.hideSyncAlert;
|
||||
|
||||
const hideSettingsMoveGuide = (s: UserStore) => s.preference.guide?.moveSettingsToAvatar;
|
||||
|
||||
const isPreferenceInit = (s: UserStore) => s.isPreferenceInit;
|
||||
|
||||
export const preferenceSelectors = {
|
||||
hideSettingsMoveGuide,
|
||||
hideSyncAlert,
|
||||
isPreferenceInit,
|
||||
useCmdEnterToSend,
|
||||
|
||||
@ -5,8 +5,10 @@ import type { StateCreator } from 'zustand/vanilla';
|
||||
|
||||
import { userService } from '@/services/user';
|
||||
import type { UserStore } from '@/store/user';
|
||||
import { LocaleMode } from '@/types/locale';
|
||||
import { LobeAgentSettings } from '@/types/session';
|
||||
import { GlobalSettings } from '@/types/settings';
|
||||
import { switchLang } from '@/utils/client/switchLang';
|
||||
import { difference } from '@/utils/difference';
|
||||
import { merge } from '@/utils/merge';
|
||||
|
||||
@ -14,6 +16,7 @@ export interface GeneralSettingsAction {
|
||||
importAppSettings: (settings: GlobalSettings) => Promise<void>;
|
||||
resetSettings: () => Promise<void>;
|
||||
setSettings: (settings: DeepPartial<GlobalSettings>) => Promise<void>;
|
||||
switchLocale: (locale: LocaleMode) => Promise<void>;
|
||||
switchThemeMode: (themeMode: ThemeMode) => Promise<void>;
|
||||
updateDefaultAgent: (agent: DeepPartial<LobeAgentSettings>) => Promise<void>;
|
||||
}
|
||||
@ -47,6 +50,11 @@ export const generalSettingsSlice: StateCreator<
|
||||
await userService.updateUserSettings(diffs);
|
||||
await get().refreshUserConfig();
|
||||
},
|
||||
switchLocale: async (locale) => {
|
||||
await get().setSettings({ language: locale });
|
||||
|
||||
switchLang(locale);
|
||||
},
|
||||
switchThemeMode: async (themeMode) => {
|
||||
await get().setSettings({ themeMode });
|
||||
},
|
||||
|
||||
9
src/types/user.ts
Normal file
9
src/types/user.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export interface LobeUser {
|
||||
avatar?: string;
|
||||
email?: string | null;
|
||||
firstName?: string | null;
|
||||
fullName?: string | null;
|
||||
id: string;
|
||||
latestName?: string | null;
|
||||
username?: string | null;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user