feat(mobile): full settings menu and responsive profile layout

- /me/settings now mirrors desktop grouped sidebar (general/subscription/agent/system) with group titles and dividers, matching `useCategory` shape
- mobile settings header resolves tab titles across `setting/auth/subscription` namespaces via typed lookup map (was hardcoded to `setting` which showed raw keys like `tab.credits`)
- profile settings rows refactored to a single shared `ProfileRow` with pure CSS media query (no more `mobile` prop branches); windows under `md` collapse label above value+action
- profile `SkeletonRow` reuses `ProfileRow` so loading state matches the loaded three-column layout
- `/me/profile` list item: "My Account" → "Profile" (shorter header label)
This commit is contained in:
YuTengjing 2026-04-21 16:18:57 +08:00
parent 50104bed26
commit f1c1fdc83e
No known key found for this signature in database
12 changed files with 393 additions and 380 deletions

View file

@ -1,7 +1,7 @@
'use client';
import { LoadingOutlined } from '@ant-design/icons';
import { Flexbox, Icon, Text } from '@lobehub/ui';
import { Icon } from '@lobehub/ui';
import { Spin, Upload } from 'antd';
import { createStaticStyles, cssVar } from 'antd-style';
import { PencilIcon } from 'lucide-react';
@ -15,7 +15,7 @@ import { authSelectors } from '@/store/user/selectors';
import { imageToBase64 } from '@/utils/imageToBase64';
import { createUploadImageHandler } from '@/utils/uploadFIle';
import { labelStyle, rowStyle } from './ProfileRow';
import ProfileRow from './ProfileRow';
const styles = createStaticStyles(({ css }) => ({
overlay: css`
@ -48,11 +48,7 @@ const styles = createStaticStyles(({ css }) => ({
`,
}));
interface AvatarRowProps {
mobile?: boolean;
}
const AvatarRow = ({ mobile }: AvatarRowProps) => {
const AvatarRow = () => {
const { t } = useTranslation('auth');
const isLogin = useUserStore(authSelectors.isLogin);
const updateAvatar = useUserStore((s) => s.updateAvatar);
@ -104,23 +100,7 @@ const AvatarRow = ({ mobile }: AvatarRowProps) => {
<UserAvatar size={40} />
);
if (mobile) {
return (
<Flexbox horizontal align="center" gap={12} justify="space-between" style={rowStyle}>
<Text strong>{t('profile.avatar')}</Text>
{avatarContent}
</Flexbox>
);
}
return (
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
<Text style={labelStyle}>{t('profile.avatar')}</Text>
<Flexbox align="flex-end" style={{ flex: 1 }}>
{avatarContent}
</Flexbox>
</Flexbox>
);
return <ProfileRow action={avatarContent} label={t('profile.avatar')} />;
};
export default AvatarRow;

View file

@ -10,15 +10,11 @@ import { changeEmail } from '@/libs/better-auth/auth-client';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import { labelStyle, rowStyle } from './ProfileRow';
import ProfileRow from './ProfileRow';
const EMAIL_REGEX = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
interface EmailRowProps {
mobile?: boolean;
}
const EmailRow = ({ mobile }: EmailRowProps) => {
const EmailRow = () => {
const { t } = useTranslation('auth');
const email = useUserStore(userProfileSelectors.email);
const [isEditing, setIsEditing] = useState(false);
@ -74,7 +70,6 @@ const EmailRow = ({ mobile }: EmailRowProps) => {
transition={{ duration: 0.2 }}
>
<Flexbox gap={12}>
{!mobile && <Text strong>{t('profile.emailInputHint')}</Text>}
<Input
autoFocus
placeholder={t('profile.emailPlaceholder')}
@ -111,42 +106,23 @@ const EmailRow = ({ mobile }: EmailRowProps) => {
key="display"
transition={{ duration: 0.2 }}
>
{mobile ? (
<Text>{email || '--'}</Text>
) : (
<Flexbox horizontal align="center" justify="space-between">
<Text>{email || '--'}</Text>
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
{t('profile.updateEmail')}
</Text>
</Flexbox>
)}
<Text>{email || '--'}</Text>
</m.div>
);
if (mobile) {
return (
<Flexbox gap={12} style={rowStyle}>
<Flexbox horizontal align="center" justify="space-between">
<Text strong>{t('profile.email')}</Text>
{!isEditing && (
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
{t('profile.updateEmail')}
</Text>
)}
</Flexbox>
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
</Flexbox>
);
}
return (
<Flexbox horizontal gap={24} style={rowStyle}>
<Text style={labelStyle}>{t('profile.email')}</Text>
<Flexbox style={{ flex: 1 }}>
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
</Flexbox>
</Flexbox>
<ProfileRow
label={t('profile.email')}
action={
!isEditing && (
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
{t('profile.updateEmail')}
</Text>
)
}
>
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
</ProfileRow>
);
};

View file

@ -1,7 +1,7 @@
'use client';
import { LoadingOutlined } from '@ant-design/icons';
import { Flexbox, Input, Text } from '@lobehub/ui';
import { Flexbox, Input } from '@lobehub/ui';
import { type InputRef, Spin } from 'antd';
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -10,13 +10,9 @@ import { fetchErrorNotification } from '@/components/Error/fetchErrorNotificatio
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import { INPUT_WIDTH, labelStyle, rowStyle } from './ProfileRow';
import ProfileRow from './ProfileRow';
interface FullNameRowProps {
mobile?: boolean;
}
const FullNameRow = ({ mobile }: FullNameRowProps) => {
const FullNameRow = () => {
const { t } = useTranslation('auth');
const fullName = useUserStore(userProfileSelectors.fullName);
const updateFullName = useUserStore((s) => s.updateFullName);
@ -41,39 +37,22 @@ const FullNameRow = ({ mobile }: FullNameRowProps) => {
}
}, [fullName, updateFullName]);
const input = (
<Flexbox horizontal align="center" gap={8}>
{saving && <Spin indicator={<LoadingOutlined spin />} size="small" />}
<Input
defaultValue={fullName || ''}
disabled={saving}
key={fullName}
placeholder={t('profile.fullName')}
ref={inputRef}
style={mobile ? undefined : { textAlign: 'right', width: INPUT_WIDTH }}
variant="filled"
onBlur={handleSave}
onPressEnter={handleSave}
/>
</Flexbox>
);
if (mobile) {
return (
<Flexbox gap={12} style={rowStyle}>
<Text strong>{t('profile.fullName')}</Text>
{input}
</Flexbox>
);
}
return (
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
<Text style={labelStyle}>{t('profile.fullName')}</Text>
<Flexbox align="flex-end" style={{ flex: 1 }}>
{input}
<ProfileRow label={t('profile.fullName')}>
<Flexbox horizontal align="center" gap={8}>
{saving && <Spin indicator={<LoadingOutlined spin />} size="small" />}
<Input
defaultValue={fullName || ''}
disabled={saving}
key={fullName}
placeholder={t('profile.fullName')}
ref={inputRef}
variant="filled"
onBlur={handleSave}
onPressEnter={handleSave}
/>
</Flexbox>
</Flexbox>
</ProfileRow>
);
};

View file

@ -11,13 +11,9 @@ import { INTEREST_AREAS } from '@/routes/onboarding/config';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import { labelStyle, rowStyle } from './ProfileRow';
import ProfileRow from './ProfileRow';
interface InterestsRowProps {
mobile?: boolean;
}
const InterestsRow = ({ mobile }: InterestsRowProps) => {
const InterestsRow = () => {
const { t } = useTranslation('auth');
const { t: tOnboarding } = useTranslation('onboarding');
const interests = useUserStore(userProfileSelectors.interests);
@ -78,104 +74,89 @@ const InterestsRow = ({ mobile }: InterestsRowProps) => {
}
}, [customInput, interests, updateInterests]);
const content = (
<Flexbox gap={12}>
<Flexbox horizontal align="center" gap={8} justify="flex-end" wrap="wrap">
{areas.map((item) => {
const isSelected = interests.includes(item.label);
return (
<Block
clickable
horizontal
gap={8}
key={item.key}
padding={8}
variant="outlined"
style={
isSelected
? {
background: cssVar.colorFillSecondary,
borderColor: cssVar.colorFillSecondary,
opacity: saving ? 0.6 : 1,
}
: { opacity: saving ? 0.6 : 1 }
}
onClick={() => !saving && toggleInterest(item.label)}
>
<Icon color={cssVar.colorTextSecondary} icon={item.icon} size={14} />
<Text fontSize={13} weight={500}>
{item.label}
</Text>
</Block>
);
})}
{/* Render custom interests */}
{interests
.filter((i) => !areas.some((a) => a.label === i))
.map((interest) => (
<Block
clickable
key={interest}
padding={8}
variant="outlined"
style={{
background: cssVar.colorFillSecondary,
borderColor: cssVar.colorFillSecondary,
opacity: saving ? 0.6 : 1,
}}
onClick={() => !saving && toggleInterest(interest)}
>
<Text fontSize={13} weight={500}>
{interest}
</Text>
</Block>
))}
<Block
clickable
horizontal
gap={8}
padding={8}
variant="outlined"
style={
showCustomInput
? { background: cssVar.colorFillSecondary, borderColor: cssVar.colorFillSecondary }
: {}
}
onClick={() => setShowCustomInput(!showCustomInput)}
>
<Icon color={cssVar.colorTextSecondary} icon={BriefcaseIcon} size={14} />
<Text fontSize={13} weight={500}>
{tOnboarding('interests.area.other')}
</Text>
</Block>
</Flexbox>
{showCustomInput && (
<Input
placeholder={tOnboarding('interests.placeholder')}
size="small"
style={{ width: 200 }}
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onPressEnter={handleAddCustom}
/>
)}
</Flexbox>
);
if (mobile) {
return (
<Flexbox gap={12} style={rowStyle}>
<Text strong>{t('profile.interests')}</Text>
{content}
</Flexbox>
);
}
return (
<Flexbox horizontal gap={24} style={rowStyle}>
<Text style={labelStyle}>{t('profile.interests')}</Text>
<Flexbox align="flex-end" style={{ flex: 1 }}>{content}</Flexbox>
</Flexbox>
<ProfileRow label={t('profile.interests')}>
<Flexbox gap={12}>
<Flexbox horizontal align="center" gap={8} wrap="wrap">
{areas.map((item) => {
const isSelected = interests.includes(item.label);
return (
<Block
clickable
horizontal
gap={8}
key={item.key}
padding={8}
variant="outlined"
style={
isSelected
? {
background: cssVar.colorFillSecondary,
borderColor: cssVar.colorFillSecondary,
opacity: saving ? 0.6 : 1,
}
: { opacity: saving ? 0.6 : 1 }
}
onClick={() => !saving && toggleInterest(item.label)}
>
<Icon color={cssVar.colorTextSecondary} icon={item.icon} size={14} />
<Text fontSize={13} weight={500}>
{item.label}
</Text>
</Block>
);
})}
{interests
.filter((i) => !areas.some((a) => a.label === i))
.map((interest) => (
<Block
clickable
key={interest}
padding={8}
variant="outlined"
style={{
background: cssVar.colorFillSecondary,
borderColor: cssVar.colorFillSecondary,
opacity: saving ? 0.6 : 1,
}}
onClick={() => !saving && toggleInterest(interest)}
>
<Text fontSize={13} weight={500}>
{interest}
</Text>
</Block>
))}
<Block
clickable
horizontal
gap={8}
padding={8}
variant="outlined"
style={
showCustomInput
? { background: cssVar.colorFillSecondary, borderColor: cssVar.colorFillSecondary }
: {}
}
onClick={() => setShowCustomInput(!showCustomInput)}
>
<Icon color={cssVar.colorTextSecondary} icon={BriefcaseIcon} size={14} />
<Text fontSize={13} weight={500}>
{tOnboarding('interests.area.other')}
</Text>
</Block>
</Flexbox>
{showCustomInput && (
<Input
placeholder={tOnboarding('interests.placeholder')}
size="small"
style={{ width: 200 }}
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onPressEnter={handleAddCustom}
/>
)}
</Flexbox>
</ProfileRow>
);
};

View file

@ -1,6 +1,6 @@
'use client';
import { Button, Flexbox, Text } from '@lobehub/ui';
import { Button } from '@lobehub/ui';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -8,13 +8,9 @@ import { notification } from '@/components/AntdStaticMethods';
import { useUserStore } from '@/store/user';
import { authSelectors, userProfileSelectors } from '@/store/user/selectors';
import { labelStyle, rowStyle } from './ProfileRow';
import ProfileRow from './ProfileRow';
interface PasswordRowProps {
mobile?: boolean;
}
const PasswordRow = ({ mobile }: PasswordRowProps) => {
const PasswordRow = () => {
const { t } = useTranslation('auth');
const userProfile = useUserStore(userProfileSelectors.userProfile);
const hasPasswordAccount = useUserStore(authSelectors.hasPasswordAccount);
@ -43,26 +39,15 @@ const PasswordRow = ({ mobile }: PasswordRowProps) => {
}
}, [userProfile?.email, t]);
if (mobile) {
return (
<Flexbox horizontal align="center" gap={12} justify="space-between" style={rowStyle}>
<Text strong>{t('profile.password')}</Text>
<Button loading={sending} size="small" onClick={handleChangePassword}>
{hasPasswordAccount ? t('profile.changePassword') : t('profile.setPassword')}
</Button>
</Flexbox>
);
}
return (
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
<Text style={labelStyle}>{t('profile.password')}</Text>
<Flexbox align="flex-end" style={{ flex: 1 }}>
<ProfileRow
label={t('profile.password')}
action={
<Button loading={sending} size="small" onClick={handleChangePassword}>
{hasPasswordAccount ? t('profile.changePassword') : t('profile.setPassword')}
</Button>
</Flexbox>
</Flexbox>
}
/>
);
};

View file

@ -1,46 +1,62 @@
'use client';
import { Flexbox, Text } from '@lobehub/ui';
import { type CSSProperties, type ReactNode } from 'react';
import { Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { type ReactNode } from 'react';
interface ProfileRowProps {
action?: ReactNode;
children: ReactNode;
label: string;
mobile?: boolean;
children?: ReactNode;
label: ReactNode;
}
export const rowStyle: CSSProperties = {
minHeight: 48,
padding: '16px 0',
};
const styles = createStaticStyles(({ css, responsive }) => ({
action: css`
flex-shrink: 0;
`,
body: css`
display: flex;
flex: 1;
gap: 12px;
align-items: center;
justify-content: space-between;
export const labelStyle: CSSProperties = {
flexShrink: 0,
width: 160,
};
min-width: 0;
`,
label: css`
flex: 0 0 160px;
export const INPUT_WIDTH = 240;
${responsive.md} {
flex: 0 0 auto;
}
`,
row: css`
display: flex;
gap: 24px;
align-items: center;
const ProfileRow = ({ label, children, action, mobile }: ProfileRowProps) => {
if (mobile) {
return (
<Flexbox gap={12} style={rowStyle}>
<Flexbox horizontal align="center" justify="space-between">
<Text strong>{label}</Text>
{action}
</Flexbox>
<Flexbox>{children}</Flexbox>
</Flexbox>
);
}
min-height: 48px;
padding-block: 16px;
${responsive.md} {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
`,
}));
const ProfileRow = ({ label, children, action }: ProfileRowProps) => {
return (
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
<Text style={labelStyle}>{label}</Text>
<Flexbox align="flex-end" style={{ flex: 1 }}>{children}</Flexbox>
{action && <Flexbox>{action}</Flexbox>}
</Flexbox>
<div className={styles.row}>
<div className={styles.label}>
{typeof label === 'string' ? <Text strong>{label}</Text> : label}
</div>
<div className={styles.body}>
{children}
{action && <div className={styles.action}>{action}</div>}
</div>
</div>
);
};

View file

@ -10,13 +10,9 @@ import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import { INPUT_WIDTH, labelStyle, rowStyle } from './ProfileRow';
import ProfileRow from './ProfileRow';
interface UsernameRowProps {
mobile?: boolean;
}
const UsernameRow = ({ mobile }: UsernameRowProps) => {
const UsernameRow = () => {
const { t } = useTranslation('auth');
const username = useUserStore(userProfileSelectors.username);
const updateUsername = useUserStore((s) => s.updateUsername);
@ -93,8 +89,8 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => {
inputRef.current?.blur();
}, [username]);
const input = (
<Flexbox gap={4}>
return (
<ProfileRow label={t('profile.username')}>
<Flexbox horizontal align="center" gap={8}>
{saving && <Spin indicator={<LoadingOutlined spin />} size="small" />}
{error && (
@ -121,7 +117,6 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => {
placeholder={t('profile.usernamePlaceholder')}
ref={inputRef}
status={error ? 'error' : undefined}
style={mobile ? undefined : { textAlign: 'right', width: INPUT_WIDTH }}
variant="filled"
onBlur={handleSave}
onChange={handleChange}
@ -134,25 +129,7 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => {
}}
/>
</Flexbox>
</Flexbox>
);
if (mobile) {
return (
<Flexbox gap={12} style={rowStyle}>
<Text strong>{t('profile.username')}</Text>
{input}
</Flexbox>
);
}
return (
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
<Text style={labelStyle}>{t('profile.username')}</Text>
<Flexbox align="flex-end" style={{ flex: 1 }}>
{input}
</Flexbox>
</Flexbox>
</ProfileRow>
);
};

View file

@ -20,32 +20,20 @@ import FullNameRow from './features/FullNameRow';
import InterestsRow from './features/InterestsRow';
import KlavisAuthorizationList from './features/KlavisAuthorizationList';
import PasswordRow from './features/PasswordRow';
import ProfileRow, { labelStyle, rowStyle } from './features/ProfileRow';
import ProfileRow from './features/ProfileRow';
import SSOProvidersList from './features/SSOProvidersList';
import UsernameRow from './features/UsernameRow';
const SkeletonRow = ({ mobile }: { mobile?: boolean }) => {
if (mobile) {
return (
<Flexbox gap={12} style={rowStyle}>
<Skeleton.Button active size="small" style={{ height: 22, width: 60 }} />
<Skeleton.Button active size="small" style={{ height: 22, width: 120 }} />
</Flexbox>
);
}
return (
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
<Skeleton.Button active size="small" style={{ ...labelStyle, height: 22 }} />
<Skeleton.Button active size="small" style={{ height: 22, marginInlineStart: 'auto', width: 120 }} />
</Flexbox>
);
};
const SkeletonRow = () => (
<ProfileRow
action={<Skeleton.Button active size="small" style={{ height: 22, width: 80 }} />}
label={<Skeleton.Button active size="small" style={{ height: 22, width: 80 }} />}
>
<Skeleton.Button active size="small" style={{ height: 22, width: 160 }} />
</ProfileRow>
);
interface ProfileSettingProps {
mobile?: boolean;
}
const ProfileSetting = ({ mobile }: ProfileSettingProps) => {
const ProfileSetting = () => {
const isLogin = useUserStore(authSelectors.isLogin);
const [userProfile, isUserLoaded] = useUserStore((s) => [
userProfileSelectors.userProfile(s),
@ -81,64 +69,56 @@ const ProfileSetting = ({ mobile }: ProfileSettingProps) => {
<SettingHeader title={t('profile.title')} />
<FormGroup collapsible={false} gap={16} title={t('profile.account')} variant={'filled'}>
<Flexbox style={{ display: isLoading ? 'flex' : 'none' }}>
<SkeletonRow mobile={mobile} />
<SkeletonRow />
<Divider style={{ margin: 0 }} />
<SkeletonRow mobile={mobile} />
<SkeletonRow />
<Divider style={{ margin: 0 }} />
<SkeletonRow mobile={mobile} />
<SkeletonRow />
<Divider style={{ margin: 0 }} />
<SkeletonRow mobile={mobile} />
<SkeletonRow />
</Flexbox>
<Flexbox style={{ display: isLoading ? 'none' : 'flex' }}>
{/* Avatar Row - Editable */}
<AvatarRow mobile={mobile} />
<AvatarRow />
<Divider style={{ margin: 0 }} />
{/* Full Name Row - Editable */}
<FullNameRow mobile={mobile} />
<FullNameRow />
<Divider style={{ margin: 0 }} />
{/* Username Row - Editable */}
<UsernameRow mobile={mobile} />
<UsernameRow />
<Divider style={{ margin: 0 }} />
{/* Interests Row - Editable */}
<InterestsRow mobile={mobile} />
<InterestsRow />
{/* Password Row - For logged in users to change or set password */}
{!isDesktop && isLogin && !disableEmailPassword && (
<>
<Divider style={{ margin: 0 }} />
<PasswordRow mobile={mobile} />
<PasswordRow />
</>
)}
{/* Email Row - Editable */}
{isLogin && userProfile?.email && (
<>
<Divider style={{ margin: 0 }} />
<EmailRow mobile={mobile} />
<EmailRow />
</>
)}
{/* SSO Providers Row */}
{isLogin && !isDesktop && (
<>
<Divider style={{ margin: 0 }} />
<ProfileRow label={t('profile.sso.providers')} mobile={mobile}>
<ProfileRow label={t('profile.sso.providers')}>
<SSOProvidersList />
</ProfileRow>
</>
)}
{/* Klavis Authorizations Row */}
{enableKlavis && connectedServers.length > 0 && (
<>
<Divider style={{ margin: 0 }} />
<ProfileRow label={t('profile.authorizations.title')} mobile={mobile}>
<ProfileRow label={t('profile.authorizations.title')}>
<KlavisAuthorizationList servers={connectedServers} />
</ProfileRow>
</>

View file

@ -19,7 +19,7 @@ const Category = memo(() => {
{
icon: UserCircle,
key: ProfileTabs.Profile,
label: t('tab.profile'),
label: t('profile.title'),
onClick: () => navigate('/settings/profile'),
},
{

View file

@ -1,15 +1,42 @@
'use client';
import { memo } from 'react';
import { createStaticStyles, cssVar } from 'antd-style';
import { Fragment, memo } from 'react';
import Cell from '@/components/Cell';
import Divider from '@/components/Cell/Divider';
import { useCategory } from './useCategory';
const Category = memo(() => {
const items = useCategory();
const styles = createStaticStyles(({ css }) => ({
groupTitle: css`
padding-block: 16px 4px;
padding-inline: 16px;
return items?.map(({ key, ...item }, index) => <Cell key={key || index} {...item} />);
font-size: 12px;
font-weight: 500;
color: ${cssVar.colorTextSecondary};
text-transform: uppercase;
letter-spacing: 0.5px;
`,
}));
const Category = memo(() => {
const groups = useCategory();
return (
<>
{groups.map((group, groupIndex) => (
<Fragment key={group.key}>
{groupIndex > 0 && <Divider />}
<div className={styles.groupTitle}>{group.title}</div>
{group.items.map(({ key, ...item }) => (
<Cell key={key} {...item} />
))}
</Fragment>
))}
</>
);
});
export default Category;

View file

@ -1,50 +1,144 @@
import { Brain, BrainCircuit, Info, Settings2, Sparkles } from 'lucide-react';
import { SkillsIcon } from '@lobehub/ui/icons';
import {
Brain,
BrainCircuit,
ChartColumnBigIcon,
Coins,
CreditCard,
Database,
EllipsisIcon,
Gift,
Info,
KeyIcon,
KeyRound,
Map,
PaletteIcon,
Sparkles,
UserCircle,
} from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { type CellProps } from '@/components/Cell';
import { SettingsTabs } from '@/store/global/initialState';
import {
featureFlagsSelectors,
serverConfigSelectors,
useServerConfigStore,
} from '@/store/serverConfig';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/slices/settings/selectors';
export const useCategory = () => {
export enum SettingsGroupKey {
Agent = 'agent',
General = 'general',
Subscription = 'subscription',
System = 'system',
}
export interface CategoryItem extends Omit<CellProps, 'type'> {
key: SettingsTabs;
}
export interface CategoryGroup {
items: CategoryItem[];
key: SettingsGroupKey;
title: string;
}
export const useCategory = (): CategoryGroup[] => {
const navigate = useNavigate();
const { t } = useTranslation('setting');
const { t } = useTranslation(['setting', 'auth', 'subscription']);
const { hideDocs, showApiKeyManage } = useServerConfigStore(featureFlagsSelectors);
const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
const items: CellProps[] = [
{
icon: Settings2,
key: SettingsTabs.Appearance,
label: t('tab.appearance'),
},
{
icon: Brain,
key: SettingsTabs.Provider,
label: t('tab.provider'),
},
{
icon: Sparkles,
key: SettingsTabs.ServiceModel,
label: t('tab.serviceModel'),
},
{
icon: BrainCircuit,
key: SettingsTabs.Memory,
label: t('tab.memory'),
},
{
icon: Info,
key: SettingsTabs.About,
label: t('tab.about'),
},
].filter(Boolean) as CellProps[];
return useMemo(() => {
const navigateTo = (key: SettingsTabs) =>
navigate(key === SettingsTabs.Provider ? '/settings/provider/all' : `/settings/${key}`);
return items.map((item) => ({
...item,
onClick: () => {
if (item.key === SettingsTabs.Provider) {
navigate('/settings/provider/all');
} else {
navigate(`/settings/${item.key}`);
}
},
}));
const makeItem = (item: Omit<CategoryItem, 'onClick'>): CategoryItem => ({
...item,
onClick: () => navigateTo(item.key),
});
const general: CategoryItem[] = [
makeItem({ icon: UserCircle, key: SettingsTabs.Profile, label: t('auth:profile.title') }),
makeItem({ icon: ChartColumnBigIcon, key: SettingsTabs.Stats, label: t('auth:tab.stats') }),
makeItem({
icon: PaletteIcon,
key: SettingsTabs.Appearance,
label: t('setting:tab.appearance'),
}),
];
const subscription: CategoryItem[] = enableBusinessFeatures
? [
makeItem({ icon: Map, key: SettingsTabs.Plans, label: t('subscription:tab.plans') }),
makeItem({
icon: ChartColumnBigIcon,
key: SettingsTabs.Usage,
label: t('setting:tab.usage'),
}),
makeItem({
icon: Coins,
key: SettingsTabs.Credits,
label: t('subscription:tab.credits'),
}),
makeItem({
icon: CreditCard,
key: SettingsTabs.Billing,
label: t('subscription:tab.billing'),
}),
makeItem({
icon: Gift,
key: SettingsTabs.Referral,
label: t('subscription:tab.referral'),
}),
]
: [];
const agent: CategoryItem[] = [
(!enableBusinessFeatures || isDevMode) &&
makeItem({ icon: Brain, key: SettingsTabs.Provider, label: t('setting:tab.provider') }),
makeItem({
icon: Sparkles,
key: SettingsTabs.ServiceModel,
label: t('setting:tab.serviceModel'),
}),
makeItem({ icon: SkillsIcon, key: SettingsTabs.Skill, label: t('setting:tab.skill') }),
makeItem({ icon: BrainCircuit, key: SettingsTabs.Memory, label: t('setting:tab.memory') }),
makeItem({ icon: KeyRound, key: SettingsTabs.Creds, label: t('setting:tab.creds') }),
showApiKeyManage &&
makeItem({ icon: KeyIcon, key: SettingsTabs.APIKey, label: t('auth:tab.apikey') }),
].filter(Boolean) as CategoryItem[];
const system: CategoryItem[] = [
makeItem({ icon: Database, key: SettingsTabs.Storage, label: t('setting:tab.storage') }),
isDevMode &&
makeItem({ icon: KeyIcon, key: SettingsTabs.APIKey, label: t('auth:tab.apikey') }),
makeItem({
icon: EllipsisIcon,
key: SettingsTabs.Advanced,
label: t('setting:tab.advanced'),
}),
!hideDocs && makeItem({ icon: Info, key: SettingsTabs.About, label: t('setting:tab.about') }),
].filter(Boolean) as CategoryItem[];
return [
{ items: general, key: SettingsGroupKey.General, title: t('setting:group.common') },
...(subscription.length > 0
? [
{
items: subscription,
key: SettingsGroupKey.Subscription,
title: t('setting:group.subscription'),
},
]
: []),
{ items: agent, key: SettingsGroupKey.Agent, title: t('setting:group.aiConfig') },
{ items: system, key: SettingsGroupKey.System, title: t('setting:group.system') },
];
}, [t, enableBusinessFeatures, hideDocs, showApiKeyManage, isDevMode, navigate]);
};

View file

@ -7,12 +7,28 @@ import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { useShowMobileWorkspace } from '@/hooks/useShowMobileWorkspace';
import { type SettingsTabs } from '@/store/global/initialState';
import { SettingsTabs } from '@/store/global/initialState';
import { useSessionStore } from '@/store/session';
import { mobileHeaderSticky } from '@/styles/mobileHeader';
type TabNamespace = 'setting' | 'subscription' | 'auth';
// Tabs whose title key lives outside the default `setting` namespace.
const TAB_NAMESPACE: Partial<Record<SettingsTabs, TabNamespace>> = {
[SettingsTabs.Billing]: 'subscription',
[SettingsTabs.Credits]: 'subscription',
[SettingsTabs.Plans]: 'subscription',
[SettingsTabs.Referral]: 'subscription',
[SettingsTabs.Stats]: 'auth',
};
// Prefer shorter "Profile" (`auth:profile.title`) over "My Account" (`auth:tab.profile`) on mobile.
const TAB_TITLE_KEY: Partial<Record<SettingsTabs, string>> = {
[SettingsTabs.Profile]: 'auth:profile.title',
};
const Header = memo(() => {
const { t } = useTranslation('setting');
const { t } = useTranslation(['setting', 'auth', 'subscription']);
const showMobileWorkspace = useShowMobileWorkspace();
const navigate = useNavigate();
const params = useParams<{ providerId?: string; tab?: string }>();
@ -30,6 +46,12 @@ const Header = memo(() => {
}
};
const tab = params.tab as SettingsTabs | undefined;
const tabTitleKey = tab
? (TAB_TITLE_KEY[tab] ?? `${TAB_NAMESPACE[tab] ?? 'setting'}:tab.${tab}`)
: 'setting:tab.all';
const tabTitle = t(tabTitleKey as any);
return (
<ChatHeader
showBackButton
@ -38,11 +60,7 @@ const Header = memo(() => {
<ChatHeader.Title
title={
<Flexbox horizontal align={'center'} gap={8}>
<span style={{ lineHeight: 1.2 }}>
{isProvider
? params.providerId
: t(`tab.${(params.tab || 'all') as SettingsTabs}` as any)}
</span>
<span style={{ lineHeight: 1.2 }}>{isProvider ? params.providerId : tabTitle}</span>
</Flexbox>
}
/>