Co-authored-by: crazywoola <427733928@qq.com> Co-authored-by: zxhlyh <jasonapring2015@outlook.com>tags/1.2.0
| @@ -6,6 +6,7 @@ on: | |||
| - "main" | |||
| - "deploy/dev" | |||
| - "deploy/enterprise" | |||
| - release/1.1.3-fix1 | |||
| tags: | |||
| - "*" | |||
| @@ -1,7 +1,9 @@ | |||
| 'use client' | |||
| import { useCallback, useEffect, useRef, useState } from 'react' | |||
| import { useRouter } from 'next/navigation' | |||
| import { | |||
| useRouter, | |||
| } from 'next/navigation' | |||
| import useSWRInfinite from 'swr/infinite' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useDebounceFn } from 'ahooks' | |||
| @@ -7,9 +7,12 @@ import style from '../list.module.css' | |||
| import Apps from './Apps' | |||
| import AppContext from '@/context/app-context' | |||
| import { LicenseStatus } from '@/types/feature' | |||
| import { useEducationInit } from '@/app/education-apply/hooks' | |||
| const AppList = () => { | |||
| const { t } = useTranslation() | |||
| useEducationInit() | |||
| const systemFeatures = useContextSelector(AppContext, v => v.systemFeatures) | |||
| return ( | |||
| @@ -0,0 +1,29 @@ | |||
| 'use client' | |||
| import { | |||
| useEffect, | |||
| useMemo, | |||
| } from 'react' | |||
| import { | |||
| useRouter, | |||
| useSearchParams, | |||
| } from 'next/navigation' | |||
| import EducationApplyPage from '@/app/education-apply/education-apply-page' | |||
| import { useProviderContext } from '@/context/provider-context' | |||
| export default function EducationApply() { | |||
| const router = useRouter() | |||
| const { enableEducationPlan, isEducationAccount } = useProviderContext() | |||
| const searchParams = useSearchParams() | |||
| const token = searchParams.get('token') | |||
| const showEducationApplyPage = useMemo(() => { | |||
| return enableEducationPlan && !isEducationAccount && token | |||
| }, [enableEducationPlan, isEducationAccount, token]) | |||
| useEffect(() => { | |||
| if (!showEducationApplyPage) | |||
| router.replace('/') | |||
| }, [showEducationApplyPage, router]) | |||
| return <EducationApplyPage /> | |||
| } | |||
| @@ -1,7 +1,9 @@ | |||
| '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' | |||
| @@ -12,10 +14,12 @@ 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 | |||
| @@ -30,6 +34,7 @@ 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('') | |||
| @@ -135,7 +140,15 @@ export default function AccountPage() { | |||
| <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}</p> | |||
| <p className='system-xl-semibold text-text-primary'> | |||
| {userProfile.name} | |||
| {isEducationAccount && ( | |||
| <PremiumBadge size='s' color='blue' className='ml-1 !px-2'> | |||
| <RiGraduationCapFill className='w-3 h-3 mr-1' /> | |||
| <span className='system-2xs-medium'>EDU</span> | |||
| </PremiumBadge> | |||
| )} | |||
| </p> | |||
| <p className='system-xs-regular text-text-tertiary'>{userProfile.email}</p> | |||
| </div> | |||
| </div> | |||
| @@ -2,11 +2,16 @@ | |||
| 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 | |||
| @@ -16,6 +21,7 @@ export default function AppSelector() { | |||
| const router = useRouter() | |||
| const { t } = useTranslation() | |||
| const { userProfile } = useAppContext() | |||
| const { isEducationAccount } = useProviderContext() | |||
| const handleLogout = async () => { | |||
| await logout({ | |||
| @@ -68,7 +74,15 @@ export default function AppSelector() { | |||
| <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}</div> | |||
| <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='w-3 h-3 mr-1' /> | |||
| <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} /> | |||
| @@ -182,7 +182,6 @@ const DatasetConfig: FC = () => { | |||
| }, [setDatasetConfigs, datasetConfigsRef]) | |||
| const handleUpdateCondition = useCallback<HandleUpdateCondition>((id, newCondition) => { | |||
| console.log(newCondition, 'newCondition') | |||
| const conditions = datasetConfigsRef.current!.metadata_filtering_conditions?.conditions || [] | |||
| const index = conditions.findIndex(c => c.id === id) | |||
| const newInputs = produce(datasetConfigsRef.current!, (draft) => { | |||
| @@ -130,7 +130,6 @@ const DatePicker = ({ | |||
| const handleConfirmDate = () => { | |||
| // debugger | |||
| console.log(selectedDate, selectedDate?.tz(timezone)) | |||
| onChange(selectedDate ? selectedDate.tz(timezone) : undefined) | |||
| setIsOpen(false) | |||
| } | |||
| @@ -0,0 +1,3 @@ | |||
| <svg width="16" height="22" viewBox="0 0 16 22" fill="none" xmlns="http://www.w3.org/2000/svg"> | |||
| <path id="Rectangle 979" d="M0 0H16L9.91493 16.7339C8.76529 19.8955 5.76063 22 2.39658 22H0V0Z" fill="white"/> | |||
| </svg> | |||
| @@ -0,0 +1,27 @@ | |||
| { | |||
| "icon": { | |||
| "type": "element", | |||
| "isRootNode": true, | |||
| "name": "svg", | |||
| "attributes": { | |||
| "width": "16", | |||
| "height": "22", | |||
| "viewBox": "0 0 16 22", | |||
| "fill": "none", | |||
| "xmlns": "http://www.w3.org/2000/svg" | |||
| }, | |||
| "children": [ | |||
| { | |||
| "type": "element", | |||
| "name": "path", | |||
| "attributes": { | |||
| "id": "Rectangle 979", | |||
| "d": "M0 0H16L9.91493 16.7339C8.76529 19.8955 5.76063 22 2.39658 22H0V0Z", | |||
| "fill": "white" | |||
| }, | |||
| "children": [] | |||
| } | |||
| ] | |||
| }, | |||
| "name": "Triangle" | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| // GENERATE BY script | |||
| // DON NOT EDIT IT MANUALLY | |||
| import * as React from 'react' | |||
| import data from './Triangle.json' | |||
| import IconBase from '@/app/components/base/icons/IconBase' | |||
| import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase' | |||
| const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>(( | |||
| props, | |||
| ref, | |||
| ) => <IconBase {...props} ref={ref} data={data as IconData} />) | |||
| Icon.displayName = 'Triangle' | |||
| export default Icon | |||
| @@ -0,0 +1 @@ | |||
| export { default as Triangle } from './Triangle' | |||
| @@ -6,6 +6,7 @@ import { | |||
| flip, | |||
| offset, | |||
| shift, | |||
| size, | |||
| useDismiss, | |||
| useFloating, | |||
| useFocus, | |||
| @@ -27,6 +28,7 @@ export type PortalToFollowElemOptions = { | |||
| open?: boolean | |||
| offset?: number | OffsetOptions | |||
| onOpenChange?: (open: boolean) => void | |||
| triggerPopupSameWidth?: boolean | |||
| } | |||
| export function usePortalToFollowElem({ | |||
| @@ -34,6 +36,7 @@ export function usePortalToFollowElem({ | |||
| open, | |||
| offset: offsetValue = 0, | |||
| onOpenChange: setControlledOpen, | |||
| triggerPopupSameWidth, | |||
| }: PortalToFollowElemOptions = {}) { | |||
| const setOpen = setControlledOpen | |||
| @@ -50,6 +53,12 @@ export function usePortalToFollowElem({ | |||
| padding: 5, | |||
| }), | |||
| shift({ padding: 5 }), | |||
| size({ | |||
| apply({ rects, elements }) { | |||
| if (triggerPopupSameWidth) | |||
| elements.floating.style.width = `${rects.reference.width}px` | |||
| }, | |||
| }), | |||
| ], | |||
| }) | |||
| @@ -2,10 +2,12 @@ | |||
| import type { FC } from 'react' | |||
| import React from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useRouter } from 'next/navigation' | |||
| import { | |||
| RiBook2Line, | |||
| RiBox3Line, | |||
| RiFileEditLine, | |||
| RiGraduationCapLine, | |||
| RiGroup3Line, | |||
| RiGroupLine, | |||
| RiSquareLine, | |||
| @@ -15,7 +17,13 @@ import VectorSpaceInfo from '../usage-info/vector-space-info' | |||
| import AppsInfo from '../usage-info/apps-info' | |||
| import UpgradeBtn from '../upgrade-btn' | |||
| import { useProviderContext } from '@/context/provider-context' | |||
| import { useAppContext } from '@/context/app-context' | |||
| import Button from '@/app/components/base/button' | |||
| import UsageInfo from '@/app/components/billing/usage-info' | |||
| import VerifyStateModal from '@/app/education-apply/verify-state-modal' | |||
| import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants' | |||
| import { useEducationVerify } from '@/service/use-education' | |||
| import { useModalContextSelector } from '@/context/modal-context' | |||
| type Props = { | |||
| loc: string | |||
| @@ -25,7 +33,9 @@ const PlanComp: FC<Props> = ({ | |||
| loc, | |||
| }) => { | |||
| const { t } = useTranslation() | |||
| const { plan } = useProviderContext() | |||
| const router = useRouter() | |||
| const { userProfile } = useAppContext() | |||
| const { plan, enableEducationPlan, isEducationAccount } = useProviderContext() | |||
| const { | |||
| type, | |||
| } = plan | |||
| @@ -35,6 +45,18 @@ const PlanComp: FC<Props> = ({ | |||
| total, | |||
| } = plan | |||
| const [showModal, setShowModal] = React.useState(false) | |||
| const { mutateAsync } = useEducationVerify() | |||
| const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) | |||
| const handleVerify = () => { | |||
| mutateAsync().then((res) => { | |||
| localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) | |||
| router.push(`/education-apply?token=${res.token}`) | |||
| setShowAccountSettingModal(null) | |||
| }).catch(() => { | |||
| setShowModal(true) | |||
| }) | |||
| } | |||
| return ( | |||
| <div className='rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off bg-background-section-burn'> | |||
| <div className='p-6 pb-2'> | |||
| @@ -58,14 +80,22 @@ const PlanComp: FC<Props> = ({ | |||
| </div> | |||
| <div className='system-xs-regular text-util-colors-gray-gray-600'>{t(`billing.plans.${type}.for`)}</div> | |||
| </div> | |||
| {(plan.type as any) !== SelfHostedPlan.enterprise && ( | |||
| <UpgradeBtn | |||
| className='shrink-0' | |||
| isPlain={type === Plan.team} | |||
| isShort | |||
| loc={loc} | |||
| /> | |||
| )} | |||
| <div className='flex shrink-0 items-center gap-1'> | |||
| {enableEducationPlan && !isEducationAccount && ( | |||
| <Button variant='ghost' onClick={handleVerify}> | |||
| <RiGraduationCapLine className='mr-1 h-4 w-4'/> | |||
| {t('education.toVerified')} | |||
| </Button> | |||
| )} | |||
| {(plan.type as any) !== SelfHostedPlan.enterprise && ( | |||
| <UpgradeBtn | |||
| className='shrink-0' | |||
| isPlain={type === Plan.team} | |||
| isShort | |||
| loc={loc} | |||
| /> | |||
| )} | |||
| </div> | |||
| </div> | |||
| </div> | |||
| {/* Plan detail */} | |||
| @@ -92,6 +122,15 @@ const PlanComp: FC<Props> = ({ | |||
| /> | |||
| </div> | |||
| <VerifyStateModal | |||
| showLink | |||
| email={userProfile.email} | |||
| isShow={showModal} | |||
| title={t('education.rejectTitle')} | |||
| content={t('education.rejectContent')} | |||
| onConfirm={() => setShowModal(false)} | |||
| onCancel={() => setShowModal(false)} | |||
| /> | |||
| </div> | |||
| ) | |||
| } | |||
| @@ -87,6 +87,10 @@ export type CurrentPlanInfoBackend = { | |||
| can_replace_logo: boolean | |||
| model_load_balancing_enabled: boolean | |||
| dataset_operator_enabled: boolean | |||
| education: { | |||
| enabled: boolean | |||
| activated: boolean | |||
| } | |||
| } | |||
| export type SubscriptionItem = { | |||
| @@ -3,7 +3,18 @@ import { useTranslation } from 'react-i18next' | |||
| import { Fragment, useState } from 'react' | |||
| import { useRouter } from 'next/navigation' | |||
| import { useContext, useContextSelector } from 'use-context-selector' | |||
| import { RiAccountCircleLine, RiArrowDownSLine, RiArrowRightUpLine, RiBookOpenLine, RiGithubLine, RiInformation2Line, RiLogoutBoxRLine, RiMap2Line, RiSettings3Line, RiStarLine } from '@remixicon/react' | |||
| import { | |||
| RiAccountCircleLine, | |||
| RiArrowRightUpLine, | |||
| RiBookOpenLine, | |||
| RiGithubLine, | |||
| RiGraduationCapFill, | |||
| RiInformation2Line, | |||
| RiLogoutBoxRLine, | |||
| RiMap2Line, | |||
| RiSettings3Line, | |||
| RiStarLine, | |||
| } from '@remixicon/react' | |||
| import Link from 'next/link' | |||
| import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' | |||
| import Indicator from '../indicator' | |||
| @@ -11,21 +22,19 @@ import AccountAbout from '../account-about' | |||
| import GithubStar from '../github-star' | |||
| import Support from './support' | |||
| import Compliance from './compliance' | |||
| import classNames from '@/utils/classnames' | |||
| import PremiumBadge from '@/app/components/base/premium-badge' | |||
| import I18n from '@/context/i18n' | |||
| import Avatar from '@/app/components/base/avatar' | |||
| import { logout } from '@/service/common' | |||
| import AppContext, { useAppContext } from '@/context/app-context' | |||
| import { useProviderContext } from '@/context/provider-context' | |||
| import { useModalContext } from '@/context/modal-context' | |||
| import { LanguagesSupported } from '@/i18n/language' | |||
| import { LicenseStatus } from '@/types/feature' | |||
| import { IS_CLOUD_EDITION } from '@/config' | |||
| import cn from '@/utils/classnames' | |||
| export type IAppSelector = { | |||
| isMobile: boolean | |||
| } | |||
| export default function AppSelector({ isMobile }: IAppSelector) { | |||
| export default function AppSelector() { | |||
| const itemClassName = ` | |||
| flex items-center w-full h-9 pl-3 pr-2 text-text-secondary system-md-regular | |||
| rounded-lg hover:bg-state-base-hover cursor-pointer gap-1 | |||
| @@ -37,6 +46,7 @@ export default function AppSelector({ isMobile }: IAppSelector) { | |||
| const { locale } = useContext(I18n) | |||
| const { t } = useTranslation() | |||
| const { userProfile, langeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext() | |||
| const { isEducationAccount } = useProviderContext() | |||
| const { setShowAccountSettingModal } = useModalContext() | |||
| const handleLogout = async () => { | |||
| @@ -58,20 +68,8 @@ export default function AppSelector({ isMobile }: IAppSelector) { | |||
| { | |||
| ({ open }) => ( | |||
| <> | |||
| <MenuButton | |||
| className={` | |||
| inline-flex items-center | |||
| rounded-[20px] py-1 pl-1 pr-2.5 text-sm | |||
| text-text-secondary hover:bg-state-base-hover | |||
| mobile:px-1 | |||
| ${open && 'bg-state-base-hover'} | |||
| `} | |||
| > | |||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='mr-0 sm:mr-2' size={32} /> | |||
| {!isMobile && <> | |||
| {userProfile.name} | |||
| <RiArrowDownSLine className="ml-1 h-3 w-3 text-text-tertiary" /> | |||
| </>} | |||
| <MenuButton className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', open && 'bg-background-default-dodge')}> | |||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} /> | |||
| </MenuButton> | |||
| <Transition | |||
| as={Fragment} | |||
| @@ -92,7 +90,15 @@ export default function AppSelector({ isMobile }: IAppSelector) { | |||
| <MenuItem disabled> | |||
| <div className='flex flex-nowrap items-center py-[13px] pl-3 pr-2'> | |||
| <div className='grow'> | |||
| <div className='system-md-medium break-all text-text-primary'>{userProfile.name}</div> | |||
| <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='w-3 h-3 mr-1' /> | |||
| <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={36} className='mr-3' /> | |||
| @@ -101,7 +107,7 @@ export default function AppSelector({ isMobile }: IAppSelector) { | |||
| <div className="px-1 py-1"> | |||
| <MenuItem> | |||
| <Link | |||
| className={classNames(itemClassName, 'group', | |||
| className={cn(itemClassName, 'group', | |||
| 'data-[active]:bg-state-base-hover', | |||
| )} | |||
| href='/account' | |||
| @@ -112,7 +118,7 @@ export default function AppSelector({ isMobile }: IAppSelector) { | |||
| </Link> | |||
| </MenuItem> | |||
| <MenuItem> | |||
| <div className={classNames(itemClassName, | |||
| <div className={cn(itemClassName, | |||
| 'data-[active]:bg-state-base-hover', | |||
| )} onClick={() => setShowAccountSettingModal({ payload: 'members' })}> | |||
| <RiSettings3Line className='size-4 shrink-0 text-text-tertiary' /> | |||
| @@ -123,7 +129,7 @@ export default function AppSelector({ isMobile }: IAppSelector) { | |||
| <div className='p-1'> | |||
| <MenuItem> | |||
| <Link | |||
| className={classNames(itemClassName, 'group justify-between', | |||
| className={cn(itemClassName, 'group justify-between', | |||
| 'data-[active]:bg-state-base-hover', | |||
| )} | |||
| href={ | |||
| @@ -141,7 +147,7 @@ export default function AppSelector({ isMobile }: IAppSelector) { | |||
| <div className='p-1'> | |||
| <MenuItem> | |||
| <Link | |||
| className={classNames(itemClassName, 'group justify-between', | |||
| className={cn(itemClassName, 'group justify-between', | |||
| 'data-[active]:bg-state-base-hover', | |||
| )} | |||
| href='https://roadmap.dify.ai' | |||
| @@ -153,7 +159,7 @@ export default function AppSelector({ isMobile }: IAppSelector) { | |||
| </MenuItem> | |||
| {systemFeatures.license.status === LicenseStatus.NONE && <MenuItem> | |||
| <Link | |||
| className={classNames(itemClassName, 'group justify-between', | |||
| className={cn(itemClassName, 'group justify-between', | |||
| 'data-[active]:bg-state-base-hover', | |||
| )} | |||
| href='https://github.com/langgenius/dify' | |||
| @@ -169,7 +175,7 @@ export default function AppSelector({ isMobile }: IAppSelector) { | |||
| { | |||
| document?.body?.getAttribute('data-public-site-about') !== 'hide' && ( | |||
| <MenuItem> | |||
| <div className={classNames(itemClassName, 'justify-between', | |||
| <div className={cn(itemClassName, 'justify-between', | |||
| 'data-[active]:bg-state-base-hover', | |||
| )} onClick={() => setAboutVisible(true)}> | |||
| <RiInformation2Line className='size-4 shrink-0 text-text-tertiary' /> | |||
| @@ -186,7 +192,7 @@ export default function AppSelector({ isMobile }: IAppSelector) { | |||
| <MenuItem> | |||
| <div className='p-1' onClick={() => handleLogout()}> | |||
| <div | |||
| className={classNames(itemClassName, 'group justify-between', | |||
| className={cn(itemClassName, 'group justify-between', | |||
| 'data-[active]:bg-state-base-hover', | |||
| )} | |||
| > | |||
| @@ -4,10 +4,10 @@ import { useTranslation } from 'react-i18next' | |||
| import { Menu, MenuButton, MenuItems, Transition } from '@headlessui/react' | |||
| import { RiArrowDownSLine } from '@remixicon/react' | |||
| import cn from '@/utils/classnames' | |||
| import PlanBadge from '@/app/components/header/plan-badge' | |||
| import { switchWorkspace } from '@/service/common' | |||
| import { useWorkspacesContext } from '@/context/workspace-context' | |||
| import { ToastContext } from '@/app/components/base/toast' | |||
| import PlanBadge from '../../plan-badge' | |||
| import type { Plan } from '@/app/components/billing/type' | |||
| const WorkplaceSelector = () => { | |||
| @@ -94,10 +94,10 @@ const Header = () => { | |||
| } | |||
| <div className='flex shrink-0 items-center'> | |||
| <EnvNav /> | |||
| <div className='mr-3'> | |||
| <div className='mr-2'> | |||
| <PluginsNav /> | |||
| </div> | |||
| <AccountDropdown isMobile={isMobile} /> | |||
| <AccountDropdown /> | |||
| </div> | |||
| { | |||
| (isMobile && isShowNavMenu) && ( | |||
| @@ -1,6 +1,9 @@ | |||
| import { useProviderContext } from '@/context/provider-context' | |||
| import type { FC } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { | |||
| RiGraduationCapFill, | |||
| } from '@remixicon/react' | |||
| import { SparklesSoft } from '../../base/icons/src/public/common' | |||
| import PremiumBadge from '../../base/premium-badge' | |||
| import { Plan } from '../../billing/type' | |||
| @@ -13,7 +16,7 @@ type PlanBadgeProps = { | |||
| } | |||
| const PlanBadge: FC<PlanBadgeProps> = ({ plan, allowHover, sandboxAsUpgrade = false, onClick }) => { | |||
| const { isFetchedPlan } = useProviderContext() | |||
| const { isFetchedPlan, isEducationWorkspace } = useProviderContext() | |||
| const { t } = useTranslation() | |||
| if (!isFetchedPlan) return null | |||
| @@ -39,7 +42,8 @@ const PlanBadge: FC<PlanBadgeProps> = ({ plan, allowHover, sandboxAsUpgrade = fa | |||
| if (plan === Plan.professional) { | |||
| return <PremiumBadge className='select-none' size='s' color='blue' allowHover={allowHover} onClick={onClick}> | |||
| <div className='system-2xs-medium-uppercase'> | |||
| <span className='p-1'> | |||
| <span className='p-1 inline-flex items-center gap-1'> | |||
| {isEducationWorkspace && <RiGraduationCapFill className='w-3 h-3' />} | |||
| pro | |||
| </span> | |||
| </div> | |||
| @@ -5,6 +5,10 @@ import { useCallback, useEffect, useState } from 'react' | |||
| import type { ReactNode } from 'react' | |||
| import { usePathname, useRouter, useSearchParams } from 'next/navigation' | |||
| import { fetchSetupStatus } from '@/service/common' | |||
| import { | |||
| EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, | |||
| EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, | |||
| } from '@/app/education-apply/constants' | |||
| type SwrInitorProps = { | |||
| children: ReactNode | |||
| @@ -41,6 +45,11 @@ const SwrInitor = ({ | |||
| useEffect(() => { | |||
| (async () => { | |||
| const action = searchParams.get('action') | |||
| if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) | |||
| localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes') | |||
| try { | |||
| const isFinished = await isSetupFinished() | |||
| if (!isFinished) { | |||
| @@ -0,0 +1,2 @@ | |||
| export const EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION = 'getEducationVerify' | |||
| export const EDUCATION_VERIFYING_LOCALSTORAGE_ITEM = 'educationVerifying' | |||
| @@ -0,0 +1,191 @@ | |||
| 'use client' | |||
| import { | |||
| useMemo, | |||
| useState, | |||
| } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { RiExternalLinkLine } from '@remixicon/react' | |||
| import { | |||
| useRouter, | |||
| useSearchParams, | |||
| } from 'next/navigation' | |||
| import UserInfo from './user-info' | |||
| import SearchInput from './search-input' | |||
| import RoleSelector from './role-selector' | |||
| import Confirm from './verify-state-modal' | |||
| import Button from '@/app/components/base/button' | |||
| import Checkbox from '@/app/components/base/checkbox' | |||
| import { | |||
| useEducationAdd, | |||
| useInvalidateEducationStatus, | |||
| } from '@/service/use-education' | |||
| import { useProviderContext } from '@/context/provider-context' | |||
| import { useToastContext } from '@/app/components/base/toast' | |||
| import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants' | |||
| import { getLocaleOnClient } from '@/i18n' | |||
| const EducationApplyAge = () => { | |||
| const { t } = useTranslation() | |||
| const locale = getLocaleOnClient() | |||
| const [schoolName, setSchoolName] = useState('') | |||
| const [role, setRole] = useState('Student') | |||
| const [ageChecked, setAgeChecked] = useState(false) | |||
| const [inSchoolChecked, setInSchoolChecked] = useState(false) | |||
| const { | |||
| isPending, | |||
| mutateAsync: educationAdd, | |||
| } = useEducationAdd({ onSuccess: () => {} }) | |||
| const [modalShow, setShowModal] = useState<undefined | { title: string; desc: string; onConfirm?: () => void }>(undefined) | |||
| const { onPlanInfoChanged } = useProviderContext() | |||
| const updateEducationStatus = useInvalidateEducationStatus() | |||
| const { notify } = useToastContext() | |||
| const router = useRouter() | |||
| const docLink = useMemo(() => { | |||
| if (locale === 'zh-Hans') | |||
| return 'https://docs.dify.ai/zh-hans/getting-started/dify-for-education' | |||
| if (locale === 'ja-JP') | |||
| return 'https://docs.dify.ai/ja-jp/getting-started/dify-for-education' | |||
| return 'https://docs.dify.ai/getting-started/dify-for-education' | |||
| }, [locale]) | |||
| const handleModalConfirm = () => { | |||
| setShowModal(undefined) | |||
| onPlanInfoChanged() | |||
| updateEducationStatus() | |||
| localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) | |||
| router.replace('/') | |||
| } | |||
| const searchParams = useSearchParams() | |||
| const token = searchParams.get('token') | |||
| const handleSubmit = () => { | |||
| educationAdd({ | |||
| token: token || '', | |||
| role, | |||
| institution: schoolName, | |||
| }).then((res) => { | |||
| if (res.message === 'success') { | |||
| setShowModal({ | |||
| title: t('education.successTitle'), | |||
| desc: t('education.successContent'), | |||
| onConfirm: handleModalConfirm, | |||
| }) | |||
| } | |||
| else { | |||
| notify({ | |||
| type: 'error', | |||
| message: t('education.submitError'), | |||
| }) | |||
| } | |||
| }) | |||
| } | |||
| return ( | |||
| <div className='fixed inset-0 z-[31] overflow-y-auto bg-background-body p-6'> | |||
| <div className='mx-auto w-full max-w-[1408px] rounded-2xl border border-effects-highlight bg-background-default-subtle'> | |||
| <div | |||
| className="h-[349px] w-full overflow-hidden rounded-t-2xl bg-cover bg-center bg-no-repeat" | |||
| style={{ | |||
| backgroundImage: 'url(/education/bg.png)', | |||
| }} | |||
| > | |||
| </div> | |||
| <div className='mt-[-349px] flex h-[88px] items-center justify-between px-8 py-6'> | |||
| <img | |||
| src='/logo/logo-site-dark.png' | |||
| alt='dify logo' | |||
| className='h-10' | |||
| /> | |||
| </div> | |||
| <div className='mx-auto max-w-[720px] px-8 pb-[180px]'> | |||
| <div className='mb-2 flex h-[192px] flex-col justify-end pb-4 pt-3 text-text-primary-on-surface'> | |||
| <div className='title-5xl-bold mb-2 shadow-xs'>{t('education.toVerified')}</div> | |||
| <div className='system-md-medium shadow-xs'> | |||
| {t('education.toVerifiedTip.front')}  | |||
| <span className='system-md-semibold underline'>{t('education.toVerifiedTip.coupon')}</span>  | |||
| {t('education.toVerifiedTip.end')} | |||
| </div> | |||
| </div> | |||
| <div className='mb-7'> | |||
| <UserInfo /> | |||
| </div> | |||
| <div className='mb-7'> | |||
| <div className='system-md-semibold mb-1 flex h-6 items-center text-text-secondary'> | |||
| {t('education.form.schoolName.title')} | |||
| </div> | |||
| <SearchInput | |||
| value={schoolName} | |||
| onChange={setSchoolName} | |||
| /> | |||
| </div> | |||
| <div className='mb-7'> | |||
| <div className='system-md-semibold mb-1 flex h-6 items-center text-text-secondary'> | |||
| {t('education.form.schoolRole.title')} | |||
| </div> | |||
| <RoleSelector | |||
| value={role} | |||
| onChange={setRole} | |||
| /> | |||
| </div> | |||
| <div className='mb-7'> | |||
| <div className='system-md-semibold mb-1 flex h-6 items-center text-text-secondary'> | |||
| {t('education.form.terms.title')} | |||
| </div> | |||
| <div className='system-md-regular mb-1 text-text-tertiary'> | |||
| {t('education.form.terms.desc.front')}  | |||
| <a href='https://dify.ai/terms' target='_blank' className='text-text-secondary hover:underline'>{t('education.form.terms.desc.termsOfService')}</a>  | |||
| {t('education.form.terms.desc.and')}  | |||
| <a href='https://dify.ai/privacy' target='_blank' className='text-text-secondary hover:underline'>{t('education.form.terms.desc.privacyPolicy')}</a> | |||
| {t('education.form.terms.desc.end')} | |||
| </div> | |||
| <div className='system-md-regular py-2 text-text-primary'> | |||
| <div className='mb-2 flex'> | |||
| <Checkbox | |||
| className='mr-2 shrink-0' | |||
| checked={ageChecked} | |||
| onCheck={() => setAgeChecked(!ageChecked)} | |||
| /> | |||
| {t('education.form.terms.option.age')} | |||
| </div> | |||
| <div className='flex'> | |||
| <Checkbox | |||
| className='mr-2 shrink-0' | |||
| checked={inSchoolChecked} | |||
| onCheck={() => setInSchoolChecked(!inSchoolChecked)} | |||
| /> | |||
| {t('education.form.terms.option.inSchool')} | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <Button | |||
| variant='primary' | |||
| disabled={!ageChecked || !inSchoolChecked || !schoolName || !role || isPending} | |||
| onClick={handleSubmit} | |||
| > | |||
| {t('education.submit')} | |||
| </Button> | |||
| <div className='mb-4 mt-5 h-[1px] bg-gradient-to-r from-[rgba(16,24,40,0.08)]'></div> | |||
| <a | |||
| className='system-xs-regular flex items-center text-text-accent' | |||
| href={docLink} | |||
| target='_blank' | |||
| > | |||
| {t('education.learn')} | |||
| <RiExternalLinkLine className='ml-1 h-3 w-3' /> | |||
| </a> | |||
| </div> | |||
| </div> | |||
| <Confirm | |||
| isShow={!!modalShow} | |||
| title={modalShow?.title || ''} | |||
| content={modalShow?.desc} | |||
| onConfirm={modalShow?.onConfirm || (() => {})} | |||
| onCancel={modalShow?.onConfirm || (() => {})} | |||
| /> | |||
| </div> | |||
| ) | |||
| } | |||
| export default EducationApplyAge | |||
| @@ -0,0 +1,67 @@ | |||
| import { | |||
| useCallback, | |||
| useEffect, | |||
| useState, | |||
| } from 'react' | |||
| import { useDebounceFn } from 'ahooks' | |||
| import { useSearchParams } from 'next/navigation' | |||
| import type { SearchParams } from './types' | |||
| import { | |||
| EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, | |||
| EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, | |||
| } from './constants' | |||
| import { useEducationAutocomplete } from '@/service/use-education' | |||
| import { useModalContextSelector } from '@/context/modal-context' | |||
| export const useEducation = () => { | |||
| const { | |||
| mutateAsync, | |||
| isPending, | |||
| data, | |||
| } = useEducationAutocomplete() | |||
| const [prevSchools, setPrevSchools] = useState<string[]>([]) | |||
| const handleUpdateSchools = useCallback((searchParams: SearchParams) => { | |||
| if (searchParams.keywords) { | |||
| mutateAsync(searchParams).then((res) => { | |||
| const currentPage = searchParams.page || 0 | |||
| const resSchools = res.data | |||
| if (currentPage > 0) | |||
| setPrevSchools(prevSchools => [...(prevSchools || []), ...resSchools]) | |||
| else | |||
| setPrevSchools(resSchools) | |||
| }) | |||
| } | |||
| }, [mutateAsync]) | |||
| const { run: querySchoolsWithDebounced } = useDebounceFn((searchParams: SearchParams) => { | |||
| handleUpdateSchools(searchParams) | |||
| }, { | |||
| wait: 300, | |||
| }) | |||
| return { | |||
| schools: prevSchools, | |||
| setSchools: setPrevSchools, | |||
| querySchoolsWithDebounced, | |||
| handleUpdateSchools, | |||
| isLoading: isPending, | |||
| hasNext: data?.has_next, | |||
| } | |||
| } | |||
| export const useEducationInit = () => { | |||
| const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) | |||
| const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) | |||
| const searchParams = useSearchParams() | |||
| const educationVerifyAction = searchParams.get('action') | |||
| useEffect(() => { | |||
| if (educationVerifying === 'yes' || educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) { | |||
| setShowAccountSettingModal({ payload: 'billing' }) | |||
| if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) | |||
| localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes') | |||
| } | |||
| }, [setShowAccountSettingModal, educationVerifying, educationVerifyAction]) | |||
| } | |||
| @@ -0,0 +1,53 @@ | |||
| import { useTranslation } from 'react-i18next' | |||
| import cn from '@/utils/classnames' | |||
| type RoleSelectorProps = { | |||
| onChange: (value: string) => void | |||
| value: string | |||
| } | |||
| const RoleSelector = ({ | |||
| onChange, | |||
| value, | |||
| }: RoleSelectorProps) => { | |||
| const { t } = useTranslation() | |||
| const options = [ | |||
| { | |||
| key: 'Student', | |||
| value: t('education.form.schoolRole.option.student'), | |||
| }, | |||
| { | |||
| key: 'Teacher', | |||
| value: t('education.form.schoolRole.option.teacher'), | |||
| }, | |||
| { | |||
| key: 'School-Administrator', | |||
| value: t('education.form.schoolRole.option.administrator'), | |||
| }, | |||
| ] | |||
| return ( | |||
| <div className='flex'> | |||
| { | |||
| options.map(option => ( | |||
| <div | |||
| key={option.key} | |||
| className='system-md-regular mr-6 flex h-5 cursor-pointer items-center text-text-primary' | |||
| onClick={() => onChange(option.key)} | |||
| > | |||
| <div | |||
| className={cn( | |||
| 'mr-2 h-4 w-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs', | |||
| option.key === value && 'border-[5px] border-components-radio-border-checked ', | |||
| )} | |||
| > | |||
| </div> | |||
| {option.value} | |||
| </div> | |||
| )) | |||
| } | |||
| </div> | |||
| ) | |||
| } | |||
| export default RoleSelector | |||
| @@ -0,0 +1,121 @@ | |||
| import { | |||
| useCallback, | |||
| useRef, | |||
| useState, | |||
| } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useEducation } from './hooks' | |||
| import Input from '@/app/components/base/input' | |||
| import { | |||
| PortalToFollowElem, | |||
| PortalToFollowElemContent, | |||
| PortalToFollowElemTrigger, | |||
| } from '@/app/components/base/portal-to-follow-elem' | |||
| type SearchInputProps = { | |||
| value?: string | |||
| onChange: (value: string) => void | |||
| } | |||
| const SearchInput = ({ | |||
| value, | |||
| onChange, | |||
| }: SearchInputProps) => { | |||
| const { t } = useTranslation() | |||
| const [open, setOpen] = useState(false) | |||
| const { | |||
| schools, | |||
| setSchools, | |||
| querySchoolsWithDebounced, | |||
| handleUpdateSchools, | |||
| hasNext, | |||
| } = useEducation() | |||
| const pageRef = useRef(0) | |||
| const valueRef = useRef(value) | |||
| const handleSearch = useCallback((debounced?: boolean) => { | |||
| const keywords = valueRef.current | |||
| const page = pageRef.current | |||
| if (debounced) { | |||
| querySchoolsWithDebounced({ | |||
| keywords, | |||
| page, | |||
| }) | |||
| return | |||
| } | |||
| handleUpdateSchools({ | |||
| keywords, | |||
| page, | |||
| }) | |||
| }, [querySchoolsWithDebounced, handleUpdateSchools]) | |||
| const handleValueChange = useCallback((e: any) => { | |||
| setOpen(true) | |||
| setSchools([]) | |||
| pageRef.current = 0 | |||
| const inputValue = e.target.value | |||
| valueRef.current = inputValue | |||
| onChange(inputValue) | |||
| handleSearch(true) | |||
| }, [onChange, handleSearch, setSchools]) | |||
| const handleScroll = useCallback((e: Event) => { | |||
| const target = e.target as HTMLDivElement | |||
| const { | |||
| scrollTop, | |||
| scrollHeight, | |||
| clientHeight, | |||
| } = target | |||
| if (scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0 && hasNext) { | |||
| pageRef.current += 1 | |||
| handleSearch() | |||
| } | |||
| }, [handleSearch, hasNext]) | |||
| return ( | |||
| <PortalToFollowElem | |||
| open={open} | |||
| onOpenChange={setOpen} | |||
| placement='bottom' | |||
| offset={4} | |||
| triggerPopupSameWidth | |||
| > | |||
| <PortalToFollowElemTrigger className='block w-full'> | |||
| <Input | |||
| className='w-full' | |||
| placeholder={t('education.form.schoolName.placeholder')} | |||
| value={value} | |||
| onChange={handleValueChange} | |||
| /> | |||
| </PortalToFollowElemTrigger> | |||
| <PortalToFollowElemContent className='z-[32]'> | |||
| { | |||
| !!schools.length && value && ( | |||
| <div | |||
| className='max-h-[330px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1' | |||
| onScroll={handleScroll as any} | |||
| > | |||
| { | |||
| schools.map((school, index) => ( | |||
| <div | |||
| key={index} | |||
| className='system-md-regular flex h-8 cursor-pointer items-center truncate rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover' | |||
| title={school} | |||
| onClick={() => { | |||
| onChange(school) | |||
| setOpen(false) | |||
| }} | |||
| > | |||
| {school} | |||
| </div> | |||
| )) | |||
| } | |||
| </div> | |||
| ) | |||
| } | |||
| </PortalToFollowElemContent> | |||
| </PortalToFollowElem> | |||
| ) | |||
| } | |||
| export default SearchInput | |||
| @@ -0,0 +1,11 @@ | |||
| export type SearchParams = { | |||
| keywords?: string | |||
| page?: number | |||
| limit?: number | |||
| } | |||
| export type EducationAddParams = { | |||
| token: string | |||
| institution: string | |||
| role: string | |||
| } | |||
| @@ -0,0 +1,61 @@ | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useRouter } from 'next/navigation' | |||
| import Button from '@/app/components/base/button' | |||
| import { useAppContext } from '@/context/app-context' | |||
| import { logout } from '@/service/common' | |||
| import Avatar from '@/app/components/base/avatar' | |||
| import { Triangle } from '@/app/components/base/icons/src/public/education' | |||
| const UserInfo = () => { | |||
| const router = useRouter() | |||
| const { t } = useTranslation() | |||
| const { userProfile } = useAppContext() | |||
| const handleLogout = async () => { | |||
| await logout({ | |||
| url: '/logout', | |||
| params: {}, | |||
| }) | |||
| localStorage.removeItem('setup_status') | |||
| localStorage.removeItem('console_token') | |||
| localStorage.removeItem('refresh_token') | |||
| router.push('/signin') | |||
| } | |||
| return ( | |||
| <div className='relative flex items-center justify-between rounded-xl border-[4px] border-components-panel-on-panel-item-bg bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 pb-6 pl-6 pr-8 pt-9 shadow-shadow-shadow-5'> | |||
| <div className='absolute left-0 top-0 flex items-center'> | |||
| <div className='system-2xs-semibold-uppercase flex h-[22px] items-center bg-components-panel-on-panel-item-bg pl-2 pt-1 text-text-accent-light-mode-only'> | |||
| {t('education.currentSigned')} | |||
| </div> | |||
| <Triangle className='h-[22px] w-4 text-components-panel-on-panel-item-bg' /> | |||
| </div> | |||
| <div className='flex items-center'> | |||
| <Avatar | |||
| className='mr-4' | |||
| avatar={userProfile.avatar_url} | |||
| name={userProfile.name} | |||
| size={48} | |||
| /> | |||
| <div className='pt-1.5'> | |||
| <div className='system-md-semibold text-text-primary'> | |||
| {userProfile.name} | |||
| </div> | |||
| <div className='system-sm-regular text-text-secondary'> | |||
| {userProfile.email} | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <Button | |||
| variant='secondary' | |||
| onClick={handleLogout} | |||
| > | |||
| {t('common.userProfile.logout')} | |||
| </Button> | |||
| </div> | |||
| ) | |||
| } | |||
| export default UserInfo | |||
| @@ -0,0 +1,122 @@ | |||
| import React, { useEffect, useMemo, useRef, useState } from 'react' | |||
| import { createPortal } from 'react-dom' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { | |||
| RiExternalLinkLine, | |||
| } from '@remixicon/react' | |||
| import Button from '@/app/components/base/button' | |||
| import { getLocaleOnClient } from '@/i18n' | |||
| export type IConfirm = { | |||
| className?: string | |||
| isShow: boolean | |||
| title: string | |||
| content?: React.ReactNode | |||
| onConfirm: () => void | |||
| onCancel: () => void | |||
| maskClosable?: boolean | |||
| email?: string | |||
| showLink?: boolean | |||
| } | |||
| function Confirm({ | |||
| isShow, | |||
| title, | |||
| content, | |||
| onConfirm, | |||
| onCancel, | |||
| maskClosable = true, | |||
| showLink, | |||
| email, | |||
| }: IConfirm) { | |||
| const { t } = useTranslation() | |||
| const locale = getLocaleOnClient() | |||
| const dialogRef = useRef<HTMLDivElement>(null) | |||
| const [isVisible, setIsVisible] = useState(isShow) | |||
| const docLink = useMemo(() => { | |||
| if (locale === 'zh-Hans') | |||
| return 'https://docs.dify.ai/zh-hans/getting-started/dify-for-education' | |||
| if (locale === 'ja-JP') | |||
| return 'https://docs.dify.ai/ja-jp/getting-started/dify-for-education' | |||
| return 'https://docs.dify.ai/getting-started/dify-for-education' | |||
| }, [locale]) | |||
| const handleClick = () => { | |||
| window.open(docLink, '_blank', 'noopener,noreferrer') | |||
| } | |||
| useEffect(() => { | |||
| const handleKeyDown = (event: KeyboardEvent) => { | |||
| if (event.key === 'Escape') | |||
| onCancel() | |||
| } | |||
| document.addEventListener('keydown', handleKeyDown) | |||
| return () => { | |||
| document.removeEventListener('keydown', handleKeyDown) | |||
| } | |||
| }, [onCancel]) | |||
| const handleClickOutside = (event: MouseEvent) => { | |||
| if (maskClosable && dialogRef.current && !dialogRef.current.contains(event.target as Node)) | |||
| onCancel() | |||
| } | |||
| useEffect(() => { | |||
| document.addEventListener('mousedown', handleClickOutside) | |||
| return () => { | |||
| document.removeEventListener('mousedown', handleClickOutside) | |||
| } | |||
| }, [maskClosable]) | |||
| useEffect(() => { | |||
| if (isShow) { | |||
| setIsVisible(true) | |||
| } | |||
| else { | |||
| const timer = setTimeout(() => setIsVisible(false), 200) | |||
| return () => clearTimeout(timer) | |||
| } | |||
| }, [isShow]) | |||
| if (!isVisible) | |||
| return null | |||
| return createPortal( | |||
| <div className={'fixed inset-0 z-[10000000] flex items-center justify-center bg-background-overlay'} | |||
| onClick={(e) => { | |||
| e.preventDefault() | |||
| e.stopPropagation() | |||
| }} | |||
| > | |||
| <div ref={dialogRef} className={'relative w-full max-w-[481px] overflow-hidden'}> | |||
| <div className='shadows-shadow-lg flex max-w-full flex-col items-start rounded-2xl border-[0.5px] border-solid border-components-panel-border bg-components-panel-bg'> | |||
| <div className='flex flex-col items-start gap-2 self-stretch pb-4 pl-6 pr-6 pt-6'> | |||
| <div className='title-2xl-semi-bold text-text-primary'>{title}</div> | |||
| <div className='system-md-regular w-full text-text-tertiary'>{content}</div> | |||
| </div> | |||
| {email && ( | |||
| <div className='w-full space-y-1 px-6 py-3'> | |||
| <div className='system-sm-semibold py-1 text-text-secondary'>{t('education.emailLabel')}</div> | |||
| <div className='system-sm-regular rounded-lg bg-components-input-bg-disabled px-3 py-2 text-components-input-text-filled-disabled'>{email}</div> | |||
| </div> | |||
| )} | |||
| <div className='flex items-center justify-between gap-2 self-stretch p-6'> | |||
| <div className='flex items-center gap-1'> | |||
| {showLink && ( | |||
| <> | |||
| <a onClick={handleClick} href={docLink} target='_blank' className='system-xs-regular cursor-pointer text-text-accent'>{t('education.learn')}</a> | |||
| <RiExternalLinkLine className='h-3 w-3 text-text-accent' /> | |||
| </> | |||
| )} | |||
| </div> | |||
| <Button variant='primary' className='!w-20' onClick={onConfirm}>{t('common.operation.ok')}</Button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div>, document.body, | |||
| ) | |||
| } | |||
| export default React.memo(Confirm) | |||
| @@ -17,7 +17,9 @@ import type { | |||
| ModelLoadBalancingConfigEntry, | |||
| ModelProvider, | |||
| } from '@/app/components/header/account-setting/model-provider-page/declarations' | |||
| import { | |||
| EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, | |||
| } from '@/app/education-apply/constants' | |||
| import Pricing from '@/app/components/billing/pricing' | |||
| import type { ModerationConfig, PromptVariable } from '@/models/debug' | |||
| import type { | |||
| @@ -33,6 +35,7 @@ import type { OpeningStatement } from '@/app/components/base/features/types' | |||
| import type { InputVar } from '@/app/components/workflow/types' | |||
| import type { UpdatePluginPayload } from '@/app/components/plugins/types' | |||
| import UpdatePlugin from '@/app/components/plugins/update-plugin' | |||
| import { removeSpecificQueryParam } from '@/utils' | |||
| export type ModalState<T> = { | |||
| payload: T | |||
| @@ -121,6 +124,12 @@ export const ModalContextProvider = ({ | |||
| const [showPricingModal, setShowPricingModal] = useState(searchParams.get('show-pricing') === '1') | |||
| const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false) | |||
| const handleCancelAccountSettingModal = () => { | |||
| const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) | |||
| if (educationVerifying === 'yes') | |||
| localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) | |||
| removeSpecificQueryParam('action') | |||
| setShowAccountSettingModal(null) | |||
| if (showAccountSettingModal?.onCancelCallback) | |||
| showAccountSettingModal?.onCancelCallback() | |||
| @@ -22,6 +22,9 @@ import { fetchCurrentPlanInfo } from '@/service/billing' | |||
| import { parseCurrentPlan } from '@/app/components/billing/utils' | |||
| import { defaultPlan } from '@/app/components/billing/config' | |||
| import Toast from '@/app/components/base/toast' | |||
| import { | |||
| useEducationStatus, | |||
| } from '@/service/use-education' | |||
| type ProviderContextState = { | |||
| modelProviders: ModelProvider[] | |||
| @@ -40,6 +43,9 @@ type ProviderContextState = { | |||
| enableReplaceWebAppLogo: boolean | |||
| modelLoadBalancingEnabled: boolean | |||
| datasetOperatorEnabled: boolean | |||
| enableEducationPlan: boolean | |||
| isEducationWorkspace: boolean | |||
| isEducationAccount: boolean | |||
| } | |||
| const ProviderContext = createContext<ProviderContextState>({ | |||
| modelProviders: [], | |||
| @@ -70,6 +76,9 @@ const ProviderContext = createContext<ProviderContextState>({ | |||
| enableReplaceWebAppLogo: false, | |||
| modelLoadBalancingEnabled: false, | |||
| datasetOperatorEnabled: false, | |||
| enableEducationPlan: false, | |||
| isEducationWorkspace: false, | |||
| isEducationAccount: false, | |||
| }) | |||
| export const useProviderContext = () => useContext(ProviderContext) | |||
| @@ -97,13 +106,19 @@ export const ProviderContextProvider = ({ | |||
| const [modelLoadBalancingEnabled, setModelLoadBalancingEnabled] = useState(false) | |||
| const [datasetOperatorEnabled, setDatasetOperatorEnabled] = useState(false) | |||
| const [enableEducationPlan, setEnableEducationPlan] = useState(false) | |||
| const [isEducationWorkspace, setIsEducationWorkspace] = useState(false) | |||
| const { data: isEducationAccount } = useEducationStatus(!enableEducationPlan) | |||
| const fetchPlan = async () => { | |||
| const data = await fetchCurrentPlanInfo() | |||
| const enabled = data.billing.enabled | |||
| setEnableBilling(enabled) | |||
| setEnableEducationPlan(data.education.enabled) | |||
| setIsEducationWorkspace(data.education.activated) | |||
| setEnableReplaceWebAppLogo(data.can_replace_logo) | |||
| if (enabled) { | |||
| setPlan(parseCurrentPlan(data)) | |||
| setPlan(parseCurrentPlan(data) as any) | |||
| setIsFetchedPlan(true) | |||
| } | |||
| if (data.model_load_balancing_enabled) | |||
| @@ -155,6 +170,9 @@ export const ProviderContextProvider = ({ | |||
| enableReplaceWebAppLogo, | |||
| modelLoadBalancingEnabled, | |||
| datasetOperatorEnabled, | |||
| enableEducationPlan, | |||
| isEducationWorkspace, | |||
| isEducationAccount: isEducationAccount?.result || false, | |||
| }}> | |||
| {children} | |||
| </ProviderContext.Provider> | |||
| @@ -0,0 +1,47 @@ | |||
| const translation = { | |||
| toVerified: 'Get Education Verified', | |||
| toVerifiedTip: { | |||
| front: 'You are now eligible for Education Verified status. Please enter your education information below to complete the process and receive an', | |||
| coupon: 'exclusive 50% coupon', | |||
| end: 'for the Dify Professional Plan.', | |||
| }, | |||
| currentSigned: 'CURRENTLY SIGNED IN AS', | |||
| form: { | |||
| schoolName: { | |||
| title: 'Your School Name', | |||
| placeholder: 'Enter the official, unabbreviated name of your school', | |||
| }, | |||
| schoolRole: { | |||
| title: 'Your School Role', | |||
| option: { | |||
| student: 'Student', | |||
| teacher: 'Teacher', | |||
| administrator: 'School Administrator', | |||
| }, | |||
| }, | |||
| terms: { | |||
| title: 'Terms & Agreements', | |||
| desc: { | |||
| front: 'Your information and use of Education Verified status are subject to our', | |||
| and: 'and', | |||
| end: '. By submitting:', | |||
| termsOfService: 'Terms of Service', | |||
| privacyPolicy: 'Privacy Policy', | |||
| }, | |||
| option: { | |||
| age: 'I confirm I am at least 18 years old', | |||
| inSchool: 'I confirm I am enrolled or employed at the institution provided. Dify may request proof of enrollment/employment. If I misrepresent my eligibility, I agree to pay any fees initially waived based on my education status.', | |||
| }, | |||
| }, | |||
| }, | |||
| submit: 'Submit', | |||
| submitError: 'Form submission failed. Please try again later.', | |||
| learn: 'Learn how to get education verified', | |||
| successTitle: 'You Have Got Dify Education Verified', | |||
| successContent: 'We have issued a 50% discount coupon for the Dify Professional plan to your account. The coupon is valid for one year, please use it within the validity period.', | |||
| rejectTitle: 'Your Dify Educational Verification Has Been Rejected', | |||
| rejectContent: 'Unfortunately, you are not eligible for Education Verified status and therefore cannot receive the exclusive 50% coupon for the Dify Professional Plan if you use this email address.', | |||
| emailLabel: 'Your current email', | |||
| } | |||
| export default translation | |||
| @@ -4,6 +4,18 @@ import { initReactI18next } from 'react-i18next' | |||
| import { LanguagesSupported } from '@/i18n/language' | |||
| const requireSilent = (lang: string) => { | |||
| let res | |||
| try { | |||
| res = require(`./${lang}/education`).default | |||
| } | |||
| catch { | |||
| res = require('./en-US/education').default | |||
| } | |||
| return res | |||
| } | |||
| const loadLangResources = (lang: string) => ({ | |||
| translation: { | |||
| common: require(`./${lang}/common`).default, | |||
| @@ -31,6 +43,7 @@ const loadLangResources = (lang: string) => ({ | |||
| plugin: require(`./${lang}/plugin`).default, | |||
| pluginTags: require(`./${lang}/plugin-tags`).default, | |||
| time: require(`./${lang}/time`).default, | |||
| education: requireSilent(lang), | |||
| }, | |||
| }) | |||
| @@ -0,0 +1,47 @@ | |||
| const translation = { | |||
| toVerified: '教育認証を取得', | |||
| toVerifiedTip: { | |||
| front: '現在、教育認証ステータスを取得する資格があります。以下に教育情報を入力し、認証プロセスを完了すると、Difyプロフェッショナルプランの', | |||
| coupon: '50%割引クーポン', | |||
| end: 'を受け取ることができます。', | |||
| }, | |||
| currentSigned: '現在ログイン中のアカウントは', | |||
| form: { | |||
| schoolName: { | |||
| title: '学校名', | |||
| placeholder: '学校の正式名称(省略不可)を入力してください。', | |||
| }, | |||
| schoolRole: { | |||
| title: '学校での役割', | |||
| option: { | |||
| student: '学生', | |||
| teacher: '教師', | |||
| administrator: '学校管理者', | |||
| }, | |||
| }, | |||
| terms: { | |||
| title: '利用規約と同意事項', | |||
| desc: { | |||
| front: 'お客様の情報および 教育認証ステータス の利用は、当社の ', | |||
| and: 'および', | |||
| end: 'に従うものとします。送信することで以下を確認します:', | |||
| termsOfService: '利用規約', | |||
| privacyPolicy: 'プライバシーポリシー', | |||
| }, | |||
| option: { | |||
| age: '18歳以上であることを確認します。', | |||
| inSchool: '提供した教育機関に在籍または勤務している ことを確認します。Difyは在籍/雇用証明の提出を求める場合があります。不正な情報を申告した場合、教育認証に基づき免除された費用を支払うことに同意します。', | |||
| }, | |||
| }, | |||
| }, | |||
| submit: '送信', | |||
| submitError: 'フォームの送信に失敗しました。しばらくしてから再度ご提出ください。', | |||
| learn: '教育認証の取得方法はこちら', | |||
| successTitle: 'Dify教育認証を取得しました!', | |||
| successContent: 'お客様のアカウントに Difyプロフェッショナルプランの50%割引クーポン を発行しました。有効期間は 1年間 ですので、期限内にご利用ください。', | |||
| rejectTitle: 'Dify教育認証が拒否されました', | |||
| rejectContent: '申し訳ございませんが、このメールアドレスでは 教育認証 の資格を取得できず、Difyプロフェッショナルプランの50%割引クーポン を受け取ることはできません。', | |||
| emailLabel: '現在のメールアドレス', | |||
| } | |||
| export default translation | |||
| @@ -0,0 +1,48 @@ | |||
| const translation = { | |||
| toVerified: '获取教育版认证', | |||
| toVerifiedTip: { | |||
| front: '您现在符合教育版认证的资格。请在下方输入您的教育信息,以完成认证流程,并领取 Dify Professional 版的', | |||
| coupon: '50% 独家优惠券', | |||
| end: '。', | |||
| }, | |||
| currentSigned: '您当前登录的账户是', | |||
| form: { | |||
| schoolName: { | |||
| title: '您的学校名称', | |||
| placeholder: '请输入您的学校的官方全称(不得缩写)', | |||
| }, | |||
| schoolRole: { | |||
| title: '您在学校的身份', | |||
| option: { | |||
| student: '学生', | |||
| teacher: '教师', | |||
| administrator: '学校管理员', | |||
| }, | |||
| }, | |||
| terms: { | |||
| title: '条款与协议', | |||
| desc: { | |||
| front: '您的信息和教育版认证资格的使用需遵守我们的', | |||
| and: '和', | |||
| end: '。提交即表示:', | |||
| termsOfService: '服务条款', | |||
| privacyPolicy: '隐私政策', | |||
| }, | |||
| option: { | |||
| age: '我确认我已年满 18 周岁。', | |||
| inSchool: '我确认我目前已在提供的学校入学或受雇。Dify 可能会要求提供入学/雇佣证明。如我虚报资格,我同意支付因教育版认证而被减免的费用。', | |||
| }, | |||
| }, | |||
| }, | |||
| submit: '提交', | |||
| submitError: '提交表单失败,请稍后重新提交问卷。', | |||
| learn: '了解如何获取教育版认证', | |||
| successTitle: '您已成功获得 Dify 教育版认证!', | |||
| successContent: '我们已向您的账户发放 Dify Professional 版 50% 折扣优惠券。该优惠券有效期为一年,请在有效期内使用。', | |||
| rejectTitle: '您的 Dify 教育版认证已被拒绝', | |||
| rejectContent: '非常遗憾,您无法使用此电子邮件以获得教育版认证资格,也无法领取 Dify Professional 版的 50% 独家优惠券。', | |||
| emailLabel: '您当前的邮箱', | |||
| } | |||
| export default translation | |||
 
							| @@ -0,0 +1,67 @@ | |||
| import { get, post } from './base' | |||
| import { | |||
| useMutation, | |||
| useQuery, | |||
| } from '@tanstack/react-query' | |||
| import { useInvalid } from './use-base' | |||
| import type { EducationAddParams } from '@/app/education-apply/types' | |||
| const NAME_SPACE = 'education' | |||
| export const useEducationVerify = () => { | |||
| return useMutation({ | |||
| mutationKey: [NAME_SPACE, 'education-verify'], | |||
| mutationFn: () => { | |||
| return get<{ token: string }>('/account/education/verify', {}, { silent: true }) | |||
| }, | |||
| }) | |||
| } | |||
| export const useEducationAdd = ({ | |||
| onSuccess, | |||
| }: { | |||
| onSuccess?: () => void | |||
| }) => { | |||
| return useMutation({ | |||
| mutationKey: [NAME_SPACE, 'education-add'], | |||
| mutationFn: (params: EducationAddParams) => { | |||
| return post<{ message: string }>('/account/education', { | |||
| body: params, | |||
| }) | |||
| }, | |||
| onSuccess, | |||
| }) | |||
| } | |||
| type SearchParams = { | |||
| keywords?: string | |||
| page?: number | |||
| limit?: number | |||
| } | |||
| export const useEducationAutocomplete = () => { | |||
| return useMutation({ | |||
| mutationFn: (searchParams: SearchParams) => { | |||
| const { | |||
| keywords = '', | |||
| page = 0, | |||
| limit = 40, | |||
| } = searchParams | |||
| return get<{ data: string[]; has_next: boolean; curr_page: number }>(`/account/education/autocomplete?keywords=${keywords}&page=${page}&limit=${limit}`) | |||
| }, | |||
| }) | |||
| } | |||
| export const useEducationStatus = (disable?: boolean) => { | |||
| return useQuery({ | |||
| enabled: !disable, | |||
| queryKey: [NAME_SPACE, 'education-status'], | |||
| queryFn: () => { | |||
| return get<{ result: boolean }>('/account/education') | |||
| }, | |||
| retry: false, | |||
| }) | |||
| } | |||
| export const useInvalidateEducationStatus = () => { | |||
| return useInvalid([NAME_SPACE, 'education-status']) | |||
| } | |||
| @@ -90,3 +90,12 @@ export const canFindTool = (providerId: string, oldToolId?: string) => { | |||
| || providerId === `langgenius/${oldToolId}/${oldToolId}` | |||
| || providerId === `langgenius/${oldToolId}_tool/${oldToolId}` | |||
| } | |||
| export const removeSpecificQueryParam = (key: string | string[]) => { | |||
| const url = new URL(window.location.href) | |||
| if (Array.isArray(key)) | |||
| key.forEach(k => url.searchParams.delete(k)) | |||
| else | |||
| url.searchParams.delete(key) | |||
| window.history.replaceState(null, '', url.toString()) | |||
| } | |||