feat: finish slide

This commit is contained in:
Joel 2026-05-09 14:28:51 +08:00
parent 37ac79cb87
commit 4ec2291816
11 changed files with 707 additions and 328 deletions

View File

@ -265,6 +265,12 @@
line-height: 1.2;
}
@utility title-5xl-semi-bold {
font-size: 30px;
font-weight: 600;
line-height: 1.2;
}
@utility title-5xl-bold {
font-size: 30px;
font-weight: 700;

696
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -154,6 +154,7 @@ catalog:
echarts-for-react: 3.0.6
elkjs: 0.11.1
embla-carousel-autoplay: 8.6.0
embla-carousel-fade: 8.6.0
embla-carousel-react: 8.6.0
emoji-mart: 5.6.0
es-toolkit: 1.46.1

View File

@ -2,6 +2,7 @@
import type { UseEmblaCarouselType } from 'embla-carousel-react'
import { cn } from '@langgenius/dify-ui/cn'
import Autoplay from 'embla-carousel-autoplay'
import Fade from 'embla-carousel-fade'
import useEmblaCarousel from 'embla-carousel-react'
import * as React from 'react'
@ -215,6 +216,7 @@ CarouselDot.displayName = 'CarouselDot'
const CarouselPlugins = {
Autoplay,
Fade,
}
Carousel.Content = CarouselContent

View File

@ -108,17 +108,17 @@ describe('BannerItem', () => {
expect(screen.getByText('Test banner description text')).toBeInTheDocument()
})
it('renders view more text', () => {
renderBannerItem()
expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument()
})
it('renders banner image with correct src and alt', () => {
renderBannerItem()
const image = screen.getByRole('img')
expect(image).toHaveAttribute('src', 'https://example.com/image.png')
expect(image).toHaveAttribute('alt', 'Test Banner Title')
})
it('renders view more text', () => {
renderBannerItem()
expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument()
})
})
describe('click handling', () => {

View File

@ -32,19 +32,27 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => {
if (key === 'banner.greeting')
return `Welcome back, ${opts?.name} 👋`
if (key === 'banner.tagline')
return 'What if… this is where your next idea begins.'
return key
},
}),
}))
vi.mock('@/app/components/base/carousel', () => ({
Carousel: Object.assign(
({ children, onMouseEnter, onMouseLeave, className }: {
({ children, className }: {
children: React.ReactNode
onMouseEnter?: () => void
onMouseLeave?: () => void
className?: string
}) => (
<div
data-testid="carousel"
className={className}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{children}
</div>
@ -58,6 +66,7 @@ vi.mock('@/app/components/base/carousel', () => ({
),
Plugin: {
Autoplay: (config: Record<string, unknown>) => ({ type: 'autoplay', ...config }),
Fade: () => ({ type: 'fade' }),
},
},
),
@ -113,7 +122,7 @@ const createMockBanner = (id: string, status: string = 'enabled', title: string
'category': 'Featured',
title,
'description': 'Test description',
'img-src': 'https://example.com/image.png',
'img-src': `https://example.com/image-${id}.png`,
},
} as BannerType)
@ -126,6 +135,7 @@ describe('Banner', () => {
mockUseSelector.mockImplementation(selector => selector({
userProfile: {
id: 'account-123',
name: 'Evan',
},
}))
})
@ -218,6 +228,53 @@ describe('Banner', () => {
})
})
describe('greeting section', () => {
it('renders static greeting with user name', () => {
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled')],
isLoading: false,
isError: false,
})
render(<Banner />)
expect(screen.getByText('Welcome back, Evan 👋')).toBeInTheDocument()
})
it('renders tagline', () => {
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled')],
isLoading: false,
isError: false,
})
render(<Banner />)
expect(screen.getByText('What if… this is where your next idea begins.')).toBeInTheDocument()
})
it('greeting does not change when carousel slides', () => {
mockUseGetBanners.mockReturnValue({
data: [
createMockBanner('1', 'enabled', 'Banner 1'),
createMockBanner('2', 'enabled', 'Banner 2'),
],
isLoading: false,
isError: false,
})
render(<Banner />)
expect(screen.getByText('Welcome back, Evan 👋')).toBeInTheDocument()
act(() => {
setMockSelectedIndex(1)
})
expect(screen.getByText('Welcome back, Evan 👋')).toBeInTheDocument()
})
})
describe('successful render', () => {
it('renders carousel when enabled banners exist', () => {
mockUseGetBanners.mockReturnValue({
@ -264,18 +321,6 @@ describe('Banner', () => {
expect(bannerItem).toHaveAttribute('data-autoplay-delay', '5000')
})
it('renders carousel with correct class', () => {
mockUseGetBanners.mockReturnValue({
data: [createMockBanner('1', 'enabled')],
isLoading: false,
isError: false,
})
render(<Banner />)
expect(screen.getByTestId('carousel')).toHaveClass('rounded-2xl')
})
it('tracks only the current banner impression and reports the next one after slide changes', () => {
mockUseGetBanners.mockReturnValue({
data: [
@ -322,6 +367,7 @@ describe('Banner', () => {
mockUseSelector.mockImplementation(selector => selector({
userProfile: {
id: '',
name: '',
},
}))
mockUseGetBanners.mockReturnValue({
@ -346,8 +392,8 @@ describe('Banner', () => {
render(<Banner />)
const carousel = screen.getByTestId('carousel')
fireEvent.mouseEnter(carousel)
const wrapper = screen.getByText('Welcome back, Evan 👋').closest('.relative')!
fireEvent.mouseEnter(wrapper)
const bannerItem = screen.getByTestId('banner-item')
expect(bannerItem).toHaveAttribute('data-is-paused', 'true')
@ -362,10 +408,10 @@ describe('Banner', () => {
render(<Banner />)
const carousel = screen.getByTestId('carousel')
const wrapper = screen.getByText('Welcome back, Evan 👋').closest('.relative')!
fireEvent.mouseEnter(carousel)
fireEvent.mouseLeave(carousel)
fireEvent.mouseEnter(wrapper)
fireEvent.mouseLeave(wrapper)
const bannerItem = screen.getByTestId('banner-item')
expect(bannerItem).toHaveAttribute('data-is-paused', 'false')

View File

@ -1,8 +1,6 @@
/* eslint-disable react-hooks-extra/no-direct-set-state-in-use-effect */
/* eslint-disable react/set-state-in-effect */
import type { FC } from 'react'
import type { Banner } from '@/models/app'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightLine } from '@remixicon/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
@ -22,7 +20,7 @@ const RESPONSIVE_BREAKPOINT = 1200
const MAX_RESPONSIVE_WIDTH = 600
const INDICATOR_WIDTH = 20
const INDICATOR_GAP = 8
const MIN_VIEW_MORE_WIDTH = 480
const MIN_VIEW_MORE_WIDTH = 160
export const BannerItem: FC<BannerItemProps> = ({
banner,
@ -58,9 +56,10 @@ export const BannerItem: FC<BannerItemProps> = ({
const viewMoreStyle = useMemo(() => {
if (!maxWidth)
return undefined
const availableWidth = maxWidth - indicatorsWidth
return {
maxWidth: `${maxWidth}px`,
minWidth: indicatorsWidth ? `${Math.min(maxWidth - indicatorsWidth, MIN_VIEW_MORE_WIDTH)}px` : undefined,
minWidth: indicatorsWidth && availableWidth > 0 ? `${Math.min(availableWidth, MIN_VIEW_MORE_WIDTH)}px` : undefined,
}
}, [maxWidth, indicatorsWidth])
@ -100,6 +99,11 @@ export const BannerItem: FC<BannerItemProps> = ({
incrementResetKey()
}, [selectedIndex, incrementResetKey])
const handleIndicatorClick = useCallback((index: number) => {
incrementResetKey()
api?.scrollTo(index)
}, [api, incrementResetKey])
const handleBannerClick = useCallback(() => {
incrementResetKey()
@ -118,91 +122,79 @@ export const BannerItem: FC<BannerItemProps> = ({
window.open(banner.link, '_blank', 'noopener,noreferrer')
}, [accountId, banner, incrementResetKey, language, sort])
const handleIndicatorClick = useCallback((index: number) => {
incrementResetKey()
api?.scrollTo(index)
}, [api, incrementResetKey])
return (
<div
className="relative flex w-full min-w-[784px] cursor-pointer overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg pr-[288px] transition-shadow hover:shadow-md"
className="flex min-h-[168px] w-full cursor-pointer items-center gap-2 overflow-hidden rounded-2xl px-8"
onClick={handleBannerClick}
>
{/* Left content area */}
<div className="min-w-0 flex-1">
<div className="flex h-full flex-col gap-3 py-6 pr-0 pl-8">
{/* Text section */}
<div className="flex min-h-24 flex-wrap items-end gap-1 py-1">
{/* Title area */}
<div
ref={textAreaRef}
className="flex max-w-[680px] min-w-[480px] flex-[1_0_0] flex-col pr-4"
style={responsiveStyle}
>
<p className="line-clamp-1 title-4xl-semi-bold text-dify-logo-blue">
{category}
</p>
<p className="line-clamp-2 title-4xl-semi-bold text-dify-logo-black">
{title}
</p>
</div>
{/* Description area */}
<div
className="max-w-[600px] min-w-60 flex-[1_0_0] self-end overflow-hidden py-1 pr-4"
style={responsiveStyle}
>
<p className="line-clamp-4 overflow-hidden body-sm-regular text-text-tertiary">
{description}
</p>
<div className="flex h-[200px] min-w-px flex-1 flex-col items-end gap-3 rounded-2xl pt-4 pb-8">
<div className="flex min-h-24 w-full flex-wrap items-end gap-1 py-1">
<div
ref={textAreaRef}
className="flex max-w-[680px] min-w-[480px] flex-[1_1_480px] flex-col pr-4 max-xl:min-w-0"
style={responsiveStyle}
>
<p className="line-clamp-1 title-4xl-semi-bold text-dify-logo-blue">
{category}
</p>
<p className="line-clamp-2 title-4xl-semi-bold text-dify-logo-black">
{title}
</p>
</div>
<div
className="max-w-[600px] min-w-0 flex-[1_1_240px] self-end overflow-hidden py-1 pr-4"
style={responsiveStyle}
>
<p className="line-clamp-4 overflow-hidden body-sm-regular text-text-tertiary">
{description}
</p>
</div>
</div>
{/* Actions section */}
<div className="flex w-full flex-wrap items-center gap-1">
{/* View more button */}
<div
className="flex max-w-[680px] min-w-[480px] flex-[1_1_480px] items-center gap-[6px] py-1 max-xl:min-w-0"
style={viewMoreStyle}
>
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-text-accent p-[2px]">
<span className="i-ri-arrow-right-line h-3 w-3 text-text-primary-on-surface" />
</div>
<span className="system-sm-semibold-uppercase text-text-accent">
{t('banner.viewMore', { ns: 'explore' })}
</span>
</div>
{/* Actions section */}
<div className="flex items-center gap-1">
{/* View more button */}
<div
className="flex max-w-[680px] min-w-[480px] flex-[1_0_0] items-center gap-[6px] py-1 pr-8"
style={viewMoreStyle}
>
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-text-accent p-[2px]">
<RiArrowRightLine className="h-3 w-3 text-text-primary-on-surface" />
</div>
<span className="system-sm-semibold-uppercase text-text-accent">
{t('banner.viewMore', { ns: 'explore' })}
</span>
</div>
<div
className={cn('flex max-w-[600px] flex-[1_0_0] items-center gap-2 py-1 pr-10', maxWidth ? '' : 'min-w-60')}
style={responsiveStyle}
>
{/* Slide navigation indicators */}
<div className="flex items-center gap-2">
{slideInfo.slides.map((_: unknown, index: number) => (
<IndicatorButton
key={index}
index={index}
selectedIndex={selectedIndex}
isNextSlide={index === slideInfo.nextIndex}
autoplayDelay={autoplayDelay}
resetKey={resetKey}
isPaused={isPaused}
onClick={() => handleIndicatorClick(index)}
/>
))}
</div>
<div className="hidden h-px flex-1 bg-divider-regular min-[1380px]:block" />
<div
className="flex max-w-[600px] min-w-60 flex-[1_1_240px] items-center gap-2 py-1 pr-10 max-xl:min-w-0"
style={responsiveStyle}
>
{/* Slide navigation indicators */}
<div className="flex items-center gap-1">
{slideInfo.slides.map((_: unknown, index: number) => (
<IndicatorButton
key={index}
index={index}
selectedIndex={selectedIndex}
isNextSlide={index === slideInfo.nextIndex}
autoplayDelay={autoplayDelay}
resetKey={resetKey}
isPaused={isPaused}
onClick={() => handleIndicatorClick(index)}
/>
))}
</div>
<div className="hidden h-px flex-1 bg-divider-regular min-[1380px]:block" />
</div>
</div>
</div>
{/* Right image area */}
<div className="absolute top-0 right-0 flex h-full items-center p-2">
<div className="flex max-w-60 shrink-0 flex-col items-center justify-center p-4 max-lg:hidden">
<img
src={imgSrc}
alt={title}
className="aspect-4/3 h-full max-w-[296px] rounded-xl"
className="h-[168px] w-56 shrink-0 rounded-xl object-cover"
/>
</div>
</div>

View File

@ -2,6 +2,7 @@ import type { FC } from 'react'
import type { Banner as BannerType } from '@/models/app'
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import { Carousel, useCarousel } from '@/app/components/base/carousel'
import { useSelector } from '@/context/app-context'
@ -16,7 +17,7 @@ const RESIZE_DEBOUNCE_DELAY = 50
const LoadingState: FC = () => (
<div
className="flex items-center justify-center rounded-2xl bg-components-panel-on-panel-item-bg shadow-md"
className="flex items-center justify-center rounded-[24px] bg-background-default-dodge shadow-xs"
style={{ minHeight: MIN_LOADING_HEIGHT }}
>
<Loading />
@ -63,9 +64,11 @@ const BannerImpressionTracker: FC<BannerImpressionTrackerProps> = ({
}
const Banner: FC = () => {
const { t } = useTranslation()
const locale = useLocale()
const { data: banners, isLoading, isError } = useGetBanners(locale)
const accountId = useSelector(s => s.userProfile.id)
const userName = useSelector(s => s.userProfile.name)
const [isHovered, setIsHovered] = useState(false)
const [isResizing, setIsResizing] = useState(false)
const resizeTimerRef = useRef<NodeJS.Timeout | null>(null)
@ -107,40 +110,54 @@ const Banner: FC = () => {
return null
return (
<Carousel
opts={{ loop: true }}
plugins={[
Carousel.Plugin.Autoplay({
delay: AUTOPLAY_DELAY,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
]}
className="rounded-2xl"
<div
className="relative flex w-full flex-col items-start overflow-hidden rounded-[24px] bg-background-default-dodge transition-shadow hover:shadow-xs"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<BannerImpressionTracker
banners={enabledBanners}
accountId={accountId}
language={locale}
trackedBannerIdsRef={trackedBannerIdsRef}
/>
<Carousel.Content>
{enabledBanners.map((banner, index) => (
<Carousel.Item key={banner.id}>
<BannerItem
banner={banner}
autoplayDelay={AUTOPLAY_DELAY}
isPaused={isPaused}
sort={index + 1}
language={locale}
accountId={accountId}
/>
</Carousel.Item>
))}
</Carousel.Content>
</Carousel>
<div className="flex w-full flex-col gap-1 px-8 pt-8">
<p className="truncate title-5xl-semi-bold text-dify-logo-black">
{t('banner.greeting', { name: userName, ns: 'explore' })}
</p>
<p className="truncate body-md-regular text-text-secondary">
{t('banner.tagline', { ns: 'explore' })}
</p>
</div>
<Carousel
opts={{ loop: true }}
plugins={[
Carousel.Plugin.Fade(),
Carousel.Plugin.Autoplay({
delay: AUTOPLAY_DELAY,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
]}
className="w-full rounded-2xl shadow-xs"
>
<BannerImpressionTracker
banners={enabledBanners}
accountId={accountId}
language={locale}
trackedBannerIdsRef={trackedBannerIdsRef}
/>
<Carousel.Content>
{enabledBanners.map((banner, index) => (
<Carousel.Item key={banner.id}>
<BannerItem
banner={banner}
autoplayDelay={AUTOPLAY_DELAY}
isPaused={isPaused}
sort={index + 1}
language={locale}
accountId={accountId}
/>
</Carousel.Item>
))}
</Carousel.Content>
</Carousel>
</div>
)
}

View File

@ -8,6 +8,8 @@
"apps.resetFilter": "Clear filter",
"apps.resultNum": "{{num}} results",
"apps.title": "Try Dify's curated apps to find AI solutions for your business",
"banner.greeting": "Welcome back, {{name}} 👋",
"banner.tagline": "What if… this is where your next idea begins.",
"banner.viewMore": "VIEW MORE",
"category.Agent": "Agent",
"category.Assistant": "Assistant",

View File

@ -8,6 +8,8 @@
"apps.resetFilter": "清除筛选",
"apps.resultNum": "{{num}} 个结果",
"apps.title": "试用 Dify 精选示例应用,为您的业务寻找 AI 解决方案",
"banner.greeting": "欢迎回来,{{name}} 👋",
"banner.tagline": "如果……你的下一个想法从这里开始。",
"banner.viewMore": "查看更多",
"category.Agent": "Agent",
"category.Assistant": "助手",

View File

@ -92,6 +92,7 @@
"echarts-for-react": "catalog:",
"elkjs": "catalog:",
"embla-carousel-autoplay": "catalog:",
"embla-carousel-fade": "catalog:",
"embla-carousel-react": "catalog:",
"emoji-mart": "catalog:",
"es-toolkit": "catalog:",