Compare commits

...

14 Commits

Author SHA1 Message Date
zxhlyh
5fa8709989 fix: education verify 2025-03-18 16:01:25 +08:00
zxhlyh
ce180706d0 fix: search school 2025-03-18 15:10:33 +08:00
zxhlyh
f1efaabf97 feat: add education apply 2025-03-17 13:16:06 +08:00
zxhlyh
358b70821a feat: add education apply 2025-03-17 11:43:16 +08:00
JzoNg
05e25096ba education verify redirection 2025-03-15 14:32:50 +08:00
JzoNg
9f673dab0d merge main 2025-03-15 11:59:58 +08:00
zxhlyh
4a347b92ab feat: add education apply 2025-03-14 15:13:56 +08:00
jZonG
08c00ff71d verify modal 2025-03-13 16:28:35 +08:00
zxhlyh
8b53254de5 feat: add education apply 2025-03-13 15:52:43 +08:00
zxhlyh
f17a76a00e feat: add education apply 2025-03-12 17:36:44 +08:00
zxhlyh
bed9407045 feat: add education apply 2025-03-12 10:05:10 +08:00
zxhlyh
233dfd3c79 feat: add education apply 2025-03-11 15:56:15 +08:00
JzoNg
b2780f7c4b edu verify button 2025-03-11 15:26:49 +08:00
JzoNg
ed83f5f1ca EDU badge 2025-03-11 14:58:06 +08:00
39 changed files with 1087 additions and 186 deletions

View File

@ -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'
@ -26,6 +28,8 @@ import { useStore as useTagStore } from '@/app/components/base/tag-management/st
import TagManagementModal from '@/app/components/base/tag-management'
import TagFilter from '@/app/components/base/tag-management/filter'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
import { useModalContextSelector } from '@/context/modal-context'
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/components/constants'
const getKey = (
pageIndex: number,
@ -132,6 +136,14 @@ const Apps = () => {
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
}, [isCreatedByMe, setQuery])
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
useEffect(() => {
if (educationVerifying === 'yes')
setShowAccountSettingModal({ payload: 'billing' })
}, [setShowAccountSettingModal, educationVerifying])
return (
<>
<div className='sticky top-0 flex justify-between items-center pt-4 px-12 pb-2 leading-[56px] bg-background-body z-10 flex-wrap gap-y-2'>

View File

@ -1,15 +1,28 @@
'use client'
import { useEffect } from 'react'
import { useContextSelector } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import { RiDiscordFill, RiGithubFill } from '@remixicon/react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import style from '../list.module.css'
import Apps from './Apps'
import AppContext from '@/context/app-context'
import { LicenseStatus } from '@/types/feature'
import {
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
} from '@/app/education-apply/components/constants'
const AppList = () => {
const { t } = useTranslation()
const searchParams = useSearchParams()
const educationVerifyAction = searchParams.get('action')
useEffect(() => {
if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
}, [educationVerifyAction])
const systemFeatures = useContextSelector(AppContext, v => v.systemFeatures)
return (

View File

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

View File

@ -2,13 +2,18 @@
import { useTranslation } from 'react-i18next'
import { Fragment } from 'react'
import { useRouter } from 'next/navigation'
import {
RiGraduationCapFill,
} from '@remixicon/react'
import { Menu, 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 interface IAppSelector {
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 text-text-primary break-all'>{userProfile.name}</div>
<div className='system-md-medium text-text-primary break-all'>
{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 text-text-tertiary break-all'>{userProfile.email}</div>
</div>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} />

View File

@ -22,7 +22,7 @@ const Header = () => {
<div className='w-[1px] h-4 bg-divider-regular' />
<p className='text-text-primary title-3xl-semi-bold'>{t('common.account.account')}</p>
</div>
<div className='flex items-center flex-shrink-0 gap-3'>
<div className='flex items-center shrink-0 gap-3'>
<Button className='gap-2 py-2 px-3 system-sm-medium' onClick={back}>
<RiRobot2Line className='w-4 h-4' />
<p>{t('common.account.studio')}</p>

View File

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

After

Width:  |  Height:  |  Size: 214 B

View File

@ -0,0 +1,5 @@
<svg width="18" height="16" viewBox="0 0 18 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="loop">
<path id="Vector" fill-rule="evenodd" clip-rule="evenodd" d="M2.02915 5.34506C3.50752 3.88498 5.9006 3.88498 7.37896 5.34506L8.99983 6.94588L10.6207 5.34506C12.0991 3.88499 14.4921 3.88498 15.9705 5.34506C17.454 6.81027 17.454 9.18971 15.9705 10.6549C14.4921 12.115 12.0991 12.115 10.6207 10.655L8.99983 9.05413L7.37896 10.655C5.9006 12.115 3.50753 12.115 2.02916 10.655C0.545627 9.18974 0.545611 6.81028 2.02915 5.34506ZM7.93251 8L6.32492 6.4123C5.4308 5.52924 3.97732 5.52923 3.08319 6.4123C2.19426 7.29026 2.19426 8.70975 3.0832 9.58772C3.97733 10.4708 5.4308 10.4707 6.32492 9.58771C6.32492 9.58772 6.32492 9.58771 6.32492 9.58771L7.93251 8ZM10.0671 8L11.6747 9.5877C11.6747 9.58769 11.6747 9.58771 11.6747 9.5877C12.5688 10.4707 14.0223 10.4707 14.9165 9.58773C15.8054 8.70975 15.8054 7.29024 14.9165 6.41229C14.0223 5.52923 12.5689 5.52924 11.6747 6.4123C11.6747 6.4123 11.6747 6.41229 11.6747 6.4123L10.0671 8Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

View File

@ -2,7 +2,7 @@
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './LoopStart.json'
import data from './Triangle.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
@ -11,6 +11,6 @@ const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseP
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'LoopStart'
Icon.displayName = 'Triangle'
export default Icon

View File

@ -0,0 +1 @@
export { default as Triangle } from './Triangle'

View File

@ -4,9 +4,9 @@
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "40",
"height": "40",
"viewBox": "0 0 40 40",
"width": "18",
"height": "16",
"viewBox": "0 0 18 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
@ -15,46 +15,18 @@
"type": "element",
"name": "g",
"attributes": {
"filter": "url(#filter0_dd_10886_10012)",
"style": "transform: scale(2.5) translate(-12px, -8px)"
"id": "loop"
},
"children": [
{
"type": "element",
"name": "rect",
"attributes": {
"x": "8",
"y": "5",
"width": "24",
"height": "24",
"rx": "8",
"fill": "#06AED4"
},
"children": []
},
{
"type": "element",
"name": "rect",
"attributes": {
"x": "8.25",
"y": "5.25",
"width": "23.5",
"height": "23.5",
"rx": "7.75",
"stroke": "#101828",
"stroke-opacity": "0.04",
"stroke-width": "0.5"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"id": "Vector",
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M13.0293 14.3451C14.5076 12.885 16.9007 12.885 18.3791 14.3451L19.9999 15.9459L21.6208 14.3451C23.0992 12.885 25.4922 12.885 26.9706 14.3451C28.4541 15.8103 28.4541 18.1897 26.9707 19.6549C25.4923 21.115 23.0992 21.115 21.6208 19.655L19.9999 18.0541L18.3791 19.655C16.9007 21.115 14.5076 21.115 13.0293 19.655C11.5457 18.1897 11.5457 15.8103 13.0293 14.3451ZM18.9326 17L17.325 15.4123C16.4309 14.5292 14.9774 14.5292 14.0833 15.4123C13.1944 16.2903 13.1944 17.7097 14.0833 18.5877C14.9774 19.4708 16.4309 19.4707 17.325 18.5877C17.325 18.5877 17.325 18.5877 17.325 18.5877L18.9326 17ZM21.0673 17L22.6748 18.5877C22.6748 18.5877 22.6748 18.5877 22.6748 18.5877C23.569 19.4707 25.0224 19.4707 25.9166 18.5877C26.8055 17.7098 26.8055 16.2902 25.9166 15.4123C25.0224 14.5292 23.569 14.5292 22.6748 15.4123C22.6748 15.4123 22.6748 15.4123 22.6748 15.4123L21.0673 17Z",
"fill": "white"
"d": "M2.02915 5.34506C3.50752 3.88498 5.9006 3.88498 7.37896 5.34506L8.99983 6.94588L10.6207 5.34506C12.0991 3.88499 14.4921 3.88498 15.9705 5.34506C17.454 6.81027 17.454 9.18971 15.9705 10.6549C14.4921 12.115 12.0991 12.115 10.6207 10.655L8.99983 9.05413L7.37896 10.655C5.9006 12.115 3.50753 12.115 2.02916 10.655C0.545627 9.18974 0.545611 6.81028 2.02915 5.34506ZM7.93251 8L6.32492 6.4123C5.4308 5.52924 3.97732 5.52923 3.08319 6.4123C2.19426 7.29026 2.19426 8.70975 3.0832 9.58772C3.97733 10.4708 5.4308 10.4707 6.32492 9.58771C6.32492 9.58772 6.32492 9.58771 6.32492 9.58771L7.93251 8ZM10.0671 8L11.6747 9.5877C11.6747 9.58769 11.6747 9.58771 11.6747 9.5877C12.5688 10.4707 14.0223 10.4707 14.9165 9.58773C15.8054 8.70975 15.8054 7.29024 14.9165 6.41229C14.0223 5.52923 12.5689 5.52924 11.6747 6.4123C11.6747 6.4123 11.6747 6.41229 11.6747 6.4123L10.0671 8Z",
"fill": "currentColor"
},
"children": []
}
@ -63,4 +35,4 @@
]
},
"name": "Loop"
}
}

View File

@ -1,36 +0,0 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "12",
"height": "12",
"viewBox": "0 0 12 12",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "icons/block-start"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"id": "Vector",
"d": "M6.8498 1.72732C6.3379 1.3754 5.6621 1.3754 5.1502 1.72732L2.1502 3.78982C1.74317 4.06965 1.5 4.53193 1.5 5.02588V8.99983C1.5 9.82828 2.17158 10.4998 3 10.4998H4.25C4.52614 10.4998 4.75 10.276 4.75 9.99983V8.24983C4.75 7.55948 5.30965 6.99983 6 6.99983C6.69035 6.99983 7.25 7.55948 7.25 8.24983V9.99983C7.25 10.276 7.47385 10.4998 7.75 10.4998H9C9.82845 10.4998 10.5 9.82828 10.5 8.99983V5.02588C10.5 4.53193 10.2568 4.06965 9.8498 3.78982L6.8498 1.72732Z",
"fill": "red"
},
"children": []
}
]
}
]
},
"name": "LoopStart"
}

View File

@ -9,12 +9,11 @@ export { default as Http } from './Http'
export { default as IfElse } from './IfElse'
export { default as IterationStart } from './IterationStart'
export { default as Iteration } from './Iteration'
export { default as LoopStart } from './LoopStart'
export { default as Loop } from './Loop'
export { default as Jinja } from './Jinja'
export { default as KnowledgeRetrieval } from './KnowledgeRetrieval'
export { default as ListFilter } from './ListFilter'
export { default as Llm } from './Llm'
export { default as Loop } from './Loop'
export { default as ParameterExtractor } from './ParameterExtractor'
export { default as QuestionClassifier } from './QuestionClassifier'
export { default as TemplatingTransform } from './TemplatingTransform'

View File

@ -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`
},
}),
],
})

View File

@ -1,60 +0,0 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import UpgradeBtn from '../upgrade-btn'
import { Plan } from '../type'
import cn from '@/utils/classnames'
import { useProviderContext } from '@/context/provider-context'
type Props = {
onClick?: () => void
isDisplayOnly?: boolean
}
const HeaderBillingBtn: FC<Props> = ({
onClick,
isDisplayOnly = false,
}) => {
const { plan, enableBilling, isFetchedPlan } = useProviderContext()
const {
type,
} = plan
const name = (() => {
if (type === Plan.professional)
return 'pro'
return type
})()
const classNames = (() => {
if (type === Plan.professional)
return `border-[#E0F2FE] ${!isDisplayOnly ? 'hover:border-[#B9E6FE]' : ''} bg-[#E0F2FE] text-[#026AA2]`
if (type === Plan.team)
return `border-[#E0EAFF] ${!isDisplayOnly ? 'hover:border-[#C7D7FE]' : ''} bg-[#E0EAFF] text-[#3538CD]`
return ''
})()
if (!enableBilling || !isFetchedPlan)
return null
if (type === Plan.sandbox)
return <UpgradeBtn onClick={isDisplayOnly ? undefined : onClick} isShort />
const handleClick = () => {
if (!isDisplayOnly && onClick)
onClick()
}
return (
<div
onClick={handleClick}
className={cn(
classNames,
'flex items-center h-[22px] px-2 rounded-md border text-xs font-semibold uppercase',
isDisplayOnly ? 'cursor-default' : 'cursor-pointer',
)}
>
{name}
</div>
)
}
export default React.memo(HeaderBillingBtn)

View File

@ -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,12 @@ 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/components/verify-state-modal'
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/components/constants'
import { useEducationVerify } from '@/service/use-education'
type Props = {
loc: string
@ -25,7 +32,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 +44,16 @@ const PlanComp: FC<Props> = ({
total,
} = plan
const [showModal, setShowModal] = React.useState(false)
const { mutateAsync } = useEducationVerify()
const handleVerify = () => {
mutateAsync().then((res) => {
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
router.push(`/education-apply?token=${res.token}`)
}).catch(() => {
setShowModal(true)
})
}
return (
<div className='bg-background-section-burn rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off'>
<div className='p-6 pb-2'>
@ -58,14 +77,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='shrink-0 flex items-center gap-1'>
{enableEducationPlan && !isEducationAccount && (
<Button variant='ghost' onClick={handleVerify}>
<RiGraduationCapLine className='w-4 h-4 mr-1'/>
{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 +119,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>
)
}

View File

@ -86,6 +86,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 = {

View File

@ -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, 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 }) => (
<>
<Menu.Button
className={`
inline-flex items-center
rounded-[20px] py-1 pr-2.5 pl-1 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='sm:mr-2 mr-0' size={32} />
{!isMobile && <>
{userProfile.name}
<RiArrowDownSLine className="w-3 h-3 ml-1 text-text-tertiary" />
</>}
<Menu.Button 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} />
</Menu.Button>
<Transition
as={Fragment}
@ -92,7 +90,15 @@ export default function AppSelector({ isMobile }: IAppSelector) {
<Menu.Item disabled>
<div className='flex flex-nowrap items-center pl-3 pr-2 py-[13px]'>
<div className='grow'>
<div className='system-md-medium text-text-primary break-all'>{userProfile.name}</div>
<div className='system-md-medium text-text-primary break-all'>
{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 text-text-tertiary break-all'>{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">
<Menu.Item>
{({ active }) => <Link
className={classNames(itemClassName, 'group',
className={cn(itemClassName, 'group',
active && 'bg-state-base-hover',
)}
href='/account'
@ -112,7 +118,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
</Link>}
</Menu.Item>
<Menu.Item>
{({ active }) => <div className={classNames(itemClassName,
{({ active }) => <div className={cn(itemClassName,
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'>
<Menu.Item>
{({ active }) => <Link
className={classNames(itemClassName, 'group justify-between',
className={cn(itemClassName, 'group justify-between',
active && 'bg-state-base-hover',
)}
href={
@ -141,7 +147,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
<div className='p-1'>
<Menu.Item>
{({ active }) => <Link
className={classNames(itemClassName, 'group justify-between',
className={cn(itemClassName, 'group justify-between',
active && 'bg-state-base-hover',
)}
href='https://roadmap.dify.ai'
@ -153,7 +159,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
</Menu.Item>
{systemFeatures.license.status === LicenseStatus.NONE && <Menu.Item>
{({ active }) => <Link
className={classNames(itemClassName, 'group justify-between',
className={cn(itemClassName, 'group justify-between',
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' && (
<Menu.Item>
{({ active }) => <div className={classNames(itemClassName, 'justify-between',
{({ active }) => <div className={cn(itemClassName, 'justify-between',
active && 'bg-state-base-hover',
)} onClick={() => setAboutVisible(true)}>
<RiInformation2Line className='shrink-0 size-4 text-text-tertiary' />
@ -186,7 +192,7 @@ export default function AppSelector({ isMobile }: IAppSelector) {
<Menu.Item>
{({ active }) => <div className='p-1' onClick={() => handleLogout()}>
<div
className={classNames(itemClassName, 'group justify-between',
className={cn(itemClassName, 'group justify-between',
active && 'bg-state-base-hover',
)}
>

View File

@ -4,10 +4,10 @@ import { useTranslation } from 'react-i18next'
import { Menu, 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 = () => {

View File

@ -94,10 +94,10 @@ const Header = () => {
}
<div className='flex items-center shrink-0'>
<EnvNav />
<div className='mr-3'>
<div className='mr-2'>
<PluginsNav />
</div>
<AccountDropdown isMobile={isMobile} />
<AccountDropdown />
</div>
{
(isMobile && isShowNavMenu) && (

View File

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

View File

@ -0,0 +1,2 @@
export const EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION = 'getEducationVerify'
export const EDUCATION_VERIFYING_LOCALSTORAGE_ITEM = 'educationVerifying'

View File

@ -0,0 +1,170 @@
'use client'
import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
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/components/constants'
const EducationApplyAge = () => {
const { t } = useTranslation()
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 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='flex justify-center p-6 w-full h-full'>
<div className='relative max-w-[1408px] w-full border border-effects-highlight bg-background-default-subtle rounded-2xl'>
<div
className="absolute top-0 w-full h-[349px] rounded-t-2xl overflow-hidden bg-no-repeat bg-cover bg-center"
style={{
backgroundImage: 'url(/education/bg.png)',
}}
>
</div>
<div className='relative flex items-center justify-between px-8 py-6 h-[88px] z-10'>
<img
src='/logo/logo-site-dark.png'
alt='dify logo'
className='h-10'
/>
</div>
<div className='relative m-auto px-8 max-w-[720px] z-10'>
<div className='flex flex-col justify-end mb-2 pt-3 pb-4 h-[192px] text-text-primary-on-surface'>
<div className='mb-2 title-5xl-bold shadow-xs'>{t('education.toVerified')}</div>
<div className='system-md-medium shadow-xs'>
{t('education.toVerifiedTip.front')}&nbsp;
<span className='system-md-semibold underline'>{t('education.toVerifiedTip.coupon')}</span>&nbsp;
{t('education.toVerifiedTip.end')}
</div>
</div>
<div className='mb-7'>
<UserInfo />
</div>
<div className='mb-7'>
<div className='flex items-center mb-1 h-6 system-md-semibold text-text-secondary'>
{t('education.form.schoolName.title')}
</div>
<SearchInput
value={schoolName}
onChange={setSchoolName}
/>
</div>
<div className='mb-7'>
<div className='flex items-center mb-1 h-6 system-md-semibold text-text-secondary'>
{t('education.form.schoolRole.title')}
</div>
<RoleSelector
value={role}
onChange={setRole}
/>
</div>
<div className='mb-7'>
<div className='flex items-center mb-1 h-6 system-md-semibold text-text-secondary'>
{t('education.form.terms.title')}
</div>
<div className='mb-1 system-md-regular text-text-tertiary'>
{t('education.form.terms.desc.front')}&nbsp;
<a href='https://dify.ai/terms' target='_blank' className='text-text-secondary hover:underline'>{t('education.form.terms.desc.termsOfService')}</a>&nbsp;
{t('education.form.terms.desc.and')}&nbsp;
<a href='https://dify.ai/privacy' target='_blank' className='text-text-secondary hover:underline'>{t('education.form.terms.desc.privacyPolicy')}</a>&nbsp;
{t('education.form.terms.desc.end')}
</div>
<div className='py-2 system-md-regular text-text-primary'>
<div className='flex mb-2'>
<Checkbox
className='shrink-0 mr-2'
checked={ageChecked}
onCheck={() => setAgeChecked(!ageChecked)}
/>
{t('education.form.terms.option.age')}
</div>
<div className='flex'>
<Checkbox
className='shrink-0 mr-2'
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>
</div>
<Confirm
isShow={!!modalShow}
title={modalShow?.title || ''}
content={modalShow?.desc}
onConfirm={modalShow?.onConfirm || (() => {})}
onCancel={modalShow?.onConfirm || (() => {})}
/>
</div>
)
}
export default EducationApplyAge

View File

@ -0,0 +1,44 @@
import {
useCallback,
useState,
} from 'react'
import { useDebounceFn } from 'ahooks'
import type { SearchParams } from './types'
import { useEducationAutocomplete } from '@/service/use-education'
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,
}
}

View File

@ -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='flex items-center mr-6 h-5 cursor-pointer system-md-regular text-text-primary'
onClick={() => onChange(option.key)}
>
<div
className={cn(
'mr-2 w-4 h-4 bg-components-radio-bg rounded-full border border-components-radio-border shadow-xs',
option.key === value && 'border-[5px] border-components-radio-border-checked ',
)}
>
</div>
{option.value}
</div>
))
}
</div>
)
}
export default RoleSelector

View File

@ -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-10'>
{
!!schools.length && value && (
<div
className='p-1 max-h-[330px] overflow-y-auto border-[0.5px] border-components-panel-border bg-components-panel-bg-blur rounded-xl'
onScroll={handleScroll as any}
>
{
schools.map((school, index) => (
<div
key={index}
className='flex items-center px-2 py-1.5 h-8 system-md-regular text-text-secondary truncate cursor-pointer hover:bg-state-base-hover rounded-lg'
title={school}
onClick={() => {
onChange(school)
setOpen(false)
}}
>
{school}
</div>
))
}
</div>
)
}
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default SearchInput

View File

@ -0,0 +1,11 @@
export type SearchParams = {
keywords?: string
page?: number
limit?: number
}
export type EducationAddParams = {
token: string
institution: string
role: string
}

View File

@ -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 pl-6 pt-9 pr-8 pb-6 bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 border-[4px] border-components-panel-on-panel-item-bg rounded-xl shadow-shadow-shadow-5'>
<div className='absolute top-0 left-0 flex items-center'>
<div className='flex items-center pl-2 pt-1 h-[22px] bg-components-panel-on-panel-item-bg system-2xs-semibold-uppercase text-text-accent-light-mode-only'>
{t('education.currentSigned')}
</div>
<Triangle className='w-4 h-[22px] 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

View File

@ -0,0 +1,108 @@
import React, { useEffect, 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'
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 dialogRef = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(isShow)
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 flex items-center justify-center z-[10000000] bg-background-overlay'}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<div ref={dialogRef} className={'relative w-full max-w-[481px] overflow-hidden'}>
<div className='flex flex-col items-start max-w-full rounded-2xl border-[0.5px] border-solid border-components-panel-border shadows-shadow-lg bg-components-panel-bg'>
<div className='flex pt-6 pl-6 pr-6 pb-4 flex-col items-start gap-2 self-stretch'>
<div className='title-2xl-semi-bold text-text-primary'>{title}</div>
<div className='system-md-regular text-text-tertiary w-full'>{content}</div>
</div>
{email && (
<div className='px-6 py-3 space-y-1 w-full'>
<div className='text-text-secondary system-sm-semibold py-1'>{t('education.emailLabel')}</div>
<div className='px-3 py-2 bg-components-input-bg-disabled rounded-lg text-components-input-text-filled-disabled system-sm-regular'>{email}</div>
</div>
)}
<div className='flex p-6 gap-2 justify-between items-center self-stretch'>
<div className='flex items-center gap-1'>
{showLink && (
<>
<a href='' className='text-text-accent system-xs-regular cursor-pointer'>{t('education.learn')}</a>
<RiExternalLinkLine className='w-3 h-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)

View File

@ -0,0 +1,33 @@
import React from 'react'
import type { ReactNode } from 'react'
import SwrInitor from '@/app/components/swr-initor'
import { AppContextProvider } from '@/context/app-context'
import GA, { GaType } from '@/app/components/base/ga'
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>
{children}
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</SwrInitor>
</>
)
}
export const metadata = {
title: 'Dify',
}
export default Layout

View File

@ -0,0 +1,29 @@
'use client'
import {
useEffect,
useMemo,
} from 'react'
import {
useRouter,
useSearchParams,
} from 'next/navigation'
import EducationApplyAge from './components/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 <EducationApplyAge />
}

View File

@ -17,6 +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/components/constants'
import Pricing from '@/app/components/billing/pricing'
import type { ModerationConfig, PromptVariable } from '@/models/debug'
@ -121,6 +124,10 @@ 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)
setShowAccountSettingModal(null)
if (showAccountSettingModal?.onCancelCallback)
showAccountSettingModal?.onCancelCallback()

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
web/public/education/bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

View File

@ -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/components/types'
const NAME_SPACE = 'education'
export const useEducationVerify = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'education-verify'],
mutationFn: () => {
return get<{ token: string }>('/account/education/verify')
},
})
}
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'])
}