From d648ce6888357c4fb82e2c4e64bed57f26efbb05 Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 6 May 2026 18:42:03 +0800 Subject: [PATCH 01/10] chore: improve the progress of education pay (#35851) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 10 - web/__mocks__/provider-context.ts | 1 + .../(commonLayout)/education-apply/page.tsx | 25 +- .../apikey-info-panel.test-utils.tsx | 1 + .../billing/hooks/use-education-discount.ts | 37 ++ .../billing/plan/__tests__/index.spec.tsx | 79 +++- web/app/components/billing/plan/index.tsx | 16 +- .../billing/pricing/__tests__/index.spec.tsx | 35 ++ web/app/components/billing/pricing/index.tsx | 8 +- .../cloud-plan-item/__tests__/index.spec.tsx | 131 +++++++ .../pricing/plans/cloud-plan-item/button.tsx | 36 +- .../pricing/plans/cloud-plan-item/index.tsx | 90 ++++- .../workplace-selector/index.tsx | 66 +++- .../applied-education-content.tsx | 95 +++++ .../education-apply/education-apply-page.tsx | 365 +++++++++++++----- .../education-apply/verify-state-modal.tsx | 24 +- web/context/provider-context-provider.tsx | 5 + web/context/provider-context.ts | 2 + web/i18n/en-US/education.json | 24 +- web/i18n/zh-Hans/education.json | 24 +- web/i18n/zh-Hant/education.json | 7 + 21 files changed, 905 insertions(+), 176 deletions(-) create mode 100644 web/app/components/billing/hooks/use-education-discount.ts create mode 100644 web/app/education-apply/applied-education-content.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index cd37f0ed89..2d099669d1 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1921,11 +1921,6 @@ "count": 4 } }, - "web/app/components/billing/plan/index.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/billing/pricing/assets/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 12 @@ -5099,11 +5094,6 @@ "count": 5 } }, - "web/app/education-apply/verify-state-modal.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/forgot-password/ForgotPasswordForm.spec.tsx": { "ts/no-explicit-any": { "count": 5 diff --git a/web/__mocks__/provider-context.ts b/web/__mocks__/provider-context.ts index d3296bacd0..10fac8d8b6 100644 --- a/web/__mocks__/provider-context.ts +++ b/web/__mocks__/provider-context.ts @@ -13,6 +13,7 @@ export const baseProviderContextValue: ProviderContextState = { isAPIKeySet: true, plan: defaultPlan, isFetchedPlan: false, + isFetchedPlanInfo: false, enableBilling: false, onPlanInfoChanged: noop, enableReplaceWebAppLogo: false, diff --git a/web/app/(commonLayout)/education-apply/page.tsx b/web/app/(commonLayout)/education-apply/page.tsx index 44ba5ee8ad..82e47d5c0b 100644 --- a/web/app/(commonLayout)/education-apply/page.tsx +++ b/web/app/(commonLayout)/education-apply/page.tsx @@ -1,10 +1,8 @@ 'use client' -import { - useEffect, - useMemo, -} from 'react' +import { useEffect } from 'react' import EducationApplyPage from '@/app/education-apply/education-apply-page' +import RootLoading from '@/app/loading' import { useProviderContext } from '@/context/provider-context' import { useRouter, @@ -13,17 +11,24 @@ import { export default function EducationApply() { const router = useRouter() - const { enableEducationPlan } = useProviderContext() + const { + enableEducationPlan, + isFetchedPlanInfo, + isLoadingEducationAccountInfo, + } = useProviderContext() const searchParams = useSearchParams() const token = searchParams.get('token') - const showEducationApplyPage = useMemo(() => { - return enableEducationPlan && token - }, [enableEducationPlan, token]) useEffect(() => { - if (!showEducationApplyPage) + if (!isFetchedPlanInfo) + return + + if (!enableEducationPlan || !token) router.replace('/') - }, [showEducationApplyPage, router]) + }, [enableEducationPlan, isFetchedPlanInfo, router, token]) + + if (!isFetchedPlanInfo || !enableEducationPlan || !token || isLoadingEducationAccountInfo) + return return } diff --git a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx index 5d3c008989..1be3799480 100644 --- a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx +++ b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx @@ -31,6 +31,7 @@ const defaultProviderContext = { isAPIKeySet: false, plan: defaultPlan, isFetchedPlan: false, + isFetchedPlanInfo: false, enableBilling: false, onPlanInfoChanged: noop, enableReplaceWebAppLogo: false, diff --git a/web/app/components/billing/hooks/use-education-discount.ts b/web/app/components/billing/hooks/use-education-discount.ts new file mode 100644 index 0000000000..dedad4707e --- /dev/null +++ b/web/app/components/billing/hooks/use-education-discount.ts @@ -0,0 +1,37 @@ +'use client' +import { toast } from '@langgenius/dify-ui/toast' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useAppContext } from '@/context/app-context' +import { fetchSubscriptionUrls } from '@/service/billing' +import { Plan } from '../type' + +export const useEducationDiscount = () => { + const { t } = useTranslation() + const { isCurrentWorkspaceManager } = useAppContext() + const [isEducationDiscountLoading, setIsEducationDiscountLoading] = useState(false) + + const handleEducationDiscount = useCallback(async () => { + if (isEducationDiscountLoading) + return + + if (!isCurrentWorkspaceManager) { + toast.error(t('buyPermissionDeniedTip', { ns: 'billing' })) + return + } + + setIsEducationDiscountLoading(true) + try { + const res = await fetchSubscriptionUrls(Plan.professional, 'year') + window.location.href = res.url + } + finally { + setIsEducationDiscountLoading(false) + } + }, [isCurrentWorkspaceManager, isEducationDiscountLoading, t]) + + return { + handleEducationDiscount, + isEducationDiscountLoading, + } +} diff --git a/web/app/components/billing/plan/__tests__/index.spec.tsx b/web/app/components/billing/plan/__tests__/index.spec.tsx index 27f6b3005d..e9e0fd7012 100644 --- a/web/app/components/billing/plan/__tests__/index.spec.tsx +++ b/web/app/components/billing/plan/__tests__/index.spec.tsx @@ -1,11 +1,15 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants' +import { fetchSubscriptionUrls } from '@/service/billing' import { Plan, SelfHostedPlan } from '../../type' import PlanComp from '../index' let currentPath = '/billing' const push = vi.fn() +let isCurrentWorkspaceManager = true +let assignedHref = '' +const originalLocation = window.location vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push }), @@ -27,10 +31,16 @@ vi.mock('@/context/provider-context', () => ({ vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ userProfile: { email: 'user@example.com' }, - isCurrentWorkspaceManager: true, + isCurrentWorkspaceManager, }), })) +vi.mock('@/service/billing', () => ({ + fetchSubscriptionUrls: vi.fn(), +})) + +const fetchSubscriptionUrlsMock = vi.mocked(fetchSubscriptionUrls) + const mutateAsyncMock = vi.fn() let isPending = false vi.mock('@/service/use-education', () => ({ @@ -78,10 +88,26 @@ describe('PlanComp', () => { }, } + beforeAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { + get href() { + return assignedHref + }, + set href(value: string) { + assignedHref = value + }, + } as unknown as Location, + }) + }) + beforeEach(() => { vi.clearAllMocks() currentPath = '/billing' isPending = false + isCurrentWorkspaceManager = true + assignedHref = '' providerContextMock.mockReturnValue({ plan: planMock, enableEducationPlan: true, @@ -90,6 +116,14 @@ describe('PlanComp', () => { }) mutateAsyncMock.mockReset() mutateAsyncMock.mockResolvedValue({ token: 'token' }) + fetchSubscriptionUrlsMock.mockResolvedValue({ url: 'https://subscription.example' }) + }) + + afterAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }) }) it('renders plan info and handles education verify success', async () => { @@ -170,6 +204,49 @@ describe('PlanComp', () => { expect(screen.getByText('education.toVerified'))!.toBeInTheDocument() }) + it('shows education discount button and keeps upgrade button for education accounts', async () => { + providerContextMock.mockReturnValue({ + plan: { ...planMock, type: Plan.sandbox }, + enableEducationPlan: true, + allowRefreshEducationVerify: false, + isEducationAccount: true, + }) + render() + + fireEvent.click(screen.getByText('education.useEducationDiscount')) + + await waitFor(() => { + expect(fetchSubscriptionUrlsMock).toHaveBeenCalledWith(Plan.professional, 'year') + expect(assignedHref).toBe('https://subscription.example') + }) + expect(screen.getByTestId('plan-upgrade-btn'))!.toBeInTheDocument() + }) + + it('does not show education discount button for non-sandbox education accounts', () => { + providerContextMock.mockReturnValue({ + plan: planMock, + enableEducationPlan: true, + allowRefreshEducationVerify: false, + isEducationAccount: true, + }) + render() + + expect(screen.queryByText('education.useEducationDiscount')).not.toBeInTheDocument() + }) + + it('does not show education discount button for non-manager sandbox education accounts', () => { + isCurrentWorkspaceManager = false + providerContextMock.mockReturnValue({ + plan: { ...planMock, type: Plan.sandbox }, + enableEducationPlan: true, + allowRefreshEducationVerify: false, + isEducationAccount: true, + }) + render() + + expect(screen.queryByText('education.useEducationDiscount')).not.toBeInTheDocument() + }) + it('renders enterprise plan without upgrade button', () => { providerContextMock.mockReturnValue({ plan: { ...planMock, type: SelfHostedPlan.enterprise }, diff --git a/web/app/components/billing/plan/index.tsx b/web/app/components/billing/plan/index.tsx index 49d4ffa779..498736475c 100644 --- a/web/app/components/billing/plan/index.tsx +++ b/web/app/components/billing/plan/index.tsx @@ -23,6 +23,7 @@ import { useEducationVerify } from '@/service/use-education' import { getDaysUntilEndOfMonth } from '@/utils/time' import { Loading } from '../../base/icons/src/public/thought' import { NUM_INFINITE } from '../config' +import { useEducationDiscount } from '../hooks/use-education-discount' import { Plan, SelfHostedPlan } from '../type' import UpgradeBtn from '../upgrade-btn' import AppsInfo from '../usage-info/apps-info' @@ -39,12 +40,13 @@ const PlanComp: FC = ({ const { t } = useTranslation() const router = useRouter() const path = usePathname() - const { userProfile } = useAppContext() + const { userProfile, isCurrentWorkspaceManager } = useAppContext() const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } = useProviderContext() const isAboutToExpire = allowRefreshEducationVerify const { type, } = plan + const isEnterprisePlan = String(type) === SelfHostedPlan.enterprise const { usage, @@ -65,6 +67,7 @@ const PlanComp: FC = ({ })() const [showModal, setShowModal] = React.useState(false) + const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount() const { mutateAsync, isPending } = useEducationVerify() const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) const unmountedRef = useUnmountedRef() @@ -97,7 +100,7 @@ const PlanComp: FC = ({ {plan.type === Plan.team && ( )} - {(plan.type as any) === SelfHostedPlan.enterprise && ( + {isEnterprisePlan && ( )}
@@ -115,7 +118,14 @@ const PlanComp: FC = ({ {isPending && } )} - {(plan.type as any) !== SelfHostedPlan.enterprise && ( + {enableEducationPlan && isEducationAccount && type === Plan.sandbox && isCurrentWorkspaceManager && ( + + )} + {!isEnterprisePlan && ( { usage: buildUsage(), total: buildUsage(), }, + enableEducationPlan: false, + isEducationAccount: false, }) ;(useGetPricingPageLanguage as Mock).mockImplementation(() => mockLanguage) }) @@ -72,6 +74,39 @@ describe('Pricing', () => { expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument() expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features') }) + + it('should default to yearly billing for education accounts', () => { + ;(useProviderContext as Mock).mockReturnValue({ + plan: { + type: Plan.sandbox, + usage: buildUsage(), + total: buildUsage(), + }, + enableEducationPlan: true, + isEducationAccount: true, + }) + + render() + + expect(screen.getByRole('switch')).toBeChecked() + }) + + it('should not default to yearly billing for non-manager education accounts', () => { + ;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: false }) + ;(useProviderContext as Mock).mockReturnValue({ + plan: { + type: Plan.sandbox, + usage: buildUsage(), + total: buildUsage(), + }, + enableEducationPlan: true, + isEducationAccount: true, + }) + + render() + + expect(screen.getByRole('switch')).not.toBeChecked() + }) }) describe('Props', () => { diff --git a/web/app/components/billing/pricing/index.tsx b/web/app/components/billing/pricing/index.tsx index cd88be5fb3..6d9b0f67cf 100644 --- a/web/app/components/billing/pricing/index.tsx +++ b/web/app/components/billing/pricing/index.tsx @@ -39,9 +39,11 @@ const pricingScrollAreaClassNames = { const Pricing: FC = ({ onCancel, }) => { - const { plan } = useProviderContext() + const { plan, enableEducationPlan, isEducationAccount } = useProviderContext() const { isCurrentWorkspaceManager } = useAppContext() - const [planRange, setPlanRange] = React.useState(PlanRange.monthly) + const shouldDefaultToYearly = isCurrentWorkspaceManager && enableEducationPlan && isEducationAccount + const [selectedPlanRange, setSelectedPlanRange] = React.useState() + const planRange = selectedPlanRange ?? (shouldDefaultToYearly ? PlanRange.yearly : PlanRange.monthly) const [currentCategory, setCurrentCategory] = useState(CategoryEnum.CLOUD) const canPay = isCurrentWorkspaceManager @@ -73,7 +75,7 @@ const Pricing: FC = ({ currentCategory={currentCategory} onChangeCategory={setCurrentCategory} currentPlanRange={planRange} - onChangePlanRange={setPlanRange} + onChangePlanRange={setSelectedPlanRange} /> ({ useAppContext: vi.fn(), })) +vi.mock('@/context/provider-context', () => ({ + useProviderContext: vi.fn(), +})) + vi.mock('@/service/billing', () => ({ fetchSubscriptionUrls: vi.fn(), })) @@ -38,6 +43,7 @@ vi.mock('../../../assets', () => ({ })) const mockUseAppContext = useAppContext as Mock +const mockUseProviderContext = useProviderContext as Mock const mockUseAsyncWindowOpen = useAsyncWindowOpen as Mock const mockBillingInvoices = consoleClient.billing.invoices as Mock const mockFetchSubscriptionUrls = fetchSubscriptionUrls as Mock @@ -72,6 +78,10 @@ beforeEach(() => { vi.clearAllMocks() toast.dismiss() mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true }) + mockUseProviderContext.mockReturnValue({ + enableEducationPlan: false, + isEducationAccount: false, + }) mockUseAsyncWindowOpen.mockReturnValue(vi.fn(async open => await open())) mockBillingInvoices.mockResolvedValue({ url: 'https://billing.example' }) mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://subscription.example' }) @@ -260,6 +270,127 @@ describe('CloudPlanItem', () => { }) }) + it('should use education discount checkout for yearly professional plan when education account is active', async () => { + mockUseProviderContext.mockReturnValue({ + enableEducationPlan: true, + isEducationAccount: true, + }) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'education.useEducationDiscount' })) + + await waitFor(() => { + expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'year') + expect(assignedHref).toBe('https://subscription.example') + }) + }) + + it('should show default CTA and hide warning when current user is not workspace manager', () => { + mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false }) + mockUseProviderContext.mockReturnValue({ + enableEducationPlan: true, + isEducationAccount: true, + }) + + render( + , + ) + + expect(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))!.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'education.useEducationDiscount' })).not.toBeInTheDocument() + expect(screen.queryByText('education.planNotSupportEducationDiscount')).not.toBeInTheDocument() + }) + + it('should hide education unsupported warning when current user is not workspace manager', () => { + mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false }) + mockUseProviderContext.mockReturnValue({ + enableEducationPlan: true, + isEducationAccount: true, + }) + + render( + , + ) + + expect(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))!.toBeInTheDocument() + expect(screen.queryByText('education.planNotSupportEducationDiscount')).not.toBeInTheDocument() + }) + + it('should show education unsupported warning below the button without changing button text or blocking checkout', async () => { + mockUseProviderContext.mockReturnValue({ + enableEducationPlan: true, + isEducationAccount: true, + }) + + render( + , + ) + + const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }) + expect(button)!.not.toBeDisabled() + expect(screen.getByText('education.planNotSupportEducationDiscount'))!.toBeInTheDocument() + + fireEvent.click(button) + expect(screen.getByText('education.educationPricingConfirm.title'))!.toBeInTheDocument() + expect(screen.getByText(/^education\.educationPricingConfirm\.description/))!.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.close' }))!.not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }))!.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.continue' })) + + await waitFor(() => { + expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month') + expect(assignedHref).toBe('https://subscription.example') + }) + }) + + it('should close the unsupported plan confirm without checkout when canceled', async () => { + mockUseProviderContext.mockReturnValue({ + enableEducationPlan: true, + isEducationAccount: true, + }) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' })) + fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' })) + + await waitFor(() => { + expect(screen.queryByText('education.educationPricingConfirm.title'))!.not.toBeInTheDocument() + }) + expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() + expect(assignedHref).toBe('') + }) + // Covers L62-63: loading guard prevents double click it('should ignore second click while loading', async () => { // Make the first fetch hang until we resolve it diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx index 8115646748..5e3f1cab0d 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx @@ -1,6 +1,5 @@ import type { BasicPlan } from '../../../type' import { cn } from '@langgenius/dify-ui/cn' -import { RiArrowRightLine } from '@remixicon/react' import * as React from 'react' import { Plan } from '../../../type' @@ -24,6 +23,7 @@ type ButtonProps = { isPlanDisabled: boolean btnText: string handleGetPayUrl: () => void + warningText?: string } const Button = ({ @@ -31,22 +31,30 @@ const Button = ({ isPlanDisabled, btnText, handleGetPayUrl, + warningText, }: ButtonProps) => { return ( - + {warningText && ( +
+ {warningText} +
)} - onClick={handleGetPayUrl} - > - {btnText} - {!isPlanDisabled && } - +
) } diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx index 53d5025f08..d3dc47b29f 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx @@ -1,15 +1,26 @@ 'use client' import type { FC } from 'react' import type { BasicPlan } from '../../../type' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useAppContext } from '@/context/app-context' +import { useProviderContext } from '@/context/provider-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { fetchSubscriptionUrls } from '@/service/billing' import { consoleClient } from '@/service/client' import { ALL_PLANS } from '../../../config' +import { useEducationDiscount } from '../../../hooks/use-education-discount' import { Plan } from '../../../type' import { Professional, Sandbox, Team } from '../../assets' import { PlanRange } from '../../plan-switcher/plan-range-switcher' @@ -22,6 +33,10 @@ const ICON_MAP = { [Plan.team]: , } +type ConfirmType = { + type: 'info' | 'warning' +} + type CloudPlanItemProps = { currentPlan: BasicPlan plan: BasicPlan @@ -33,6 +48,7 @@ const CloudPlanItem: FC = ({ plan, currentPlan, planRange, + canPay, }) => { const { t } = useTranslation() const [loading, setLoading] = React.useState(false) @@ -45,9 +61,23 @@ const CloudPlanItem: FC = ({ const isCurrentPaidPlan = isCurrent && !isFreePlan const isPlanDisabled = isCurrentPaidPlan ? false : planInfo.level <= ALL_PLANS[currentPlan].level const { isCurrentWorkspaceManager } = useAppContext() + const { enableEducationPlan, isEducationAccount } = useProviderContext() + const isEducationDiscountMode = enableEducationPlan && isEducationAccount + const isEducationDiscountSupportedPlan = plan === Plan.professional && isYear + const selectedPlanName = t(`${i18nPrefix}.name`, { ns: 'billing' }) + const selectedBillingPeriod = t(`educationPricingConfirm.billingPeriod.${isYear ? 'yearly' : 'monthly'}`, { ns: 'education' }) + const educationDiscountWarningText = canPay && isEducationDiscountMode && !isFreePlan && !isEducationDiscountSupportedPlan + ? t('planNotSupportEducationDiscount', { ns: 'education' }) + : undefined const openAsyncWindow = useAsyncWindowOpen() + const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount() + const [showEducationPricingConfirm, setShowEducationPricingConfirm] = React.useState(false) + const educationPricingConfirmInfo: ConfirmType = { type: 'warning' } const btnText = useMemo(() => { + if (canPay && isEducationDiscountMode && isEducationDiscountSupportedPlan && !isCurrent) + return t('useEducationDiscount', { ns: 'education' }) + if (isCurrent) return t('plansCommon.currentPlan', { ns: 'billing' }) @@ -56,15 +86,20 @@ const CloudPlanItem: FC = ({ [Plan.professional]: t('plansCommon.startBuilding', { ns: 'billing' }), [Plan.team]: t('plansCommon.getStarted', { ns: 'billing' }), })[plan] - }, [isCurrent, plan, t]) + }, [canPay, isCurrent, isEducationDiscountMode, isEducationDiscountSupportedPlan, plan, t]) - const handleGetPayUrl = async () => { - if (loading) + const handlePayCurrentPlan = async () => { + if (loading || isEducationDiscountLoading) return if (isPlanDisabled) return + if (isEducationDiscountMode && isEducationDiscountSupportedPlan && !isCurrentPaidPlan) { + await handleEducationDiscount() + return + } + if (!isCurrentWorkspaceManager) { toast.error(t('buyPermissionDeniedTip', { ns: 'billing' })) return @@ -96,6 +131,18 @@ const CloudPlanItem: FC = ({ setLoading(false) } } + const handleGetPayUrl = async () => { + if (educationDiscountWarningText && !isPlanDisabled) { + setShowEducationPricingConfirm(true) + return + } + + await handlePayCurrentPlan() + } + const handleContinueCurrentPlan = async () => { + setShowEducationPricingConfirm(false) + await handlePayCurrentPlan() + } return (
@@ -146,9 +193,46 @@ const CloudPlanItem: FC = ({ isPlanDisabled={isPlanDisabled} btnText={btnText} handleGetPayUrl={handleGetPayUrl} + warningText={educationDiscountWarningText} />
+ + {showEducationPricingConfirm &&
} + +
+ + {t('educationPricingConfirm.title', { ns: 'education' })} + + + {t('educationPricingConfirm.description', { + ns: 'education', + planName: selectedPlanName, + billingPeriod: selectedBillingPeriod, + })} + +
+ + setShowEducationPricingConfirm(false)} + disabled={loading} + > + {t('educationPricingConfirm.cancel', { ns: 'education' })} + + + {t('educationPricingConfirm.continue', { ns: 'education' })} + + +
+
) } diff --git a/web/app/components/header/account-dropdown/workplace-selector/index.tsx b/web/app/components/header/account-dropdown/workplace-selector/index.tsx index ae6f6729fd..a86da65797 100644 --- a/web/app/components/header/account-dropdown/workplace-selector/index.tsx +++ b/web/app/components/header/account-dropdown/workplace-selector/index.tsx @@ -1,4 +1,5 @@ import type { Plan } from '@/app/components/billing/type' +import type { IWorkspace } from '@/models/common' import { Select, SelectContent, @@ -9,12 +10,58 @@ import { SelectTrigger, } from '@langgenius/dify-ui/select' import { toast } from '@langgenius/dify-ui/toast' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import PlanBadge from '@/app/components/header/plan-badge' import { useWorkspacesContext } from '@/context/workspace-context' import { switchWorkspace } from '@/service/common' import { basePath } from '@/utils/var' +type WorkplaceSelectorContentProps = { + workspaces: IWorkspace[] + popupClassName?: string +} + +type WorkplaceSelectorItemProps = { + workspace: IWorkspace +} + +const WorkplaceSelectorItem = memo(({ + workspace, +}: WorkplaceSelectorItemProps) => ( + +
+ + {workspace.name[0]?.toLocaleUpperCase()} + +
+ {workspace.name} + +
+)) +WorkplaceSelectorItem.displayName = 'WorkplaceSelectorItem' + +export const WorkplaceSelectorContent = memo(({ + workspaces, + popupClassName = 'w-[280px] transition-none data-starting-style:scale-100 data-starting-style:opacity-100 data-ending-style:scale-100 data-ending-style:opacity-100', +}: WorkplaceSelectorContentProps) => { + const { t } = useTranslation() + + return ( + + + + {t('userProfile.workspace', { ns: 'common' })} + + {workspaces.map(workspace => ( + + ))} + + + ) +}) +WorkplaceSelectorContent.displayName = 'WorkplaceSelectorContent' + const WorkplaceSelector = () => { const { t } = useTranslation() const { workspaces } = useWorkspacesContext() @@ -55,24 +102,7 @@ const WorkplaceSelector = () => { - - - - {t('userProfile.workspace', { ns: 'common' })} - - {workspaces.map(workspace => ( - -
- - {workspace.name[0]?.toLocaleUpperCase()} - -
- {workspace.name} - -
- ))} -
-
+ ) } diff --git a/web/app/education-apply/applied-education-content.tsx b/web/app/education-apply/applied-education-content.tsx new file mode 100644 index 0000000000..c3ff35b1b9 --- /dev/null +++ b/web/app/education-apply/applied-education-content.tsx @@ -0,0 +1,95 @@ +'use client' + +import type { ReactNode } from 'react' +import type { Plan as PlanType } from '@/app/components/billing/type' +import type { ICurrentWorkspace, IWorkspace } from '@/models/common' +import { + Select, + SelectTrigger, +} from '@langgenius/dify-ui/select' +import { useTranslation } from 'react-i18next' +import { Plan } from '@/app/components/billing/type' +import { WorkplaceSelectorContent } from '@/app/components/header/account-dropdown/workplace-selector' +import PlanBadge from '@/app/components/header/plan-badge' + +type AppliedEducationContentProps = { + workspaces: IWorkspace[] + currentWorkspace: ICurrentWorkspace + plan: PlanType + action: ReactNode + onSwitchWorkspace: (tenantId: string) => void +} + +const AppliedEducationContent = ({ + workspaces, + currentWorkspace, + plan, + action, + onSwitchWorkspace, +}: AppliedEducationContentProps) => { + const { t } = useTranslation() + const currentWorkspaceInList = workspaces.find(workspace => workspace.current) + const workspacePlan = Object.values(Plan).includes(currentWorkspaceInList?.plan as Plan) + ? currentWorkspaceInList?.plan as Plan + : Object.values(Plan).includes(plan as Plan) + ? plan as Plan + : Plan.sandbox + const workspaceName = currentWorkspaceInList?.name || currentWorkspace?.name + const workspaceId = currentWorkspaceInList?.id || currentWorkspace?.id + + return ( +
+
+
+
+ +
+
+
+ {t('applied.step1.description', { ns: 'education' })} +
+
+
+
+
+
+
+ 2 +
+
+
+ {t('applied.step2.description', { ns: 'education' })} +
+
+
+
+ +
+ {action} +
+
+
+
+ ) +} + +export default AppliedEducationContent diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index 7998af6e66..555e82e1d8 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -1,57 +1,79 @@ 'use client' +import type { ReactNode } from 'react' +import type { Plan as PlanType } from '@/app/components/billing/type' +import type { ICurrentWorkspace } from '@/models/common' import { Button } from '@langgenius/dify-ui/button' import { toast } from '@langgenius/dify-ui/toast' -import { RiExternalLinkLine } from '@remixicon/react' +import { useQueryClient } from '@tanstack/react-query' import { noop } from 'es-toolkit/function' -import { - useState, -} from 'react' -import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' +import { useEducationDiscount } from '@/app/components/billing/hooks/use-education-discount' +import { Plan } from '@/app/components/billing/type' import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants' +import { useAppContext } from '@/context/app-context' import { useDocLink } from '@/context/i18n' import { useProviderContext } from '@/context/provider-context' +import { useWorkspacesContext } from '@/context/workspace-context' +import { WorkspaceProvider } from '@/context/workspace-context-provider' +import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useRouter, useSearchParams, } from '@/next/navigation' +import { consoleClient } from '@/service/client' +import { switchWorkspace } from '@/service/common' +import { commonQueryKeys } from '@/service/use-common' import { useEducationAdd, useInvalidateEducationStatus, } from '@/service/use-education' import DifyLogo from '../components/base/logo/dify-logo' +import AppliedEducationContent from './applied-education-content' import RoleSelector from './role-selector' import SearchInput from './search-input' import UserInfo from './user-info' -import Confirm from './verify-state-modal' -const EducationApplyAge = () => { +const AppliedEducationCase = { + eligible: 'eligible', + activeSubscription: 'activeSubscription', + noPaymentPermission: 'noPaymentPermission', +} as const + +const EducationApplyAgeContent = () => { const { t } = useTranslation() const [schoolName, setSchoolName] = useState('') const [role, setRole] = useState('Student') const [ageChecked, setAgeChecked] = useState(false) const [inSchoolChecked, setInSchoolChecked] = useState(false) + const [hasSubmittedEducation, setHasSubmittedEducation] = useState(false) + const [isOpeningBillingPortal, setIsOpeningBillingPortal] = useState(false) const { isPending, mutateAsync: educationAdd, } = useEducationAdd({ onSuccess: noop }) - const [modalShow, setShowModal] = useState void }>(undefined) - const { onPlanInfoChanged } = useProviderContext() + const { onPlanInfoChanged, isEducationAccount, plan } = useProviderContext() + const { currentWorkspace, isCurrentWorkspaceManager } = useAppContext() const updateEducationStatus = useInvalidateEducationStatus() - const router = useRouter() const docLink = useDocLink() - - const handleModalConfirm = () => { - setShowModal(undefined) - onPlanInfoChanged() - updateEducationStatus() - localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) - router.replace('/') - } + const { handleEducationDiscount } = useEducationDiscount() + const router = useRouter() + const openAsyncWindow = useAsyncWindowOpen() + const queryClient = useQueryClient() const searchParams = useSearchParams() const token = searchParams.get('token') + const appliedEducationCase = (() => { + if (!isCurrentWorkspaceManager) + return AppliedEducationCase.noPaymentPermission + + if (plan.type === Plan.sandbox) + return AppliedEducationCase.eligible + + return AppliedEducationCase.activeSubscription + })() const handleSubmit = () => { educationAdd({ token: token || '', @@ -59,17 +81,113 @@ const EducationApplyAge = () => { institution: schoolName, }).then((res) => { if (res.message === 'success') { - setShowModal({ - title: t('successTitle', { ns: 'education' }), - desc: t('successContent', { ns: 'education' }), - onConfirm: handleModalConfirm, - }) + onPlanInfoChanged() + updateEducationStatus() + localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) + setHasSubmittedEducation(true) } else { toast.error(t('submitError', { ns: 'education' })) } }) } + const handleOpenBillingPortal = async () => { + if (isOpeningBillingPortal) + return + + setIsOpeningBillingPortal(true) + try { + await openAsyncWindow(async () => { + const res = await consoleClient.billing.invoices() + if (res.url) + return res.url + + throw new Error('Failed to open billing page') + }, { + onError: (err) => { + toast.error(err.message || String(err)) + }, + }) + } + finally { + setIsOpeningBillingPortal(false) + } + } + const handleReturnHome = () => { + router.push('/') + } + const renderBackToDifyButton = () => ( + + ) + const handleSwitchWorkspace = async (tenantId: string) => { + if (tenantId === currentWorkspace?.id) + return + + try { + await switchWorkspace({ url: '/workspaces/switch', body: { tenant_id: tenantId } }) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: commonQueryKeys.currentWorkspace }), + queryClient.invalidateQueries({ queryKey: commonQueryKeys.workspaces }), + ]) + onPlanInfoChanged() + updateEducationStatus() + } + catch { + toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' })) + } + } + + const renderAppliedEducationAction = () => { + if (appliedEducationCase === AppliedEducationCase.eligible) { + return ( + + ) + } + + if (appliedEducationCase === AppliedEducationCase.activeSubscription) { + return ( +
+
+ +
+ + ), + }} + /> +
+
+ {renderBackToDifyButton()} +
+ ) + } + + return ( +
+
+ +
+ {t('applied.noPaymentPermission.description', { ns: 'education' })} +
+
+ {renderBackToDifyButton()} +
+ ) + } return (
@@ -89,94 +207,141 @@ const EducationApplyAge = () => {
{t('toVerified', { ns: 'education' })}
{t('toVerifiedTip.front', { ns: 'education' })} -  +   {t('toVerifiedTip.coupon', { ns: 'education' })} -  +   {t('toVerifiedTip.end', { ns: 'education' })}
-
-
- {t('form.schoolName.title', { ns: 'education' })} -
- -
-
-
- {t('form.schoolRole.title', { ns: 'education' })} -
- -
-
-
- {t('form.terms.title', { ns: 'education' })} -
-
- {t('form.terms.desc.front', { ns: 'education' })} -  - {t('form.terms.desc.termsOfService', { ns: 'education' })} -  - {t('form.terms.desc.and', { ns: 'education' })} -  - {t('form.terms.desc.privacyPolicy', { ns: 'education' })} - {t('form.terms.desc.end', { ns: 'education' })} -
-
-
- setAgeChecked(!ageChecked)} - /> - {t('form.terms.option.age', { ns: 'education' })} -
-
- setInSchoolChecked(!inSchoolChecked)} - /> - {t('form.terms.option.inSchool', { ns: 'education' })} -
-
-
- -
- - {t('learn', { ns: 'education' })} - - + {isEducationAccount || hasSubmittedEducation + ? ( +
+ { + void handleSwitchWorkspace(value) + }} + /> +
+ ) + : ( + <> +
+
+ {t('form.schoolName.title', { ns: 'education' })} +
+ +
+
+
+ {t('form.schoolRole.title', { ns: 'education' })} +
+ +
+
+
+ {t('form.terms.title', { ns: 'education' })} +
+
+ {t('form.terms.desc.front', { ns: 'education' })} +   + {t('form.terms.desc.termsOfService', { ns: 'education' })} +   + {t('form.terms.desc.and', { ns: 'education' })} +   + {t('form.terms.desc.privacyPolicy', { ns: 'education' })} + {t('form.terms.desc.end', { ns: 'education' })} +
+
+
+ setAgeChecked(!ageChecked)} + /> + {t('form.terms.option.age', { ns: 'education' })} +
+
+ setInSchoolChecked(!inSchoolChecked)} + /> + {t('form.terms.option.inSchool', { ns: 'education' })} +
+
+
+ +
+ + {t('learn', { ns: 'education' })} + + + + )} - ) } +type AppliedEducationWorkspaceBlockProps = { + currentWorkspace: ICurrentWorkspace + plan: PlanType + action: ReactNode + onSwitchWorkspace: (tenantId: string) => void +} + +function AppliedEducationWorkspaceContent({ + currentWorkspace, + plan, + action, + onSwitchWorkspace, +}: AppliedEducationWorkspaceBlockProps) { + const { workspaces } = useWorkspacesContext() + + return ( + + ) +} + +function AppliedEducationWorkspaceBlock(props: AppliedEducationWorkspaceBlockProps) { + return ( + + + + ) +} + +const EducationApplyAge = () => + export default EducationApplyAge + +type AppliedEducationCase = typeof AppliedEducationCase[keyof typeof AppliedEducationCase] diff --git a/web/app/education-apply/verify-state-modal.tsx b/web/app/education-apply/verify-state-modal.tsx index d51a815297..9103e94f36 100644 --- a/web/app/education-apply/verify-state-modal.tsx +++ b/web/app/education-apply/verify-state-modal.tsx @@ -3,7 +3,7 @@ import { RiExternalLinkLine, } from '@remixicon/react' import * as React from 'react' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' @@ -18,6 +18,7 @@ type IConfirm = { maskClosable?: boolean email?: string showLink?: boolean + confirmText?: string } function Confirm({ @@ -29,6 +30,7 @@ function Confirm({ maskClosable = true, showLink, email, + confirmText, }: IConfirm) { const { t } = useTranslation() const docLink = useDocLink() @@ -52,26 +54,24 @@ function Confirm({ } }, [onCancel]) - const handleClickOutside = (event: MouseEvent) => { + const handleClickOutside = useCallback((event: MouseEvent) => { if (maskClosable && dialogRef.current && !dialogRef.current.contains(event.target as Node)) onCancel() - } + }, [maskClosable, onCancel]) useEffect(() => { document.addEventListener('mousedown', handleClickOutside) return () => { document.removeEventListener('mousedown', handleClickOutside) } - }, [maskClosable]) + }, [handleClickOutside]) useEffect(() => { - if (isShow) { - setIsVisible(true) - } - else { - const timer = setTimeout(() => setIsVisible(false), 200) - return () => clearTimeout(timer) - } + const timer = setTimeout(() => { + setIsVisible(isShow) + }, isShow ? 0 : 200) + + return () => clearTimeout(timer) }, [isShow]) if (!isVisible) @@ -106,7 +106,7 @@ function Confirm({ )} - + diff --git a/web/context/provider-context-provider.tsx b/web/context/provider-context-provider.tsx index 0af0f24b9a..160a559a77 100644 --- a/web/context/provider-context-provider.tsx +++ b/web/context/provider-context-provider.tsx @@ -38,6 +38,7 @@ export const ProviderContextProvider = ({ const [plan, setPlan] = useState(defaultPlan) const [isFetchedPlan, setIsFetchedPlan] = useState(false) + const [isFetchedPlanInfo, setIsFetchedPlanInfo] = useState(false) const [enableBilling, setEnableBilling] = useState(true) const [enableReplaceWebAppLogo, setEnableReplaceWebAppLogo] = useState(false) const [modelLoadBalancingEnabled, setModelLoadBalancingEnabled] = useState(false) @@ -103,6 +104,9 @@ export const ProviderContextProvider = ({ setIsEducationWorkspace(false) setEnableReplaceWebAppLogo(false) } + finally { + setIsFetchedPlanInfo(true) + } } useEffect(() => { fetchPlan() @@ -150,6 +154,7 @@ export const ProviderContextProvider = ({ supportRetrievalMethods: supportRetrievalMethods?.retrieval_method || [], plan, isFetchedPlan, + isFetchedPlanInfo, enableBilling, onPlanInfoChanged: fetchPlan, enableReplaceWebAppLogo, diff --git a/web/context/provider-context.ts b/web/context/provider-context.ts index 5d6c49c6b2..29a1c01f1e 100644 --- a/web/context/provider-context.ts +++ b/web/context/provider-context.ts @@ -20,6 +20,7 @@ export type ProviderContextState = { reset: UsageResetInfo } isFetchedPlan: boolean + isFetchedPlanInfo: boolean enableBilling: boolean onPlanInfoChanged: () => void enableReplaceWebAppLogo: boolean @@ -53,6 +54,7 @@ export const baseProviderContextValue: ProviderContextState = { isAPIKeySet: true, plan: defaultPlan, isFetchedPlan: false, + isFetchedPlanInfo: false, enableBilling: false, onPlanInfoChanged: noop, enableReplaceWebAppLogo: false, diff --git a/web/i18n/en-US/education.json b/web/i18n/en-US/education.json index a0fb01c014..e26b1cc24d 100644 --- a/web/i18n/en-US/education.json +++ b/web/i18n/en-US/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "You have an active subscription. You can use the education discount after your subscription expires. Confirm your subscription in Stripe.", + "applied.description": "Congratulations! You've successfully applied for the education discount.", + "applied.noPaymentPermission.description": "You don't have payment permission in this workspace. Please switch to a workspace where you can manage billing to use the education discount.", + "applied.noPaymentPermission.returnHome": "Back to Dify", + "applied.step1.description": "You've successfully applied for the education discount.", + "applied.step1.title": "Step 1", + "applied.step2.description": "Select the workspace you want to use the education discount with.", + "applied.step2.title": "Step 2", + "applied.tabs.activeSubscription": "In subscription", + "applied.tabs.eligible": "Can buy", + "applied.tabs.noPaymentPermission": "No payment permission", + "applied.title": "Education discount applied", + "applied.workspace.plan": "Paid plan", + "applied.workspace.title": "Current Workspace", "currentSigned": "CURRENTLY SIGNED IN AS", + "educationPricingConfirm.billingPeriod.monthly": "monthly", + "educationPricingConfirm.billingPeriod.yearly": "annual", + "educationPricingConfirm.cancel": "Cancel", + "educationPricingConfirm.continue": "Continue without discount", + "educationPricingConfirm.description": "Your {{planName}} {{billingPeriod}} plan doesn't support the education discount. Only the Professional annual plan is eligible.", + "educationPricingConfirm.title": "Education discount not available", "emailLabel": "Your current email", "form.schoolName.placeholder": "Enter the official, unabbreviated name of your school", "form.schoolName.title": "Your School Name", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Re-verify now to get a new coupon for the upcoming academic year. We'll add it to your account and you can use it for the next upgrade.", "notice.stillInEducation.isAboutToExpire": "Re-verify now to get a new coupon for the upcoming academic year. It'll be saved to your account and ready to use at your next renewal.", "notice.stillInEducation.title": "Still in education?", + "planNotSupportEducationDiscount": "Not eligible for education pricing", "rejectContent": "Unfortunately, you are not eligible for Education Verified status and therefore cannot receive the exclusive 100% coupon for the Dify Professional Plan if you use this email address.", "rejectTitle": "Your Dify Educational Verification Has Been Rejected", "submit": "Submit", @@ -40,5 +61,6 @@ "toVerified": "Get Education Verified", "toVerifiedTip.coupon": "exclusive 100% coupon", "toVerifiedTip.end": "for the Dify Professional Plan.", - "toVerifiedTip.front": "You are now eligible for Education Verified status. Please enter your education information below to complete the process and receive an" + "toVerifiedTip.front": "You are now eligible for Education Verified status. Please enter your education information below to complete the process and receive an", + "useEducationDiscount": "Use education discount" } diff --git a/web/i18n/zh-Hans/education.json b/web/i18n/zh-Hans/education.json index 7fa38cd82e..657d265424 100644 --- a/web/i18n/zh-Hans/education.json +++ b/web/i18n/zh-Hans/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "你当前有生效中的订阅。订阅到期后即可使用教育优惠。请前往 Stripe 确认你的订阅。", + "applied.description": "您已成功申请教育优惠。", + "applied.noPaymentPermission.description": "你没有此工作空间的付款权限。请切换到你可以管理账单的工作空间,以使用教育优惠。", + "applied.noPaymentPermission.returnHome": "返回 Dify", + "applied.step1.description": "您已成功申请教育优惠。", + "applied.step1.title": "第一步", + "applied.step2.description": "选择要使用教育优惠的 workspace。", + "applied.step2.title": "第二步", + "applied.tabs.activeSubscription": "在订阅中", + "applied.tabs.eligible": "能买", + "applied.tabs.noPaymentPermission": "无付款权限", + "applied.title": "教育优惠申请成功", + "applied.workspace.plan": "付费计划", + "applied.workspace.title": "当前 Workspace", "currentSigned": "您当前登录的账户是", + "educationPricingConfirm.billingPeriod.monthly": "月付", + "educationPricingConfirm.billingPeriod.yearly": "年付", + "educationPricingConfirm.cancel": "取消", + "educationPricingConfirm.continue": "不使用优惠继续", + "educationPricingConfirm.description": "你的 {{planName}} 计划{{billingPeriod}}不支持教育优惠。只有 Professional 的年付计划符合条件。", + "educationPricingConfirm.title": "教育优惠不适用于该计划", "emailLabel": "您当前的邮箱", "form.schoolName.placeholder": "请输入您的学校的官方全称(不得缩写)", "form.schoolName.title": "您的学校名称", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "立即重新认证,获取新学年的教育优惠券。优惠券将发放至您的账户,并可在下次升级时使用。", "notice.stillInEducation.isAboutToExpire": "立即重新验证,获取新学年的教育优惠券。优惠券将发放至您的账户,并可在下次续订时使用。", "notice.stillInEducation.title": "仍在就读?", + "planNotSupportEducationDiscount": "不适用教育优惠价格", "rejectContent": "非常遗憾,您无法使用此电子邮件以获得教育版认证资格,也无法领取 Dify Professional 版的 100% 独家优惠券。", "rejectTitle": "您的 Dify 教育版认证已被拒绝", "submit": "提交", @@ -40,5 +61,6 @@ "toVerified": "获取教育版认证", "toVerifiedTip.coupon": "100% 独家优惠券", "toVerifiedTip.end": "。", - "toVerifiedTip.front": "您现在符合教育版认证的资格。请在下方输入您的教育信息,以完成认证流程,并领取 Dify Professional 版的" + "toVerifiedTip.front": "您现在符合教育版认证的资格。请在下方输入您的教育信息,以完成认证流程,并领取 Dify Professional 版的", + "useEducationDiscount": "使用教育优惠" } diff --git a/web/i18n/zh-Hant/education.json b/web/i18n/zh-Hant/education.json index 4324e7dd86..9d24800ae5 100644 --- a/web/i18n/zh-Hant/education.json +++ b/web/i18n/zh-Hant/education.json @@ -1,5 +1,12 @@ { + "applied.step2.description": "選擇要使用教育優惠的 workspace。", "currentSigned": "當前以以下身份登入", + "educationPricingConfirm.billingPeriod.monthly": "月付", + "educationPricingConfirm.billingPeriod.yearly": "年付", + "educationPricingConfirm.cancel": "取消", + "educationPricingConfirm.continue": "不使用優惠繼續", + "educationPricingConfirm.description": "你的 {{planName}} 方案{{billingPeriod}}不支援教育優惠。只有 Professional 的年付方案符合資格。", + "educationPricingConfirm.title": "教育優惠不適用於此方案", "emailLabel": "您當前的電子郵件", "form.schoolName.placeholder": "請輸入您學校的正式全名", "form.schoolName.title": "你的學校名稱", From 7e6745e105771a87853e1016bc241a2024629639 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 20:50:46 +0800 Subject: [PATCH 02/10] chore(i18n): sync translations with en-US (#35853) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- web/i18n/ar-TN/education.json | 24 +++++++++++++++++++++++- web/i18n/de-DE/education.json | 24 +++++++++++++++++++++++- web/i18n/es-ES/education.json | 24 +++++++++++++++++++++++- web/i18n/fa-IR/education.json | 24 +++++++++++++++++++++++- web/i18n/fr-FR/education.json | 24 +++++++++++++++++++++++- web/i18n/hi-IN/education.json | 24 +++++++++++++++++++++++- web/i18n/id-ID/education.json | 24 +++++++++++++++++++++++- web/i18n/it-IT/education.json | 24 +++++++++++++++++++++++- web/i18n/ja-JP/education.json | 24 +++++++++++++++++++++++- web/i18n/ko-KR/education.json | 24 +++++++++++++++++++++++- web/i18n/nl-NL/education.json | 24 +++++++++++++++++++++++- web/i18n/pl-PL/education.json | 24 +++++++++++++++++++++++- web/i18n/pt-BR/education.json | 24 +++++++++++++++++++++++- web/i18n/ro-RO/education.json | 24 +++++++++++++++++++++++- web/i18n/ru-RU/education.json | 24 +++++++++++++++++++++++- web/i18n/sl-SI/education.json | 24 +++++++++++++++++++++++- web/i18n/th-TH/education.json | 24 +++++++++++++++++++++++- web/i18n/tr-TR/education.json | 24 +++++++++++++++++++++++- web/i18n/uk-UA/education.json | 24 +++++++++++++++++++++++- web/i18n/vi-VN/education.json | 24 +++++++++++++++++++++++- web/i18n/zh-Hant/education.json | 17 ++++++++++++++++- 21 files changed, 476 insertions(+), 21 deletions(-) diff --git a/web/i18n/ar-TN/education.json b/web/i18n/ar-TN/education.json index 6a25741f05..250e6b7d26 100644 --- a/web/i18n/ar-TN/education.json +++ b/web/i18n/ar-TN/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "لديك اشتراك نشط. يمكنك استخدام الخصم التعليمي بعد انتهاء صلاحية اشتراكك. تأكيد اشتراكك في Stripe.", + "applied.description": "تهانينا! لقد قدمت بنجاح طلباً للحصول على الخصم التعليمي.", + "applied.noPaymentPermission.description": "ليس لديك صلاحية الدفع في هذه مساحة العمل. يرجى التبديل إلى مساحة عمل حيث يمكنك إدارة الفوترة لاستخدام الخصم التعليمي.", + "applied.noPaymentPermission.returnHome": "العودة إلى Dify", + "applied.step1.description": "لقد قدمت بنجاح طلباً للحصول على الخصم التعليمي.", + "applied.step1.title": "الخطوة 1", + "applied.step2.description": "اختر مساحة العمل التي تريد استخدامها مع الخصم التعليمي.", + "applied.step2.title": "الخطوة 2", + "applied.tabs.activeSubscription": "في الاشتراك", + "applied.tabs.eligible": "يمكن الشراء", + "applied.tabs.noPaymentPermission": "لا توجد صلاحية دفع", + "applied.title": "تم تطبيق الخصم التعليمي", + "applied.workspace.plan": "خطة مدفوعة", + "applied.workspace.title": "مساحة العمل الحالية", "currentSigned": "تم تسجيل الدخول حاليًا باسم", + "educationPricingConfirm.billingPeriod.monthly": "شهري", + "educationPricingConfirm.billingPeriod.yearly": "سنوي", + "educationPricingConfirm.cancel": "إلغاء", + "educationPricingConfirm.continue": "المتابعة بدون خصم", + "educationPricingConfirm.description": "خطتك {{planName}} {{billingPeriod}} لا تدعم الخصم التعليمي. فقط خطة Professional السنوية مؤهلة.", + "educationPricingConfirm.title": "الخصم التعليمي غير متاح", "emailLabel": "بريدك الإلكتروني الحالي", "form.schoolName.placeholder": "أدخل الاسم الرسمي الكامل لمدرستك", "form.schoolName.title": "اسم مدرستك", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "تحقق مرة أخرى الآن للحصول على كوبون جديد للعام الدراسي القادم. سنضيفه إلى حسابك ويمكنك استخدامه للترقية التالية.", "notice.stillInEducation.isAboutToExpire": "تحقق مرة أخرى الآن للحصول على كوبون جديد للعام الدراسي القادم. سيتم حفظه في حسابك وجاهز للاستخدام في تجديدك التالي.", "notice.stillInEducation.title": "هل ما زلت في التعليم؟", + "planNotSupportEducationDiscount": "غير مؤهل لأسعار التعليم", "rejectContent": "لسوء الحظ، أنت غير مؤهل للحصول على حالة التحقق التعليمي وبالتالي لا يمكنك الحصول على كوبون حصري 100٪ لخطة Dify Professional إذا كنت تستخدم عنوان البريد الإلكتروني هذا.", "rejectTitle": "تم رفض التحقق التعليمي الخاص بك في Dify", "submit": "إرسال", @@ -40,5 +61,6 @@ "toVerified": "احصل على التحقق التعليمي", "toVerifiedTip.coupon": "كوبون حصري 100٪", "toVerifiedTip.end": "لخطة Dify الاحترافية.", - "toVerifiedTip.front": "أنت الآن مؤهل للحصول على حالة التحقق التعليمي. يرجى إدخال معلومات التعليم الخاصة بك أدناه لإكمال العملية والحصول على" + "toVerifiedTip.front": "أنت الآن مؤهل للحصول على حالة التحقق التعليمي. يرجى إدخال معلومات التعليم الخاصة بك أدناه لإكمال العملية والحصول على", + "useEducationDiscount": "استخدام الخصم التعليمي" } diff --git a/web/i18n/de-DE/education.json b/web/i18n/de-DE/education.json index ceba9dcd23..32dd76bd46 100644 --- a/web/i18n/de-DE/education.json +++ b/web/i18n/de-DE/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "Sie haben ein aktives Abonnement. Sie können den Bildungsrabatt verwenden, nachdem Ihr Abonnement abläuft. Bestätigen Sie Ihr Abonnement in Stripe.", + "applied.description": "Herzlichen Glückwunsch! Sie haben erfolgreich den Bildungsrabatt beantragt.", + "applied.noPaymentPermission.description": "Sie haben keine Zahlungsberechtigung in diesem Arbeitsbereich. Bitte wechseln Sie zu einem Arbeitsbereich, in dem Sie die Abrechnung verwalten können, um den Bildungsrabatt zu nutzen.", + "applied.noPaymentPermission.returnHome": "Zurück zu Dify", + "applied.step1.description": "Sie haben erfolgreich den Bildungsrabatt beantragt.", + "applied.step1.title": "Schritt 1", + "applied.step2.description": "Wählen Sie den Arbeitsbereich aus, den Sie mit dem Bildungsrabatt verwenden möchten.", + "applied.step2.title": "Schritt 2", + "applied.tabs.activeSubscription": "Im Abonnement", + "applied.tabs.eligible": "Kann kaufen", + "applied.tabs.noPaymentPermission": "Keine Zahlungsberechtigung", + "applied.title": "Bildungsrabatt angewendet", + "applied.workspace.plan": "Bezahlter Plan", + "applied.workspace.title": "Aktueller Arbeitsbereich", "currentSigned": "DERZEIT ANGEMELDET ALS", + "educationPricingConfirm.billingPeriod.monthly": "monatlich", + "educationPricingConfirm.billingPeriod.yearly": "jährlich", + "educationPricingConfirm.cancel": "Abbrechen", + "educationPricingConfirm.continue": "Ohne Rabatt fortfahren", + "educationPricingConfirm.description": "Ihr {{planName}} {{billingPeriod}} Plan unterstützt den Bildungsrabatt nicht. Nur der Professional-Jahresplan ist berechtigt.", + "educationPricingConfirm.title": "Bildungsrabatt nicht verfügbar", "emailLabel": "Ihre aktuelle E-Mail", "form.schoolName.placeholder": "Geben Sie den offiziellen, unabgekürzten Namen Ihrer Schule ein.", "form.schoolName.title": "Ihr Schulname", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Überprüfen Sie jetzt erneut, um einen neuen Gutschein für das kommende akademische Jahr zu erhalten. Wir fügen ihn Ihrem Konto hinzu und Sie können ihn für das nächste Upgrade verwenden.", "notice.stillInEducation.isAboutToExpire": "Überprüfen Sie jetzt erneut, um einen neuen Gutschein für das kommende Studienjahr zu erhalten. Er wird in Ihrem Konto gespeichert und ist bereit zur Nutzung bei Ihrer nächsten Verlängerung.", "notice.stillInEducation.title": "Immer noch in der Ausbildung?", + "planNotSupportEducationDiscount": "Nicht für Bildungspreise berechtigt", "rejectContent": "Leider sind Sie nicht für den Status \"Education Verified\" berechtigt und können daher den exklusiven 100%-Gutschein für den Dify Professional Plan nicht erhalten, wenn Sie diese E-Mail-Adresse verwenden.", "rejectTitle": "Ihre Dify-Ausbildungsüberprüfung wurde abgelehnt.", "submit": "Einreichen", @@ -40,5 +61,6 @@ "toVerified": "Bildung überprüfen lassen", "toVerifiedTip.coupon": "exklusiver 100% Gutschein", "toVerifiedTip.end": "für den Dify Professional Plan.", - "toVerifiedTip.front": "Sie sind jetzt berechtigt, den Status „Bildung verifiziert“ zu erhalten. Bitte geben Sie unten Ihre Bildungsinformationen ein, um den Prozess abzuschließen und eine Zu erhalten." + "toVerifiedTip.front": "Sie sind jetzt berechtigt, den Status Bildung verifiziert zu erhalten. Bitte geben Sie unten Ihre Bildungsinformationen ein, um den Prozess abzuschließen und eine zu erhalten.", + "useEducationDiscount": "Bildungsrabatt verwenden" } diff --git a/web/i18n/es-ES/education.json b/web/i18n/es-ES/education.json index c3c3f05e44..0b2ac91b00 100644 --- a/web/i18n/es-ES/education.json +++ b/web/i18n/es-ES/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "Tienes una suscripción activa. Puedes usar el descuento educativo después de que expire tu suscripción. Confirma tu suscripción en Stripe.", + "applied.description": "¡Felicitaciones! Has solicitado exitosamente el descuento educativo.", + "applied.noPaymentPermission.description": "No tienes permiso de pago en este workspace. Por favor, cambia a un workspace donde puedas gestionar la facturación para usar el descuento educativo.", + "applied.noPaymentPermission.returnHome": "Volver a Dify", + "applied.step1.description": "Has solicitado exitosamente el descuento educativo.", + "applied.step1.title": "Paso 1", + "applied.step2.description": "Selecciona el workspace que deseas usar con el descuento educativo.", + "applied.step2.title": "Paso 2", + "applied.tabs.activeSubscription": "En suscripción", + "applied.tabs.eligible": "Puede comprar", + "applied.tabs.noPaymentPermission": "Sin permiso de pago", + "applied.title": "Descuento educativo aplicado", + "applied.workspace.plan": "Plan de pago", + "applied.workspace.title": "Workspace actual", "currentSigned": "ACTUALMENTE CONECTADO COMO", + "educationPricingConfirm.billingPeriod.monthly": "mensual", + "educationPricingConfirm.billingPeriod.yearly": "anual", + "educationPricingConfirm.cancel": "Cancelar", + "educationPricingConfirm.continue": "Continuar sin descuento", + "educationPricingConfirm.description": "Tu plan {{planName}} {{billingPeriod}} no admite el descuento educativo. Solo el plan Professional anual es elegible.", + "educationPricingConfirm.title": "Descuento educativo no disponible", "emailLabel": "Tu correo electrónico actual", "form.schoolName.placeholder": "Ingrese el nombre oficial y completo de su escuela", "form.schoolName.title": "El nombre de tu escuela", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Verifica de nuevo ahora para obtener un nuevo cupón para el próximo año académico. Lo añadiremos a tu cuenta y podrás usarlo para la próxima actualización.", "notice.stillInEducation.isAboutToExpire": "Verifica de nuevo ahora para obtener un nuevo cupón para el próximo año académico. Se guardará en tu cuenta y estará listo para usar en tu próxima renovación.", "notice.stillInEducation.title": "¿Aún en educación?", + "planNotSupportEducationDiscount": "No elegible para precios educativos", "rejectContent": "Desafortunadamente, no eres elegible para el estado de Educación Verificada y, por lo tanto, no puedes recibir el exclusivo cupón del 100% para el Plan Profesional de Dify si utilizas esta dirección de correo electrónico.", "rejectTitle": "Su verificación educativa de Dify ha sido rechazada.", "submit": "Enviar", @@ -40,5 +61,6 @@ "toVerified": "Verifica la educación", "toVerifiedTip.coupon": "cupón exclusivo del 100%", "toVerifiedTip.end": "para el Plan Profesional de Dify.", - "toVerifiedTip.front": "Ahora eres elegible para el estado de Educación Verificada. Por favor, introduce tu información educativa a continuación para completar el proceso y recibir un" + "toVerifiedTip.front": "Ahora eres elegible para el estado de Educación Verificada. Por favor, introduce tu información educativa a continuación para completar el proceso y recibir un", + "useEducationDiscount": "Usar descuento educativo" } diff --git a/web/i18n/fa-IR/education.json b/web/i18n/fa-IR/education.json index 9ce7662b88..63150df78b 100644 --- a/web/i18n/fa-IR/education.json +++ b/web/i18n/fa-IR/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "شما یک اشتراک فعال دارید. پس از انقضای اشتراک می‌توانید از تخفیف آموزشی استفاده کنید. اشتراک خود را در Stripe تأیید کنید.", + "applied.description": "تبریک می‌گوییم! درخواست تخفیف آموزشی شما با موفقیت ثبت شد.", + "applied.noPaymentPermission.description": "شما در این workspace مجوز پرداخت ندارید. لطفاً به workspace‌ای بروید که بتوانید صورتحساب را مدیریت کنید تا از تخفیف آموزشی استفاده کنید.", + "applied.noPaymentPermission.returnHome": "بازگشت به Dify", + "applied.step1.description": "درخواست تخفیف آموزشی شما با موفقیت ثبت شد.", + "applied.step1.title": "مرحله ۱", + "applied.step2.description": "workspace‌ای را که می‌خواهید با تخفیف آموزشی استفاده کنید انتخاب کنید.", + "applied.step2.title": "مرحله ۲", + "applied.tabs.activeSubscription": "در اشتراک", + "applied.tabs.eligible": "می‌تواند خرید کند", + "applied.tabs.noPaymentPermission": "بدون مجوز پرداخت", + "applied.title": "تخفیف آموزشی اعمال شد", + "applied.workspace.plan": "طرح پولی", + "applied.workspace.title": "Workspace فعلی", "currentSigned": "اکنون به عنوان", + "educationPricingConfirm.billingPeriod.monthly": "ماهانه", + "educationPricingConfirm.billingPeriod.yearly": "سالانه", + "educationPricingConfirm.cancel": "لغو", + "educationPricingConfirm.continue": "ادامه بدون تخفیف", + "educationPricingConfirm.description": "طرح {{planName}} {{billingPeriod}} شما از تخفیف آموزشی پشتیبانی نمی‌کند. فقط طرح سالانه Professional واجد شرایط است.", + "educationPricingConfirm.title": "تخفیف آموزشی در دسترس نیست", "emailLabel": "ایمیل فعلی شما", "form.schoolName.placeholder": "نام رسمی و کامل مدرسه خود را وارد کنید", "form.schoolName.title": "نام مدرسه شما", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "هم‌اکنون دوباره تأیید کنید تا یک کوپن جدید برای سال تحصیلی آینده دریافت کنید. ما آن را به حساب شما اضافه خواهیم کرد و می‌توانید از آن برای ارتقاء بعدی استفاده کنید.", "notice.stillInEducation.isAboutToExpire": "در حال حاضر دوباره تأیید کنید تا یک کوپن جدید برای سال تحصیلی آینده دریافت کنید. این کوپن به حساب شما ذخیره خواهد شد و در زمان تمدید بعدی شما آماده استفاده است.", "notice.stillInEducation.title": "آیا هنوز در حال تحصیل هستید؟", + "planNotSupportEducationDiscount": "واجد شرایط قیمت‌گذاری آموزشی نیست", "rejectContent": "متاسفانه، شما واجد شرایط وضعیت تأیید شده آموزشی نیستید و به همین دلیل نمی‌توانید کوپن انحصاری ۱۰۰٪ برای طرح حرفه‌ای Dify را در صورت استفاده از این آدرس ایمیل دریافت کنید.", "rejectTitle": "تأییدیه آموزشی دیفی شما رد شده است", "submit": "ارسال", @@ -40,5 +61,6 @@ "toVerified": "تحصیلات خود را تأیید کنید", "toVerifiedTip.coupon": "کوپن انحصاری ۱۰۰٪", "toVerifiedTip.end": "برای طرح حرفه‌ای دیفی.", - "toVerifiedTip.front": "شما اکنون برای وضعیت تأیید شده آموزشی واجد شرایط هستید. لطفاً اطلاعات تحصیلی خود را در زیر وارد کنید تا فرآیند را کامل کرده و یک دریافت کنید." + "toVerifiedTip.front": "شما اکنون برای وضعیت تأیید شده آموزشی واجد شرایط هستید. لطفاً اطلاعات تحصیلی خود را در زیر وارد کنید تا فرآیند را کامل کرده و یک دریافت کنید.", + "useEducationDiscount": "استفاده از تخفیف آموزشی" } diff --git a/web/i18n/fr-FR/education.json b/web/i18n/fr-FR/education.json index 9a8952e541..d201b6f031 100644 --- a/web/i18n/fr-FR/education.json +++ b/web/i18n/fr-FR/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "Vous avez un abonnement actif. Vous pouvez utiliser la remise éducative après l'expiration de votre abonnement. Confirmez votre abonnement dans Stripe.", + "applied.description": "Félicitations ! Vous avez fait la demande de remise éducative avec succès.", + "applied.noPaymentPermission.description": "Vous n'avez pas la permission de payer dans cet espace de travail. Veuillez passer à un espace de travail où vous pouvez gérer la facturation pour utiliser la remise éducative.", + "applied.noPaymentPermission.returnHome": "Retour à Dify", + "applied.step1.description": "Vous avez fait la demande de remise éducative avec succès.", + "applied.step1.title": "Étape 1", + "applied.step2.description": "Sélectionnez l'espace de travail que vous souhaitez utiliser avec la remise éducative.", + "applied.step2.title": "Étape 2", + "applied.tabs.activeSubscription": "En abonnement", + "applied.tabs.eligible": "Peut acheter", + "applied.tabs.noPaymentPermission": "Pas de permission de paiement", + "applied.title": "Remise éducative appliquée", + "applied.workspace.plan": "Plan payant", + "applied.workspace.title": "Espace de travail actuel", "currentSigned": "ACTUELLEMENT CONNECTÉ EN TANT QUE", + "educationPricingConfirm.billingPeriod.monthly": "mensuel", + "educationPricingConfirm.billingPeriod.yearly": "annuel", + "educationPricingConfirm.cancel": "Annuler", + "educationPricingConfirm.continue": "Continuer sans remise", + "educationPricingConfirm.description": "Votre plan {{planName}} {{billingPeriod}} ne prend pas en charge la remise éducative. Seul le plan Professional annuel est éligible.", + "educationPricingConfirm.title": "Remise éducative non disponible", "emailLabel": "Votre email actuel", "form.schoolName.placeholder": "Entrez le nom officiel et complet de votre école", "form.schoolName.title": "Le nom de votre école", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Veuillez vérifier à nouveau maintenant pour obtenir un nouveau coupon pour la prochaine année académique. Nous l'ajouterons à votre compte et vous pourrez l'utiliser pour la prochaine mise à niveau.", "notice.stillInEducation.isAboutToExpire": "Vérifiez de nouveau maintenant pour obtenir un nouveau coupon pour la prochaine année académique. Il sera enregistré dans votre compte et prêt à être utilisé lors de votre prochain renouvellement.", "notice.stillInEducation.title": "Encore dans l'éducation ?", + "planNotSupportEducationDiscount": "Non éligible aux tarifs éducatifs", "rejectContent": "Malheureusement, vous n'êtes pas éligible au statut Éducation Vérifié et ne pouvez donc pas recevoir le coupon exclusif de 100 % pour le Plan Professionnel Dify si vous utilisez cette adresse e-mail.", "rejectTitle": "Votre vérification éducative Dify a été rejetée.", "submit": "Soumettre", @@ -40,5 +61,6 @@ "toVerified": "Faire vérifier l'éducation", "toVerifiedTip.coupon": "coupon exclusif 100%", "toVerifiedTip.end": "pour le Plan Professionnel Dify.", - "toVerifiedTip.front": "Vous êtes maintenant éligible pour le statut Vérifié en Éducation. Veuillez entrer vos informations éducatives ci-dessous pour compléter le processus et recevoir un" + "toVerifiedTip.front": "Vous êtes maintenant éligible pour le statut Vérifié en Éducation. Veuillez entrer vos informations éducatives ci-dessous pour compléter le processus et recevoir un", + "useEducationDiscount": "Utiliser la remise éducative" } diff --git a/web/i18n/hi-IN/education.json b/web/i18n/hi-IN/education.json index 9c8ae16dbc..a580491cb7 100644 --- a/web/i18n/hi-IN/education.json +++ b/web/i18n/hi-IN/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "आपके पास एक सक्रिय सदस्यता है। आपकी सदस्यता समाप्त होने के बाद आप शिक्षा छूट का उपयोग कर सकते हैं। Stripe में अपनी सदस्यता की पुष्टि करें।", + "applied.description": "बधाई हो! आपने शिक्षा छूट के लिए सफलतापूर्वक आवेदन किया है।", + "applied.noPaymentPermission.description": "इस workspace में आपके पास भुगतान की अनुमति नहीं है। शिक्षा छूट का उपयोग करने के लिए कृपया ऐसे workspace पर स्विच करें जहाँ आप बिलिंग प्रबंधित कर सकते हैं।", + "applied.noPaymentPermission.returnHome": "Dify पर वापस जाएं", + "applied.step1.description": "आपने शिक्षा छूट के लिए सफलतापूर्वक आवेदन किया है।", + "applied.step1.title": "चरण 1", + "applied.step2.description": "वह workspace चुनें जिसे आप शिक्षा छूट के साथ उपयोग करना चाहते हैं।", + "applied.step2.title": "चरण 2", + "applied.tabs.activeSubscription": "सदस्यता में", + "applied.tabs.eligible": "खरीद सकते हैं", + "applied.tabs.noPaymentPermission": "भुगतान की अनुमति नहीं", + "applied.title": "शिक्षा छूट लागू की गई", + "applied.workspace.plan": "भुगतान योजना", + "applied.workspace.title": "वर्तमान Workspace", "currentSigned": "वर्तमान में साइन इन किया गया है के रूप में", + "educationPricingConfirm.billingPeriod.monthly": "मासिक", + "educationPricingConfirm.billingPeriod.yearly": "वार्षिक", + "educationPricingConfirm.cancel": "रद्द करें", + "educationPricingConfirm.continue": "छूट के बिना जारी रखें", + "educationPricingConfirm.description": "आपका {{planName}} {{billingPeriod}} प्लान शिक्षा छूट का समर्थन नहीं करता। केवल Professional वार्षिक प्लान पात्र है।", + "educationPricingConfirm.title": "शिक्षा छूट उपलब्ध नहीं", "emailLabel": "आपका वर्तमान ईमेल", "form.schoolName.placeholder": "अपनी स्कूल का आधिकारिक, बिना संक्षिप्त नाम दर्ज करें", "form.schoolName.title": "आपके स्कूल का नाम", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "अब पुनः सत्यापित करें ताकि आप आगामी शैक्षणिक वर्ष के लिए एक नया कूपन प्राप्त कर सकें। हम इसे आपके खाते में जोड़ देंगे और आप इसे अगले अपग्रेड के लिए उपयोग कर सकेंगे।", "notice.stillInEducation.isAboutToExpire": "अब फिर से सत्यापित करें ताकि आगामी शैक्षणिक वर्ष के लिए एक नया कूपन मिल सके। यह आपके खाते में सहेजा जाएगा और आपकी अगली नवीनीकरण पर उपयोग के लिए तैयार होगा।", "notice.stillInEducation.title": "क्या आप अभी भी शिक्षा में हैं?", + "planNotSupportEducationDiscount": "शिक्षा मूल्य निर्धारण के लिए पात्र नहीं", "rejectContent": "दुर्भाग्यवश, आप शिक्षा सत्यापित स्थिति के लिए योग्य नहीं हैं और इसलिए यदि आप इस ईमेल पते का उपयोग करते हैं, तो आप डिफाई प्रोफेशनल योजना के लिए विशेष 100% कूपन प्राप्त नहीं कर सकते।", "rejectTitle": "आपकी डिफाई शैक्षणिक सत्यापन को अस्वीकृत कर दिया गया है", "submit": "सबमिट करें", @@ -40,5 +61,6 @@ "toVerified": "शिक्षा की पुष्टि कराएँ", "toVerifiedTip.coupon": "विशेष 100% कूपन", "toVerifiedTip.end": "Dify प्रोफेशनल योजना के लिए।", - "toVerifiedTip.front": "आप अब शिक्षा सत्यापित स्थिति के लिए योग्य हैं। कृपया नीचे अपनी शिक्षा की जानकारी प्रदान करें ताकि प्रक्रिया को पूरा किया जा सके और एक प्राप्त हो सके" + "toVerifiedTip.front": "आप अब शिक्षा सत्यापित स्थिति के लिए योग्य हैं। कृपया नीचे अपनी शिक्षा की जानकारी प्रदान करें ताकि प्रक्रिया को पूरा किया जा सके और एक प्राप्त हो सके", + "useEducationDiscount": "शिक्षा छूट का उपयोग करें" } diff --git a/web/i18n/id-ID/education.json b/web/i18n/id-ID/education.json index 00e0f4099a..3fa6d70a60 100644 --- a/web/i18n/id-ID/education.json +++ b/web/i18n/id-ID/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "Anda memiliki langganan aktif. Anda dapat menggunakan diskon pendidikan setelah langganan Anda berakhir. Konfirmasi langganan Anda di Stripe.", + "applied.description": "Selamat! Anda telah berhasil mengajukan diskon pendidikan.", + "applied.noPaymentPermission.description": "Anda tidak memiliki izin pembayaran di workspace ini. Silakan beralih ke workspace di mana Anda dapat mengelola penagihan untuk menggunakan diskon pendidikan.", + "applied.noPaymentPermission.returnHome": "Kembali ke Dify", + "applied.step1.description": "Anda telah berhasil mengajukan diskon pendidikan.", + "applied.step1.title": "Langkah 1", + "applied.step2.description": "Pilih workspace yang ingin Anda gunakan dengan diskon pendidikan.", + "applied.step2.title": "Langkah 2", + "applied.tabs.activeSubscription": "Dalam langganan", + "applied.tabs.eligible": "Dapat membeli", + "applied.tabs.noPaymentPermission": "Tidak ada izin pembayaran", + "applied.title": "Diskon pendidikan diterapkan", + "applied.workspace.plan": "Paket berbayar", + "applied.workspace.title": "Workspace saat ini", "currentSigned": "SAAT INI MASUK SEBAGAI", + "educationPricingConfirm.billingPeriod.monthly": "bulanan", + "educationPricingConfirm.billingPeriod.yearly": "tahunan", + "educationPricingConfirm.cancel": "Batal", + "educationPricingConfirm.continue": "Lanjutkan tanpa diskon", + "educationPricingConfirm.description": "Paket {{planName}} {{billingPeriod}} Anda tidak mendukung diskon pendidikan. Hanya paket Professional tahunan yang memenuhi syarat.", + "educationPricingConfirm.title": "Diskon pendidikan tidak tersedia", "emailLabel": "Email Anda saat ini", "form.schoolName.placeholder": "Masukkan nama resmi sekolah Anda yang tidak disingkat", "form.schoolName.title": "Nama Sekolah Anda", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Verifikasi ulang sekarang untuk mendapatkan kupon baru untuk tahun akademik mendatang. Kami akan menambahkannya ke akun Anda dan Anda dapat menggunakannya untuk peningkatan berikutnya.", "notice.stillInEducation.isAboutToExpire": "Verifikasi ulang sekarang untuk mendapatkan kupon baru untuk tahun akademik mendatang. Ini akan disimpan ke akun Anda dan siap digunakan pada perpanjangan berikutnya.", "notice.stillInEducation.title": "Masih dalam pendidikan?", + "planNotSupportEducationDiscount": "Tidak memenuhi syarat untuk harga pendidikan", "rejectContent": "Sayangnya, Anda tidak memenuhi syarat untuk status Education Verified dan oleh karena itu tidak dapat menerima kupon 100% eksklusif untuk Paket Dify Professional jika Anda menggunakan alamat email ini.", "rejectTitle": "Verifikasi Pendidikan Dify Anda telah ditolak", "submit": "Kirim", @@ -40,5 +61,6 @@ "toVerified": "Dapatkan Pendidikan Terverifikasi", "toVerifiedTip.coupon": "kupon eksklusif 100%", "toVerifiedTip.end": "untuk Paket Profesional Dify.", - "toVerifiedTip.front": "Anda sekarang memenuhi syarat untuk status Terverifikasi Pendidikan. Silakan masukkan informasi pendidikan Anda di bawah ini untuk menyelesaikan proses dan menerima" + "toVerifiedTip.front": "Anda sekarang memenuhi syarat untuk status Terverifikasi Pendidikan. Silakan masukkan informasi pendidikan Anda di bawah ini untuk menyelesaikan proses dan menerima", + "useEducationDiscount": "Gunakan diskon pendidikan" } diff --git a/web/i18n/it-IT/education.json b/web/i18n/it-IT/education.json index c75768e54a..b1ccc69308 100644 --- a/web/i18n/it-IT/education.json +++ b/web/i18n/it-IT/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "Hai un abbonamento attivo. Puoi utilizzare lo sconto educativo dopo la scadenza dell'abbonamento. Conferma il tuo abbonamento su Stripe.", + "applied.description": "Congratulazioni! Hai fatto domanda per lo sconto educativo con successo.", + "applied.noPaymentPermission.description": "Non hai il permesso di pagamento in questo workspace. Passa a un workspace in cui puoi gestire la fatturazione per utilizzare lo sconto educativo.", + "applied.noPaymentPermission.returnHome": "Torna a Dify", + "applied.step1.description": "Hai fatto domanda per lo sconto educativo con successo.", + "applied.step1.title": "Passo 1", + "applied.step2.description": "Seleziona il workspace che vuoi utilizzare con lo sconto educativo.", + "applied.step2.title": "Passo 2", + "applied.tabs.activeSubscription": "In abbonamento", + "applied.tabs.eligible": "Può acquistare", + "applied.tabs.noPaymentPermission": "Nessun permesso di pagamento", + "applied.title": "Sconto educativo applicato", + "applied.workspace.plan": "Piano a pagamento", + "applied.workspace.title": "Workspace corrente", "currentSigned": "ATTUALMENTE ACCEDUTO COME", + "educationPricingConfirm.billingPeriod.monthly": "mensile", + "educationPricingConfirm.billingPeriod.yearly": "annuale", + "educationPricingConfirm.cancel": "Annulla", + "educationPricingConfirm.continue": "Continua senza sconto", + "educationPricingConfirm.description": "Il tuo piano {{planName}} {{billingPeriod}} non supporta lo sconto educativo. Solo il piano Professional annuale è idoneo.", + "educationPricingConfirm.title": "Sconto educativo non disponibile", "emailLabel": "La tua email attuale", "form.schoolName.placeholder": "Inserisci il nome ufficiale e completo della tua scuola", "form.schoolName.title": "Il Nome della tua Scuola", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Verifica di nuovo ora per ottenere un nuovo coupon per il prossimo anno accademico. Lo aggiungeremo al tuo account e potrai usarlo per il prossimo aggiornamento.", "notice.stillInEducation.isAboutToExpire": "Verifica di nuovo ora per ottenere un nuovo coupon per il prossimo anno accademico. Sarà salvato nel tuo account e pronto per essere utilizzato al tuo prossimo rinnovo.", "notice.stillInEducation.title": "Ancora in formazione?", + "planNotSupportEducationDiscount": "Non idoneo per i prezzi educativi", "rejectContent": "Sfortunatamente, non sei idoneo per lo status di Educazione Verificata e quindi non puoi ricevere il coupon esclusivo del 100% per il Piano Professionale Dify se usi questo indirizzo email.", "rejectTitle": "La tua verifica educativa Dify è stata rifiutata.", "submit": "Invia", @@ -40,5 +61,6 @@ "toVerified": "Fai verificare la tua istruzione", "toVerifiedTip.coupon": "coupon esclusivo al 100%", "toVerifiedTip.end": "per il Piano Professionale Dify.", - "toVerifiedTip.front": "Ora sei idoneo per lo stato di Educazione Verificata. Per favore, inserisci le tue informazioni educative qui sotto per completare il processo e ricevere un" + "toVerifiedTip.front": "Ora sei idoneo per lo stato di Educazione Verificata. Per favore, inserisci le tue informazioni educative qui sotto per completare il processo e ricevere un", + "useEducationDiscount": "Utilizza lo sconto educativo" } diff --git a/web/i18n/ja-JP/education.json b/web/i18n/ja-JP/education.json index 3a68b5ffc6..978b561ff0 100644 --- a/web/i18n/ja-JP/education.json +++ b/web/i18n/ja-JP/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "現在有効なサブスクリプションがあります。サブスクリプションの有効期限が切れた後、教育割引を使用できます。Stripe でサブスクリプションを確認してください。", + "applied.description": "おめでとうございます!教育割引の申請が成功しました。", + "applied.noPaymentPermission.description": "このワークスペースでは支払い権限がありません。教育割引を使用するには、請求を管理できるワークスペースに切り替えてください。", + "applied.noPaymentPermission.returnHome": "Dify に戻る", + "applied.step1.description": "教育割引の申請が成功しました。", + "applied.step1.title": "ステップ 1", + "applied.step2.description": "教育割引を使用するワークスペースを選択してください。", + "applied.step2.title": "ステップ 2", + "applied.tabs.activeSubscription": "サブスクリプション中", + "applied.tabs.eligible": "購入可能", + "applied.tabs.noPaymentPermission": "支払い権限なし", + "applied.title": "教育割引が適用されました", + "applied.workspace.plan": "有料プラン", + "applied.workspace.title": "現在のワークスペース", "currentSigned": "現在ログイン中のアカウントは", + "educationPricingConfirm.billingPeriod.monthly": "月次", + "educationPricingConfirm.billingPeriod.yearly": "年次", + "educationPricingConfirm.cancel": "キャンセル", + "educationPricingConfirm.continue": "割引なしで続行", + "educationPricingConfirm.description": "{{planName}} {{billingPeriod}} プランは教育割引に対応していません。Professional 年次プランのみが対象です。", + "educationPricingConfirm.title": "教育割引は利用できません", "emailLabel": "現在のメールアドレス", "form.schoolName.placeholder": "学校の正式名称(省略不可)を入力してください。", "form.schoolName.title": "学校名", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "今すぐ再認証して、次の学年度向けの教育クーポンを取得してください。クーポンはあなたのアカウントに追加され、次回のアップグレード時にご利用いただけます。", "notice.stillInEducation.isAboutToExpire": "今すぐ再認証して、次の学年度向けの教育クーポンを取得してください。クーポンは個人のアカウントに保存され、次回の更新時に使用できます。", "notice.stillInEducation.title": "まだ在学中ですか?", + "planNotSupportEducationDiscount": "教育価格の対象外", "rejectContent": "申し訳ございませんが、このメールアドレスでは 教育認証 の資格を取得できず、Dify プロフェッショナルプランの 100%割引クーポン を受け取ることはできません。", "rejectTitle": "Dify 教育認証が拒否されました", "submit": "送信", @@ -40,5 +61,6 @@ "toVerified": "教育認証を取得", "toVerifiedTip.coupon": "100%割引クーポン", "toVerifiedTip.end": "を受け取ることができます。", - "toVerifiedTip.front": "現在、教育認証ステータスを取得する資格があります。以下に教育情報を入力し、認証プロセスを完了すると、Dify プロフェッショナルプランの" + "toVerifiedTip.front": "現在、教育認証ステータスを取得する資格があります。以下に教育情報を入力し、認証プロセスを完了すると、Dify プロフェッショナルプランの", + "useEducationDiscount": "教育割引を使用" } diff --git a/web/i18n/ko-KR/education.json b/web/i18n/ko-KR/education.json index 5b9e009b23..c7db9a99b7 100644 --- a/web/i18n/ko-KR/education.json +++ b/web/i18n/ko-KR/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "현재 활성 구독이 있습니다. 구독이 만료된 후 교육 할인을 사용할 수 있습니다. Stripe에서 구독을 확인하세요.", + "applied.description": "축하합니다! 교육 할인 신청이 성공적으로 완료되었습니다.", + "applied.noPaymentPermission.description": "이 워크스페이스에서 결제 권한이 없습니다. 교육 할인을 사용하려면 청구를 관리할 수 있는 워크스페이스로 전환하세요.", + "applied.noPaymentPermission.returnHome": "Dify로 돌아가기", + "applied.step1.description": "교육 할인 신청이 성공적으로 완료되었습니다.", + "applied.step1.title": "1단계", + "applied.step2.description": "교육 할인을 사용할 워크스페이스를 선택하세요.", + "applied.step2.title": "2단계", + "applied.tabs.activeSubscription": "구독 중", + "applied.tabs.eligible": "구매 가능", + "applied.tabs.noPaymentPermission": "결제 권한 없음", + "applied.title": "교육 할인 적용됨", + "applied.workspace.plan": "유료 플랜", + "applied.workspace.title": "현재 워크스페이스", "currentSigned": "현재 로그인 중입니다", + "educationPricingConfirm.billingPeriod.monthly": "월간", + "educationPricingConfirm.billingPeriod.yearly": "연간", + "educationPricingConfirm.cancel": "취소", + "educationPricingConfirm.continue": "할인 없이 계속", + "educationPricingConfirm.description": "{{planName}} {{billingPeriod}} 플랜은 교육 할인을 지원하지 않습니다. Professional 연간 플랜만 자격이 있습니다.", + "educationPricingConfirm.title": "교육 할인 불가", "emailLabel": "현재 이메일", "form.schoolName.placeholder": "귀하의 학교의 공식 약어가 아닌 전체 이름을 입력하세요.", "form.schoolName.title": "당신의 학교 이름", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "지금 다시 확인하여 다가오는 학년도에 사용할 새 쿠폰을 받아보세요. 우리는 그것을 귀하의 계정에 추가하며, 다음 업그레이드에 사용할 수 있습니다.", "notice.stillInEducation.isAboutToExpire": "새로운 학년을 위한 쿠폰을 받으시려면 지금 다시 인증하십시오. 쿠폰은 귀하의 계정에 저장되어 다음 갱신 시 사용할 수 있습니다.", "notice.stillInEducation.title": "아직 학업 중이신가요?", + "planNotSupportEducationDiscount": "교육 가격 대상 아님", "rejectContent": "안타깝게도, 귀하는 교육 인증 상태에 적합하지 않으므로 이 이메일 주소를 사용할 경우 Dify Professional Plan 의 독점 100% 쿠폰을 받을 수 없습니다.", "rejectTitle": "귀하의 Dify 교육 인증이 거부되었습니다.", "submit": "제출", @@ -40,5 +61,6 @@ "toVerified": "교육 인증 받기", "toVerifiedTip.coupon": "독점 100% 쿠폰", "toVerifiedTip.end": "Dify 프로페셔널 플랜을 위해.", - "toVerifiedTip.front": "당신은 이제 교육 인증 상태를 받을 자격이 있습니다. 아래에 귀하의 교육 정보를 입력하여 과정을 완료하고 인증을 받으십시오." + "toVerifiedTip.front": "당신은 이제 교육 인증 상태를 받을 자격이 있습니다. 아래에 귀하의 교육 정보를 입력하여 과정을 완료하고 인증을 받으십시오.", + "useEducationDiscount": "교육 할인 사용" } diff --git a/web/i18n/nl-NL/education.json b/web/i18n/nl-NL/education.json index a0fb01c014..6bf16ef619 100644 --- a/web/i18n/nl-NL/education.json +++ b/web/i18n/nl-NL/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "U heeft een actief abonnement. U kunt de onderwijskorting gebruiken nadat uw abonnement is verlopen. Bevestig uw abonnement in Stripe.", + "applied.description": "Gefeliciteerd! U heeft met succes de onderwijskorting aangevraagd.", + "applied.noPaymentPermission.description": "U heeft geen betalingsrechten in deze werkruimte. Schakel over naar een werkruimte waar u facturering kunt beheren om de onderwijskorting te gebruiken.", + "applied.noPaymentPermission.returnHome": "Terug naar Dify", + "applied.step1.description": "U heeft met succes de onderwijskorting aangevraagd.", + "applied.step1.title": "Stap 1", + "applied.step2.description": "Selecteer de werkruimte die u wilt gebruiken met de onderwijskorting.", + "applied.step2.title": "Stap 2", + "applied.tabs.activeSubscription": "In abonnement", + "applied.tabs.eligible": "Kan kopen", + "applied.tabs.noPaymentPermission": "Geen betalingsrechten", + "applied.title": "Onderwijskorting toegepast", + "applied.workspace.plan": "Betaald plan", + "applied.workspace.title": "Huidige werkruimte", "currentSigned": "CURRENTLY SIGNED IN AS", + "educationPricingConfirm.billingPeriod.monthly": "maandelijks", + "educationPricingConfirm.billingPeriod.yearly": "jaarlijks", + "educationPricingConfirm.cancel": "Annuleren", + "educationPricingConfirm.continue": "Doorgaan zonder korting", + "educationPricingConfirm.description": "Uw {{planName}} {{billingPeriod}} abonnement ondersteunt de onderwijskorting niet. Alleen het jaarlijkse Professional abonnement komt in aanmerking.", + "educationPricingConfirm.title": "Onderwijskorting niet beschikbaar", "emailLabel": "Your current email", "form.schoolName.placeholder": "Enter the official, unabbreviated name of your school", "form.schoolName.title": "Your School Name", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Re-verify now to get a new coupon for the upcoming academic year. We'll add it to your account and you can use it for the next upgrade.", "notice.stillInEducation.isAboutToExpire": "Re-verify now to get a new coupon for the upcoming academic year. It'll be saved to your account and ready to use at your next renewal.", "notice.stillInEducation.title": "Still in education?", + "planNotSupportEducationDiscount": "Niet in aanmerking voor onderwijsprijzen", "rejectContent": "Unfortunately, you are not eligible for Education Verified status and therefore cannot receive the exclusive 100% coupon for the Dify Professional Plan if you use this email address.", "rejectTitle": "Your Dify Educational Verification Has Been Rejected", "submit": "Submit", @@ -40,5 +61,6 @@ "toVerified": "Get Education Verified", "toVerifiedTip.coupon": "exclusive 100% coupon", "toVerifiedTip.end": "for the Dify Professional Plan.", - "toVerifiedTip.front": "You are now eligible for Education Verified status. Please enter your education information below to complete the process and receive an" + "toVerifiedTip.front": "You are now eligible for Education Verified status. Please enter your education information below to complete the process and receive an", + "useEducationDiscount": "Gebruik onderwijskorting" } diff --git a/web/i18n/pl-PL/education.json b/web/i18n/pl-PL/education.json index d8a6a08607..cb71de4572 100644 --- a/web/i18n/pl-PL/education.json +++ b/web/i18n/pl-PL/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "Masz aktywną subskrypcję. Możesz skorzystać z rabatu edukacyjnego po wygaśnięciu subskrypcji. Potwierdź subskrypcję w Stripe.", + "applied.description": "Gratulacje! Pomyślnie złożono wniosek o rabat edukacyjny.", + "applied.noPaymentPermission.description": "Nie masz uprawnień do płatności w tym obszarze roboczym. Przejdź do obszaru roboczego, w którym możesz zarządzać rozliczeniami, aby skorzystać z rabatu edukacyjnego.", + "applied.noPaymentPermission.returnHome": "Powrót do Dify", + "applied.step1.description": "Pomyślnie złożono wniosek o rabat edukacyjny.", + "applied.step1.title": "Krok 1", + "applied.step2.description": "Wybierz obszar roboczy, który chcesz używać z rabatem edukacyjnym.", + "applied.step2.title": "Krok 2", + "applied.tabs.activeSubscription": "W subskrypcji", + "applied.tabs.eligible": "Może kupić", + "applied.tabs.noPaymentPermission": "Brak uprawnień do płatności", + "applied.title": "Rabat edukacyjny zastosowany", + "applied.workspace.plan": "Plan płatny", + "applied.workspace.title": "Aktualny obszar roboczy", "currentSigned": "AKTUALNIE ZALOGOWANY JAKO", + "educationPricingConfirm.billingPeriod.monthly": "miesięcznie", + "educationPricingConfirm.billingPeriod.yearly": "rocznie", + "educationPricingConfirm.cancel": "Anuluj", + "educationPricingConfirm.continue": "Kontynuuj bez rabatu", + "educationPricingConfirm.description": "Twój plan {{planName}} {{billingPeriod}} nie obsługuje rabatu edukacyjnego. Tylko roczny plan Professional jest uprawniony.", + "educationPricingConfirm.title": "Rabat edukacyjny niedostępny", "emailLabel": "Twój aktualny email", "form.schoolName.placeholder": "Wpisz oficjalną, pełną nazwę swojej szkoły", "form.schoolName.title": "Nazwa Twojej Szkoły", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Sprawdź ponownie teraz, aby otrzymać nowy kupon na nadchodzący rok akademicki. Dodamy go do twojego konta i będziesz mógł go użyć przy następnej aktualizacji.", "notice.stillInEducation.isAboutToExpire": "Zweryfikuj ponownie teraz, aby otrzymać nowy kupon na nadchodzący rok akademicki. Zostanie zapisany na Twoim koncie i gotowy do użycia przy następnej odnowie.", "notice.stillInEducation.title": "Wciąż w edukacji?", + "planNotSupportEducationDiscount": "Nie kwalifikuje się do cen edukacyjnych", "rejectContent": "Niestety, nie kwalifikujesz się do statusu Zweryfikowanej Edukacji i w związku z tym nie możesz otrzymać ekskluzywnego kuponu 100% na plan Dify Professional, jeśli korzystasz z tego adresu e-mail.", "rejectTitle": "Twoja weryfikacja edukacyjna Dify została odrzucona", "submit": "Zatwierdź", @@ -40,5 +61,6 @@ "toVerified": "Uzyskaj potwierdzenie edukacji", "toVerifiedTip.coupon": "ekskluzywny kupon 100%", "toVerifiedTip.end": "dla Profesjonalnego Planu Dify.", - "toVerifiedTip.front": "Teraz jesteś uprawniony do statusu zweryfikowanej edukacji. Proszę wprowadzić swoje informacje edukacyjne poniżej, aby zakończyć proces i otrzymać" + "toVerifiedTip.front": "Teraz jesteś uprawniony do statusu zweryfikowanej edukacji. Proszę wprowadzić swoje informacje edukacyjne poniżej, aby zakończyć proces i otrzymać", + "useEducationDiscount": "Użyj rabatu edukacyjnego" } diff --git a/web/i18n/pt-BR/education.json b/web/i18n/pt-BR/education.json index 93a35940ea..c6929f5840 100644 --- a/web/i18n/pt-BR/education.json +++ b/web/i18n/pt-BR/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "Você tem uma assinatura ativa. Você pode usar o desconto educacional após o vencimento da assinatura. Confirme sua assinatura no Stripe.", + "applied.description": "Parabéns! Você solicitou com sucesso o desconto educacional.", + "applied.noPaymentPermission.description": "Você não tem permissão de pagamento neste workspace. Por favor, mude para um workspace onde você possa gerenciar o faturamento para usar o desconto educacional.", + "applied.noPaymentPermission.returnHome": "Voltar para o Dify", + "applied.step1.description": "Você solicitou com sucesso o desconto educacional.", + "applied.step1.title": "Passo 1", + "applied.step2.description": "Selecione o workspace que deseja usar com o desconto educacional.", + "applied.step2.title": "Passo 2", + "applied.tabs.activeSubscription": "Em assinatura", + "applied.tabs.eligible": "Pode comprar", + "applied.tabs.noPaymentPermission": "Sem permissão de pagamento", + "applied.title": "Desconto educacional aplicado", + "applied.workspace.plan": "Plano pago", + "applied.workspace.title": "Workspace atual", "currentSigned": "ATUALMENTE CONECTADO COMO", + "educationPricingConfirm.billingPeriod.monthly": "mensal", + "educationPricingConfirm.billingPeriod.yearly": "anual", + "educationPricingConfirm.cancel": "Cancelar", + "educationPricingConfirm.continue": "Continuar sem desconto", + "educationPricingConfirm.description": "Seu plano {{planName}} {{billingPeriod}} não suporta o desconto educacional. Apenas o plano Professional anual é elegível.", + "educationPricingConfirm.title": "Desconto educacional não disponível", "emailLabel": "Seu e-mail atual", "form.schoolName.placeholder": "Digite o nome oficial e não abreviado da sua escola", "form.schoolName.title": "O nome da sua escola", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Reveja agora para obter um novo cupom para o próximo ano acadêmico. Nós o adicionaremos à sua conta e você poderá usá-lo na próxima atualização.", "notice.stillInEducation.isAboutToExpire": "Verifique novamente agora para receber um novo cupom para o próximo ano acadêmico. Ele será salvo na sua conta e estará pronto para ser usado na sua próxima renovação.", "notice.stillInEducation.title": "Ainda na educação?", + "planNotSupportEducationDiscount": "Não elegível para preço educacional", "rejectContent": "Infelizmente, você não é elegível para o status de Educação Verificada e, portanto, não pode receber o cupom exclusivo de 100% para o Plano Profissional Dify se usar este endereço de e-mail.", "rejectTitle": "A sua verificação educacional Dify foi rejeitada.", "submit": "Enviar", @@ -40,5 +61,6 @@ "toVerified": "Verifique a Educação", "toVerifiedTip.coupon": "cupom exclusivo de 100%", "toVerifiedTip.end": "para o Plano Profissional Dify.", - "toVerifiedTip.front": "Você agora está elegível para o status de Educação Verificada. Por favor, insira suas informações educacionais abaixo para concluir o processo e receber um" + "toVerifiedTip.front": "Você agora está elegível para o status de Educação Verificada. Por favor, insira suas informações educacionais abaixo para concluir o processo e receber um", + "useEducationDiscount": "Usar desconto educacional" } diff --git a/web/i18n/ro-RO/education.json b/web/i18n/ro-RO/education.json index 6d92793a71..61d257f08b 100644 --- a/web/i18n/ro-RO/education.json +++ b/web/i18n/ro-RO/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "Ai un abonament activ. Poți folosi reducerea educațională după expirarea abonamentului. Confirmați abonamentul în Stripe.", + "applied.description": "Felicitări! Ai aplicat cu succes pentru reducerea educațională.", + "applied.noPaymentPermission.description": "Nu ai permisiunea de plată în acest workspace. Te rugăm să treci la un workspace unde poți gestiona facturarea pentru a folosi reducerea educațională.", + "applied.noPaymentPermission.returnHome": "Înapoi la Dify", + "applied.step1.description": "Ai aplicat cu succes pentru reducerea educațională.", + "applied.step1.title": "Pasul 1", + "applied.step2.description": "Selectează workspace-ul pe care dorești să-l utilizezi cu reducerea educațională.", + "applied.step2.title": "Pasul 2", + "applied.tabs.activeSubscription": "În abonament", + "applied.tabs.eligible": "Poate cumpăra", + "applied.tabs.noPaymentPermission": "Fără permisiune de plată", + "applied.title": "Reducere educațională aplicată", + "applied.workspace.plan": "Plan plătit", + "applied.workspace.title": "Workspace-ul curent", "currentSigned": "CONEXIUNE ÎN PREZENT CA", + "educationPricingConfirm.billingPeriod.monthly": "lunar", + "educationPricingConfirm.billingPeriod.yearly": "anual", + "educationPricingConfirm.cancel": "Anulează", + "educationPricingConfirm.continue": "Continuă fără reducere", + "educationPricingConfirm.description": "Planul tău {{planName}} {{billingPeriod}} nu suportă reducerea educațională. Doar planul Professional anual este eligibil.", + "educationPricingConfirm.title": "Reducerea educațională nu este disponibilă", "emailLabel": "Emailul tău curent", "form.schoolName.placeholder": "Introduceți numele oficial, neabbreviat al școlii dumneavoastră", "form.schoolName.title": "Numele Școlii Tale", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Re-verificați acum pentru a obține un nou cupon pentru următorul an academic. Vom adăuga acest cupon în contul dvs. și îl puteți folosi pentru următoarea actualizare.", "notice.stillInEducation.isAboutToExpire": "Re-verifică acum pentru a obține un nou cupon pentru anul universitar următor. Va fi salvat în contul tău și gata de utilizat la următoarea reînnoire.", "notice.stillInEducation.title": "Încă în educație?", + "planNotSupportEducationDiscount": "Nu este eligibil pentru prețuri educaționale", "rejectContent": "Din păcate, nu ești eligibil pentru statutul de Verificat Educațional și, prin urmare, nu poți primi cuponul exclusiv de 100% pentru Planul Profesional Dify dacă folosești această adresă de email.", "rejectTitle": "Verificarea educațională Dify a fost respinsă", "submit": "Trimite", @@ -40,5 +61,6 @@ "toVerified": "Obțineți verificarea educației", "toVerifiedTip.coupon": "cupom exclusiv 100%", "toVerifiedTip.end": "pentru Planul Profesional Dify.", - "toVerifiedTip.front": "Sunteți acum eligibil pentru statutul de Educație Verificată. Vă rugăm să introduceți informațiile despre educația dumneavoastră mai jos pentru a finaliza procesul și a primi un" + "toVerifiedTip.front": "Sunteți acum eligibil pentru statutul de Educație Verificată. Vă rugăm să introduceți informațiile despre educația dumneavoastră mai jos pentru a finaliza procesul și a primi un", + "useEducationDiscount": "Folosește reducerea educațională" } diff --git a/web/i18n/ru-RU/education.json b/web/i18n/ru-RU/education.json index f5ea530184..ce9300745f 100644 --- a/web/i18n/ru-RU/education.json +++ b/web/i18n/ru-RU/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "У вас есть активная подписка. Вы можете использовать образовательную скидку после истечения срока действия подписки. Подтвердите подписку в Stripe.", + "applied.description": "Поздравляем! Вы успешно подали заявку на образовательную скидку.", + "applied.noPaymentPermission.description": "У вас нет прав на оплату в этом рабочем пространстве. Пожалуйста, переключитесь в рабочее пространство, где вы можете управлять выставлением счетов, чтобы использовать образовательную скидку.", + "applied.noPaymentPermission.returnHome": "Вернуться в Dify", + "applied.step1.description": "Вы успешно подали заявку на образовательную скидку.", + "applied.step1.title": "Шаг 1", + "applied.step2.description": "Выберите рабочее пространство, которое хотите использовать с образовательной скидкой.", + "applied.step2.title": "Шаг 2", + "applied.tabs.activeSubscription": "В подписке", + "applied.tabs.eligible": "Можно купить", + "applied.tabs.noPaymentPermission": "Нет прав на оплату", + "applied.title": "Образовательная скидка применена", + "applied.workspace.plan": "Платный план", + "applied.workspace.title": "Текущее рабочее пространство", "currentSigned": "В ДАННЫЙ МОМЕНТ ВХОД В ПРОФИЛЬ КАК", + "educationPricingConfirm.billingPeriod.monthly": "ежемесячно", + "educationPricingConfirm.billingPeriod.yearly": "ежегодно", + "educationPricingConfirm.cancel": "Отмена", + "educationPricingConfirm.continue": "Продолжить без скидки", + "educationPricingConfirm.description": "Ваш план {{planName}} {{billingPeriod}} не поддерживает образовательную скидку. Только годовой план Professional имеет право на скидку.", + "educationPricingConfirm.title": "Образовательная скидка недоступна", "emailLabel": "Ваш текущий адрес электронной почты", "form.schoolName.placeholder": "Введите официальное, полное название вашей школы", "form.schoolName.title": "Название вашей школы", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Переутвердите сейчас, чтобы получить новый купон на предстоящий учебный год. Мы добавим его на ваш аккаунт, и вы сможете использовать его для следующего обновления.", "notice.stillInEducation.isAboutToExpire": "Проверьте еще раз, чтобы получить новый купон на предстоящий учебный год. Он будет сохранен в вашем аккаунте и готов к использованию при следующем продлении.", "notice.stillInEducation.title": "Все еще учишься?", + "planNotSupportEducationDiscount": "Не подходит для образовательной цены", "rejectContent": "К сожалению, вы не имеете права на статус Проверенного образованием и, следовательно, не можете получить эксклюзивный купон на 100% для профессионального плана Dify, если вы используете этот адрес электронной почты.", "rejectTitle": "Ваша образовательная проверка Dify была отклонена", "submit": "Отправить", @@ -40,5 +61,6 @@ "toVerified": "Получите подтверждение образования", "toVerifiedTip.coupon": "эксклюзивный 100% купон", "toVerifiedTip.end": "для профессионального плана Dify.", - "toVerifiedTip.front": "Теперь вы имеете право на статус \"Проверенное образование\". Пожалуйста, введите свои образовательные данные ниже, чтобы завершить процесс и получить" + "toVerifiedTip.front": "Теперь вы имеете право на статус \"Проверенное образование\". Пожалуйста, введите свои образовательные данные ниже, чтобы завершить процесс и получить", + "useEducationDiscount": "Использовать образовательную скидку" } diff --git a/web/i18n/sl-SI/education.json b/web/i18n/sl-SI/education.json index 92895ccff8..94abe1f58d 100644 --- a/web/i18n/sl-SI/education.json +++ b/web/i18n/sl-SI/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "Imate aktivno naročnino. Izobraževalni popust lahko uporabite po poteku naročnine. Potrdite naročnino v Stripe.", + "applied.description": "Čestitamo! Uspešno ste se prijavili za izobraževalni popust.", + "applied.noPaymentPermission.description": "V tem delovnem prostoru nimate dovoljenja za plačilo. Preklopite na delovni prostor, kjer lahko upravljate obračunavanje, da uporabite izobraževalni popust.", + "applied.noPaymentPermission.returnHome": "Nazaj na Dify", + "applied.step1.description": "Uspešno ste se prijavili za izobraževalni popust.", + "applied.step1.title": "Korak 1", + "applied.step2.description": "Izberite delovni prostor, ki ga želite uporabiti z izobraževalnim popustom.", + "applied.step2.title": "Korak 2", + "applied.tabs.activeSubscription": "V naročnini", + "applied.tabs.eligible": "Lahko kupi", + "applied.tabs.noPaymentPermission": "Brez dovoljenja za plačilo", + "applied.title": "Izobraževalni popust je bil uporabljen", + "applied.workspace.plan": "Plačljiv načrt", + "applied.workspace.title": "Trenutni delovni prostor", "currentSigned": "Trenutno prijavljen kot", + "educationPricingConfirm.billingPeriod.monthly": "mesečno", + "educationPricingConfirm.billingPeriod.yearly": "letno", + "educationPricingConfirm.cancel": "Prekliči", + "educationPricingConfirm.continue": "Nadaljuj brez popusta", + "educationPricingConfirm.description": "Vaš načrt {{planName}} {{billingPeriod}} ne podpira izobraževalnega popusta. Do popusta je upravičen samo letni načrt Professional.", + "educationPricingConfirm.title": "Izobraževalni popust ni na voljo", "emailLabel": "Vaš trenutni elektronski naslov", "form.schoolName.placeholder": "Vpišite uradno, neokrnjeno ime vaše šole", "form.schoolName.title": "Ime vaše šole", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Ponovno preverite zdaj, da pridobite nov kupon za prihajajoče šolsko leto. Dodali ga bomo vašemu računu in lahko ga uporabite za naslednjo nadgradnjo.", "notice.stillInEducation.isAboutToExpire": "Ponovno preverite zdaj, da pridobite nov kupon za prihajajoče akademsko leto. Shranjen bo na vašem računu in pripravljen za uporabo ob vaši naslednji obnovitvi.", "notice.stillInEducation.title": "Še vedno v izobraževanju?", + "planNotSupportEducationDiscount": "Ni upravičen do izobraževalnih cen", "rejectContent": "Na žalost niste upravičeni do statusa Verificirane izobrazbe in zato ne morete prejeti ekskluzivnega 100-odstotnega kupona za Dify profesionalni načrt, če uporabljate ta e-poštni naslov.", "rejectTitle": "Vaša Dify izobraževalna verifikacija je bila zavrnjena.", "submit": "Predloži", @@ -40,5 +61,6 @@ "toVerified": "Preverite izobrazbo", "toVerifiedTip.coupon": "izključno 100% kupon", "toVerifiedTip.end": "za profesionalni načrt Dify.", - "toVerifiedTip.front": "Zdaj ste upravičeni do statusa Preverjeno izobraževanje. Prosimo, vnesite svoje izobraževalne podatke spodaj, da zaključite postopek in prejmete" + "toVerifiedTip.front": "Zdaj ste upravičeni do statusa Preverjeno izobraževanje. Prosimo, vnesite svoje izobraževalne podatke spodaj, da zaključite postopek in prejmete", + "useEducationDiscount": "Uporabi izobraževalni popust" } diff --git a/web/i18n/th-TH/education.json b/web/i18n/th-TH/education.json index a309bfbe6b..b6b50a9181 100644 --- a/web/i18n/th-TH/education.json +++ b/web/i18n/th-TH/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "คุณมีการสมัครสมาชิกที่ยังใช้งานอยู่ คุณสามารถใช้ส่วนลดการศึกษาได้หลังจากการสมัครสมาชิกของคุณหมดอายุ ยืนยันการสมัครสมาชิกของคุณใน Stripe", + "applied.description": "ยินดีด้วย! คุณได้สมัครรับส่วนลดการศึกษาสำเร็จแล้ว", + "applied.noPaymentPermission.description": "คุณไม่มีสิทธิ์การชำระเงินในพื้นที่ทำงานนี้ โปรดเปลี่ยนไปยังพื้นที่ทำงานที่คุณสามารถจัดการการเรียกเก็บเงินเพื่อใช้ส่วนลดการศึกษา", + "applied.noPaymentPermission.returnHome": "กลับไปที่ Dify", + "applied.step1.description": "คุณได้สมัครรับส่วนลดการศึกษาสำเร็จแล้ว", + "applied.step1.title": "ขั้นตอนที่ 1", + "applied.step2.description": "เลือกพื้นที่ทำงานที่คุณต้องการใช้กับส่วนลดการศึกษา", + "applied.step2.title": "ขั้นตอนที่ 2", + "applied.tabs.activeSubscription": "อยู่ในการสมัครสมาชิก", + "applied.tabs.eligible": "สามารถซื้อได้", + "applied.tabs.noPaymentPermission": "ไม่มีสิทธิ์ชำระเงิน", + "applied.title": "ใช้ส่วนลดการศึกษาแล้ว", + "applied.workspace.plan": "แผนชำระเงิน", + "applied.workspace.title": "พื้นที่ทำงานปัจจุบัน", "currentSigned": "ลงชื่อเข้าใช้ในฐานะ", + "educationPricingConfirm.billingPeriod.monthly": "รายเดือน", + "educationPricingConfirm.billingPeriod.yearly": "รายปี", + "educationPricingConfirm.cancel": "ยกเลิก", + "educationPricingConfirm.continue": "ดำเนินการต่อโดยไม่มีส่วนลด", + "educationPricingConfirm.description": "แผน {{planName}} {{billingPeriod}} ของคุณไม่รองรับส่วนลดการศึกษา เฉพาะแผน Professional รายปีเท่านั้นที่มีสิทธิ์", + "educationPricingConfirm.title": "ส่วนลดการศึกษาไม่พร้อมใช้งาน", "emailLabel": "อีเมลปัจจุบันของคุณ", "form.schoolName.placeholder": "กรุณาใส่ชื่อของโรงเรียนอย่างเป็นทางการที่ไม่มีการย่อ", "form.schoolName.title": "ชื่อโรงเรียนของคุณ", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "ตรวจสอบอีกครั้งตอนนี้เพื่อรับคูปองใหม่สำหรับปีการศึกษาใหม่ เราจะเพิ่มมันเข้ากับบัญชีของคุณและคุณสามารถใช้มันสำหรับการอัปเกรดครั้งถัดไปได้", "notice.stillInEducation.isAboutToExpire": "ตรวจสอบอีกครั้งเดี๋ยวนี้เพื่อรับคูปองใหม่สำหรับปีการศึกษาที่จะมาถึง มันจะถูกบันทึกในบัญชีของคุณและพร้อมใช้งานในการต่ออายุครั้งถัดไปของคุณ.", "notice.stillInEducation.title": "ยังอยู่ในวัยเรียนใช่ไหม?", + "planNotSupportEducationDiscount": "ไม่มีสิทธิ์รับราคาการศึกษา", "rejectContent": "น่าเสียดายที่คุณไม่มีสิทธิ์ได้รับสถานะการตรวจสอบการศึกษาและดังนั้นคุณจึงไม่สามารถรับคูปองพิเศษ 100% สำหรับแผนมืออาชีพ Dify หากคุณใช้ที่อยู่อีเมลนี้.", "rejectTitle": "การตรวจสอบการศึกษา Dify ของคุณถูกปฏิเสธ", "submit": "ส่ง", @@ -40,5 +61,6 @@ "toVerified": "ตรวจสอบการศึกษา", "toVerifiedTip.coupon": "คูปองพิเศษ 100%", "toVerifiedTip.end": "สำหรับแผนมืออาชีพของ Dify.", - "toVerifiedTip.front": "คุณมีสิทธิ์ได้รับสถานะการตรวจสอบการศึกษาแล้ว กรุณากรอกข้อมูลการศึกษาของคุณด้านล่างเพื่อดำเนินการให้เสร็จสิ้นและรับสิทธิ์" + "toVerifiedTip.front": "คุณมีสิทธิ์ได้รับสถานะการตรวจสอบการศึกษาแล้ว กรุณากรอกข้อมูลการศึกษาของคุณด้านล่างเพื่อดำเนินการให้เสร็จสิ้นและรับสิทธิ์", + "useEducationDiscount": "ใช้ส่วนลดการศึกษา" } diff --git a/web/i18n/tr-TR/education.json b/web/i18n/tr-TR/education.json index 6f182e3b60..736407f362 100644 --- a/web/i18n/tr-TR/education.json +++ b/web/i18n/tr-TR/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "Aktif bir aboneliğiniz var. Aboneliğinizin süresi dolduktan sonra eğitim indirimini kullanabilirsiniz. Aboneliğinizi Stripe'da onaylayın.", + "applied.description": "Tebrikler! Eğitim indirimi için başarıyla başvurdunuz.", + "applied.noPaymentPermission.description": "Bu workspace'te ödeme izniniz yok. Eğitim indirimini kullanmak için lütfen faturalamayı yönetebileceğiniz bir workspace'e geçin.", + "applied.noPaymentPermission.returnHome": "Dify'e geri dön", + "applied.step1.description": "Eğitim indirimi için başarıyla başvurdunuz.", + "applied.step1.title": "Adım 1", + "applied.step2.description": "Eğitim indirimiyle kullanmak istediğiniz workspace'i seçin.", + "applied.step2.title": "Adım 2", + "applied.tabs.activeSubscription": "Abonelikte", + "applied.tabs.eligible": "Satın alabilir", + "applied.tabs.noPaymentPermission": "Ödeme izni yok", + "applied.title": "Eğitim indirimi uygulandı", + "applied.workspace.plan": "Ücretli plan", + "applied.workspace.title": "Mevcut Workspace", "currentSigned": "ŞU ANDA GİRİŞ YAPILDIĞI KİŞİ", + "educationPricingConfirm.billingPeriod.monthly": "aylık", + "educationPricingConfirm.billingPeriod.yearly": "yıllık", + "educationPricingConfirm.cancel": "İptal", + "educationPricingConfirm.continue": "İndirim olmadan devam et", + "educationPricingConfirm.description": "{{planName}} {{billingPeriod}} planınız eğitim indirimini desteklemiyor. Yalnızca Professional yıllık plan uygun.", + "educationPricingConfirm.title": "Eğitim indirimi mevcut değil", "emailLabel": "Şu anki e-posta adresin", "form.schoolName.placeholder": "Okulunuzun resmi, kısaltılmamış adını girin", "form.schoolName.title": "Okulunuzun Adı", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Şimdi yeniden doğrulayın, böylece yaklaşan akademik yıl için yeni bir kupon alın. Bu kuponu hesabınıza ekleyeceğiz ve sonraki yükseltme için kullanabilirsiniz.", "notice.stillInEducation.isAboutToExpire": "Şimdi yeniden doğrulayın ve gelecek akademik yıl için yeni bir kupon alın. Bu, hesabınıza kaydedilecek ve bir sonraki yenilemenizde kullanıma hazır olacak.", "notice.stillInEducation.title": "Hala eğitimde misin?", + "planNotSupportEducationDiscount": "Eğitim fiyatlandırması için uygun değil", "rejectContent": "Maalesef, Eğitim Doğrulama statüsüne uygun değilsiniz ve bu nedenle bu e-posta adresini kullanıyorsanız Dify Profesyonel Planı için özel %100 kuponu alamazsınız.", "rejectTitle": "Dify Eğitim Doğrulamanız Rededildi", "submit": "Gönder", @@ -40,5 +61,6 @@ "toVerified": "Eğitim Bilgilerinizi Doğrulayın", "toVerifiedTip.coupon": "özel %100 kupon", "toVerifiedTip.end": "Dify Profesyonel Planı için.", - "toVerifiedTip.front": "Artık Eğitim Doğrulandı statüsüne uygun oldunuz. Lütfen süreci tamamlamak ve bir almak için eğitim bilgilerinizi aşağıya girin." + "toVerifiedTip.front": "Artık Eğitim Doğrulandı statüsüne uygun oldunuz. Lütfen süreci tamamlamak ve bir almak için eğitim bilgilerinizi aşağıya girin.", + "useEducationDiscount": "Eğitim indirimini kullan" } diff --git a/web/i18n/uk-UA/education.json b/web/i18n/uk-UA/education.json index 0564552f6d..d0cf4a77de 100644 --- a/web/i18n/uk-UA/education.json +++ b/web/i18n/uk-UA/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "У вас є активна підписка. Ви можете скористатися освітньою знижкою після закінчення терміну дії підписки. Підтвердіть підписку в Stripe.", + "applied.description": "Вітаємо! Ви успішно подали заявку на освітню знижку.", + "applied.noPaymentPermission.description": "У вас немає прав на оплату в цьому робочому просторі. Будь ласка, перейдіть до робочого простору, де ви можете керувати платіжними даними, щоб скористатися освітньою знижкою.", + "applied.noPaymentPermission.returnHome": "Повернутися до Dify", + "applied.step1.description": "Ви успішно подали заявку на освітню знижку.", + "applied.step1.title": "Крок 1", + "applied.step2.description": "Виберіть робочий простір, який ви хочете використовувати з освітньою знижкою.", + "applied.step2.title": "Крок 2", + "applied.tabs.activeSubscription": "У підписці", + "applied.tabs.eligible": "Можна купити", + "applied.tabs.noPaymentPermission": "Немає прав на оплату", + "applied.title": "Освітню знижку застосовано", + "applied.workspace.plan": "Платний план", + "applied.workspace.title": "Поточний робочий простір", "currentSigned": "В даний момент ви підписані як", + "educationPricingConfirm.billingPeriod.monthly": "щомісячно", + "educationPricingConfirm.billingPeriod.yearly": "щорічно", + "educationPricingConfirm.cancel": "Скасувати", + "educationPricingConfirm.continue": "Продовжити без знижки", + "educationPricingConfirm.description": "Ваш план {{planName}} {{billingPeriod}} не підтримує освітню знижку. Лише річний план Professional має право на знижку.", + "educationPricingConfirm.title": "Освітня знижка недоступна", "emailLabel": "Ваш поточний електронний лист", "form.schoolName.placeholder": "Введіть офіційну, повну назву вашої школи", "form.schoolName.title": "Ваша назва школи", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Перевірте ще раз зараз, щоб отримати новий купон на наступний навчальний рік. Ми додамо його до вашого облікового запису, і ви зможете скористатися ним для наступного оновлення.", "notice.stillInEducation.isAboutToExpire": "Перевірте ще раз зараз, щоб отримати новий купон на наступний навчальний рік. Він буде збережений у вашому обліковому записі та готовий до використання при наступному поновленні.", "notice.stillInEducation.title": "Все ще навчаєшся?", + "planNotSupportEducationDiscount": "Не підходить для освітньої ціни", "rejectContent": "На жаль, ви не відповідаєте вимогам для статусу Education Verified і тому не можете отримати ексклюзивний купон на 100% для професійного плану Dify, якщо використовуєте цю електронну адресу.", "rejectTitle": "Ваша перевірка освіти Dify була відхилена", "submit": "Надіслати", @@ -40,5 +61,6 @@ "toVerified": "Отримайте підтвердження освіти", "toVerifiedTip.coupon": "ексклюзивний купон 100%", "toVerifiedTip.end": "для професійного плану Dify.", - "toVerifiedTip.front": "Ви тепер маєте право на статус перевіреної освіти. Будь ласка, введіть свою інформацію про освіту нижче, щоб завершити процес і отримати" + "toVerifiedTip.front": "Ви тепер маєте право на статус перевіреної освіти. Будь ласка, введіть свою інформацію про освіту нижче, щоб завершити процес і отримати", + "useEducationDiscount": "Використати освітню знижку" } diff --git a/web/i18n/vi-VN/education.json b/web/i18n/vi-VN/education.json index ac5b249928..2edc6965a1 100644 --- a/web/i18n/vi-VN/education.json +++ b/web/i18n/vi-VN/education.json @@ -1,5 +1,25 @@ { + "applied.activeSubscription.description": "Bạn có một gói đăng ký đang hoạt động. Bạn có thể sử dụng giảm giá giáo dục sau khi gói đăng ký hết hạn. Xác nhận gói đăng ký của bạn trên Stripe.", + "applied.description": "Chúc mừng! Bạn đã đăng ký giảm giá giáo dục thành công.", + "applied.noPaymentPermission.description": "Bạn không có quyền thanh toán trong workspace này. Vui lòng chuyển sang workspace mà bạn có thể quản lý thanh toán để sử dụng giảm giá giáo dục.", + "applied.noPaymentPermission.returnHome": "Quay lại Dify", + "applied.step1.description": "Bạn đã đăng ký giảm giá giáo dục thành công.", + "applied.step1.title": "Bước 1", + "applied.step2.description": "Chọn workspace bạn muốn sử dụng với giảm giá giáo dục.", + "applied.step2.title": "Bước 2", + "applied.tabs.activeSubscription": "Đang đăng ký", + "applied.tabs.eligible": "Có thể mua", + "applied.tabs.noPaymentPermission": "Không có quyền thanh toán", + "applied.title": "Giảm giá giáo dục đã áp dụng", + "applied.workspace.plan": "Gói trả phí", + "applied.workspace.title": "Workspace hiện tại", "currentSigned": "HIỆN ĐANG ĐĂNG NHẬP VÀO", + "educationPricingConfirm.billingPeriod.monthly": "hàng tháng", + "educationPricingConfirm.billingPeriod.yearly": "hàng năm", + "educationPricingConfirm.cancel": "Hủy", + "educationPricingConfirm.continue": "Tiếp tục không có giảm giá", + "educationPricingConfirm.description": "Gói {{planName}} {{billingPeriod}} của bạn không hỗ trợ giảm giá giáo dục. Chỉ gói Professional hàng năm mới được áp dụng.", + "educationPricingConfirm.title": "Giảm giá giáo dục không khả dụng", "emailLabel": "Email hiện tại của bạn", "form.schoolName.placeholder": "Nhập tên chính thức, không viết tắt của trường bạn", "form.schoolName.title": "Tên Trường Của Bạn", @@ -31,6 +51,7 @@ "notice.stillInEducation.expired": "Xác minh lại ngay bây giờ để nhận một phiếu giảm giá mới cho năm học sắp tới. Chúng tôi sẽ thêm nó vào tài khoản của bạn và bạn có thể sử dụng nó cho lần nâng cấp tiếp theo.", "notice.stillInEducation.isAboutToExpire": "Xác minh lại ngay bây giờ để nhận một phiếu giảm giá mới cho năm học sắp tới. Nó sẽ được lưu vào tài khoản của bạn và sẵn sàng sử dụng khi bạn gia hạn tiếp theo.", "notice.stillInEducation.title": "Vẫn đang học tập?", + "planNotSupportEducationDiscount": "Không đủ điều kiện cho giá giáo dục", "rejectContent": "Rất tiếc, bạn không đủ điều kiện để nhận trạng thái Xác minh Giáo dục và do đó không thể nhận được mã giảm giá độc quyền 100% cho Kế hoạch Chuyên nghiệp Dify nếu bạn sử dụng địa chỉ email này.", "rejectTitle": "Yêu cầu xác minh giáo dục Dify của bạn đã bị từ chối", "submit": "Gửi", @@ -40,5 +61,6 @@ "toVerified": "Xác thực giáo dục", "toVerifiedTip.coupon": "mã giảm giá độc quyền 100%", "toVerifiedTip.end": "cho Kế hoạch Chuyên nghiệp Dify.", - "toVerifiedTip.front": "Bạn hiện đủ điều kiện để có trạng thái Xác minh Giáo dục. Vui lòng nhập thông tin giáo dục của bạn bên dưới để hoàn tất quá trình và nhận một" + "toVerifiedTip.front": "Bạn hiện đủ điều kiện để có trạng thái Xác minh Giáo dục. Vui lòng nhập thông tin giáo dục của bạn bên dưới để hoàn tất quá trình và nhận một", + "useEducationDiscount": "Sử dụng giảm giá giáo dục" } diff --git a/web/i18n/zh-Hant/education.json b/web/i18n/zh-Hant/education.json index 9d24800ae5..7447470a4c 100644 --- a/web/i18n/zh-Hant/education.json +++ b/web/i18n/zh-Hant/education.json @@ -1,5 +1,18 @@ { + "applied.activeSubscription.description": "你目前有生效中的訂閱。訂閱到期後即可使用教育優惠。請前往 Stripe 確認你的訂閱。", + "applied.description": "恭喜!您已成功申請教育優惠。", + "applied.noPaymentPermission.description": "你沒有此工作空間的付款權限。請切換到你可以管理帳單的工作空間,以使用教育優惠。", + "applied.noPaymentPermission.returnHome": "返回 Dify", + "applied.step1.description": "您已成功申請教育優惠。", + "applied.step1.title": "第一步", "applied.step2.description": "選擇要使用教育優惠的 workspace。", + "applied.step2.title": "第二步", + "applied.tabs.activeSubscription": "在訂閱中", + "applied.tabs.eligible": "能買", + "applied.tabs.noPaymentPermission": "無付款權限", + "applied.title": "教育優惠申請成功", + "applied.workspace.plan": "付費方案", + "applied.workspace.title": "目前 Workspace", "currentSigned": "當前以以下身份登入", "educationPricingConfirm.billingPeriod.monthly": "月付", "educationPricingConfirm.billingPeriod.yearly": "年付", @@ -38,6 +51,7 @@ "notice.stillInEducation.expired": "立即重新驗證,以獲得即將到來的學年新優惠券。我們會將其新增到您的帳戶中,您可以用於下一次升級。", "notice.stillInEducation.isAboutToExpire": "現在重新驗證以獲得即將到來的學年新優惠券。它將保存在您的帳戶中,並在下次續訂時隨時可以使用。", "notice.stillInEducation.title": "仍在接受教育嗎?", + "planNotSupportEducationDiscount": "不適用教育優惠價格", "rejectContent": "不幸的是,您不符合教育驗證狀態,因此如果您使用此電子郵件地址,將無法獲得 Dify 專業計劃的 100% 獨家優惠券。", "rejectTitle": "您的 Dify 教育驗證已被拒絕", "submit": "提交", @@ -47,5 +61,6 @@ "toVerified": "獲取教育證明", "toVerifiedTip.coupon": "獨家 100% 優惠券", "toVerifiedTip.end": "用於 Dify 專業計劃。", - "toVerifiedTip.front": "您現在符合教育驗證狀態的資格。請在下面輸入您的教育資訊以完成此流程並獲得一個" + "toVerifiedTip.front": "您現在符合教育驗證狀態的資格。請在下面輸入您的教育資訊以完成此流程並獲得一個", + "useEducationDiscount": "使用教育優惠" } From 00bf3f83f2368218d1aeab4d33e6e70f87b4c2e3 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 7 May 2026 09:36:10 +0800 Subject: [PATCH 03/10] refactor: verticalize tag management and batch bindings (#35840) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/tag/tags.py | 61 +--- .../service_api/dataset/dataset.py | 30 +- api/services/tag_service.py | 23 +- .../service_api/dataset/test_dataset.py | 48 ++- .../services/test_tag_service.py | 39 +- .../controllers/console/tag/test_tags.py | 111 ++---- eslint-suppressions.json | 44 --- web/.storybook/main.ts | 5 +- .../(appDetailLayout)/[appId]/layout-main.tsx | 10 - .../account-page/email-change-modal.tsx | 42 ++- .../(commonLayout)/account-page/index.tsx | 14 +- .../apps/__tests__/app-card.spec.tsx | 33 +- .../components/apps/__tests__/list.spec.tsx | 14 +- web/app/components/apps/app-card.tsx | 34 +- web/app/components/apps/list.tsx | 25 +- .../__tests__/tag-remove-modal.spec.tsx | 123 ------- .../base/tag-management/constant.ts | 6 - .../components/base/tag-management/index.tsx | 62 ---- .../base/tag-management/selector.tsx | 116 ------ .../components/base/tag-management/store.ts | 19 - .../base/tag-management/tag-remove-modal.tsx | 48 --- .../datasets/list/__tests__/datasets.spec.tsx | 10 +- .../datasets/list/__tests__/index.spec.tsx | 28 +- .../dataset-card/__tests__/index.spec.tsx | 6 +- .../components/__tests__/tag-area.spec.tsx | 198 ----------- .../list/dataset-card/components/tag-area.tsx | 55 --- .../__tests__/use-dataset-card-state.spec.ts | 48 --- .../hooks/use-dataset-card-state.ts | 12 +- .../datasets/list/dataset-card/index.tsx | 29 +- web/app/components/datasets/list/datasets.tsx | 4 +- web/app/components/datasets/list/index.tsx | 26 +- .../create-app-modal/__tests__/index.spec.tsx | 16 - .../explore/create-app-modal/index.tsx | 180 +++++----- web/contract/console/tags.ts | 91 +++++ web/contract/router.ts | 16 + .../__tests__/dataset-card-tags.spec.tsx | 152 ++++++++ .../__tests__/tag-filter.spec.tsx} | 74 +--- .../__tests__/tag-item-editor.spec.tsx | 72 +--- .../__tests__/tag-management-modal.spec.tsx} | 179 +++------- .../__tests__/tag-panel.spec.tsx} | 335 ++++-------------- .../__tests__/tag-selector.spec.tsx} | 201 ++++++++--- .../__tests__/tag-trigger.spec.tsx} | 14 +- .../__tests__/app-card-tags.spec.tsx | 95 +++++ .../components/app-card-tags.tsx | 31 ++ .../components/dataset-card-tags.tsx | 42 +++ .../tag-management/components/tag-filter.tsx} | 55 ++- .../components}/tag-item-editor.tsx | 118 +++--- .../components/tag-management-modal.tsx | 68 ++++ .../tag-management/components/tag-panel.tsx} | 141 +++----- .../components/tag-selector.tsx | 144 ++++++++ .../components/tag-trigger.tsx} | 19 +- .../__tests__/use-tag-mutations.spec.tsx | 274 ++++++++++++++ .../tag-management/hooks/use-tag-mutations.ts | 102 ++++++ .../tag-management-modal.stories.tsx} | 42 +-- web/models/datasets.ts | 2 +- web/service/knowledge/use-dataset.ts | 23 +- web/service/tag.ts | 47 --- web/types/app.ts | 2 +- 58 files changed, 1851 insertions(+), 2007 deletions(-) delete mode 100644 web/app/components/base/tag-management/__tests__/tag-remove-modal.spec.tsx delete mode 100644 web/app/components/base/tag-management/constant.ts delete mode 100644 web/app/components/base/tag-management/index.tsx delete mode 100644 web/app/components/base/tag-management/selector.tsx delete mode 100644 web/app/components/base/tag-management/store.ts delete mode 100644 web/app/components/base/tag-management/tag-remove-modal.tsx delete mode 100644 web/app/components/datasets/list/dataset-card/components/__tests__/tag-area.spec.tsx delete mode 100644 web/app/components/datasets/list/dataset-card/components/tag-area.tsx create mode 100644 web/contract/console/tags.ts create mode 100644 web/features/tag-management/__tests__/dataset-card-tags.spec.tsx rename web/{app/components/base/tag-management/__tests__/filter.spec.tsx => features/tag-management/__tests__/tag-filter.spec.tsx} (82%) rename web/{app/components/base => features}/tag-management/__tests__/tag-item-editor.spec.tsx (81%) rename web/{app/components/base/tag-management/__tests__/index.spec.tsx => features/tag-management/__tests__/tag-management-modal.spec.tsx} (59%) rename web/{app/components/base/tag-management/__tests__/panel.spec.tsx => features/tag-management/__tests__/tag-panel.spec.tsx} (69%) rename web/{app/components/base/tag-management/__tests__/selector.spec.tsx => features/tag-management/__tests__/tag-selector.spec.tsx} (63%) rename web/{app/components/base/tag-management/__tests__/trigger.spec.tsx => features/tag-management/__tests__/tag-trigger.spec.tsx} (82%) create mode 100644 web/features/tag-management/components/__tests__/app-card-tags.spec.tsx create mode 100644 web/features/tag-management/components/app-card-tags.tsx create mode 100644 web/features/tag-management/components/dataset-card-tags.tsx rename web/{app/components/base/tag-management/filter.tsx => features/tag-management/components/tag-filter.tsx} (71%) rename web/{app/components/base/tag-management => features/tag-management/components}/tag-item-editor.tsx (64%) create mode 100644 web/features/tag-management/components/tag-management-modal.tsx rename web/{app/components/base/tag-management/panel.tsx => features/tag-management/components/tag-panel.tsx} (50%) create mode 100644 web/features/tag-management/components/tag-selector.tsx rename web/{app/components/base/tag-management/trigger.tsx => features/tag-management/components/tag-trigger.tsx} (56%) create mode 100644 web/features/tag-management/hooks/__tests__/use-tag-mutations.spec.tsx create mode 100644 web/features/tag-management/hooks/use-tag-mutations.ts rename web/{app/components/base/tag-management/index.stories.tsx => features/tag-management/tag-management-modal.stories.tsx} (72%) delete mode 100644 web/service/tag.ts diff --git a/api/controllers/console/tag/tags.py b/api/controllers/console/tag/tags.py index f73e2da54e..b9e876c906 100644 --- a/api/controllers/console/tag/tags.py +++ b/api/controllers/console/tag/tags.py @@ -32,12 +32,7 @@ class TagBindingPayload(BaseModel): class TagBindingRemovePayload(BaseModel): - tag_id: str = Field(description="Tag ID to remove") - target_id: str = Field(description="Target ID to unbind tag from") - type: TagType = Field(description="Tag type") - - -class TagBindingItemDeletePayload(BaseModel): + tag_ids: list[str] = Field(description="Tag IDs to remove", min_length=1) target_id: str = Field(description="Target ID to unbind tag from") type: TagType = Field(description="Tag type") @@ -75,7 +70,6 @@ register_schema_models( TagBasePayload, TagBindingPayload, TagBindingRemovePayload, - TagBindingItemDeletePayload, TagListQueryParam, TagResponse, ) @@ -184,13 +178,13 @@ def _create_tag_bindings() -> tuple[dict[str, str], int]: return {"result": "success"}, 200 -def _remove_tag_binding() -> tuple[dict[str, str], int]: +def _remove_tag_bindings() -> tuple[dict[str, str], int]: _require_tag_binding_edit_permission() payload = TagBindingRemovePayload.model_validate(console_ns.payload or {}) TagService.delete_tag_binding( TagBindingDeletePayload( - tag_id=payload.tag_id, + tag_ids=payload.tag_ids, target_id=payload.target_id, type=payload.type, ) @@ -211,54 +205,15 @@ class TagBindingCollectionApi(Resource): return _create_tag_bindings() -@console_ns.route("/tag-bindings/") -class TagBindingItemApi(Resource): - """Canonical item resource for tag binding deletion.""" - - @console_ns.doc("delete_tag_binding") - @console_ns.doc(params={"id": "Tag ID"}) - @console_ns.expect(console_ns.models[TagBindingItemDeletePayload.__name__]) - @setup_required - @login_required - @account_initialization_required - def delete(self, id): - _require_tag_binding_edit_permission() - payload = TagBindingItemDeletePayload.model_validate(console_ns.payload or {}) - TagService.delete_tag_binding( - TagBindingDeletePayload( - tag_id=str(id), - target_id=payload.target_id, - type=payload.type, - ) - ) - return {"result": "success"}, 200 - - -@console_ns.route("/tag-bindings/create") -class DeprecatedTagBindingCreateApi(Resource): - """Deprecated verb-based alias for tag binding creation.""" - - @console_ns.doc("create_tag_binding_deprecated") - @console_ns.doc(deprecated=True) - @console_ns.doc(description="Deprecated legacy alias. Use POST /tag-bindings instead.") - @console_ns.expect(console_ns.models[TagBindingPayload.__name__]) - @setup_required - @login_required - @account_initialization_required - def post(self): - return _create_tag_bindings() - - @console_ns.route("/tag-bindings/remove") -class DeprecatedTagBindingRemoveApi(Resource): - """Deprecated verb-based alias for tag binding deletion.""" +class TagBindingRemoveApi(Resource): + """Batch resource for tag binding deletion.""" - @console_ns.doc("delete_tag_binding_deprecated") - @console_ns.doc(deprecated=True) - @console_ns.doc(description="Deprecated legacy alias. Use DELETE /tag-bindings/{id} instead.") + @console_ns.doc("remove_tag_bindings") + @console_ns.doc(description="Remove one or more tag bindings from a target.") @console_ns.expect(console_ns.models[TagBindingRemovePayload.__name__]) @setup_required @login_required @account_initialization_required def post(self): - return _remove_tag_binding() + return _remove_tag_bindings() diff --git a/api/controllers/service_api/dataset/dataset.py b/api/controllers/service_api/dataset/dataset.py index 76519cad0a..3eb773fa7c 100644 --- a/api/controllers/service_api/dataset/dataset.py +++ b/api/controllers/service_api/dataset/dataset.py @@ -2,7 +2,7 @@ from typing import Any, Literal, cast from flask import request from flask_restx import marshal -from pydantic import BaseModel, Field, TypeAdapter, field_validator +from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_validator from werkzeug.exceptions import Forbidden, NotFound import services @@ -100,9 +100,27 @@ class TagBindingPayload(BaseModel): class TagUnbindingPayload(BaseModel): - tag_id: str + """Accept the legacy single-tag Service API payload while exposing a normalized tag_ids list internally.""" + + tag_ids: list[str] = Field(default_factory=list) + tag_id: str | None = None target_id: str + @model_validator(mode="before") + @classmethod + def normalize_legacy_tag_id(cls, data: object) -> object: + if not isinstance(data, dict): + return data + if not data.get("tag_ids") and data.get("tag_id"): + return {**data, "tag_ids": [data["tag_id"]]} + return data + + @model_validator(mode="after") + def validate_tag_ids(self) -> "TagUnbindingPayload": + if not self.tag_ids: + raise ValueError("Tag IDs is required.") + return self + class DatasetListQuery(BaseModel): page: int = Field(default=1, description="Page number") @@ -601,11 +619,11 @@ class DatasetTagBindingApi(DatasetApiResource): @service_api_ns.route("/datasets/tags/unbinding") class DatasetTagUnbindingApi(DatasetApiResource): @service_api_ns.expect(service_api_ns.models[TagUnbindingPayload.__name__]) - @service_api_ns.doc("unbind_dataset_tag") - @service_api_ns.doc(description="Unbind a tag from a dataset") + @service_api_ns.doc("unbind_dataset_tags") + @service_api_ns.doc(description="Unbind tags from a dataset") @service_api_ns.doc( responses={ - 204: "Tag unbound successfully", + 204: "Tags unbound successfully", 401: "Unauthorized - invalid API token", 403: "Forbidden - insufficient permissions", } @@ -618,7 +636,7 @@ class DatasetTagUnbindingApi(DatasetApiResource): payload = TagUnbindingPayload.model_validate(service_api_ns.payload or {}) TagService.delete_tag_binding( - TagBindingDeletePayload(tag_id=payload.tag_id, target_id=payload.target_id, type=TagType.KNOWLEDGE) + TagBindingDeletePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE) ) return "", 204 diff --git a/api/services/tag_service.py b/api/services/tag_service.py index 1882c855ea..8043a99be1 100644 --- a/api/services/tag_service.py +++ b/api/services/tag_service.py @@ -1,9 +1,11 @@ import uuid +from typing import cast import sqlalchemy as sa from flask_login import current_user from pydantic import BaseModel, Field -from sqlalchemy import func, select +from sqlalchemy import delete, func, select +from sqlalchemy.engine import CursorResult from werkzeug.exceptions import NotFound from extensions.ext_database import db @@ -29,7 +31,7 @@ class TagBindingCreatePayload(BaseModel): class TagBindingDeletePayload(BaseModel): - tag_id: str + tag_ids: list[str] = Field(min_length=1) target_id: str type: TagType @@ -178,13 +180,18 @@ class TagService: @staticmethod def delete_tag_binding(payload: TagBindingDeletePayload): TagService.check_target_exists(payload.type, payload.target_id) - tag_binding = db.session.scalar( - select(TagBinding) - .where(TagBinding.target_id == payload.target_id, TagBinding.tag_id == payload.tag_id) - .limit(1) + result = cast( + CursorResult, + db.session.execute( + delete(TagBinding).where( + TagBinding.target_id == payload.target_id, + TagBinding.tag_id.in_(payload.tag_ids), + TagBinding.tenant_id == current_user.current_tenant_id, + ) + ), ) - if tag_binding: - db.session.delete(tag_binding) + + if result.rowcount: db.session.commit() @staticmethod diff --git a/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py b/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py index 9b913d6d3d..437b199ec2 100644 --- a/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py +++ b/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py @@ -217,10 +217,20 @@ class TestTagUnbindingPayload: """Test suite for TagUnbindingPayload Pydantic model.""" def test_payload_with_valid_data(self): - payload = TagUnbindingPayload(tag_id="tag_123", target_id="dataset_456") - assert payload.tag_id == "tag_123" + payload = TagUnbindingPayload(tag_ids=["tag_123"], target_id="dataset_456") + assert payload.tag_ids == ["tag_123"] assert payload.target_id == "dataset_456" + def test_payload_normalizes_legacy_tag_id(self): + payload = TagUnbindingPayload(tag_id="tag_123", target_id="dataset_456") + assert payload.tag_ids == ["tag_123"] + assert payload.target_id == "dataset_456" + + def test_payload_rejects_empty_tag_ids(self): + with pytest.raises(ValueError) as exc_info: + TagUnbindingPayload(tag_ids=[], target_id="dataset_456") + assert "Tag IDs is required" in str(exc_info.value) + # --------------------------------------------------------------------------- # Helpers @@ -1012,6 +1022,36 @@ class TestDatasetTagUnbindingApiPost: mock_current_user.is_dataset_editor = True mock_tag_svc.delete_tag_binding.return_value = None + with app.test_request_context( + "/datasets/tags/unbinding", + method="POST", + json={"tag_ids": ["tag-1"], "target_id": "ds-1"}, + ): + api = DatasetTagUnbindingApi() + result = api.post(_=None) + + assert result == ("", 204) + from services.tag_service import TagBindingDeletePayload + + mock_tag_svc.delete_tag_binding.assert_called_once_with( + TagBindingDeletePayload(tag_ids=["tag-1"], target_id="ds-1", type="knowledge") + ) + + @patch("controllers.service_api.dataset.dataset.TagService") + @patch("controllers.service_api.dataset.dataset.current_user") + def test_unbind_legacy_tag_id_success( + self, + mock_current_user, + mock_tag_svc, + app, + ): + from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi + + mock_current_user.__class__ = Account + mock_current_user.has_edit_permission = True + mock_current_user.is_dataset_editor = True + mock_tag_svc.delete_tag_binding.return_value = None + with app.test_request_context( "/datasets/tags/unbinding", method="POST", @@ -1024,7 +1064,7 @@ class TestDatasetTagUnbindingApiPost: from services.tag_service import TagBindingDeletePayload mock_tag_svc.delete_tag_binding.assert_called_once_with( - TagBindingDeletePayload(tag_id="tag-1", target_id="ds-1", type="knowledge") + TagBindingDeletePayload(tag_ids=["tag-1"], target_id="ds-1", type="knowledge") ) @patch("controllers.service_api.dataset.dataset.current_user") @@ -1038,7 +1078,7 @@ class TestDatasetTagUnbindingApiPost: with app.test_request_context( "/datasets/tags/unbinding", method="POST", - json={"tag_id": "tag-1", "target_id": "ds-1"}, + json={"tag_ids": ["tag-1"], "target_id": "ds-1"}, ): api = DatasetTagUnbindingApi() with pytest.raises(Forbidden): diff --git a/api/tests/test_containers_integration_tests/services/test_tag_service.py b/api/tests/test_containers_integration_tests/services/test_tag_service.py index 5a6bf0466e..583b6128e6 100644 --- a/api/tests/test_containers_integration_tests/services/test_tag_service.py +++ b/api/tests/test_containers_integration_tests/services/test_tag_service.py @@ -1099,38 +1099,39 @@ class TestTagService: db_session_with_containers, mock_external_service_dependencies ) - # Create tag - tag = self._create_test_tags( - db_session_with_containers, mock_external_service_dependencies, tenant.id, "knowledge", 1 - )[0] + # Create tags + tags = self._create_test_tags( + db_session_with_containers, mock_external_service_dependencies, tenant.id, "knowledge", 2 + ) - # Create dataset and bind tag + # Create dataset and bind tags dataset = self._create_test_dataset(db_session_with_containers, mock_external_service_dependencies, tenant.id) self._create_test_tag_bindings( - db_session_with_containers, mock_external_service_dependencies, [tag], dataset.id, tenant.id + db_session_with_containers, mock_external_service_dependencies, tags, dataset.id, tenant.id ) - # Verify binding exists before deletion - - binding_before = ( + # Verify bindings exist before deletion + bindings_before = ( db_session_with_containers.query(TagBinding) - .where(TagBinding.tag_id == tag.id, TagBinding.target_id == dataset.id) - .first() + .where(TagBinding.tag_id.in_([tag.id for tag in tags]), TagBinding.target_id == dataset.id) + .all() ) - assert binding_before is not None + assert len(bindings_before) == 2 # Act: Execute the method under test - delete_payload = TagBindingDeletePayload(type="knowledge", target_id=dataset.id, tag_id=tag.id) + delete_payload = TagBindingDeletePayload( + type="knowledge", target_id=dataset.id, tag_ids=[tag.id for tag in tags] + ) TagService.delete_tag_binding(delete_payload) # Assert: Verify the expected outcomes - # Verify tag binding was deleted - binding_after = ( + # Verify tag bindings were deleted + bindings_after = ( db_session_with_containers.query(TagBinding) - .where(TagBinding.tag_id == tag.id, TagBinding.target_id == dataset.id) - .first() + .where(TagBinding.tag_id.in_([tag.id for tag in tags]), TagBinding.target_id == dataset.id) + .all() ) - assert binding_after is None + assert len(bindings_after) == 0 def test_delete_tag_binding_non_existent_binding( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -1156,7 +1157,7 @@ class TestTagService: app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, tenant.id) # Act: Try to delete non-existent binding - delete_payload = TagBindingDeletePayload(type="app", target_id=app.id, tag_id=tag.id) + delete_payload = TagBindingDeletePayload(type="app", target_id=app.id, tag_ids=[tag.id]) TagService.delete_tag_binding(delete_payload) # Assert: Verify the expected outcomes diff --git a/api/tests/unit_tests/controllers/console/tag/test_tags.py b/api/tests/unit_tests/controllers/console/tag/test_tags.py index 6405558bb4..a26d171649 100644 --- a/api/tests/unit_tests/controllers/console/tag/test_tags.py +++ b/api/tests/unit_tests/controllers/console/tag/test_tags.py @@ -8,10 +8,8 @@ from werkzeug.exceptions import Forbidden import controllers.console.tag.tags as module from controllers.console import console_ns from controllers.console.tag.tags import ( - DeprecatedTagBindingCreateApi, - DeprecatedTagBindingRemoveApi, TagBindingCollectionApi, - TagBindingItemApi, + TagBindingRemoveApi, TagListApi, TagUpdateDeleteApi, ) @@ -249,39 +247,13 @@ class TestTagBindingCollectionApi: method(api) -class TestDeprecatedTagBindingCreateApi: - def test_create_success(self, app, admin_user, payload_patch): - api = DeprecatedTagBindingCreateApi() +class TestTagBindingRemoveApi: + def test_remove_success(self, app, admin_user, payload_patch): + api = TagBindingRemoveApi() method = unwrap(api.post) payload = { - "tag_ids": ["tag-1"], - "target_id": "target-1", - "type": "knowledge", - } - - with app.test_request_context("/", json=payload): - with ( - patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(admin_user, None), - ), - payload_patch(payload), - patch("controllers.console.tag.tags.TagService.save_tag_binding") as save_mock, - ): - result, status = method(api) - - save_mock.assert_called_once() - assert status == 200 - assert result["result"] == "success" - - -class TestTagBindingItemApi: - def test_delete_success(self, app, admin_user, payload_patch): - api = TagBindingItemApi() - method = unwrap(api.delete) - - payload = { + "tag_ids": ["tag-1", "tag-2"], "target_id": "target-1", "type": "knowledge", } @@ -295,57 +267,16 @@ class TestTagBindingItemApi: payload_patch(payload), patch("controllers.console.tag.tags.TagService.delete_tag_binding") as delete_mock, ): - result, status = method(api, "tag-1") + result, status = method(api) delete_mock.assert_called_once() delete_payload = delete_mock.call_args.args[0] - assert delete_payload.tag_id == "tag-1" - assert delete_payload.target_id == "target-1" - assert delete_payload.type == TagType.KNOWLEDGE - assert status == 200 - assert result["result"] == "success" - - def test_delete_forbidden(self, app, readonly_user): - api = TagBindingItemApi() - method = unwrap(api.delete) - - with app.test_request_context("/"): - with patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(readonly_user, None), - ): - with pytest.raises(Forbidden): - method(api, "tag-1") - - -class TestDeprecatedTagBindingRemoveApi: - def test_remove_success(self, app, admin_user, payload_patch): - api = DeprecatedTagBindingRemoveApi() - method = unwrap(api.post) - - payload = { - "tag_id": "tag-1", - "target_id": "target-1", - "type": "knowledge", - } - - with app.test_request_context("/", json=payload): - with ( - patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(admin_user, None), - ), - payload_patch(payload), - patch("controllers.console.tag.tags.TagService.delete_tag_binding") as delete_mock, - ): - result, status = method(api) - - delete_mock.assert_called_once() + assert delete_payload.tag_ids == ["tag-1", "tag-2"] assert status == 200 assert result["result"] == "success" def test_remove_forbidden(self, app, readonly_user, payload_patch): - api = DeprecatedTagBindingRemoveApi() + api = TagBindingRemoveApi() method = unwrap(api.post) with app.test_request_context("/", json={}): @@ -371,32 +302,30 @@ class TestTagResponseModel: class TestTagBindingRouteMetadata: - def test_legacy_write_routes_are_marked_deprecated(self): - assert DeprecatedTagBindingCreateApi.post.__apidoc__["deprecated"] is True - assert DeprecatedTagBindingRemoveApi.post.__apidoc__["deprecated"] is True + def test_write_routes_are_not_deprecated(self): assert TagBindingCollectionApi.post.__apidoc__.get("deprecated") is not True - assert TagBindingItemApi.delete.__apidoc__.get("deprecated") is not True + assert TagBindingRemoveApi.post.__apidoc__.get("deprecated") is not True def test_write_routes_have_stable_operation_ids(self): assert TagBindingCollectionApi.post.__apidoc__["id"] == "create_tag_binding" - assert TagBindingItemApi.delete.__apidoc__["id"] == "delete_tag_binding" - assert DeprecatedTagBindingCreateApi.post.__apidoc__["id"] == "create_tag_binding_deprecated" - assert DeprecatedTagBindingRemoveApi.post.__apidoc__["id"] == "delete_tag_binding_deprecated" + assert TagBindingRemoveApi.post.__apidoc__["id"] == "remove_tag_bindings" - def test_canonical_and_legacy_write_routes_are_registered(self): + def test_write_routes_are_registered(self): route_map = { resource.__name__: urls for resource, urls, _route_doc, _kwargs in console_ns.resources if resource.__name__ in { "TagBindingCollectionApi", - "TagBindingItemApi", - "DeprecatedTagBindingCreateApi", - "DeprecatedTagBindingRemoveApi", + "TagBindingRemoveApi", } } assert route_map["TagBindingCollectionApi"] == ("/tag-bindings",) - assert route_map["TagBindingItemApi"] == ("/tag-bindings/",) - assert route_map["DeprecatedTagBindingCreateApi"] == ("/tag-bindings/create",) - assert route_map["DeprecatedTagBindingRemoveApi"] == ("/tag-bindings/remove",) + assert route_map["TagBindingRemoveApi"] == ("/tag-bindings/remove",) + + def test_legacy_write_routes_are_not_registered(self): + urls = {url for _resource, resource_urls, _route_doc, _kwargs in console_ns.resources for url in resource_urls} + + assert "/tag-bindings/create" not in urls + assert "/tag-bindings/" not in urls diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 2d099669d1..2147bb95e8 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -155,9 +155,6 @@ } }, "web/app/account/(commonLayout)/account-page/email-change-modal.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, "ts/no-explicit-any": { "count": 5 } @@ -1824,26 +1821,6 @@ "count": 1 } }, - "web/app/components/base/tag-management/__tests__/panel.spec.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, - "web/app/components/base/tag-management/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/base/tag-management/tag-item-editor.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/base/tag-management/tag-remove-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/text-generation/hooks.ts": { "ts/no-explicit-any": { "count": 1 @@ -2354,11 +2331,6 @@ "count": 1 } }, - "web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx": { "ts/no-explicit-any": { "count": 2 @@ -2464,17 +2436,6 @@ "count": 2 } }, - "web/app/components/explore/create-app-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - }, - "unicorn/prefer-number-properties": { - "count": 1 - } - }, "web/app/components/explore/item-operation/index.tsx": { "react/set-state-in-effect": { "count": 1 @@ -5368,11 +5329,6 @@ "count": 2 } }, - "web/service/knowledge/use-dataset.ts": { - "@tanstack/query/exhaustive-deps": { - "count": 1 - } - }, "web/service/share.ts": { "erasable-syntax-only/enums": { "count": 1 diff --git a/web/.storybook/main.ts b/web/.storybook/main.ts index 918860c786..e5bf0ee65e 100644 --- a/web/.storybook/main.ts +++ b/web/.storybook/main.ts @@ -1,7 +1,10 @@ import type { StorybookConfig } from '@storybook/nextjs-vite' const config: StorybookConfig = { - stories: ['../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + stories: [ + '../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)', + '../features/**/*.stories.@(js|jsx|mjs|ts|tsx)', + ], addons: [ // Not working with Storybook Vite framework // '@storybook/addon-onboarding', diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 8a1a6fd131..46d7f7833e 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -21,20 +21,14 @@ import { useShallow } from 'zustand/react/shallow' import AppSideBar from '@/app/components/app-sidebar' import { useStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' -import { useStore as useTagStore } from '@/app/components/base/tag-management/store' import { useAppContext } from '@/context/app-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' -import dynamic from '@/next/dynamic' import { usePathname, useRouter } from '@/next/navigation' import { fetchAppDetailDirect } from '@/service/apps' import { AppModeEnum } from '@/types/app' import s from './style.module.css' -const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { - ssr: false, -}) - type IAppDetailLayoutProps = { children: React.ReactNode appId: string @@ -56,7 +50,6 @@ const AppDetailLayout: FC = (props) => { setAppDetail: state.setAppDetail, setAppSidebarExpand: state.setAppSidebarExpand, }))) - const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false) const [appDetailRes, setAppDetailRes] = useState(null) const [navigation, setNavigation] = useState = (props) => {
{children}
- {showTagManagementModal && ( - - )} ) } diff --git a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx index f3bb71c2d2..83bca6a8cb 100644 --- a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx +++ b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx @@ -3,8 +3,7 @@ import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' -import * as React from 'react' -import { useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import { useRouter } from '@/next/navigation' @@ -18,22 +17,23 @@ import { useLogout } from '@/service/use-common' import { asyncRunSafe } from '@/utils' type Props = { - show: boolean onClose: () => void email: string } -enum STEP { - start = 'start', - verifyOrigin = 'verifyOrigin', - newEmail = 'newEmail', - verifyNew = 'verifyNew', -} +const STEP = { + start: 'start', + verifyOrigin: 'verifyOrigin', + newEmail: 'newEmail', + verifyNew: 'verifyNew', +} as const -const EmailChangeModal = ({ onClose, email, show }: Props) => { +type Step = typeof STEP[keyof typeof STEP] + +const EmailChangeModal = ({ onClose, email }: Props) => { const { t } = useTranslation() const router = useRouter() - const [step, setStep] = useState(STEP.start) + const [step, setStep] = useState(STEP.start) const [code, setCode] = useState('') const [mail, setMail] = useState('') const [time, setTime] = useState(0) @@ -41,13 +41,25 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => { const [newEmailExited, setNewEmailExited] = useState(false) const [unAvailableEmail, setUnAvailableEmail] = useState(false) const [isCheckingEmail, setIsCheckingEmail] = useState(false) + const timerRef = useRef | null>(null) + + const clearCountdown = useCallback(() => { + if (!timerRef.current) + return + + clearInterval(timerRef.current) + timerRef.current = null + }, []) + + useEffect(() => clearCountdown, [clearCountdown]) const startCount = () => { + clearCountdown() setTime(60) - const timer = setInterval(() => { + timerRef.current = setInterval(() => { setTime((prev) => { - if (prev <= 0) { - clearInterval(timer) + if (prev <= 1) { + clearCountdown() return 0 } return prev - 1 @@ -181,7 +193,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => { } return ( - !open && onClose()}> + !open && onClose()}>
diff --git a/web/app/account/(commonLayout)/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx index 2a4ae86f84..0de33a2a71 100644 --- a/web/app/account/(commonLayout)/account-page/index.tsx +++ b/web/app/account/(commonLayout)/account-page/index.tsx @@ -332,11 +332,15 @@ export default function AccountPage() { /> ) } - setShowUpdateEmail(false)} - email={userProfile.email} - /> + {/* Use conditional JSX instead of a mounted controlled Dialog so closing destroys the email-change form session. */} + {showUpdateEmail + ? ( + setShowUpdateEmail(false)} + email={userProfile.email} + /> + ) + : null} ) } diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 6a71dbac52..4edf5604da 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -301,9 +301,9 @@ vi.mock('@/app/components/base/tooltip', () => ({ default: ({ children, popupContent }: { children: React.ReactNode, popupContent: React.ReactNode }) => React.createElement('div', { title: popupContent }, children), })) -// TagSelector has API dependency (service/tag) - mock for isolated testing -vi.mock('@/app/components/base/tag-management/selector', () => ({ - default: ({ tags }: { tags?: { id: string, name: string }[] }) => { +// AppCardTags has tag API dependencies - mock for isolated testing +vi.mock('@/features/tag-management/components/app-card-tags', () => ({ + AppCardTags: ({ tags }: { tags?: { id: string, name: string }[] }) => { return React.createElement('div', { 'aria-label': 'tag-selector' }, tags?.map((tag: { id: string, name: string }) => React.createElement('span', { key: tag.id }, tag.name))) }, })) @@ -400,13 +400,30 @@ describe('AppCard', () => { it('should handle app with tags', () => { const appWithTags = { ...mockApp, - tags: [{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: 0 }], + tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: 0 }], } render() // Verify the tag selector component renders expect(screen.getByLabelText('tag-selector')).toBeInTheDocument() }) + it('should display refreshed tag names from app props when tag ids stay the same', () => { + const firstApp = createMockApp({ + tags: [{ id: 'tag1', name: 'Old Tag', type: 'app' as const, binding_count: 0 }], + }) + const refreshedApp = createMockApp({ + tags: [{ id: 'tag1', name: 'New Tag', type: 'app' as const, binding_count: 0 }], + }) + + const { rerender } = render() + expect(screen.getByText('Old Tag')).toBeInTheDocument() + + rerender() + + expect(screen.getByText('New Tag')).toBeInTheDocument() + expect(screen.queryByText('Old Tag')).not.toBeInTheDocument() + }) + it('should render with onRefresh callback', () => { render() expect(screen.getByTitle('Test App')).toBeInTheDocument() @@ -1167,9 +1184,9 @@ describe('AppCard', () => { const multiTagApp = { ...mockApp, tags: [ - { id: 'tag1', name: 'Tag 1', type: 'app', binding_count: 0 }, - { id: 'tag2', name: 'Tag 2', type: 'app', binding_count: 0 }, - { id: 'tag3', name: 'Tag 3', type: 'app', binding_count: 0 }, + { id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: 0 }, + { id: 'tag2', name: 'Tag 2', type: 'app' as const, binding_count: 0 }, + { id: 'tag3', name: 'Tag 3', type: 'app' as const, binding_count: 0 }, ], } render() @@ -1324,7 +1341,7 @@ describe('AppCard', () => { it('should stop propagation when clicking tag selector area', () => { const multiTagApp = createMockApp({ - tags: [{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: 0 }], + tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: 0 }], }) render() diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 9d1b39ef06..41d2ccbc80 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -1,7 +1,6 @@ import { act, fireEvent, screen } from '@testing-library/react' import * as React from 'react' import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' -import { useStore as useTagStore } from '@/app/components/base/tag-management/store' import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' @@ -29,6 +28,11 @@ vi.mock('@/service/client', () => ({ infiniteOptions: (options: unknown) => mockAppListInfiniteOptions(options), }, }, + tags: { + list: { + queryOptions: (options: unknown) => options, + }, + }, systemFeatures: { queryKey: () => ['console', 'systemFeatures'], }, @@ -139,10 +143,6 @@ vi.mock('@/service/use-apps', () => ({ }), })) -vi.mock('@/service/tag', () => ({ - fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]), -})) - vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() return { @@ -236,10 +236,6 @@ type AppListInfiniteOptions = { describe('List', () => { beforeEach(() => { vi.clearAllMocks() - useTagStore.setState({ - tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }], - showTagManagementModal: false, - }) mockIsCurrentWorkspaceEditor.mockReturnValue(true) mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false) mockDragging = false diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 458c7578c7..06c5c8a9d8 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -1,7 +1,6 @@ 'use client' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' -import type { Tag } from '@/app/components/base/tag-management/constant' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { EnvironmentVariable } from '@/app/components/workflow/types' import type { WorkflowOnlineUser } from '@/models/app' @@ -36,11 +35,11 @@ import { Trans, useTranslation } from 'react-i18next' import { AppTypeIcon } from '@/app/components/app/type-selector' import AppIcon from '@/app/components/base/app-icon' import Input from '@/app/components/base/input' -import TagSelector from '@/app/components/base/tag-management/selector' import { UserAvatarList } from '@/app/components/base/user-avatar-list' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' +import { AppCardTags } from '@/features/tag-management/components/app-card-tags' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { AccessMode } from '@/models/access-control' import dynamic from '@/next/dynamic' @@ -77,6 +76,7 @@ type AppCardProps = { app: App onlineUsers?: WorkflowOnlineUser[] onRefresh?: () => void + onOpenTagManagement?: () => void } type AppCardOperationsMenuProps = { @@ -207,7 +207,7 @@ const AppCardOperationsMenuContent: React.FC ) } -const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { +const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => {} }: AppCardProps) => { const { t } = useTranslation() const deleteAppNameInputId = useId() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) @@ -396,19 +396,6 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { const shouldShowAccessControlOption = systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]' - const appTagsKey = useMemo(() => app.tags.map(tag => tag.id).join(','), [app.tags]) - const [tagState, setTagState] = useState<{ key: string, tags: Tag[] }>(() => ({ - key: appTagsKey, - tags: app.tags, - })) - const tags = tagState.key === appTagsKey ? tagState.tags : app.tags - const handleTagsUpdate = useCallback((nextTags: Tag[]) => { - setTagState({ - key: appTagsKey, - tags: nextTags, - }) - }, [appTagsKey]) - const EditTimeText = useMemo(() => { const timeText = formatTime({ date: (app.updated_at || app.created_at) * 1000, @@ -523,15 +510,12 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { e.preventDefault() }} > -
- tag.id)} - selectedTags={tags} - onCacheUpdate={handleTagsUpdate} - onChange={onRefresh} +
+
diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 728ef38ba5..0fd31dfb79 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -11,10 +11,9 @@ import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' import Input from '@/app/components/base/input' import TabSliderNew from '@/app/components/base/tab-slider-new' -import TagFilter from '@/app/components/base/tag-management/filter' -import { useStore as useTagStore } from '@/app/components/base/tag-management/store' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' +import { TagFilter } from '@/features/tag-management/components/tag-filter' import { CheckModal } from '@/hooks/use-pay' import dynamic from '@/next/dynamic' import { consoleQuery } from '@/service/client' @@ -24,12 +23,12 @@ import AppCard from './app-card' import { AppCardSkeleton } from './app-card-skeleton' import Empty from './empty' import Footer from './footer' -import useAppsQueryState from './hooks/use-apps-query-state' +import useAppsQueryStateHook from './hooks/use-apps-query-state' import { useDSLDragDrop } from './hooks/use-dsl-drag-drop' import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users' import NewAppCard from './new-app-card' -const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { +const TagManagementModal = dynamic(() => import('@/features/tag-management/components/tag-management-modal').then(mod => mod.TagManagementModal), { ssr: false, }) const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-from-dsl-modal'), { @@ -57,18 +56,20 @@ const List: FC = ({ const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() - const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const [activeTab, setActiveTab] = useQueryState( 'category', parseAsAppListCategory, ) - const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState() + // eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState + const appsQuery = useAppsQueryStateHook() + const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = appsQuery const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe) const [tagFilterValue, setTagFilterValue] = useState(tagIDs) const [searchKeywords, setSearchKeywords] = useState(keywords) const newAppCardRef = useRef(null) const containerRef = useRef(null) + const [showTagManagementModal, setShowTagManagementModal] = useState(false) const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) const [droppedDSLFile, setDroppedDSLFile] = useState() const setKeywords = useCallback((keywords: string) => { @@ -245,7 +246,7 @@ const List: FC = ({ {t('showMyCreatedAppsOnly', { ns: 'app' })}
- + setShowTagManagementModal(true)} /> = ({ app={app} onlineUsers={workflowOnlineUsersMap[app.id] ?? []} onRefresh={refetch} + onOpenTagManagement={() => setShowTagManagementModal(true)} /> )) : } @@ -302,9 +304,12 @@ const List: FC = ({ )}
- {showTagManagementModal && ( - - )} + setShowTagManagementModal(false)} + onTagsChange={refetch} + /> {showCreateFromDSLModal && ( diff --git a/web/app/components/base/tag-management/__tests__/tag-remove-modal.spec.tsx b/web/app/components/base/tag-management/__tests__/tag-remove-modal.spec.tsx deleted file mode 100644 index 943b7bc8ff..0000000000 --- a/web/app/components/base/tag-management/__tests__/tag-remove-modal.spec.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import type { Tag } from '../constant' -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import TagRemoveModal from '../tag-remove-modal' - -const mockTag: Tag = { - id: 'tag-1', - name: 'Frontend', - type: 'app', - binding_count: 3, -} - -describe('TagRemoveModal', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // Rendering behavior and visibility control. - describe('Rendering', () => { - it('should render modal content when show is true', () => { - render( - , - ) - - expect(screen.getByText('common.tag.delete')).toBeInTheDocument() - expect(screen.getByText('"Frontend"')).toBeInTheDocument() - expect(screen.getByText('common.tag.deleteTip')).toBeInTheDocument() - expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() - expect(screen.getByText('common.operation.delete')).toBeInTheDocument() - }) - - it('should not render modal content when show is false', () => { - render( - , - ) - - expect(screen.queryByText('common.tag.delete')).not.toBeInTheDocument() - expect(screen.queryByText('common.tag.deleteTip')).not.toBeInTheDocument() - }) - }) - - // User interactions for closing and confirming actions. - describe('User Interactions', () => { - it('should call onClose when top-right close icon is clicked', async () => { - const user = userEvent.setup() - const onClose = vi.fn() - render( - , - ) - - const closeIconButton = screen.getByTestId('tag-remove-modal-close-button') - expect(closeIconButton).toBeInTheDocument() - await user.click(closeIconButton) - - expect(onClose).toHaveBeenCalledTimes(1) - }) - - it('should call onClose when cancel button is clicked', async () => { - const user = userEvent.setup() - const onClose = vi.fn() - - render( - , - ) - - await user.click(screen.getByText('common.operation.cancel')) - expect(onClose).toHaveBeenCalledTimes(1) - }) - - it('should call onConfirm when delete button is clicked', async () => { - const user = userEvent.setup() - const onConfirm = vi.fn() - - render( - , - ) - - await user.click(screen.getByText('common.operation.delete')) - expect(onConfirm).toHaveBeenCalledTimes(1) - }) - }) - - // Edge case for unusual tag names in the title. - describe('Edge Cases', () => { - it('should render quoted empty tag name safely', () => { - render( - , - ) - - expect(screen.getByText('""')).toBeInTheDocument() - }) - }) -}) diff --git a/web/app/components/base/tag-management/constant.ts b/web/app/components/base/tag-management/constant.ts deleted file mode 100644 index 3c60041383..0000000000 --- a/web/app/components/base/tag-management/constant.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type Tag = { - id: string - name: string - type: string - binding_count: number -} diff --git a/web/app/components/base/tag-management/index.tsx b/web/app/components/base/tag-management/index.tsx deleted file mode 100644 index 19fbfcb7c9..0000000000 --- a/web/app/components/base/tag-management/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -'use client' -import { toast } from '@langgenius/dify-ui/toast' -import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' -import { createTag, fetchTagList } from '@/service/tag' -import { useStore as useTagStore } from './store' -import TagItemEditor from './tag-item-editor' - -type TagManagementModalProps = { - type: 'knowledge' | 'app' - show: boolean -} -const TagManagementModal = ({ show, type }: TagManagementModalProps) => { - const { t } = useTranslation() - const tagList = useTagStore(s => s.tagList) - const setTagList = useTagStore(s => s.setTagList) - const setShowTagManagementModal = useTagStore(s => s.setShowTagManagementModal) - const getTagList = async (type: 'knowledge' | 'app') => { - const res = await fetchTagList(type) - setTagList(res) - } - const [pending, setPending] = useState(false) - const [name, setName] = useState('') - const createNewTag = async () => { - if (!name) - return - if (pending) - return - try { - setPending(true) - const newTag = await createTag(name, type) - toast.success(t('tag.created', { ns: 'common' })) - setTagList([ - newTag, - ...tagList, - ]) - setName('') - setPending(false) - } - catch { - toast.error(t('tag.failed', { ns: 'common' })) - setPending(false) - } - } - useEffect(() => { - getTagList(type) - }, [type]) - return ( - setShowTagManagementModal(false)}> -
{t('tag.manageTags', { ns: 'common' })}
-
setShowTagManagementModal(false)}> - -
-
- setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && !e.nativeEvent.isComposing && createNewTag()} onBlur={createNewTag} /> - {tagList.map(tag => ())} -
-
- ) -} -export default TagManagementModal diff --git a/web/app/components/base/tag-management/selector.tsx b/web/app/components/base/tag-management/selector.tsx deleted file mode 100644 index 0eb233ba4b..0000000000 --- a/web/app/components/base/tag-management/selector.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import type { FC } from 'react' -import type { Tag } from '@/app/components/base/tag-management/constant' -import { cn } from '@langgenius/dify-ui/cn' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' -import { useCallback, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { fetchTagList } from '@/service/tag' -import Panel from './panel' -import { useStore as useTagStore } from './store' -import Trigger from './trigger' - -export type TagSelectorProps = { - targetID: string - isPopover?: boolean - position?: 'bl' | 'br' - type: 'knowledge' | 'app' - value: string[] - selectedTags: Tag[] - onCacheUpdate: (tags: Tag[]) => void - onChange?: () => void - minWidth?: number | string -} - -const TagSelector: FC = ({ - targetID, - isPopover = true, - position, - type, - value, - selectedTags, - onCacheUpdate, - onChange, - minWidth, -}) => { - const { t } = useTranslation() - const tagList = useTagStore(s => s.tagList) - const setTagList = useTagStore(s => s.setTagList) - const [open, setOpen] = useState(false) - - const getTagList = useCallback(async () => { - const res = await fetchTagList(type) - setTagList(res) - }, [setTagList, type]) - - const tags = useMemo(() => { - if (selectedTags?.length) - return selectedTags.filter(selectedTag => tagList.find(tag => tag.id === selectedTag.id)).map(tag => tag.name) - return [] - }, [selectedTags, tagList]) - - const placement = useMemo(() => { - if (position === 'bl') - return 'bottom-start' as const - if (position === 'br') - return 'bottom-end' as const - return 'bottom' as const - }, [position]) - - const resolvedMinWidth = useMemo(() => { - if (minWidth == null) - return undefined - - return typeof minWidth === 'number' ? `${minWidth}px` : minWidth - }, [minWidth]) - - const triggerLabel = useMemo(() => { - if (tags.length) - return tags.join(', ') - - return t('tag.addTag', { ns: 'common' }) - }, [tags, t]) - - if (!isPopover) - return null - - return ( - - - - - - - - - ) -} - -export default TagSelector diff --git a/web/app/components/base/tag-management/store.ts b/web/app/components/base/tag-management/store.ts deleted file mode 100644 index 197d31ed7a..0000000000 --- a/web/app/components/base/tag-management/store.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Tag } from './constant' -import { create } from 'zustand' - -type State = { - tagList: Tag[] - showTagManagementModal: boolean -} - -type Action = { - setTagList: (tagList?: Tag[]) => void - setShowTagManagementModal: (showTagManagementModal: boolean) => void -} - -export const useStore = create(set => ({ - tagList: [], - setTagList: tagList => set(() => ({ tagList })), - showTagManagementModal: false, - setShowTagManagementModal: showTagManagementModal => set(() => ({ showTagManagementModal })), -})) diff --git a/web/app/components/base/tag-management/tag-remove-modal.tsx b/web/app/components/base/tag-management/tag-remove-modal.tsx deleted file mode 100644 index 1088ca2043..0000000000 --- a/web/app/components/base/tag-management/tag-remove-modal.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client' - -import type { Tag } from '@/app/components/base/tag-management/constant' -import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { noop } from 'es-toolkit/function' -import { useTranslation } from 'react-i18next' -import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' -import Modal from '@/app/components/base/modal' - -type TagRemoveModalProps = { - show: boolean - tag: Tag - onConfirm: () => void - onClose: () => void -} - -const TagRemoveModal = ({ show, tag, onConfirm, onClose }: TagRemoveModalProps) => { - const { t } = useTranslation() - - return ( - -
- -
-
- -
-
- {`${t('tag.delete', { ns: 'common' })} `} - {`"${tag.name}"`} -
-
- {t('tag.deleteTip', { ns: 'common' })} -
-
- - -
-
- ) -} - -export default TagRemoveModal diff --git a/web/app/components/datasets/list/__tests__/datasets.spec.tsx b/web/app/components/datasets/list/__tests__/datasets.spec.tsx index 5b777e0b2e..f78622a9cd 100644 --- a/web/app/components/datasets/list/__tests__/datasets.spec.tsx +++ b/web/app/components/datasets/list/__tests__/datasets.spec.tsx @@ -56,8 +56,6 @@ vi.mock('@/context/app-context', () => ({ // Mock useDatasetCardState hook vi.mock('../dataset-card/hooks/use-dataset-card-state', () => ({ useDatasetCardState: () => ({ - tags: [], - setTags: vi.fn(), modalState: { showRenameModal: false, showConfirmDelete: false, @@ -77,6 +75,14 @@ vi.mock('../../rename-modal', () => ({ default: () => null, })) +vi.mock('../dataset-card', () => ({ + default: ({ dataset }: { dataset: DataSet }) => ( +
+ {dataset.name} +
+ ), +})) + function createMockDataset(overrides: Partial = {}): DataSet { return { id: 'dataset-1', diff --git a/web/app/components/datasets/list/__tests__/index.spec.tsx b/web/app/components/datasets/list/__tests__/index.spec.tsx index adc53debbd..7e46c46f1a 100644 --- a/web/app/components/datasets/list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/__tests__/index.spec.tsx @@ -36,11 +36,6 @@ vi.mock('@/context/external-api-panel-context', () => ({ }), })) -// Mock tag management store -vi.mock('@/app/components/base/tag-management/store', () => ({ - useStore: () => false, -})) - // Mock useDocumentTitle hook vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn(), @@ -108,15 +103,16 @@ vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ })) // Mock TagManagementModal -vi.mock('@/app/components/base/tag-management', () => ({ - default: () =>
, +vi.mock('@/features/tag-management/components/tag-management-modal', () => ({ + TagManagementModal: ({ show }: { show: boolean }) => show ?
: null, })) // Mock TagFilter -vi.mock('@/app/components/base/tag-management/filter', () => ({ - default: ({ onChange }: { value: string[], onChange: (val: string[]) => void }) => ( +vi.mock('@/features/tag-management/components/tag-filter', () => ({ + TagFilter: ({ onChange, onOpenTagManagement }: { value: string[], onChange: (val: string[]) => void, onOpenTagManagement: () => void }) => (
+
), })) @@ -226,7 +222,7 @@ describe('List', () => { it('should have correct container styling', () => { const { container } = render() const mainContainer = container.firstChild as HTMLElement - expect(mainContainer).toHaveClass('scroll-container', 'relative', 'flex', 'grow', 'flex-col') + expect(mainContainer).toHaveClass('relative', 'flex', 'grow', 'flex-col') }) }) @@ -312,15 +308,9 @@ describe('List', () => { expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(false) }) - it('should show TagManagementModal when showTagManagementModal is true', async () => { - vi.doMock('@/app/components/base/tag-management/store', () => ({ - useStore: () => true, // showTagManagementModal is true - })) - - vi.resetModules() - const { default: ListComponent } = await import('../index') - - render() + it('should show TagManagementModal when tag management is opened', () => { + render() + fireEvent.click(screen.getByText('Manage Tags')) expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument() }) diff --git a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx index f6c7e1e93d..55176faf47 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx @@ -30,8 +30,6 @@ vi.mock('@/context/app-context', () => ({ vi.mock('../hooks/use-dataset-card-state', () => ({ useDatasetCardState: () => ({ - tags: [], - setTags: vi.fn(), modalState: { showRenameModal: false, showConfirmDelete: false, @@ -55,8 +53,8 @@ vi.mock('../components/dataset-card-header', () => ({ vi.mock('../components/dataset-card-modals', () => ({ default: () =>
, })) -vi.mock('../components/tag-area', () => ({ - default: ({ onClick }: { onClick: (e: React.MouseEvent) => void, ref?: React.Ref }) => ( +vi.mock('@/features/tag-management/components/dataset-card-tags', () => ({ + DatasetCardTags: ({ onClick }: { onClick: (e: React.MouseEvent) => void }) => (
), })) diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/tag-area.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/tag-area.spec.tsx deleted file mode 100644 index 2858469cdb..0000000000 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/tag-area.spec.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import type { Tag } from '@/app/components/base/tag-management/constant' -import type { DataSet } from '@/models/datasets' -import { fireEvent, render, screen } from '@testing-library/react' -import { useRef } from 'react' -import { describe, expect, it, vi } from 'vitest' -import { IndexingType } from '@/app/components/datasets/create/step-two' -import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import TagArea from '../tag-area' - -// Mock TagSelector as it's a complex component from base -vi.mock('@/app/components/base/tag-management/selector', () => ({ - default: ({ value, selectedTags, onCacheUpdate, onChange }: { - value: string[] - selectedTags: Tag[] - onCacheUpdate: (tags: Tag[]) => void - onChange?: () => void - }) => ( -
-
{value.join(',')}
-
- {selectedTags.length} - {' '} - tags -
- - -
- ), -})) - -describe('TagArea', () => { - const createMockDataset = (overrides: Partial = {}): DataSet => ({ - id: 'dataset-1', - name: 'Test Dataset', - description: 'Test description', - provider: 'vendor', - permission: DatasetPermission.allTeamMembers, - data_source_type: DataSourceType.FILE, - indexing_technique: IndexingType.QUALIFIED, - embedding_available: true, - app_count: 5, - document_count: 10, - word_count: 1000, - updated_at: 1609545600, - tags: [], - embedding_model: 'text-embedding-ada-002', - embedding_model_provider: 'openai', - created_by: 'user-1', - doc_form: ChunkingMode.text, - ...overrides, - } as DataSet) - - const mockTags: Tag[] = [ - { id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 }, - { id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }, - ] - - const defaultProps = { - dataset: createMockDataset(), - tags: mockTags, - setTags: vi.fn(), - onSuccess: vi.fn(), - isHoveringTagSelector: false, - onClick: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - render() - expect(screen.getByTestId('tag-selector')).toBeInTheDocument() - }) - - it('should render TagSelector with correct value', () => { - render() - expect(screen.getByTestId('tag-values')).toHaveTextContent('tag-1,tag-2') - }) - - it('should display selected tags count', () => { - render() - expect(screen.getByTestId('selected-count')).toHaveTextContent('2 tags') - }) - }) - - describe('Props', () => { - it('should pass dataset id to TagSelector', () => { - const dataset = createMockDataset({ id: 'custom-dataset-id' }) - render() - expect(screen.getByTestId('tag-selector')).toBeInTheDocument() - }) - - it('should render with empty tags', () => { - render() - expect(screen.getByTestId('selected-count')).toHaveTextContent('0 tags') - }) - - it('should forward ref correctly', () => { - const TestComponent = () => { - const ref = useRef(null) - return - } - render() - expect(screen.getByTestId('tag-selector')).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call onClick when container is clicked', () => { - const onClick = vi.fn() - const { container } = render() - - const wrapper = container.firstChild as HTMLElement - fireEvent.click(wrapper) - - expect(onClick).toHaveBeenCalledTimes(1) - }) - - it('should call setTags when tags are updated', () => { - const setTags = vi.fn() - render() - - fireEvent.click(screen.getByText('Update Tags')) - - expect(setTags).toHaveBeenCalledWith([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }]) - }) - - it('should call onSuccess when onChange is triggered', () => { - const onSuccess = vi.fn() - render() - - fireEvent.click(screen.getByText('Trigger Change')) - - expect(onSuccess).toHaveBeenCalledTimes(1) - }) - }) - - describe('Styles', () => { - it('should have opacity class when embedding is not available', () => { - const dataset = createMockDataset({ embedding_available: false }) - const { container } = render() - const wrapper = container.firstChild as HTMLElement - expect(wrapper).toHaveClass('opacity-30') - }) - - it('should not have opacity class when embedding is available', () => { - const dataset = createMockDataset({ embedding_available: true }) - const { container } = render() - const wrapper = container.firstChild as HTMLElement - expect(wrapper).not.toHaveClass('opacity-30') - }) - - it('should show mask when not hovering and has tags', () => { - const { container } = render() - const maskDiv = container.querySelector('.bg-tag-selector-mask-bg') - expect(maskDiv).toBeInTheDocument() - expect(maskDiv).not.toHaveClass('hidden') - }) - - it('should hide mask when hovering', () => { - const { container } = render() - // When hovering, the mask div should have 'hidden' class - const maskDiv = container.querySelector('.absolute.right-0.top-0') - expect(maskDiv).toHaveClass('hidden') - }) - - it('should make TagSelector visible when tags exist', () => { - const { container } = render() - const tagSelectorWrapper = container.querySelector('.visible') - expect(tagSelectorWrapper).toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should handle undefined onSuccess', () => { - render() - // Should not throw when clicking Trigger Change - expect(() => fireEvent.click(screen.getByText('Trigger Change'))).not.toThrow() - }) - - it('should handle many tags', () => { - const manyTags: Tag[] = Array.from({ length: 20 }, (_, i) => ({ - id: `tag-${i}`, - name: `Tag ${i}`, - type: 'knowledge' as const, - binding_count: 0, - })) - render() - expect(screen.getByTestId('selected-count')).toHaveTextContent('20 tags') - }) - }) -}) diff --git a/web/app/components/datasets/list/dataset-card/components/tag-area.tsx b/web/app/components/datasets/list/dataset-card/components/tag-area.tsx deleted file mode 100644 index 2c8d6aa73a..0000000000 --- a/web/app/components/datasets/list/dataset-card/components/tag-area.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { Tag } from '@/app/components/base/tag-management/constant' -import type { DataSet } from '@/models/datasets' -import { cn } from '@langgenius/dify-ui/cn' -import * as React from 'react' -import TagSelector from '@/app/components/base/tag-management/selector' - -type TagAreaProps = { - dataset: DataSet - tags: Tag[] - setTags: (tags: Tag[]) => void - onSuccess?: () => void - isHoveringTagSelector: boolean - onClick: (e: React.MouseEvent) => void -} - -const TagArea = React.forwardRef(({ - dataset, - tags, - setTags, - onSuccess, - isHoveringTagSelector, - onClick, -}, ref) => ( -
-
0 && 'visible', - )} - > - tag.id)} - selectedTags={tags} - onCacheUpdate={setTags} - onChange={onSuccess} - /> -
-
-
-)) -TagArea.displayName = 'TagArea' - -export default TagArea diff --git a/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts b/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts index 7d07bcf9d0..fa4868f391 100644 --- a/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts +++ b/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts @@ -66,15 +66,6 @@ describe('useDatasetCardState', () => { }) describe('Initial State', () => { - it('should return tags from dataset', () => { - const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) - - expect(result.current.tags).toEqual(dataset.tags) - }) - it('should have initial modal state closed', () => { const dataset = createMockDataset() const { result } = renderHook(() => @@ -96,36 +87,6 @@ describe('useDatasetCardState', () => { }) }) - describe('Tags State', () => { - it('should update tags when setTags is called', () => { - const dataset = createMockDataset() - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) - - act(() => { - result.current.setTags([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }]) - }) - - expect(result.current.tags).toEqual([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }]) - }) - - it('should sync tags when dataset tags change', () => { - const dataset = createMockDataset() - const { result, rerender } = renderHook( - ({ dataset }) => useDatasetCardState({ dataset, onSuccess: vi.fn() }), - { initialProps: { dataset } }, - ) - - const newTags = [{ id: 'tag-3', name: 'Tag 3', type: 'knowledge', binding_count: 0 }] - const updatedDataset = createMockDataset({ tags: newTags }) - - rerender({ dataset: updatedDataset }) - - expect(result.current.tags).toEqual(newTags) - }) - }) - describe('Modal Handlers', () => { it('should open rename modal when openRenameModal is called', () => { const dataset = createMockDataset() @@ -279,15 +240,6 @@ describe('useDatasetCardState', () => { }) describe('Edge Cases', () => { - it('should handle empty tags array', () => { - const dataset = createMockDataset({ tags: [] }) - const { result } = renderHook(() => - useDatasetCardState({ dataset, onSuccess: vi.fn() }), - ) - - expect(result.current.tags).toEqual([]) - }) - it('should handle undefined onSuccess', async () => { const dataset = createMockDataset() const { result } = renderHook(() => diff --git a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts b/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts index 6cffbb6828..88aa7b50ae 100644 --- a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts +++ b/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts @@ -1,7 +1,6 @@ -import type { Tag } from '@/app/components/base/tag-management/constant' import type { DataSet } from '@/models/datasets' import { toast } from '@langgenius/dify-ui/toast' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useCheckDatasetUsage, useDeleteDataset } from '@/service/use-dataset-card' import { useExportPipelineDSL } from '@/service/use-pipeline' @@ -20,11 +19,6 @@ type UseDatasetCardStateOptions = { export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateOptions) => { const { t } = useTranslation() - const [tags, setTags] = useState(dataset.tags) - - useEffect(() => { - setTags(dataset.tags) - }, [dataset.tags]) // Modal state const [modalState, setModalState] = useState({ @@ -113,10 +107,6 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO }, [dataset.id, deleteDatasetMutation, onSuccess, t, closeConfirmDelete]) return { - // Tag state - tags, - setTags, - // Modal state modalState, openRenameModal, diff --git a/web/app/components/datasets/list/dataset-card/index.tsx b/web/app/components/datasets/list/dataset-card/index.tsx index 5bd032d151..3fe4b6f7c0 100644 --- a/web/app/components/datasets/list/dataset-card/index.tsx +++ b/web/app/components/datasets/list/dataset-card/index.tsx @@ -1,8 +1,8 @@ 'use client' import type { DataSet } from '@/models/datasets' -import { useHover } from 'ahooks' -import { useMemo, useRef } from 'react' +import { useMemo } from 'react' import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { DatasetCardTags } from '@/features/tag-management/components/dataset-card-tags' import { useRouter } from '@/next/navigation' import CornerLabels from './components/corner-labels' import DatasetCardFooter from './components/dataset-card-footer' @@ -10,29 +10,27 @@ import DatasetCardHeader from './components/dataset-card-header' import DatasetCardModals from './components/dataset-card-modals' import Description from './components/description' import OperationsDropdown from './components/operations-dropdown' -import TagArea from './components/tag-area' -import { useDatasetCardState } from './hooks/use-dataset-card-state' +import { useDatasetCardState as useDatasetCardController } from './hooks/use-dataset-card-state' const EXTERNAL_PROVIDER = 'external' type DatasetCardProps = { dataset: DataSet onSuccess?: () => void + onOpenTagManagement?: () => void } const DatasetCard = ({ dataset, onSuccess, + onOpenTagManagement = () => {}, }: DatasetCardProps) => { const { push } = useRouter() const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator) - const tagSelectorRef = useRef(null) - const isHoveringTagSelector = useHover(tagSelectorRef) + const datasetCard = useDatasetCardController({ dataset, onSuccess }) const { - tags, - setTags, modalState, openRenameModal, closeRenameModal, @@ -40,7 +38,7 @@ const DatasetCard = ({ handleExportPipeline, detectIsUsedByApp, onConfirmDelete, - } = useDatasetCardState({ dataset, onSuccess }) + } = datasetCard const isExternalProvider = dataset.provider === EXTERNAL_PROVIDER const isPipelineUnpublished = useMemo(() => { @@ -72,14 +70,13 @@ const DatasetCard = ({ - void } const Datasets = ({ tags, keywords, includeAll, + onOpenTagManagement = () => {}, }: Props) => { const { t } = useTranslation() const isCurrentWorkspaceEditor = useAppContextWithSelector(state => state.isCurrentWorkspaceEditor) @@ -60,7 +62,7 @@ const Datasets = ({