mirror of
https://git.mirrors.martin98.com/https://github.com/langgenius/dify.git
synced 2025-08-11 01:18:58 +08:00
feat: Add ability to change profile avatar (#12642)
This commit is contained in:
parent
2f41bd495d
commit
f582d4a13e
@ -1,6 +1,6 @@
|
||||
from flask_restful import fields # type: ignore
|
||||
|
||||
from libs.helper import TimestampField
|
||||
from libs.helper import AvatarUrlField, TimestampField
|
||||
|
||||
simple_account_fields = {"id": fields.String, "name": fields.String, "email": fields.String}
|
||||
|
||||
@ -8,6 +8,7 @@ account_fields = {
|
||||
"id": fields.String,
|
||||
"name": fields.String,
|
||||
"avatar": fields.String,
|
||||
"avatar_url": AvatarUrlField,
|
||||
"email": fields.String,
|
||||
"is_password_set": fields.Boolean,
|
||||
"interface_language": fields.String,
|
||||
@ -22,6 +23,7 @@ account_with_role_fields = {
|
||||
"id": fields.String,
|
||||
"name": fields.String,
|
||||
"avatar": fields.String,
|
||||
"avatar_url": AvatarUrlField,
|
||||
"email": fields.String,
|
||||
"last_login_at": TimestampField,
|
||||
"last_active_at": TimestampField,
|
||||
|
@ -41,6 +41,18 @@ class AppIconUrlField(fields.Raw):
|
||||
return None
|
||||
|
||||
|
||||
class AvatarUrlField(fields.Raw):
|
||||
def output(self, key, obj):
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
from models.account import Account
|
||||
|
||||
if isinstance(obj, Account) and obj.avatar is not None:
|
||||
return file_helpers.get_signed_file_url(obj.avatar)
|
||||
return None
|
||||
|
||||
|
||||
class TimestampField(fields.Raw):
|
||||
def format(self, value) -> int:
|
||||
return int(value.timestamp())
|
||||
|
122
web/app/account/account-page/AvatarWithEdit.tsx
Normal file
122
web/app/account/account-page/AvatarWithEdit.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import type { Area } from 'react-easy-crop'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { RiPencilLine } from '@remixicon/react'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import ImageInput, { type OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Avatar, { type AvatarProps } from '@/app/components/base/avatar'
|
||||
import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks'
|
||||
import type { ImageFile } from '@/types/app'
|
||||
import getCroppedImg from '@/app/components/base/app-icon-picker/utils'
|
||||
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
|
||||
|
||||
type InputImageInfo = { file: File } | { tempUrl: string; croppedAreaPixels: Area; fileName: string }
|
||||
type AvatarWithEditProps = AvatarProps & { onSave?: () => void }
|
||||
|
||||
const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
|
||||
const [inputImageInfo, setInputImageInfo] = useState<InputImageInfo>()
|
||||
const [isShowAvatarPicker, setIsShowAvatarPicker] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const handleImageInput: OnImageInput = useCallback(async (isCropped: boolean, fileOrTempUrl: string | File, croppedAreaPixels?: Area, fileName?: string) => {
|
||||
setInputImageInfo(
|
||||
isCropped
|
||||
? { tempUrl: fileOrTempUrl as string, croppedAreaPixels: croppedAreaPixels!, fileName: fileName! }
|
||||
: { file: fileOrTempUrl as File },
|
||||
)
|
||||
}, [setInputImageInfo])
|
||||
|
||||
const handleSaveAvatar = useCallback(async (uploadedFileId: string) => {
|
||||
try {
|
||||
await updateUserProfile({ url: 'account/avatar', body: { avatar: uploadedFileId } })
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
setIsShowAvatarPicker(false)
|
||||
onSave?.()
|
||||
}
|
||||
catch (e) {
|
||||
notify({ type: 'error', message: (e as Error).message })
|
||||
}
|
||||
}, [notify, onSave, t])
|
||||
|
||||
const { handleLocalFileUpload } = useLocalFileUploader({
|
||||
limit: 3,
|
||||
disabled: false,
|
||||
onUpload: (imageFile: ImageFile) => {
|
||||
if (imageFile.progress === 100) {
|
||||
setUploading(false)
|
||||
setInputImageInfo(undefined)
|
||||
handleSaveAvatar(imageFile.fileId)
|
||||
}
|
||||
|
||||
// Error
|
||||
if (imageFile.progress === -1)
|
||||
setUploading(false)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSelect = useCallback(async () => {
|
||||
if (!inputImageInfo)
|
||||
return
|
||||
setUploading(true)
|
||||
if ('file' in inputImageInfo) {
|
||||
handleLocalFileUpload(inputImageInfo.file)
|
||||
return
|
||||
}
|
||||
const blob = await getCroppedImg(inputImageInfo.tempUrl, inputImageInfo.croppedAreaPixels, inputImageInfo.fileName)
|
||||
const file = new File([blob], inputImageInfo.fileName, { type: blob.type })
|
||||
handleLocalFileUpload(file)
|
||||
}, [handleLocalFileUpload, inputImageInfo])
|
||||
|
||||
if (DISABLE_UPLOAD_IMAGE_AS_ICON)
|
||||
return <Avatar {...props} />
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="relative group">
|
||||
<Avatar {...props} />
|
||||
<div
|
||||
onClick={() => { setIsShowAvatarPicker(true) }}
|
||||
className="absolute inset-0 bg-black bg-opacity-50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer flex items-center justify-center"
|
||||
>
|
||||
<span className="text-white text-xs">
|
||||
<RiPencilLine />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
closable
|
||||
className="!w-[362px] !p-0"
|
||||
isShow={isShowAvatarPicker}
|
||||
onClose={() => setIsShowAvatarPicker(false)}
|
||||
>
|
||||
<ImageInput onImageInput={handleImageInput} cropShape='round' />
|
||||
<Divider className='m-0' />
|
||||
|
||||
<div className='w-full flex items-center justify-center p-3 gap-2'>
|
||||
<Button className='w-full' onClick={() => setIsShowAvatarPicker(false)}>
|
||||
{t('app.iconPicker.cancel')}
|
||||
</Button>
|
||||
|
||||
<Button variant="primary" className='w-full' disabled={uploading || !inputImageInfo} loading={uploading} onClick={handleSelect}>
|
||||
{t('app.iconPicker.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AvatarWithEdit
|
@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import DeleteAccount from '../delete-account'
|
||||
import s from './index.module.css'
|
||||
import AvatarWithEdit from './AvatarWithEdit'
|
||||
import Collapse from '@/app/components/header/account-setting/collapse'
|
||||
import type { IItem } from '@/app/components/header/account-setting/collapse'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
@ -13,7 +14,6 @@ import { updateUserProfile } from '@/service/common'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import Input from '@/app/components/base/input'
|
||||
|
||||
@ -133,7 +133,7 @@ export default function AccountPage() {
|
||||
<h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4>
|
||||
</div>
|
||||
<div className='mb-8 p-6 rounded-xl flex items-center bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1'>
|
||||
<Avatar name={userProfile.name} size={64} />
|
||||
<AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={ mutateUserProfile } size={64} />
|
||||
<div className='ml-4'>
|
||||
<p className='system-xl-semibold text-text-primary'>{userProfile.name}</p>
|
||||
<p className='system-xs-regular text-text-tertiary'>{userProfile.email}</p>
|
||||
|
@ -45,7 +45,7 @@ export default function AppSelector() {
|
||||
${open && 'bg-components-panel-bg-blur'}
|
||||
`}
|
||||
>
|
||||
<Avatar name={userProfile.name} size={32} />
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} />
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Transition
|
||||
@ -71,7 +71,7 @@ export default function AppSelector() {
|
||||
<div className='system-md-medium text-text-primary break-all'>{userProfile.name}</div>
|
||||
<div className='system-xs-regular text-text-tertiary break-all'>{userProfile.email}</div>
|
||||
</div>
|
||||
<Avatar name={userProfile.name} size={32} />
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} />
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
|
@ -149,7 +149,7 @@ const ChatItem: FC<ChatItemProps> = ({
|
||||
suggestedQuestions={suggestedQuestions}
|
||||
onSend={doSend}
|
||||
showPromptLog
|
||||
questionIcon={<Avatar name={userProfile.name} size={40} />}
|
||||
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />}
|
||||
allToolIcons={allToolIcons}
|
||||
hideLogModal
|
||||
noSpacing
|
||||
|
@ -175,7 +175,7 @@ const DebugWithSingleModel = forwardRef<DebugWithSingleModelRefType, DebugWithSi
|
||||
onRegenerate={doRegenerate}
|
||||
onStopResponding={handleStop}
|
||||
showPromptLog
|
||||
questionIcon={<Avatar name={userProfile.name} size={40} />}
|
||||
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />}
|
||||
allToolIcons={allToolIcons}
|
||||
onAnnotationEdited={handleAnnotationEdited}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
|
@ -2,8 +2,7 @@
|
||||
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import { createRef, useEffect, useState } from 'react'
|
||||
import type { Area } from 'react-easy-crop'
|
||||
import Cropper from 'react-easy-crop'
|
||||
import Cropper, { type Area, type CropperProps } from 'react-easy-crop'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import { ImagePlus } from '../icons/src/vender/line/images'
|
||||
@ -18,11 +17,13 @@ export type OnImageInput = {
|
||||
|
||||
type UploaderProps = {
|
||||
className?: string
|
||||
cropShape?: CropperProps['cropShape']
|
||||
onImageInput?: OnImageInput
|
||||
}
|
||||
|
||||
const ImageInput: FC<UploaderProps> = ({
|
||||
className,
|
||||
cropShape,
|
||||
onImageInput,
|
||||
}) => {
|
||||
const [inputImage, setInputImage] = useState<{ file: File; url: string }>()
|
||||
@ -78,6 +79,7 @@ const ImageInput: FC<UploaderProps> = ({
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape={cropShape}
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={onCropComplete}
|
||||
onZoomChange={setZoom}
|
||||
|
@ -2,9 +2,9 @@
|
||||
import { useState } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type AvatarProps = {
|
||||
export type AvatarProps = {
|
||||
name: string
|
||||
avatar?: string
|
||||
avatar: string | null
|
||||
size?: number
|
||||
className?: string
|
||||
textClassName?: string
|
||||
|
@ -74,7 +74,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange,
|
||||
>
|
||||
{permission === 'only_me' && (
|
||||
<div className={cn('flex items-center px-3 py-[6px] rounded-lg bg-gray-100 cursor-pointer hover:bg-gray-200', open && 'bg-gray-200', disabled && 'hover:!bg-gray-100 !cursor-default')}>
|
||||
<Avatar name={userProfile.name} className='shrink-0 mr-2' size={24} />
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0 mr-2' size={24} />
|
||||
<div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsOnlyMe')}</div>
|
||||
{!disabled && <RiArrowDownSLine className='shrink-0 w-4 h-4 text-gray-700' />}
|
||||
</div>
|
||||
@ -106,7 +106,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange,
|
||||
setOpen(false)
|
||||
}}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Avatar name={userProfile.name} className='shrink-0 mr-2' size={24} />
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0 mr-2' size={24} />
|
||||
<div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsOnlyMe')}</div>
|
||||
{permission === 'only_me' && <Check className='w-4 h-4 text-primary-600' />}
|
||||
</div>
|
||||
@ -149,7 +149,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange,
|
||||
</div>
|
||||
{showMe && (
|
||||
<div className='pl-3 pr-[10px] py-1 flex gap-2 items-center rounded-lg'>
|
||||
<Avatar name={userProfile.name} className='shrink-0' size={24} />
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0' size={24} />
|
||||
<div className='grow'>
|
||||
<div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'>
|
||||
{userProfile.name}
|
||||
@ -162,7 +162,7 @@ const PermissionSelector = ({ disabled, permission, value, memberList, onChange,
|
||||
)}
|
||||
{filteredMemberList.map(member => (
|
||||
<div key={member.id} className='pl-3 pr-[10px] py-1 flex gap-2 items-center rounded-lg hover:bg-gray-100 cursor-pointer' onClick={() => selectMember(member)}>
|
||||
<Avatar name={member.name} className='shrink-0' size={24} />
|
||||
<Avatar avatar={userProfile.avatar_url} name={member.name} className='shrink-0' size={24} />
|
||||
<div className='grow'>
|
||||
<div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'>{member.name}</div>
|
||||
<div className='text-xs text-gray-500 leading-[18px] truncate'>{member.email}</div>
|
||||
|
@ -68,7 +68,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
|
||||
${open && 'bg-gray-200'}
|
||||
`}
|
||||
>
|
||||
<Avatar name={userProfile.name} className='sm:mr-2 mr-0' size={32} />
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='sm:mr-2 mr-0' size={32} />
|
||||
{!isMobile && <>
|
||||
{userProfile.name}
|
||||
<RiArrowDownSLine className="w-3 h-3 ml-1 text-gray-700" />
|
||||
@ -92,7 +92,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
|
||||
>
|
||||
<Menu.Item disabled>
|
||||
<div className='flex flex-nowrap items-center px-4 py-[13px]'>
|
||||
<Avatar name={userProfile.name} size={36} className='mr-3' />
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} className='mr-3' />
|
||||
<div className='grow'>
|
||||
<div className='system-md-medium text-text-primary break-all'>{userProfile.name}</div>
|
||||
<div className='system-xs-regular text-text-tertiary break-all'>{userProfile.email}</div>
|
||||
|
@ -95,7 +95,7 @@ const MembersPage = () => {
|
||||
accounts.map(account => (
|
||||
<div key={account.id} className='flex border-b border-divider-subtle'>
|
||||
<div className='grow flex items-center py-2 px-3'>
|
||||
<Avatar size={24} className='mr-2' name={account.name} />
|
||||
<Avatar avatar={account.avatar_url} size={24} className='mr-2' name={account.name} />
|
||||
<div className=''>
|
||||
<div className='text-text-secondary system-sm-medium'>
|
||||
{account.name}
|
||||
|
@ -22,6 +22,7 @@ export type UserProfileResponse = {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
avatar_url: string | null
|
||||
is_password_set: boolean
|
||||
interface_language?: string
|
||||
interface_theme?: string
|
||||
@ -62,7 +63,7 @@ export type TenantInfoResponse = {
|
||||
trial_end_reason: null | 'trial_exceeded' | 'using_custom'
|
||||
}
|
||||
|
||||
export type Member = Pick<UserProfileResponse, 'id' | 'name' | 'email' | 'last_login_at' | 'last_active_at' | 'created_at'> & {
|
||||
export type Member = Pick<UserProfileResponse, 'id' | 'name' | 'email' | 'last_login_at' | 'last_active_at' | 'created_at' | 'avatar_url'> & {
|
||||
avatar: string
|
||||
status: 'pending' | 'active' | 'banned' | 'closed'
|
||||
role: 'owner' | 'admin' | 'editor' | 'normal' | 'dataset_operator'
|
||||
|
Loading…
x
Reference in New Issue
Block a user