mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
✨ 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:
parent
50104bed26
commit
f1c1fdc83e
12 changed files with 393 additions and 380 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const Category = memo(() => {
|
|||
{
|
||||
icon: UserCircle,
|
||||
key: ProfileTabs.Profile,
|
||||
label: t('tab.profile'),
|
||||
label: t('profile.title'),
|
||||
onClick: () => navigate('/settings/profile'),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue