Просмотр исходного кода

feat: Add ability to change profile avatar (#12642)

Shun Miyazawa 3 месяцев назад
Родитель
Сommit
f582d4a13e

+ 3 - 1
api/fields/member_fields.py

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

+ 12 - 0
api/libs/helper.py

@@ -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 - 0
web/app/account/account-page/AvatarWithEdit.tsx

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

+ 2 - 2
web/app/account/account-page/index.tsx

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

+ 2 - 2
web/app/account/avatar.tsx

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

+ 1 - 1
web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx

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

+ 1 - 1
web/app/components/app/configuration/debug/debug-with-single-model/index.tsx

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

+ 4 - 2
web/app/components/base/app-icon-picker/ImageInput.tsx

@@ -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 - 2
web/app/components/base/avatar/index.tsx

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

+ 4 - 4
web/app/components/datasets/settings/permission-selector/index.tsx

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

+ 2 - 2
web/app/components/header/account-dropdown/index.tsx

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

+ 1 - 1
web/app/components/header/account-setting/members-page/index.tsx

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

+ 2 - 1
web/models/common.ts

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