feat: 优化聊天背景

This commit is contained in:
liuyonghe0111 2025-12-19 18:22:04 +08:00
parent fcab2aefc1
commit 46c30795c3
36 changed files with 121 additions and 188 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -1,4 +0,0 @@
<svg width="38" height="36" viewBox="0 0 38 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.7099 2.93655C25.9272 2.12908 27.0728 2.12907 27.2901 2.93655L28.986 9.23985C29.0584 9.50889 29.2627 9.72261 29.5282 9.80701L35.5471 11.7203C36.3075 11.962 36.3075 13.038 35.5471 13.2797L29.5282 15.193C29.2627 15.2774 29.0584 15.4911 28.986 15.7602L27.2901 22.0634C27.0728 22.8709 25.9272 22.8709 25.7099 22.0634L24.014 15.7602C23.9416 15.4911 23.7373 15.2774 23.4718 15.193L17.4529 13.2797C16.6925 13.038 16.6925 11.962 17.4529 11.7203L23.4718 9.80701C23.7373 9.72261 23.9416 9.50889 24.014 9.23985L25.7099 2.93655Z" fill="white"/>
<path d="M7.10254 19.6309C7.204 19.2146 7.796 19.2146 7.89746 19.6309L9.1445 24.7479C9.17728 24.8824 9.27586 24.9912 9.40649 25.037L13.9001 26.614C14.2649 26.742 14.2649 27.258 13.9001 27.386L9.40649 28.963C9.27586 29.0088 9.17728 29.1176 9.1445 29.2521L7.89746 34.3691C7.796 34.7854 7.204 34.7854 7.10254 34.3691L5.8555 29.2521C5.82272 29.1176 5.72414 29.0088 5.59351 28.963L1.09994 27.386C0.73511 27.258 0.735109 26.742 1.09994 26.614L5.59351 25.037C5.72414 24.9912 5.82272 24.8824 5.8555 24.7479L7.10254 19.6309Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -19,7 +19,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
<IconButton variant="tertiary" size="large" iconfont="icon-Like" /> <IconButton variant="tertiary" size="large" iconfont="icon-Like" />
</div> </div>
{/* 内容区 */} {/* 内容区 */}
<div className="mx-auto flex-1 overflow-auto w-full"> <div className="mx-auto flex-1 overflow-auto pb-4 w-full">
<header className="flex items-end gap-10 justify-between"> <header className="flex items-end gap-10 justify-between">
<div className="flex flex-1 sm:flex-row flex-col gap-6 items-center sm:items-end"> <div className="flex flex-1 sm:flex-row flex-col gap-6 items-center sm:items-end">
<Avatar className="size-32"> <Avatar className="size-32">
@ -37,9 +37,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
</div> </div>
</div> </div>
</div> </div>
<div className="sm:block hidden"> <ChatButton className="sm:block hidden" id={id}></ChatButton>
<ChatButton id={id}></ChatButton>
</div>
</header> </header>
<div className="w-full flex bg-white/10 rounded-m py-3 mt-12"> <div className="w-full flex bg-white/10 rounded-m py-3 mt-12">
<div className="w-full flex items-center flex-col"> <div className="w-full flex items-center flex-col">
@ -67,7 +65,9 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
<div className="mt-4">{character?.description}</div> <div className="mt-4">{character?.description}</div>
</div> </div>
</div> </div>
<div className="sm:hidden w-full mb-4">
{/* 移动端聊天按钮 */}
<div className="sm:hidden w-full my-4">
<ChatButton className="w-full" id={id}></ChatButton> <ChatButton className="w-full" id={id}></ChatButton>
</div> </div>
</div> </div>

View File

@ -0,0 +1,45 @@
'use client';
import React from 'react';
function Background({ imageUrl }: { imageUrl: string }) {
if (!imageUrl) {
return null;
}
return (
<div className="bg-background-default absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
<div className="absolute top-0 bottom-0 left-1/2 w-[752px] -translate-x-1/2">
{imageUrl && (
<img
src={imageUrl}
alt="Background"
className="pointer-events-none h-full w-full object-cover"
width={720}
height={1280}
style={{ objectPosition: 'center -48px' }}
/>
)}
<div
className="absolute top-0 bottom-0 left-0 w-120"
style={{
background: 'linear-gradient(to right, rgba(6, 3, 24, 1), transparent)',
}}
/>
<div
className="absolute top-0 bottom-0 right-0 w-120"
style={{
background: 'linear-gradient(to left, rgba(6, 3, 24, 1), transparent)',
}}
/>
<div
className="absolute bottom-0 h-60 w-full"
style={{
background: 'linear-gradient(to top, rgba(6, 3, 24, 1), transparent)',
}}
></div>
</div>
</div>
);
}
export default React.memo(Background);

View File

@ -7,15 +7,25 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useCharacter } from '@/hooks/services/character'; import { useCharacter } from '@/hooks/services/character';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import React from 'react'; import React from 'react';
// import CrushLevelAvatar from './CrushLevelAvatar' import Link from 'next/link';
export const CharacterAvatorAndName = ({ name, avator }: { name: string; avator: string }) => { export const CharacterAvatorAndName = ({
name,
avator,
id,
}: {
name: string;
avator: string;
id?: string;
}) => {
return ( return (
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
<Link href={`/character/${id}`}>
<Avatar className="h-20 w-20"> <Avatar className="h-20 w-20">
<AvatarImage src={avator} /> <AvatarImage src={avator} />
<AvatarFallback>{name?.slice(0, 1)}</AvatarFallback> <AvatarFallback>{name?.slice(0, 1)}</AvatarFallback>
</Avatar> </Avatar>
</Link>
<div className="txt-headline-s text-center text-white">{name}</div> <div className="txt-headline-s text-center text-white">{name}</div>
</div> </div>
); );
@ -26,7 +36,8 @@ function ChatMessageUserHeader() {
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false); const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false);
const textRef = useRef<HTMLDivElement>(null); const textRef = useRef<HTMLDivElement>(null);
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { data: character = {} } = useCharacter(id.split('-')[2]); const characterId = id.split('-')[2];
const { data: character = {} } = useCharacter(characterId);
// 检测文本是否超过三行 // 检测文本是否超过三行
useEffect(() => { useEffect(() => {
@ -40,8 +51,11 @@ function ChatMessageUserHeader() {
return ( return (
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
<CharacterAvatorAndName name={character.name || '-'} avator={character.headPortrait || ''} /> <CharacterAvatorAndName
id={characterId}
name={character.name || '-'}
avator={character.headPortrait || ''}
/>
<div className="bg-surface-element-normal border-outline-normal flex w-full flex-col gap-2 rounded-lg border border-solid p-4 backdrop-blur-2xl"> <div className="bg-surface-element-normal border-outline-normal flex w-full flex-col gap-2 rounded-lg border border-solid p-4 backdrop-blur-2xl">
<div <div
ref={textRef} ref={textRef}

View File

@ -198,7 +198,11 @@ export default function Profile({ onActiveTab }: ProfileProps) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex flex-1 overflow-y-auto pr-1 show-scrollbar flex-col gap-4"> <div className="flex flex-1 overflow-y-auto pr-1 show-scrollbar flex-col gap-4">
<CharacterAvatorAndName avator={character.headPortrait} name={character.name} /> <CharacterAvatorAndName
id={characterId}
avator={character.headPortrait}
name={character.name}
/>
{/* Tags */} {/* Tags */}
<div className="flex w-full flex-col items-start justify-start gap-4"> <div className="flex w-full flex-col items-start justify-start gap-4">

View File

@ -86,7 +86,7 @@ export default function Input() {
}; };
return ( return (
<div className="flex flex-col mb-6 items-end gap-4"> <div className="flex flex-col mb-6 items-end gap-4 z-10">
<div></div> <div></div>
<div className="flex w-full items-end gap-4"> <div className="flex w-full items-end gap-4">
{/* 打电话按钮 */} {/* 打电话按钮 */}

View File

@ -34,7 +34,7 @@ export default function MessageList() {
}; };
return ( return (
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0 relative z-10">
<VirtualList className="h-full" data={itemList} itemContent={itemContent} /> <VirtualList className="h-full" data={itemList} itemContent={itemContent} />
</div> </div>
); );

View File

@ -7,12 +7,16 @@ import SettingDialog from './Drawer';
import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat'; import { useStreamChatStore } from '@/app/(main)/chat/[id]/stream-chat';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useCharacter } from '@/hooks/services/character';
import Background from './Background';
export default function ChatPage() { export default function ChatPage() {
const { id } = useParams(); const { id } = useParams<{ id: string }>();
const characterId = id?.split('-')[2] || '';
const [settingOpen, setSettingOpen] = useState(false); const [settingOpen, setSettingOpen] = useState(false);
const switchToChannel = useStreamChatStore((s) => s.switchToChannel); const switchToChannel = useStreamChatStore((s) => s.switchToChannel);
const client = useStreamChatStore((s) => s.client); const client = useStreamChatStore((s) => s.client);
const { data: character } = useCharacter(characterId);
useEffect(() => { useEffect(() => {
if (id && client) { if (id && client) {
@ -21,9 +25,10 @@ export default function ChatPage() {
}, [id, client]); }, [id, client]);
return ( return (
<div className="flex h-full"> <div className="flex bg-[rgba(6,3,24,1)] h-full">
<div className="relative px-4 w-full flex-1 flex justify-center"> <div className="relative px-4 w-full flex-1 flex justify-center">
<div className="max-w-[752px] w-full h-full flex flex-col"> <div className="max-w-[752px] w-full relative h-full flex flex-col">
<Background imageUrl={character?.characterStand || character?.coverImage} />
<MessageList /> <MessageList />
<Input /> <Input />
</div> </div>

View File

@ -24,7 +24,7 @@ const Character = () => {
<InfiniteScrollList<any> <InfiniteScrollList<any>
items={dataSource} items={dataSource}
columns={(width) => { columns={(width) => {
const cardWidth = width > 1200 ? 256 : width > 588 ? 200 : 170; const cardWidth = width > 1200 ? 256 : width > 588 ? 200 : width > 375 ? 170 : 150;
return Math.floor(width / cardWidth); return Math.floor(width / cardWidth);
}} }}
renderItem={(character) => <AIStandardCard character={character} />} renderItem={(character) => <AIStandardCard character={character} />}

View File

@ -36,8 +36,8 @@ const NumDisplay = localFont({
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'), metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'),
title: 'CrushLevel', title: 'Spicyxx.AI',
description: 'CrushLevel - Next Generation Social Platform', description: 'Spicyxx.AI - Next Generation Social Platform',
}; };
export default async function RootLayout({ export default async function RootLayout({

View File

@ -1,108 +0,0 @@
'use client'
import Image from 'next/image'
import { useState, useEffect, useMemo } from 'react'
import { ScrollingBackground } from './ScrollingBackground'
interface LeftPanelProps {
scrollBg: string
images: string[]
}
// 基础文字内容
const baseTexts = [
{ title: 'AI Date', subtitle: "From 'Hi' to 'I Do', sparked by every chat." },
{ title: 'Crush', subtitle: "From 'Hi' to 'I Do', sparked by every chat." },
{ title: 'Chat', subtitle: "From 'Hi' to 'I Do', sparked by every chat." },
]
// 根据图片数量循环生成文字内容
const generateImageTexts = (count: number) => {
return Array.from({ length: count }, (_, i) => baseTexts[i % baseTexts.length])
}
export function LeftPanel({ scrollBg, images }: LeftPanelProps) {
const [currentIndex, setCurrentIndex] = useState(0)
const [isTransitioning, setIsTransitioning] = useState(false)
// 根据图片数量动态生成文案(使用 useMemo 优化性能)
const imageTexts = useMemo(() => generateImageTexts(images.length), [images.length])
useEffect(() => {
if (images.length <= 1) return
const timer = setInterval(() => {
setIsTransitioning(true)
setTimeout(() => {
setCurrentIndex((prevIndex) => (prevIndex === images.length - 1 ? 0 : prevIndex + 1))
setIsTransitioning(false)
}, 300)
}, 3000) // 每3秒切换一次
return () => clearInterval(timer)
}, [images.length])
const currentText = imageTexts[currentIndex] || imageTexts[0]
return (
<div className="relative h-full w-full overflow-hidden">
{/* 滚动背景 */}
<ScrollingBackground imageSrc={scrollBg} />
{/* 内容层 */}
<div className="relative z-10 flex h-full flex-col justify-end">
{/* 底部遮罩层 - 铺满背景底部高度500px */}
<div
className="absolute right-0 bottom-0 left-0 z-[5] h-[500px]"
style={{
background: 'linear-gradient(180deg, rgba(33, 26, 43, 0) 0%, #211A2B 100%)',
boxShadow: '0px 4px 4px 0px #00000040',
}}
/>
{/* 文字内容 - 在图片上方 */}
<div
className={`absolute right-0 bottom-16 left-0 z-10 mb-6 px-4 text-center transition-opacity duration-700 lg:bottom-20 lg:mb-8 lg:px-8 ${
isTransitioning ? 'opacity-0' : 'opacity-100'
}`}
>
<div className="mx-auto flex items-center justify-center px-4 py-2 lg:px-6 lg:py-3">
<h2 className="txt-headline-m lg:txt-headline-l relative flex items-center gap-2 text-white">
{currentText.title}
<Image
src="/images/login/v1/icon-star-right.svg"
alt="logo"
width={38}
height={36}
className="absolute -top-[18px] -right-[38px]"
/>
</h2>
</div>
<p className="txt-body-m lg:txt-body-l mx-auto max-w-[320px] lg:max-w-[380px]">
{currentText.subtitle}
</p>
</div>
{/* 角色图片 - 尽可能放大,紧贴底部 */}
<div className="relative h-[80vh] w-full lg:h-[85vh]">
{images.map((image, index) => (
<div
key={`${image}-${index}`}
className={`absolute inset-0 transition-opacity duration-700 ${
index === currentIndex && !isTransitioning ? 'opacity-100' : 'opacity-0'
}`}
>
<Image
src={image}
alt={`Character ${index + 1}`}
fill
className="object-contain object-bottom"
priority={index === 0}
/>
</div>
))}
</div>
</div>
</div>
)
}

View File

@ -1,24 +1,9 @@
'use client'; 'use client';
import Image from 'next/image'; import Image from 'next/image';
import { LoginForm } from './components/login-form'; import { LoginForm } from './components/login-form';
import { LeftPanel } from './components/LeftPanel';
import { IconButton } from '@/components/ui/button'; import { IconButton } from '@/components/ui/button';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
const scrollBg = '/images/login/v1/bg.png';
const images = [
'/images/login/v1/1.png',
'/images/login/v1/2.png',
'/images/login/v1/3.png',
'/images/login/v1/4.png',
'/images/login/v1/5.png',
'/images/login/v1/6.png',
'/images/login/v1/7.png',
'/images/login/v1/8.png',
'/images/login/v1/9.png',
'/images/login/v1/10.png',
];
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
@ -38,7 +23,7 @@ export default function LoginPage() {
<div className="relative flex w-full flex-col items-center justify-center px-6"> <div className="relative flex w-full flex-col items-center justify-center px-6">
{/* Logo */} {/* Logo */}
<div className="relative mb-8 h-[48px] w-[120px] sm:mb-12 sm:h-[64px] sm:w-[160px]"> <div className="relative mb-8 h-12 sm:mb-12 sm:h-16">
<i className="iconfont-v2 iconv2-Logo" style={{ fontSize: '50px' }} /> <i className="iconfont-v2 iconv2-Logo" style={{ fontSize: '50px' }} />
</div> </div>

View File

@ -1,31 +1,31 @@
import type { Metadata } from 'next' import type { Metadata } from 'next';
import LoginPage from './login-page' import LoginPage from './login-page';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Login - CrushLevel AI', title: 'Login - Spicyxx.AI',
description: description:
'Sign in to CrushLevel AI to start your love story. Login with Discord, Google, or Apple to connect with AI companions and begin chatting.', 'Sign in to Spicyxx.AI to start your love story. Login with Discord, Google, or Apple to connect with AI companions and begin chatting.',
keywords: [ keywords: [
'CrushLevel login', 'Spicyxx.AI login',
'CrushLevel sign in', 'Spicyxx.AI sign in',
'AI companion login', 'AI companion login',
'Discord login', 'Discord login',
'Google login', 'Google login',
'Apple login', 'Apple login',
'CrushLevel account', 'Spicyxx.AI account',
], ],
openGraph: { openGraph: {
title: 'Login - CrushLevel AI', title: 'Login - Spicyxx.AI',
description: description:
'Sign in to CrushLevel AI to start your love story. Login with Discord, Google, or Apple to connect with AI companions.', 'Sign in to Spicyxx.AI to start your love story. Login with Discord, Google, or Apple to connect with AI companions.',
url: 'https://www.crushlevel.com/login', url: 'https://www.crushlevel.com/login',
siteName: 'CrushLevel AI', siteName: 'Spicyxx.AI',
images: [ images: [
{ {
url: '/logo.svg', url: '/logo.svg',
width: 1200, width: 1200,
height: 630, height: 630,
alt: 'CrushLevel AI Login', alt: 'Spicyxx.AI Login',
}, },
], ],
locale: 'en_US', locale: 'en_US',
@ -33,9 +33,9 @@ export const metadata: Metadata = {
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
title: 'Login - CrushLevel AI', title: 'Login - Spicyxx.AI',
description: description:
'Sign in to CrushLevel AI to start your love story. Login with Discord, Google, or Apple.', 'Sign in to Spicyxx.AI to start your love story. Login with Discord, Google, or Apple.',
images: ['/logo.svg'], images: ['/logo.svg'],
}, },
robots: { robots: {
@ -52,10 +52,10 @@ export const metadata: Metadata = {
alternates: { alternates: {
canonical: 'https://www.crushlevel.com/login', canonical: 'https://www.crushlevel.com/login',
}, },
} };
const Page = () => { const Page = () => {
return <LoginPage /> return <LoginPage />;
} };
export default Page export default Page;

View File

@ -380,7 +380,7 @@
--color-emphasis-onpic-normal: rgba(78, 72, 255, 0.85); --color-emphasis-onpic-normal: rgba(78, 72, 255, 0.85);
/* Background */ /* Background */
--color-background-default: rgba(6, 3, 24, 1); --color-background-default: #0d0f29;
/* Surface */ /* Surface */
--color-surface-base-normal: rgba(26, 21, 42, 1); --color-surface-base-normal: rgba(26, 21, 42, 1);

View File

@ -25,6 +25,8 @@ function matchRoutePattern(pathname: string, patterns: string[]): boolean {
}); });
} }
const blurPages = ['/chat/:id'];
function Topbar() { function Topbar() {
const [isBlur, setIsBlur] = useState(false); const [isBlur, setIsBlur] = useState(false);
const { data: user } = useCurrentUser(); const { data: user } = useCurrentUser();
@ -68,7 +70,10 @@ function Topbar() {
if (response.sm || items.some((item) => item.path === pathname)) { if (response.sm || items.some((item) => item.path === pathname)) {
return ( return (
<Link href="/"> <Link href="/">
<i className="iconfont-v2 iconv2-Logo" style={{ fontSize: '50px' }} /> <i
className="iconfont-v2 iconv2-Logo"
style={{ fontSize: response.sm ? '50px' : '32px' }}
/>
</Link> </Link>
); );
} }
@ -86,7 +91,7 @@ function Topbar() {
if (!user) if (!user)
return ( return (
<Link href={loginHref} prefetch> <Link href={loginHref} prefetch>
<Button size="small">Login in / Sign up</Button> <Button size="small">{`Login in${response?.sm ? ' / Sign up' : ''}`}</Button>
</Link> </Link>
); );
return ( return (
@ -110,13 +115,15 @@ function Topbar() {
if (response && !response.sm && matchRoutePattern(pathname, mobileHidenMenus)) return null; if (response && !response.sm && matchRoutePattern(pathname, mobileHidenMenus)) return null;
const finalIgBlur = isBlur || matchRoutePattern(pathname, blurPages);
return ( return (
<header <header
className={cn('flex h-16 w-full items-center justify-between px-4 sm:px-8 transition-all', { className={cn('flex h-16 w-full items-center justify-between px-4 sm:px-8 transition-all', {
'backdrop-blur-[10px]': isBlur, 'backdrop-blur-[10px]': finalIgBlur,
})} })}
> >
{isBlur && <div className="bg-background-default absolute inset-0 opacity-85" />} {finalIgBlur && <div className="bg-background-default absolute inset-0 opacity-85" />}
<div className="relative inset-0 flex w-full items-center justify-between"> <div className="relative inset-0 flex w-full items-center justify-between">
{leftDomRender()} {leftDomRender()}
{rightDomRender()} {rightDomRender()}