Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing

This commit is contained in:
yyh
2026-03-11 14:26:14 +08:00
23 changed files with 185 additions and 375 deletions

View File

@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import ImageInput from '@/app/components/base/app-icon-picker/ImageInput'
import getCroppedImg from '@/app/components/base/app-icon-picker/utils'
import Avatar from '@/app/components/base/avatar'
import { Avatar } from '@/app/components/base/avatar'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks'
@ -103,7 +103,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
<>
<div>
<div className="group relative">
<Avatar {...props} onError={(x: boolean) => setOnAvatarError(x)} />
<Avatar {...props} onLoadingStatusChange={status => setOnAvatarError(status === 'error')} />
<div
className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => {

View File

@ -4,6 +4,7 @@ import type { App } from '@/types/app'
import {
RiGraduationCapFill,
} from '@remixicon/react'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
@ -15,11 +16,11 @@ import PremiumBadge from '@/app/components/base/premium-badge'
import { ToastContext } from '@/app/components/base/toast/context'
import Collapse from '@/app/components/header/account-setting/collapse'
import { IS_CE_EDITION, validPassword } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { updateUserProfile } from '@/service/common'
import { useAppList } from '@/service/use-apps'
import { commonQueryKeys, useUserProfile } from '@/service/use-common'
import DeleteAccount from '../delete-account'
import AvatarWithEdit from './AvatarWithEdit'
@ -37,7 +38,10 @@ export default function AccountPage() {
const { systemFeatures } = useGlobalPublicStore()
const { data: appList } = useAppList({ page: 1, limit: 100, name: '' })
const apps = appList?.data || []
const { mutateUserProfile, userProfile } = useAppContext()
const queryClient = useQueryClient()
const { data: userProfileResp } = useUserProfile()
const userProfile = userProfileResp?.profile
const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: commonQueryKeys.userProfile })
const { isEducationAccount } = useProviderContext()
const { notify } = useContext(ToastContext)
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
@ -53,6 +57,9 @@ export default function AccountPage() {
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [showUpdateEmail, setShowUpdateEmail] = useState(false)
if (!userProfile)
return null
const handleEditName = () => {
setEditNameModalVisible(true)
setEditName(userProfile.name)
@ -149,7 +156,7 @@ export default function AccountPage() {
<h4 className="text-text-primary title-2xl-semi-bold">{t('account.myAccount', { ns: 'common' })}</h4>
</div>
<div className="mb-8 flex items-center rounded-xl bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6">
<AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={mutateUserProfile} size={64} />
<AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={mutateUserProfile} size="3xl" />
<div className="ml-4">
<p className="text-text-primary system-xl-semibold">
{userProfile.name}

View File

@ -7,12 +7,11 @@ import { useRouter } from 'next/navigation'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
import Avatar from '@/app/components/base/avatar'
import { Avatar } from '@/app/components/base/avatar'
import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
import PremiumBadge from '@/app/components/base/premium-badge'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useLogout } from '@/service/use-common'
import { useLogout, useUserProfile } from '@/service/use-common'
export type IAppSelector = {
isMobile: boolean
@ -21,10 +20,15 @@ export type IAppSelector = {
export default function AppSelector() {
const router = useRouter()
const { t } = useTranslation()
const { userProfile } = useAppContext()
const { data: userProfileResp } = useUserProfile()
const userProfile = userProfileResp?.profile
const { isEducationAccount } = useProviderContext()
const { mutateAsync: logout } = useLogout()
if (!userProfile)
return null
const handleLogout = async () => {
await logout()
@ -50,7 +54,7 @@ export default function AppSelector() {
${open && 'bg-components-panel-bg-blur'}
`}
>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} />
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} />
</MenuButton>
</div>
<Transition
@ -84,7 +88,7 @@ export default function AppSelector() {
</div>
<div className="system-xs-regular break-all text-text-tertiary">{userProfile.email}</div>
</div>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} />
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} />
</div>
</div>
</MenuItem>

View File

@ -11,14 +11,13 @@ import { useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Avatar from '@/app/components/base/avatar'
import { Avatar } from '@/app/components/base/avatar'
import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
import { useAppContext } from '@/context/app-context'
import { useIsLogin } from '@/service/use-common'
import { useIsLogin, useUserProfile } from '@/service/use-common'
import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
function buildReturnUrl(pathname: string, search: string) {
@ -62,7 +61,8 @@ export default function OAuthAuthorize() {
const searchParams = useSearchParams()
const client_id = decodeURIComponent(searchParams.get('client_id') || '')
const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '')
const { userProfile } = useAppContext()
const { data: userProfileResp } = useUserProfile()
const userProfile = userProfileResp?.profile
const { data: authAppInfo, isLoading: isOAuthLoading, isError } = useOAuthAppInfo(client_id, redirect_uri)
const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp()
const hasNotifiedRef = useRef(false)
@ -138,7 +138,7 @@ export default function OAuthAuthorize() {
{isLoggedIn && userProfile && (
<div className="flex items-center justify-between rounded-xl bg-background-section-burn-inverted p-3">
<div className="flex items-center gap-2.5">
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" />
<div>
<div className="system-md-semi-bold text-text-secondary">{userProfile.name}</div>
<div className="text-text-tertiary system-xs-regular">{userProfile.email}</div>

View File

@ -10,7 +10,7 @@ import { SubjectType } from '@/models/access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import { cn } from '@/utils/classnames'
import useAccessControlStore from '../../../../context/access-control-store'
import Avatar from '../../base/avatar'
import { Avatar } from '../../base/avatar'
import Button from '../../base/button'
import Checkbox from '../../base/checkbox'
import Input from '../../base/input'
@ -203,7 +203,7 @@ function MemberItem({ member }: MemberItemProps) {
<div className="flex grow items-center">
<div className="mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center">
<Avatar className="h-[14px] w-[14px]" textClassName="text-[12px]" avatar={null} name={member.name} />
<Avatar size="xxs" avatar={null} name={member.name} />
</div>
</div>
<p className="system-sm-medium mr-1 text-text-secondary">{member.name}</p>

View File

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import Avatar from '../../base/avatar'
import { Avatar } from '../../base/avatar'
import Loading from '../../base/loading'
import Tooltip from '../../base/tooltip'
import AddMemberOrGroupDialog from './add-member-or-group-pop'
@ -106,7 +106,7 @@ function MemberItem({ member }: MemberItemProps) {
}, [member, setSpecificMembers, specificMembers])
return (
<BaseItem
icon={<Avatar className="h-[14px] w-[14px]" textClassName="text-[12px]" avatar={null} name={member.name} />}
icon={<Avatar size="xxs" avatar={null} name={member.name} />}
onRemove={handleRemoveMember}
>
<p className="system-xs-regular text-text-primary">{member.name}</p>

View File

@ -91,7 +91,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({
}))
vi.mock('@/app/components/base/avatar', () => ({
default: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
Avatar: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
}))
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({

View File

@ -7,7 +7,7 @@ import {
useCallback,
useMemo,
} from 'react'
import Avatar from '@/app/components/base/avatar'
import { Avatar } from '@/app/components/base/avatar'
import Chat from '@/app/components/base/chat/chat'
import { useChat } from '@/app/components/base/chat/chat/hooks'
import { getLastAnswer } from '@/app/components/base/chat/utils'
@ -149,7 +149,7 @@ const ChatItem: FC<ChatItemProps> = ({
suggestedQuestions={suggestedQuestions}
onSend={doSend}
showPromptLog
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />}
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xl" />}
allToolIcons={allToolIcons}
hideLogModal
noSpacing

View File

@ -3,7 +3,7 @@ import type { ChatConfig, ChatItem, OnSend } from '@/app/components/base/chat/ty
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { memo, useCallback, useImperativeHandle, useMemo } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import Avatar from '@/app/components/base/avatar'
import { Avatar } from '@/app/components/base/avatar'
import Chat from '@/app/components/base/chat/chat'
import { useChat } from '@/app/components/base/chat/chat/hooks'
import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils'
@ -168,7 +168,7 @@ const DebugWithSingleModel = (
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
onStopResponding={handleStop}
showPromptLog
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />}
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xl" />}
allToolIcons={allToolIcons}
onAnnotationEdited={handleAnnotationEdited}
onAnnotationAdded={handleAnnotationAdded}

View File

@ -1,308 +1,114 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Avatar from '../index'
import { render, screen } from '@testing-library/react'
import { Avatar } from '../index'
describe('Avatar', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests - verify component renders correctly in different states
describe('Rendering', () => {
it('should render img element with correct alt and src when avatar URL is provided', () => {
const avatarUrl = 'https://example.com/avatar.jpg'
const props = { name: 'John Doe', avatar: avatarUrl }
render(<Avatar {...props} />)
it('should render img element when avatar URL is provided', () => {
render(<Avatar name="John Doe" avatar="https://example.com/avatar.jpg" />)
const img = screen.getByRole('img', { name: 'John Doe' })
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', avatarUrl)
expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg')
})
it('should render fallback div with uppercase initial when avatar is null', () => {
const props = { name: 'alice', avatar: null }
render(<Avatar {...props} />)
it('should render fallback with uppercase initial when avatar is null', () => {
render(<Avatar name="alice" avatar={null} />)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
})
// Props tests - verify all props are applied correctly
describe('Props', () => {
describe('size prop', () => {
it.each([
{ size: undefined, expected: '30px', label: 'default (30px)' },
{ size: 50, expected: '50px', label: 'custom (50px)' },
])('should apply $label size to img element', ({ size, expected }) => {
const props = { name: 'Test', avatar: 'https://example.com/avatar.jpg', size }
it('should render both image and fallback when avatar is provided', () => {
render(<Avatar name="John" avatar="https://example.com/avatar.jpg" />)
render(<Avatar {...props} />)
expect(screen.getByRole('img')).toHaveStyle({
width: expected,
height: expected,
fontSize: expected,
lineHeight: expected,
})
})
it('should apply size to fallback div when avatar is null', () => {
const props = { name: 'Test', avatar: null, size: 40 }
render(<Avatar {...props} />)
const textElement = screen.getByText('T')
const outerDiv = textElement.parentElement as HTMLElement
expect(outerDiv).toHaveStyle({ width: '40px', height: '40px' })
})
})
describe('className prop', () => {
it('should merge className with default avatar classes on img', () => {
const props = {
name: 'Test',
avatar: 'https://example.com/avatar.jpg',
className: 'custom-class',
}
render(<Avatar {...props} />)
const img = screen.getByRole('img')
expect(img).toHaveClass('custom-class')
expect(img).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600')
})
it('should merge className with default avatar classes on fallback div', () => {
const props = {
name: 'Test',
avatar: null,
className: 'my-custom-class',
}
render(<Avatar {...props} />)
const textElement = screen.getByText('T')
const outerDiv = textElement.parentElement as HTMLElement
expect(outerDiv).toHaveClass('my-custom-class')
expect(outerDiv).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600')
})
})
describe('textClassName prop', () => {
it('should apply textClassName to the initial text element', () => {
const props = {
name: 'Test',
avatar: null,
textClassName: 'custom-text-class',
}
render(<Avatar {...props} />)
const textElement = screen.getByText('T')
expect(textElement).toHaveClass('custom-text-class')
expect(textElement).toHaveClass('scale-[0.4]', 'text-center', 'text-white')
})
})
})
// State Management tests - verify useState and useEffect behavior
describe('State Management', () => {
it('should switch to fallback when image fails to load', async () => {
const props = { name: 'John', avatar: 'https://example.com/broken.jpg' }
render(<Avatar {...props} />)
const img = screen.getByRole('img')
fireEvent.error(img)
await waitFor(() => {
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
expect(screen.getByText('J')).toBeInTheDocument()
})
it('should reset error state when avatar URL changes', async () => {
const initialProps = { name: 'John', avatar: 'https://example.com/broken.jpg' }
const { rerender } = render(<Avatar {...initialProps} />)
const img = screen.getByRole('img')
// First, trigger error
fireEvent.error(img)
await waitFor(() => {
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
expect(screen.getByText('J')).toBeInTheDocument()
rerender(<Avatar name="John" avatar="https://example.com/new-avatar.jpg" />)
await waitFor(() => {
expect(screen.getByRole('img')).toBeInTheDocument()
})
expect(screen.queryByText('J')).not.toBeInTheDocument()
})
it('should not reset error state if avatar becomes null', async () => {
const initialProps = { name: 'John', avatar: 'https://example.com/broken.jpg' }
const { rerender } = render(<Avatar {...initialProps} />)
// Trigger error
fireEvent.error(screen.getByRole('img'))
await waitFor(() => {
expect(screen.getByText('J')).toBeInTheDocument()
})
rerender(<Avatar name="John" avatar={null} />)
await waitFor(() => {
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
expect(screen.getByRole('img')).toBeInTheDocument()
expect(screen.getByText('J')).toBeInTheDocument()
})
})
// Event Handlers tests - verify onError callback behavior
describe('Event Handlers', () => {
it('should call onError with true when image fails to load', () => {
const onErrorMock = vi.fn()
const props = {
name: 'John',
avatar: 'https://example.com/broken.jpg',
onError: onErrorMock,
}
render(<Avatar {...props} />)
describe('Size variants', () => {
it.each([
{ size: 'xxs' as const, expectedClass: 'size-4' },
{ size: 'xs' as const, expectedClass: 'size-5' },
{ size: 'sm' as const, expectedClass: 'size-6' },
{ size: 'md' as const, expectedClass: 'size-8' },
{ size: 'lg' as const, expectedClass: 'size-9' },
{ size: 'xl' as const, expectedClass: 'size-10' },
{ size: '2xl' as const, expectedClass: 'size-12' },
{ size: '3xl' as const, expectedClass: 'size-16' },
])('should apply $expectedClass for size="$size"', ({ size, expectedClass }) => {
const { container } = render(<Avatar name="Test" avatar={null} size={size} />)
fireEvent.error(screen.getByRole('img'))
expect(onErrorMock).toHaveBeenCalledTimes(1)
expect(onErrorMock).toHaveBeenCalledWith(true)
const root = container.firstElementChild as HTMLElement
expect(root).toHaveClass(expectedClass)
})
it('should call onError with false when image loads successfully', () => {
const onErrorMock = vi.fn()
const props = {
name: 'John',
avatar: 'https://example.com/avatar.jpg',
onError: onErrorMock,
}
render(<Avatar {...props} />)
it('should default to md size when size is not specified', () => {
const { container } = render(<Avatar name="Test" avatar={null} />)
fireEvent.load(screen.getByRole('img'))
expect(onErrorMock).toHaveBeenCalledTimes(1)
expect(onErrorMock).toHaveBeenCalledWith(false)
})
it('should not throw when onError is not provided', async () => {
const props = { name: 'John', avatar: 'https://example.com/broken.jpg' }
render(<Avatar {...props} />)
expect(() => fireEvent.error(screen.getByRole('img'))).not.toThrow()
await waitFor(() => {
expect(screen.getByText('J')).toBeInTheDocument()
})
const root = container.firstElementChild as HTMLElement
expect(root).toHaveClass('size-8')
})
})
describe('className prop', () => {
it('should merge className with avatar variant classes on root', () => {
const { container } = render(
<Avatar name="Test" avatar={null} className="custom-class" />,
)
const root = container.firstElementChild as HTMLElement
expect(root).toHaveClass('custom-class')
expect(root).toHaveClass('rounded-full', 'bg-primary-600')
})
})
// Edge Cases tests - verify handling of unusual inputs
describe('Edge Cases', () => {
it('should handle empty string name gracefully', () => {
const props = { name: '', avatar: null }
const { container } = render(<Avatar name="" avatar={null} />)
const { container } = render(<Avatar {...props} />)
// Note: Using querySelector here because empty name produces no visible text,
// making semantic queries (getByRole, getByText) impossible
const textElement = container.querySelector('.text-white') as HTMLElement
expect(textElement).toBeInTheDocument()
expect(textElement.textContent).toBe('')
const fallback = container.querySelector('.text-white') as HTMLElement
expect(fallback).toBeInTheDocument()
expect(fallback.textContent).toBe('')
})
it.each([
{ name: '中文名', expected: '中', label: 'Chinese characters' },
{ name: '123User', expected: '1', label: 'number' },
])('should display first character when name starts with $label', ({ name, expected }) => {
const props = { name, avatar: null }
render(<Avatar {...props} />)
render(<Avatar name={name} avatar={null} />)
expect(screen.getByText(expected)).toBeInTheDocument()
})
it('should handle empty string avatar as falsy value', () => {
const props = { name: 'Test', avatar: '' as string | null }
render(<Avatar {...props} />)
render(<Avatar name="Test" avatar={'' as string | null} />)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.getByText('T')).toBeInTheDocument()
})
it('should handle undefined className and textClassName', () => {
const props = { name: 'Test', avatar: null }
render(<Avatar {...props} />)
const textElement = screen.getByText('T')
const outerDiv = textElement.parentElement as HTMLElement
expect(outerDiv).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600')
})
it.each([
{ size: 0, expected: '0px', label: 'zero' },
{ size: 1000, expected: '1000px', label: 'very large' },
])('should handle $label size value', ({ size, expected }) => {
const props = { name: 'Test', avatar: null, size }
render(<Avatar {...props} />)
const textElement = screen.getByText('T')
const outerDiv = textElement.parentElement as HTMLElement
expect(outerDiv).toHaveStyle({ width: expected, height: expected })
})
})
// Combined props tests - verify props work together correctly
describe('Combined Props', () => {
it('should apply all props correctly when used together', () => {
const onErrorMock = vi.fn()
const props = {
name: 'Test User',
avatar: 'https://example.com/avatar.jpg',
size: 64,
className: 'custom-avatar',
onError: onErrorMock,
}
describe('onLoadingStatusChange', () => {
it('should render image when avatar and onLoadingStatusChange are provided', () => {
render(
<Avatar
name="John"
avatar="https://example.com/avatar.jpg"
onLoadingStatusChange={vi.fn()}
/>,
)
render(<Avatar {...props} />)
const img = screen.getByRole('img')
expect(img).toHaveAttribute('alt', 'Test User')
expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg')
expect(img).toHaveStyle({ width: '64px', height: '64px' })
expect(img).toHaveClass('custom-avatar')
// Trigger load to verify onError callback
fireEvent.load(img)
expect(onErrorMock).toHaveBeenCalledWith(false)
expect(screen.getByRole('img')).toBeInTheDocument()
})
it('should apply all fallback props correctly when used together', () => {
const props = {
name: 'Fallback User',
avatar: null,
size: 48,
className: 'fallback-custom',
textClassName: 'custom-text-style',
}
it('should not render image when avatar is null even with onLoadingStatusChange', () => {
const onStatusChange = vi.fn()
render(
<Avatar name="John" avatar={null} onLoadingStatusChange={onStatusChange} />,
)
render(<Avatar {...props} />)
const textElement = screen.getByText('F')
const outerDiv = textElement.parentElement as HTMLElement
expect(outerDiv).toHaveClass('fallback-custom')
expect(outerDiv).toHaveStyle({ width: '48px', height: '48px' })
expect(textElement).toHaveClass('custom-text-style')
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
})
})

View File

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import Avatar from '.'
import { Avatar } from '.'
const meta = {
title: 'Base/Data Display/Avatar',
@ -7,12 +7,12 @@ const meta = {
parameters: {
docs: {
description: {
component: 'Initials or image-based avatar used across contacts and member lists. Falls back to the first letter when the image fails to load.',
component: 'Initials or image-based avatar built on Base UI. Falls back to the first letter when the image fails to load.',
},
source: {
language: 'tsx',
code: `
<Avatar name="Alex Doe" avatar="https://cloud.dify.ai/logo/logo.svg" size={40} />
<Avatar name="Alex Doe" avatar="https://i.pravatar.cc/96?u=avatar-default" size="xl" />
`.trim(),
},
},
@ -20,8 +20,8 @@ const meta = {
tags: ['autodocs'],
args: {
name: 'Alex Doe',
avatar: 'https://cloud.dify.ai/logo/logo.svg',
size: 40,
avatar: 'https://i.pravatar.cc/96?u=avatar-default',
size: 'xl',
},
} satisfies Meta<typeof Avatar>
@ -40,23 +40,20 @@ export const WithFallback: Story = {
source: {
language: 'tsx',
code: `
<Avatar name="Fallback" avatar={null} size={40} />
<Avatar name="Fallback" avatar={null} size="xl" />
`.trim(),
},
},
},
}
export const CustomSizes: Story = {
export const AllSizes: Story = {
render: args => (
<div className="flex items-end gap-4">
{[24, 32, 48, 64].map(size => (
{(['xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const).map(size => (
<div key={size} className="flex flex-col items-center gap-2">
<Avatar {...args} size={size} avatar="https://i.pravatar.cc/96?u=size-test" />
<span className="text-xs text-text-tertiary">
{size}
px
</span>
<span className="text-xs text-text-tertiary">{size}</span>
</div>
))}
</div>
@ -66,7 +63,7 @@ export const CustomSizes: Story = {
source: {
language: 'tsx',
code: `
{[24, 32, 48, 64].map(size => (
{(['xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const).map(size => (
<Avatar key={size} name="Size Test" size={size} avatar="https://i.pravatar.cc/96?u=size-test" />
))}
`.trim(),
@ -74,3 +71,16 @@ export const CustomSizes: Story = {
},
},
}
export const AllFallbackSizes: Story = {
render: args => (
<div className="flex items-end gap-4">
{(['xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const).map(size => (
<div key={size} className="flex flex-col items-center gap-2">
<Avatar {...args} size={size} avatar={null} name="Alex" />
<span className="text-xs text-text-tertiary">{size}</span>
</div>
))}
</div>
),
}

View File

@ -1,64 +1,52 @@
'use client'
import { useEffect, useState } from 'react'
import type { ImageLoadingStatus } from '@base-ui/react/avatar'
import { Avatar as BaseAvatar } from '@base-ui/react/avatar'
import { cn } from '@/utils/classnames'
const SIZES = {
'xxs': { root: 'size-4', text: 'text-[7px]' },
'xs': { root: 'size-5', text: 'text-[8px]' },
'sm': { root: 'size-6', text: 'text-[10px]' },
'md': { root: 'size-8', text: 'text-xs' },
'lg': { root: 'size-9', text: 'text-sm' },
'xl': { root: 'size-10', text: 'text-base' },
'2xl': { root: 'size-12', text: 'text-xl' },
'3xl': { root: 'size-16', text: 'text-2xl' },
} as const
export type AvatarSize = keyof typeof SIZES
export type AvatarProps = {
name: string
avatar: string | null
size?: number
size?: AvatarSize
className?: string
textClassName?: string
onError?: (x: boolean) => void
onLoadingStatusChange?: (status: ImageLoadingStatus) => void
}
const Avatar = ({
const BASE_CLASS = 'relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full bg-primary-600'
export const Avatar = ({
name,
avatar,
size = 30,
size = 'md',
className,
textClassName,
onError,
onLoadingStatusChange,
}: AvatarProps) => {
const avatarClassName = 'shrink-0 flex items-center rounded-full bg-primary-600'
const style = { width: `${size}px`, height: `${size}px`, fontSize: `${size}px`, lineHeight: `${size}px` }
const [imgError, setImgError] = useState(false)
const handleError = () => {
setImgError(true)
onError?.(true)
}
// after uploaded, api would first return error imgs url: '.../files//file-preview/...'. Then return the right url, Which caused not show the avatar
useEffect(() => {
if (avatar && imgError)
setImgError(false)
}, [avatar])
if (avatar && !imgError) {
return (
<img
className={cn(avatarClassName, className)}
style={style}
alt={name}
src={avatar}
onError={handleError}
onLoad={() => onError?.(false)}
/>
)
}
const sizeConfig = SIZES[size]
return (
<div
className={cn(avatarClassName, className)}
style={style}
>
<div
className={cn(textClassName, 'scale-[0.4] text-center text-white')}
style={style}
>
{name && name[0].toLocaleUpperCase()}
</div>
</div>
<BaseAvatar.Root className={cn(BASE_CLASS, sizeConfig.root, className)}>
{avatar && (
<BaseAvatar.Image
src={avatar}
alt={name}
className="absolute inset-0 size-full object-cover"
onLoadingStatusChange={onLoadingStatusChange}
/>
)}
<BaseAvatar.Fallback className={cn('font-medium text-white', sizeConfig.text)}>
{name?.[0]?.toLocaleUpperCase()}
</BaseAvatar.Fallback>
</BaseAvatar.Root>
)
}
export default Avatar

View File

@ -23,7 +23,7 @@ import { submitHumanInputForm as submitHumanInputFormService } from '@/service/w
import { TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
import { formatBooleanInputs } from '@/utils/model-config'
import Avatar from '../../avatar'
import { Avatar } from '../../avatar'
import Chat from '../chat'
import { useChat } from '../chat/hooks'
import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
@ -351,7 +351,7 @@ const ChatWrapper = () => {
<Avatar
avatar={initUserVariables.avatar_url}
name={initUserVariables.name || 'user'}
size={40}
size="xl"
/>
)
: undefined

View File

@ -23,7 +23,7 @@ import {
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
import { TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
import Avatar from '../../avatar'
import { Avatar } from '../../avatar'
import Chat from '../chat'
import { useChat } from '../chat/hooks'
import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
@ -337,7 +337,7 @@ const ChatWrapper = () => {
<Avatar
avatar={initUserVariables.avatar_url}
name={initUserVariables.name || 'user'}
size={40}
size="xl"
/>
)
: undefined

View File

@ -4,7 +4,7 @@ import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Avatar from '@/app/components/base/avatar'
import { Avatar } from '@/app/components/base/avatar'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
@ -106,7 +106,7 @@ const PermissionSelector = ({
isOnlyMe && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={20} />
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
</div>
<div className="system-sm-regular grow p-1 text-components-input-text-filled">
{t('form.permissionsOnlyMe', { ns: 'datasetSettings' })}
@ -135,7 +135,7 @@ const PermissionSelector = ({
<Avatar
avatar={selectedMembers[0].avatar_url}
name={selectedMembers[0].name}
size={20}
size="xs"
/>
)
}
@ -146,13 +146,13 @@ const PermissionSelector = ({
avatar={selectedMembers[0].avatar_url}
name={selectedMembers[0].name}
className="absolute left-0 top-0 z-0"
size={16}
size="xxs"
/>
<Avatar
avatar={selectedMembers[1].avatar_url}
name={selectedMembers[1].name}
className="absolute bottom-0 right-0 z-10"
size={16}
size="xxs"
/>
</>
)
@ -182,7 +182,7 @@ const PermissionSelector = ({
{/* Only me */}
<Item
leftIcon={
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className="shrink-0" size={24} />
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className="shrink-0" size="sm" />
}
text={t('form.permissionsOnlyMe', { ns: 'datasetSettings' })}
onClick={onSelectOnlyMe}
@ -226,7 +226,7 @@ const PermissionSelector = ({
{showMe && (
<MemberItem
leftIcon={
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className="shrink-0" size={24} />
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className="shrink-0" size="sm" />
}
name={userProfile.name}
email={userProfile.email}
@ -237,7 +237,7 @@ const PermissionSelector = ({
{filteredMemberList.map(member => (
<MemberItem
leftIcon={
<Avatar avatar={member.avatar_url} name={member.name} className="shrink-0" size={24} />
<Avatar avatar={member.avatar_url} name={member.name} className="shrink-0" size="sm" />
}
name={member.name}
email={member.email}

View File

@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
import Avatar from '@/app/components/base/avatar'
import { Avatar } from '@/app/components/base/avatar'
import PremiumBadge from '@/app/components/base/premium-badge'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
@ -140,7 +140,7 @@ export default function AppSelector() {
aria-label={t('account.account', { ns: 'common' })}
className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', isAccountMenuOpen && 'bg-background-default-dodge')}
>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" />
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={6}
@ -160,7 +160,7 @@ export default function AppSelector() {
</div>
<div className="break-all text-text-tertiary system-xs-regular">{userProfile.email}</div>
</div>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" />
</div>
<AccountMenuRouteItem
href="/account"

View File

@ -2,7 +2,7 @@
import type { InvitationResult } from '@/models/common'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Avatar from '@/app/components/base/avatar'
import { Avatar } from '@/app/components/base/avatar'
import Tooltip from '@/app/components/base/tooltip'
import { NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
@ -120,7 +120,7 @@ const MembersPage = () => {
accounts.map(account => (
<div key={account.id} className="flex border-b border-divider-subtle">
<div className="flex grow items-center px-3 py-2">
<Avatar avatar={account.avatar_url} size={24} className="mr-2" name={account.name} />
<Avatar avatar={account.avatar_url} size="sm" className="mr-2" name={account.name} />
<div className="">
<div className="text-text-secondary system-sm-medium">
{account.name}

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Avatar from '@/app/components/base/avatar'
import { Avatar } from '@/app/components/base/avatar'
import Input from '@/app/components/base/input'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import { useMembers } from '@/service/use-common'
@ -69,7 +69,7 @@ const MemberSelector: FC<Props> = ({
)}
{currentValue && (
<>
<Avatar avatar={currentValue.avatar_url} size={24} name={currentValue.name} />
<Avatar avatar={currentValue.avatar_url} size="sm" name={currentValue.name} />
<div className="grow truncate text-text-secondary system-sm-medium">{currentValue.name}</div>
<div className="text-text-quaternary system-xs-regular">{currentValue.email}</div>
</>
@ -98,7 +98,7 @@ const MemberSelector: FC<Props> = ({
setOpen(false)
}}
>
<Avatar avatar={account.avatar_url} size={24} name={account.name} />
<Avatar avatar={account.avatar_url} size="sm" name={account.name} />
<div className="grow truncate text-text-secondary system-sm-medium">{account.name}</div>
<div className="text-text-quaternary system-xs-regular">{account.email}</div>
</div>

View File

@ -3,7 +3,7 @@ import type { Member } from '@/models/common'
import { RiCloseCircleFill, RiErrorWarningFill } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Avatar from '@/app/components/base/avatar'
import { Avatar } from '@/app/components/base/avatar'
import { cn } from '@/utils/classnames'
type Props = {
@ -34,7 +34,7 @@ const EmailItem = ({
{isError && (
<RiErrorWarningFill className="h-4 w-4 text-text-destructive" />
)}
{!isError && <Avatar avatar={data.avatar_url} size={16} name={data.name || data.email} />}
{!isError && <Avatar avatar={data.avatar_url} size="xxs" name={data.name || data.email} />}
<div title={data.email} className="system-xs-regular max-w-[500px] truncate text-text-primary">
{email === data.email ? data.name : data.email}
{email === data.email && <span className="system-xs-regular text-text-tertiary">{t('members.you', { ns: 'common' })}</span>}

View File

@ -4,7 +4,7 @@ import type { Recipient } from '@/app/components/workflow/nodes/human-input/type
import type { Member } from '@/models/common'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Avatar from '@/app/components/base/avatar'
import { Avatar } from '@/app/components/base/avatar'
import Input from '@/app/components/base/input'
import { cn } from '@/utils/classnames'
@ -65,7 +65,7 @@ const MemberList: FC<Props> = ({ searchValue, list, value, onSearchChange, onSel
onSelect(account.id)
}}
>
<Avatar className={cn(value.some(item => item.user_id === account.id) && 'opacity-50')} avatar={account.avatar_url} size={24} name={account.name} />
<Avatar className={cn(value.some(item => item.user_id === account.id) && 'opacity-50')} avatar={account.avatar_url} size="sm" name={account.name} />
<div className={cn('grow', value.some(item => item.user_id === account.id) && 'opacity-50')}>
<div className="system-sm-medium text-text-secondary">
{account.name}

View File

@ -1,6 +1,6 @@
import { useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import Avatar from '@/app/components/base/avatar'
import { Avatar } from '@/app/components/base/avatar'
import Button from '@/app/components/base/button'
import { Triangle } from '@/app/components/base/icons/src/public/education'
import { useAppContext } from '@/context/app-context'
@ -34,7 +34,7 @@ const UserInfo = () => {
className="mr-4"
avatar={userProfile.avatar_url}
name={userProfile.name}
size={48}
size="2xl"
/>
<div className="pt-1.5">
<div className="system-md-semibold text-text-primary">

View File

@ -1436,11 +1436,6 @@
"count": 1
}
},
"app/components/base/avatar/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
},
"app/components/base/block-input/index.stories.tsx": {
"no-console": {
"count": 2

View File

@ -151,7 +151,7 @@ export default antfu(
},
{
name: 'dify/base-ui-primitives',
files: ['app/components/base/ui/**/*.tsx'],
files: ['app/components/base/ui/**/*.tsx', 'app/components/base/avatar/**/*.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},