Initial commit

This commit is contained in:
2025-10-14 14:17:21 +08:00
commit ac715a8b88
35011 changed files with 3834178 additions and 0 deletions

View 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="group relative">
<Avatar {...props} />
<div
onClick={() => { setIsShowAvatarPicker(true) }}
className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black bg-opacity-50 opacity-0 transition-opacity group-hover:opacity-100"
>
<span className="text-xs text-white">
<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='flex w-full items-center justify-center gap-2 p-3'>
<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

View File

@@ -0,0 +1,9 @@
.modal {
padding: 24px 32px !important;
width: 400px !important;
}
.bg {
background: linear-gradient(180deg, rgba(217, 45, 32, 0.05) 0%, rgba(217, 45, 32, 0.00) 24.02%), #F9FAFB;
}

View File

@@ -0,0 +1,320 @@
'use client'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiGraduationCapFill,
} from '@remixicon/react'
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'
import Button from '@/app/components/base/button'
import { updateUserProfile } from '@/service/common'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { ToastContext } from '@/app/components/base/toast'
import AppIcon from '@/app/components/base/app-icon'
import { IS_CE_EDITION } from '@/config'
import Input from '@/app/components/base/input'
import PremiumBadge from '@/app/components/base/premium-badge'
const titleClassName = `
system-sm-semibold text-text-secondary
`
const descriptionClassName = `
mt-1 body-xs-regular text-text-tertiary
`
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
export default function AccountPage() {
const { t } = useTranslation()
const { systemFeatures } = useAppContext()
const { mutateUserProfile, userProfile, apps } = useAppContext()
const { isEducationAccount } = useProviderContext()
const { notify } = useContext(ToastContext)
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
const [editName, setEditName] = useState('')
const [editing, setEditing] = useState(false)
const [editPasswordModalVisible, setEditPasswordModalVisible] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false)
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const handleEditName = () => {
setEditNameModalVisible(true)
setEditName(userProfile.name)
}
const handleSaveName = async () => {
try {
setEditing(true)
await updateUserProfile({ url: 'account/name', body: { name: editName } })
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
mutateUserProfile()
setEditNameModalVisible(false)
setEditing(false)
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
setEditNameModalVisible(false)
setEditing(false)
}
}
const showErrorMessage = (message: string) => {
notify({
type: 'error',
message,
})
}
const valid = () => {
if (!password.trim()) {
showErrorMessage(t('login.error.passwordEmpty'))
return false
}
if (!validPassword.test(password)) {
showErrorMessage(t('login.error.passwordInvalid'))
return false
}
if (password !== confirmPassword) {
showErrorMessage(t('common.account.notEqual'))
return false
}
return true
}
const resetPasswordForm = () => {
setCurrentPassword('')
setPassword('')
setConfirmPassword('')
}
const handleSavePassword = async () => {
if (!valid())
return
try {
setEditing(true)
await updateUserProfile({
url: 'account/password',
body: {
password: currentPassword,
new_password: password,
repeat_new_password: confirmPassword,
},
})
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
mutateUserProfile()
setEditPasswordModalVisible(false)
resetPasswordForm()
setEditing(false)
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
setEditPasswordModalVisible(false)
setEditing(false)
}
}
const renderAppItem = (item: IItem) => {
return (
<div className='flex px-3 py-1'>
<div className='mr-3'>
<AppIcon size='tiny' />
</div>
<div className='system-sm-medium mt-[3px] text-text-secondary'>{item.name}</div>
</div>
)
}
return (
<>
<div className='pb-3 pt-2'>
<h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</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} />
<div className='ml-4'>
<p className='system-xl-semibold text-text-primary'>
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size='s' color='blue' className='ml-1 !px-2'>
<RiGraduationCapFill className='mr-1 h-3 w-3' />
<span className='system-2xs-medium'>EDU</span>
</PremiumBadge>
)}
</p>
<p className='system-xs-regular text-text-tertiary'>{userProfile.email}</p>
</div>
</div>
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.name')}</div>
<div className='mt-2 flex w-full items-center justify-between gap-2'>
<div className='system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled '>
<span className='pl-1'>{userProfile.name}</span>
</div>
<div className='system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text' onClick={handleEditName}>
{t('common.operation.edit')}
</div>
</div>
</div>
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.email')}</div>
<div className='mt-2 flex w-full items-center justify-between gap-2'>
<div className='system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled '>
<span className='pl-1'>{userProfile.email}</span>
</div>
</div>
</div>
{
systemFeatures.enable_email_password_login && (
<div className='mb-8 flex justify-between gap-2'>
<div>
<div className='system-sm-semibold mb-1 text-text-secondary'>{t('common.account.password')}</div>
<div className='body-xs-regular mb-2 text-text-tertiary'>{t('common.account.passwordTip')}</div>
</div>
<Button onClick={() => setEditPasswordModalVisible(true)}>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</Button>
</div>
)
}
<div className='mb-6 border-[1px] border-divider-subtle' />
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.langGeniusAccount')}</div>
<div className={descriptionClassName}>{t('common.account.langGeniusAccountTip')}</div>
{!!apps.length && (
<Collapse
title={`${t('common.account.showAppLength', { length: apps.length })}`}
items={apps.map(app => ({ key: app.id, name: app.name }))}
renderItem={renderAppItem}
wrapperClassName='mt-2'
/>
)}
{!IS_CE_EDITION && <Button className='mt-2 text-components-button-destructive-secondary-text' onClick={() => setShowDeleteAccountModal(true)}>{t('common.account.delete')}</Button>}
</div>
{
editNameModalVisible && (
<Modal
isShow
onClose={() => setEditNameModalVisible(false)}
className={s.modal}
>
<div className='title-2xl-semi-bold mb-6 text-text-primary'>{t('common.account.editName')}</div>
<div className={titleClassName}>{t('common.account.name')}</div>
<Input className='mt-2'
value={editName}
onChange={e => setEditName(e.target.value)}
/>
<div className='mt-10 flex justify-end'>
<Button className='mr-2' onClick={() => setEditNameModalVisible(false)}>{t('common.operation.cancel')}</Button>
<Button
disabled={editing || !editName}
variant='primary'
onClick={handleSaveName}
>
{t('common.operation.save')}
</Button>
</div>
</Modal>
)
}
{
editPasswordModalVisible && (
<Modal
isShow
onClose={() => {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}
className={s.modal}
>
<div className='title-2xl-semi-bold mb-6 text-text-primary'>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</div>
{userProfile.is_password_set && (
<>
<div className={titleClassName}>{t('common.account.currentPassword')}</div>
<div className='relative mt-2'>
<Input
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
>
{showCurrentPassword ? '👀' : '😝'}
</Button>
</div>
</div>
</>
)}
<div className='system-sm-semibold mt-8 text-text-secondary'>
{userProfile.is_password_set ? t('common.account.newPassword') : t('common.account.password')}
</div>
<div className='relative mt-2'>
<Input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '👀' : '😝'}
</Button>
</div>
</div>
<div className='system-sm-semibold mt-8 text-text-secondary'>{t('common.account.confirmPassword')}</div>
<div className='relative mt-2'>
<Input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? '👀' : '😝'}
</Button>
</div>
</div>
<div className='mt-10 flex justify-end'>
<Button className='mr-2' onClick={() => {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}>{t('common.operation.cancel')}</Button>
<Button
disabled={editing}
variant='primary'
onClick={handleSavePassword}
>
{userProfile.is_password_set ? t('common.operation.reset') : t('common.operation.save')}
</Button>
</div>
</Modal>
)
}
{
showDeleteAccountModal && (
<DeleteAccount
onCancel={() => setShowDeleteAccountModal(false)}
onConfirm={() => setShowDeleteAccountModal(false)}
/>
)
}
</>
)
}

View File

@@ -0,0 +1,109 @@
'use client'
import { useTranslation } from 'react-i18next'
import { Fragment } from 'react'
import { useRouter } from 'next/navigation'
import {
RiGraduationCapFill,
} from '@remixicon/react'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import Avatar from '@/app/components/base/avatar'
import { logout } from '@/service/common'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
import PremiumBadge from '@/app/components/base/premium-badge'
export type IAppSelector = {
isMobile: boolean
}
export default function AppSelector() {
const router = useRouter()
const { t } = useTranslation()
const { userProfile } = useAppContext()
const { isEducationAccount } = useProviderContext()
const handleLogout = async () => {
await logout({
url: '/logout',
params: {},
})
localStorage.removeItem('setup_status')
localStorage.removeItem('console_token')
localStorage.removeItem('refresh_token')
router.push('/signin')
}
return (
<Menu as="div" className="relative inline-block text-left">
{
({ open }) => (
<>
<div>
<MenuButton
className={`
p-1x inline-flex
items-center rounded-[20px] text-sm
text-text-primary
mobile:px-1
${open && 'bg-components-panel-bg-blur'}
`}
>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} />
</MenuButton>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
className="
absolute -right-2 -top-1 w-60 max-w-80
origin-top-right divide-y divide-divider-subtle rounded-lg bg-components-panel-bg-blur
shadow-lg
"
>
<MenuItem>
<div className='p-1'>
<div className='flex flex-nowrap items-center px-3 py-2'>
<div className='grow'>
<div className='system-md-medium break-all text-text-primary'>
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size='s' color='blue' className='ml-1 !px-2'>
<RiGraduationCapFill className='mr-1 h-3 w-3' />
<span className='system-2xs-medium'>EDU</span>
</PremiumBadge>
)}
</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} />
</div>
</div>
</MenuItem>
<MenuItem>
<div className='p-1' onClick={() => handleLogout()}>
<div
className='group flex h-9 cursor-pointer items-center justify-start rounded-lg px-3 hover:bg-state-base-hover'
>
<LogOut01 className='mr-1 flex h-4 w-4 text-text-tertiary' />
<div className='text-[14px] font-normal text-text-secondary'>{t('common.userProfile.logout')}</div>
</div>
</div>
</MenuItem>
</MenuItems>
</Transition>
</>
)
}
</Menu>
)
}

View File

@@ -0,0 +1,48 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useState } from 'react'
import Link from 'next/link'
import { useSendDeleteAccountEmail } from '../state'
import { useAppContext } from '@/context/app-context'
import Input from '@/app/components/base/input'
import Button from '@/app/components/base/button'
type DeleteAccountProps = {
onCancel: () => void
onConfirm: () => void
}
export default function CheckEmail(props: DeleteAccountProps) {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const [userInputEmail, setUserInputEmail] = useState('')
const { isPending: isSendingEmail, mutateAsync: getDeleteEmailVerifyCode } = useSendDeleteAccountEmail()
const handleConfirm = useCallback(async () => {
try {
const ret = await getDeleteEmailVerifyCode()
if (ret.result === 'success')
props.onConfirm()
}
catch (error) { console.error(error) }
}, [getDeleteEmailVerifyCode, props])
return <>
<div className='body-md-medium py-1 text-text-destructive'>
{t('common.account.deleteTip')}
</div>
<div className='body-md-regular pb-2 pt-1 text-text-secondary'>
{t('common.account.deletePrivacyLinkTip')}
<Link href='https://dify.ai/privacy' className='text-text-accent'>{t('common.account.deletePrivacyLink')}</Link>
</div>
<label className='system-sm-semibold mb-1 mt-3 flex h-6 items-center text-text-secondary'>{t('common.account.deleteLabel')}</label>
<Input placeholder={t('common.account.deletePlaceholder') as string} onChange={(e) => {
setUserInputEmail(e.target.value)
}} />
<div className='mt-3 flex w-full flex-col gap-2'>
<Button className='w-full' disabled={userInputEmail !== userProfile.email || isSendingEmail} loading={isSendingEmail} variant='primary' onClick={handleConfirm}>{t('common.account.sendVerificationButton')}</Button>
<Button className='w-full' onClick={props.onCancel}>{t('common.operation.cancel')}</Button>
</div>
</>
}

View File

@@ -0,0 +1,68 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useDeleteAccountFeedback } from '../state'
import { useAppContext } from '@/context/app-context'
import Button from '@/app/components/base/button'
import CustomDialog from '@/app/components/base/dialog'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { logout } from '@/service/common'
type DeleteAccountProps = {
onCancel: () => void
onConfirm: () => void
}
export default function FeedBack(props: DeleteAccountProps) {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const router = useRouter()
const [userFeedback, setUserFeedback] = useState('')
const { isPending, mutateAsync: sendFeedback } = useDeleteAccountFeedback()
const handleSuccess = useCallback(async () => {
try {
await logout({
url: '/logout',
params: {},
})
localStorage.removeItem('refresh_token')
localStorage.removeItem('console_token')
router.push('/signin')
Toast.notify({ type: 'info', message: t('common.account.deleteSuccessTip') })
}
catch (error) { console.error(error) }
}, [router, t])
const handleSubmit = useCallback(async () => {
try {
await sendFeedback({ feedback: userFeedback, email: userProfile.email })
props.onConfirm()
await handleSuccess()
}
catch (error) { console.error(error) }
}, [handleSuccess, userFeedback, sendFeedback, userProfile, props])
const handleSkip = useCallback(() => {
props.onCancel()
handleSuccess()
}, [handleSuccess, props])
return <CustomDialog
show={true}
onClose={props.onCancel}
title={t('common.account.feedbackTitle')}
className="max-w-[480px]"
footer={false}
>
<label className='system-sm-semibold mb-1 mt-3 flex items-center text-text-secondary'>{t('common.account.feedbackLabel')}</label>
<Textarea rows={6} value={userFeedback} placeholder={t('common.account.feedbackPlaceholder') as string} onChange={(e) => {
setUserFeedback(e.target.value)
}} />
<div className='mt-3 flex w-full flex-col gap-2'>
<Button className='w-full' loading={isPending} variant='primary' onClick={handleSubmit}>{t('common.operation.submit')}</Button>
<Button className='w-full' onClick={handleSkip}>{t('common.operation.skip')}</Button>
</div>
</CustomDialog>
}

View File

@@ -0,0 +1,55 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { useAccountDeleteStore, useConfirmDeleteAccount, useSendDeleteAccountEmail } from '../state'
import Input from '@/app/components/base/input'
import Button from '@/app/components/base/button'
import Countdown from '@/app/components/signin/countdown'
const CODE_EXP = /[A-Za-z\d]{6}/gi
type DeleteAccountProps = {
onCancel: () => void
onConfirm: () => void
}
export default function VerifyEmail(props: DeleteAccountProps) {
const { t } = useTranslation()
const emailToken = useAccountDeleteStore(state => state.sendEmailToken)
const [verificationCode, setVerificationCode] = useState<string>()
const [shouldButtonDisabled, setShouldButtonDisabled] = useState(true)
const { mutate: sendEmail } = useSendDeleteAccountEmail()
const { isPending: isDeleting, mutateAsync: confirmDeleteAccount } = useConfirmDeleteAccount()
useEffect(() => {
setShouldButtonDisabled(!(verificationCode && CODE_EXP.test(verificationCode)) || isDeleting)
}, [verificationCode, isDeleting])
const handleConfirm = useCallback(async () => {
try {
const ret = await confirmDeleteAccount({ code: verificationCode!, token: emailToken })
if (ret.result === 'success')
props.onConfirm()
}
catch (error) { console.error(error) }
}, [emailToken, verificationCode, confirmDeleteAccount, props])
return <>
<div className='body-md-medium pt-1 text-text-destructive'>
{t('common.account.deleteTip')}
</div>
<div className='body-md-regular pb-2 pt-1 text-text-secondary'>
{t('common.account.deletePrivacyLinkTip')}
<Link href='https://dify.ai/privacy' className='text-text-accent'>{t('common.account.deletePrivacyLink')}</Link>
</div>
<label className='system-sm-semibold mb-1 mt-3 flex h-6 items-center text-text-secondary'>{t('common.account.verificationLabel')}</label>
<Input minLength={6} maxLength={6} placeholder={t('common.account.verificationPlaceholder') as string} onChange={(e) => {
setVerificationCode(e.target.value)
}} />
<div className='mt-3 flex w-full flex-col gap-2'>
<Button className='w-full' disabled={shouldButtonDisabled} loading={isDeleting} variant='warning' onClick={handleConfirm}>{t('common.account.permanentlyDeleteButton')}</Button>
<Button className='w-full' onClick={props.onCancel}>{t('common.operation.cancel')}</Button>
<Countdown onResend={sendEmail} />
</div>
</>
}

View File

@@ -0,0 +1,44 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useState } from 'react'
import CheckEmail from './components/check-email'
import VerifyEmail from './components/verify-email'
import FeedBack from './components/feed-back'
import CustomDialog from '@/app/components/base/dialog'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
type DeleteAccountProps = {
onCancel: () => void
onConfirm: () => void
}
export default function DeleteAccount(props: DeleteAccountProps) {
const { t } = useTranslation()
const [showVerifyEmail, setShowVerifyEmail] = useState(false)
const [showFeedbackDialog, setShowFeedbackDialog] = useState(false)
const handleEmailCheckSuccess = useCallback(async () => {
try {
setShowVerifyEmail(true)
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
}
catch (error) { console.error(error) }
}, [])
if (showFeedbackDialog)
return <FeedBack onCancel={props.onCancel} onConfirm={props.onConfirm} />
return <CustomDialog
show={true}
onClose={props.onCancel}
title={t('common.account.delete')}
className="max-w-[480px]"
footer={false}
>
{!showVerifyEmail && <CheckEmail onCancel={props.onCancel} onConfirm={handleEmailCheckSuccess} />}
{showVerifyEmail && <VerifyEmail onCancel={props.onCancel} onConfirm={() => {
setShowFeedbackDialog(true)
}} />}
</CustomDialog>
}

View File

@@ -0,0 +1,39 @@
import { useMutation } from '@tanstack/react-query'
import { create } from 'zustand'
import { sendDeleteAccountCode, submitDeleteAccountFeedback, verifyDeleteAccountCode } from '@/service/common'
type State = {
sendEmailToken: string
setSendEmailToken: (token: string) => void
}
export const useAccountDeleteStore = create<State>(set => ({
sendEmailToken: '',
setSendEmailToken: (token: string) => set({ sendEmailToken: token }),
}))
export function useSendDeleteAccountEmail() {
const updateEmailToken = useAccountDeleteStore(state => state.setSendEmailToken)
return useMutation({
mutationKey: ['delete-account'],
mutationFn: sendDeleteAccountCode,
onSuccess: (ret) => {
if (ret.result === 'success')
updateEmailToken(ret.data)
},
})
}
export function useConfirmDeleteAccount() {
return useMutation({
mutationKey: ['confirm-delete-account'],
mutationFn: verifyDeleteAccountCode,
})
}
export function useDeleteAccountFeedback() {
return useMutation({
mutationKey: ['delete-account-feedback'],
mutationFn: submitDeleteAccountFeedback,
})
}

View File

@@ -0,0 +1,37 @@
'use client'
import { useTranslation } from 'react-i18next'
import { RiArrowRightUpLine, RiRobot2Line } from '@remixicon/react'
import { useRouter } from 'next/navigation'
import Button from '../components/base/button'
import Avatar from './avatar'
import LogoSite from '@/app/components/base/logo/logo-site'
const Header = () => {
const { t } = useTranslation()
const router = useRouter()
const back = () => {
router.back()
}
return (
<div className='flex flex-1 items-center justify-between px-4'>
<div className='flex items-center gap-3'>
<div className='flex cursor-pointer items-center' onClick={back}>
<LogoSite className='object-contain' />
</div>
<div className='h-4 w-[1px] bg-divider-regular' />
<p className='title-3xl-semi-bold text-text-primary'>{t('common.account.account')}</p>
</div>
<div className='flex shrink-0 items-center gap-3'>
<Button className='system-sm-medium gap-2 px-3 py-2' onClick={back}>
<RiRobot2Line className='h-4 w-4' />
<p>{t('common.account.studio')}</p>
<RiArrowRightUpLine className='h-4 w-4' />
</Button>
<div className='h-4 w-[1px] bg-divider-regular' />
<Avatar />
</div>
</div>
)
}
export default Header

View File

@@ -0,0 +1,40 @@
import React from 'react'
import type { ReactNode } from 'react'
import Header from './header'
import SwrInitor from '@/app/components/swr-initor'
import { AppContextProvider } from '@/context/app-context'
import GA, { GaType } from '@/app/components/base/ga'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ProviderContextProvider } from '@/context/provider-context'
import { ModalContextProvider } from '@/context/modal-context'
const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
<GA gaType={GaType.admin} />
<SwrInitor>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
<div className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-components-panel-bg'>
{children}
</div>
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</SwrInitor>
</>
)
}
export const metadata = {
title: 'Dify',
}
export default Layout

View File

@@ -0,0 +1,7 @@
import AccountPage from './account-page'
export default function Account() {
return <div className='mx-auto w-full max-w-[640px] px-6 pt-12'>
<AccountPage />
</div>
}