💄 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:
Arvin Xu 2024-05-03 20:52:22 +08:00 committed by GitHub
parent 6026481f29
commit 5cecee096e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 1146 additions and 209 deletions

View File

@ -157,5 +157,19 @@
"action": "ترقية",
"hasNew": "يوجد تحديث متاح",
"newVersion": "هناك إصدار جديد متاح: {{version}}"
},
"userPanel": {
"billing": "إدارة الفواتير",
"defaultNickname": "مستخدم النسخة المجتمعية",
"discord": "الدعم المجتمعي",
"docs": "وثائق الاستخدام",
"email": "الدعم عبر البريد الإلكتروني",
"feedback": "تقديم ملاحظات واقتراحات",
"help": "مركز المساعدة",
"moveGuide": "تم نقل زر الإعدادات إلى هنا",
"plans": "خطط الاشتراك",
"profile": "إدارة الحساب",
"setting": "إعدادات التطبيق",
"usages": "إحصاءات الاستخدام"
}
}

View File

@ -157,5 +157,19 @@
"action": "Надстрой",
"hasNew": "Налична е нова актуализация",
"newVersion": "Налична е нова версия: {{version}}"
},
"userPanel": {
"billing": "Управление на сметките",
"defaultNickname": "Потребител на общността",
"discord": "Поддръжка на общността",
"docs": "Документация",
"email": "Поддръжка по имейл",
"feedback": "Обратна връзка и предложения",
"help": "Център за помощ",
"moveGuide": "Бутонът за настройки е преместен тук",
"plans": "Планове за абонамент",
"profile": "Управление на профила",
"setting": "Настройки на приложението",
"usages": "Статистика за използване"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -157,5 +157,19 @@
"action": "アップグレード",
"hasNew": "利用可能な更新があります",
"newVersion": "新しいバージョンが利用可能です:{{version}}"
},
"userPanel": {
"billing": "請求管理",
"defaultNickname": "コミュニティユーザー",
"discord": "コミュニティサポート",
"docs": "使用文書",
"email": "メールサポート",
"feedback": "フィードバックと提案",
"help": "ヘルプセンター",
"moveGuide": "設定ボタンがこちらに移動しました",
"plans": "サブスクリプションプラン",
"profile": "アカウント管理",
"setting": "アプリ設定",
"usages": "利用量統計"
}
}

View File

@ -157,5 +157,19 @@
"action": "업그레이드",
"hasNew": "사용 가능한 업데이트가 있습니다",
"newVersion": "새 버전 사용 가능: {{version}}"
},
"userPanel": {
"billing": "결제 관리",
"defaultNickname": "커뮤니티 사용자",
"discord": "커뮤니티 지원",
"docs": "사용 설명서",
"email": "이메일 지원",
"feedback": "피드백 및 제안",
"help": "도움말 센터",
"moveGuide": "설정 버튼을 여기로 이동했습니다",
"plans": "요금제",
"profile": "계정 관리",
"setting": "앱 설정",
"usages": "사용량 통계"
}
}

View File

@ -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": "用量统计"
}
}

View File

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

View File

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

View File

@ -157,5 +157,19 @@
"action": "обновить",
"hasNew": "Доступно обновление",
"newVersion": "Доступна новая версия: {{version}}"
},
"userPanel": {
"billing": "Управление счетами",
"defaultNickname": "Пользователь сообщества",
"discord": "Поддержка сообщества",
"docs": "Документация",
"email": "Поддержка по электронной почте",
"feedback": "Обратная связь и предложения",
"help": "Центр помощи",
"moveGuide": "Кнопка настроек перемещена сюда",
"plans": "Планы подписки",
"profile": "Управление аккаунтом",
"setting": "Настройки приложения",
"usages": "Статистика использования"
}
}

View File

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

View File

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

View File

@ -157,5 +157,19 @@
"action": "升级",
"hasNew": "有可用更新",
"newVersion": "有新版本可用:{{version}}"
},
"userPanel": {
"billing": "账单管理",
"defaultNickname": "社区版用户",
"discord": "社区支持",
"docs": "使用文档",
"email": "邮件支持",
"feedback": "反馈与建议",
"help": "帮助中心",
"moveGuide": "设置按钮搬到这里啦",
"plans": "订阅方案",
"profile": "账户管理",
"setting": "应用设置",
"usages": "用量统计"
}
}

View File

@ -157,5 +157,19 @@
"action": "升級",
"hasNew": "有可用更新",
"newVersion": "有新版本可用:{{version}}"
},
"userPanel": {
"billing": "帳單管理",
"defaultNickname": "社群版使用者",
"discord": "社區支援",
"docs": "使用文件",
"email": "郵件支援",
"feedback": "反饋與建議",
"help": "幫助中心",
"moveGuide": "設置按鈕搬到這裡啦",
"plans": "訂閱方案",
"profile": "帳戶管理",
"setting": "應用設定",
"usages": "用量統計"
}
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +0,0 @@
import { redirect } from 'next/navigation';
const Page = () => {
return redirect('/settings/common');
};
export default Page;

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@ -152,4 +152,18 @@ export default {
hasNew: '有可用更新',
newVersion: '有新版本可用:{{version}}',
},
userPanel: {
billing: '账单管理',
defaultNickname: '社区版用户',
discord: '社区支持',
docs: '使用文档',
email: '邮件支持',
feedback: '反馈与建议',
help: '帮助中心',
moveGuide: '设置按钮搬到这里啦',
plans: '订阅方案',
profile: '账户管理',
setting: '应用设置',
usages: '用量统计',
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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