feat: 增加打电话界面

This commit is contained in:
liuyonghe0111 2025-11-06 14:15:32 +08:00
parent 859ef2d320
commit debca09f6c
20 changed files with 254 additions and 126 deletions

View File

@ -17,7 +17,6 @@
"axios": "^1.12.2", "axios": "^1.12.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"jotai": "^2.15.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"next": "15.5.4", "next": "15.5.4",
@ -27,7 +26,8 @@
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-virtuoso": "^4.14.1", "react-virtuoso": "^4.14.1",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",

View File

@ -26,9 +26,6 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
jotai:
specifier: ^2.15.0
version: 2.15.0(@types/react@19.2.2)(react@19.1.0)
js-cookie: js-cookie:
specifier: ^3.0.5 specifier: ^3.0.5
version: 3.0.5 version: 3.0.5
@ -59,6 +56,9 @@ importers:
tailwind-merge: tailwind-merge:
specifier: ^3.3.1 specifier: ^3.3.1
version: 3.3.1 version: 3.3.1
zustand:
specifier: ^5.0.8
version: 5.0.8(@types/react@19.2.2)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0))
devDependencies: devDependencies:
'@eslint/eslintrc': '@eslint/eslintrc':
specifier: ^3 specifier: ^3
@ -2110,24 +2110,6 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
jotai@2.15.0:
resolution: {integrity: sha512-nbp/6jN2Ftxgw0VwoVnOg0m5qYM1rVcfvij+MZx99Z5IK13eGve9FJoCwGv+17JvVthTjhSmNtT5e1coJnr6aw==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@babel/core': '>=7.0.0'
'@babel/template': '>=7.0.0'
'@types/react': '>=17.0.0'
react: '>=17.0.0'
peerDependenciesMeta:
'@babel/core':
optional: true
'@babel/template':
optional: true
'@types/react':
optional: true
react:
optional: true
js-cookie@3.0.5: js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -2883,6 +2865,24 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
zustand@5.0.8:
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots: snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
@ -5076,11 +5076,6 @@ snapshots:
jiti@2.6.1: {} jiti@2.6.1: {}
jotai@2.15.0(@types/react@19.2.2)(react@19.1.0):
optionalDependencies:
'@types/react': 19.2.2
react: 19.1.0
js-cookie@3.0.5: {} js-cookie@3.0.5: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@ -5904,3 +5899,9 @@ snapshots:
yallist@5.0.0: {} yallist@5.0.0: {}
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zustand@5.0.8(@types/react@19.2.2)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)):
optionalDependencies:
'@types/react': 19.2.2
react: 19.1.0
use-sync-external-store: 1.6.0(react@19.1.0)

View File

@ -1,12 +1,12 @@
'use client'; 'use client';
import { useAtomValue } from 'jotai'; import { useChatStore } from '../store';
import { isPortraitModeAtom } from '../atoms';
import ChatMessageList from './components/ChatMessageList'; import ChatMessageList from './components/ChatMessageList';
import PortraitChat from './components/PortraitChat'; import PortraitChat from './components/PortraitChat';
export default function ChatList() { export default function ChatList() {
const isPortraitMode = useAtomValue(isPortraitModeAtom); const isPortraitMode = useChatStore((state) => state.isPortraitMode);
return ( return (
<div className="mb-10 flex-1"> <div className="mb-10 flex-1">

View File

@ -1,13 +1,12 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { useSetAtom } from 'jotai'; import { useChatStore } from '../../store';
import { historyListOpenAtom } from '../../atoms';
import Image from 'next/image'; import Image from 'next/image';
import IconFont from '@/components/ui/iconFont'; import IconFont from '@/components/ui/iconFont';
const ChatHistory = React.memo(() => { const ChatHistory = React.memo(() => {
const setHistoryListOpen = useSetAtom(historyListOpenAtom); const setHistoryListOpen = useChatStore((state) => state.setHistoryListOpen);
return ( return (
<div className="flex h-full w-full flex-col bg-[rgba(22,18,29,1)] pt-7.5 pr-5 pl-10"> <div className="flex h-full w-full flex-col bg-[rgba(22,18,29,1)] pt-7.5 pr-5 pl-10">

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useAtom, useSetAtom } from 'jotai'; import { useChatStore } from '../../store';
import { historyListOpenAtom, leftTabActiveKeyAtom } from '../../atoms';
import { cn } from '@/lib'; import { cn } from '@/lib';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@ -11,8 +10,9 @@ import ArchiveHistory from './ArchiveHistory';
import Info from './info'; import Info from './info';
export default function Side() { export default function Side() {
const [activeKey, setActiveKey] = useAtom(leftTabActiveKeyAtom); const activeKey = useChatStore((state) => state.leftTabActiveKey);
const setHistoryListOpen = useSetAtom(historyListOpenAtom); const setActiveKey = useChatStore((state) => state.setLeftTabActiveKey);
const setHistoryListOpen = useChatStore((state) => state.setHistoryListOpen);
const Component = activeKey === 'info' ? Info : ArchiveHistory; const Component = activeKey === 'info' ? Info : ArchiveHistory;
const router = useRouter(); const router = useRouter();

View File

@ -1,14 +1,13 @@
'use client'; 'use client';
import Side from './Side'; import Side from './Side';
import { useAtomValue } from 'jotai'; import { useChatStore } from '../../store';
import { historyListOpenAtom } from '../../atoms';
import { memo } from 'react'; import { memo } from 'react';
import { Drawer } from '@/components'; import { Drawer } from '@/components';
import ChatHistory from './ChatHisory'; import ChatHistory from './ChatHisory';
const Left = memo(() => { const Left = memo(() => {
const historyListOpen = useAtomValue(historyListOpenAtom); const historyListOpen = useChatStore((state) => state.historyListOpen);
return ( return (
<div className="relative flex h-full w-112"> <div className="relative flex h-full w-112">

View File

@ -4,15 +4,15 @@ import {
PhoneCallIcon, PhoneCallIcon,
PortraitModeIcon, PortraitModeIcon,
} from '@/assets/chatacter'; } from '@/assets/chatacter';
import { useAtom, useSetAtom } from 'jotai'; import { useChatStore } from '../../store';
import { isPhoneCallModeAtom, isPortraitModeAtom } from '../../atoms';
import IconFont from '@/components/ui/iconFont'; import IconFont from '@/components/ui/iconFont';
import { cn } from '@/lib'; import { cn } from '@/lib';
import Image from 'next/image'; import Image from 'next/image';
export default function Actions() { export default function Actions() {
const [isPortraitMode, setIsPortraitMode] = useAtom(isPortraitModeAtom); const isPortraitMode = useChatStore((state) => state.isPortraitMode);
const setIsPhoneCallMode = useSetAtom(isPhoneCallModeAtom); const setIsPortraitMode = useChatStore((state) => state.setIsPortraitMode);
const setIsPhoneCallMode = useChatStore((state) => state.setIsPhoneCallMode);
const className = 'text-[#0066FF] cursor-pointer hover:text-[#4269D6]'; const className = 'text-[#0066FF] cursor-pointer hover:text-[#4269D6]';
const suggestMessages = [ const suggestMessages = [

View File

@ -3,8 +3,7 @@
import Input from './input'; import Input from './input';
import Actions from './actions'; import Actions from './actions';
import ChatList from './ChatList'; import ChatList from './ChatList';
import { useAtom } from 'jotai'; import { useChatStore } from '../store';
import { settingOpenAtom } from '../atoms';
import SettingForm from './Right'; import SettingForm from './Right';
import { Drawer } from '@/components'; import { Drawer } from '@/components';
import Left from './Left'; import Left from './Left';
@ -12,7 +11,8 @@ import { ExitFullScreenIcon, FullScreenIcon } from '@/assets/common';
import { cn } from '@/lib'; import { cn } from '@/lib';
export default function Main() { export default function Main() {
const [settingOpen, setSettingOpen] = useAtom(settingOpenAtom); const settingOpen = useChatStore((state) => state.settingOpen);
const setSettingOpen = useChatStore((state) => state.setSettingOpen);
return ( return (
<> <>

View File

@ -1,15 +0,0 @@
'use client';
import { createContext } from 'react';
interface ChatContextType {}
export const ChatContext = createContext<ChatContextType>({});
export default function ChatContextProvider({
children,
}: {
children: React.ReactNode;
}) {
return <ChatContext.Provider value={{}}>{children}</ChatContext.Provider>;
}

View File

@ -0,0 +1,75 @@
'use client';
import {
GenerateInputIcon,
PhoneCallIcon,
PortraitModeIcon,
} from '@/assets/chatacter';
import { useChatStore } from '../../store';
import IconFont from '@/components/ui/iconFont';
import { cn } from '@/lib';
import Image from 'next/image';
export default function Actions() {
const isPortraitMode = useChatStore((state) => state.isPortraitMode);
const setIsPortraitMode = useChatStore((state) => state.setIsPortraitMode);
const setIsPhoneCallMode = useChatStore((state) => state.setIsPhoneCallMode);
const className = 'text-[#0066FF] cursor-pointer hover:text-[#4269D6]';
const suggestMessages = [
'The threads of fate intertwine once more...The threads of fate intert',
'The threads of fate intertwine once more.',
'The threads of fate intertwine once more.',
];
return (
<div className="mb-4">
<div className="relative mb-5 flex flex-col gap-2">
{suggestMessages.map((message, index) => (
<div
style={{
backgroundColor: 'rgba(36, 44, 80, 0.9)',
width: '70%',
}}
className="flex h-10 cursor-pointer items-center rounded-full px-4"
key={`message-${index}`}
>
<span className="line-clamp-2 text-xs">{message}</span>
</div>
))}
<span
style={{ left: '71%' }}
className="flex-center absolute bottom-0 h-7 w-7 cursor-pointer rounded-full bg-black/40"
>
<IconFont type="icon-zhongxie" size={18} />
</span>
</div>
{/* action */}
<div className="flex justify-between">
<div onClick={() => null} className="flex items-center gap-5">
<div className={className}>
<GenerateInputIcon />
</div>
<div
onClick={() => setIsPhoneCallMode(true)}
className={cn(className, 'relative')}
>
<PhoneCallIcon />
<Image
className="absolute right-0 bottom-0"
src="/component/vip.svg"
alt="phone call"
width={20}
height={20}
/>
</div>
</div>
<div
onClick={() => setIsPortraitMode(!isPortraitMode)}
className="hover:cursor-pointer"
>
<PortraitModeIcon />
</div>
</div>
</div>
);
}

View File

@ -1,5 +1,68 @@
'use client'; 'use client';
import IconFont from '@/components/ui/iconFont';
import { cn } from '@/lib';
import Image from 'next/image';
import { useState } from 'react';
import { useChatStore } from '../store';
const message1 =
'The threads of fate intertwine once more... I have been awaiting your arrival, seeker.The threads of fate intertwine once more... I have been awaiting your arrival, seeker.The threads of fate intertwine once more... I have been awaiting your arrival, seeker.';
const message2 =
'The threads of fate intertwine once more... I have been awaiting your arrival, seeker.The threads of fate intertwine once more';
export default function PhoneCallMode() { export default function PhoneCallMode() {
return <div>PhoneCallMode</div>; const setIsPhoneCallMode = useChatStore((state) => state.setIsPhoneCallMode);
const [isTextVisible, setIsTextVisible] = useState(true);
return (
<>
<div className="flex h-full w-[calc(100vw-900px)] flex-col items-center">
<div className="mt-5 flex flex-col items-center gap-6">
<div className="flex gap-1">
<Image
className="flex-shrink-0 rounded-full object-cover"
src="/avator.png"
alt="avatar"
width={20}
height={20}
/>
<span className="font-bold">{'Character 1 · 18'}</span>
</div>
<div className="text-xs font-black">{'0001'}</div>
</div>
<div className="flex w-75 flex-1 flex-col justify-end gap-7.5 pb-25 text-sm">
{isTextVisible && (
<>
<div className="text-white/40">{message1}</div>
<div>{message2}</div>
</>
)}
</div>
<div className="mb-10 cursor-pointer text-xs font-black">
Tag to top
</div>
<div
onClick={() => setIsPhoneCallMode(false)}
className={cn(
'flex-center mb-25 h-20 w-20 cursor-pointer rounded-full bg-white/10',
'bg-[linear-gradient(180deg,rgba(255,59,48,1)0%,rgba(222,46,36,1)100%)]'
)}
>
<IconFont type="icon-tonghua" size={50} />
</div>
</div>
{/* 显示文本按钮 */}
<div
onClick={() => setIsTextVisible(!isTextVisible)}
className={cn(
'flex-center absolute top-8 right-10 h-10 w-10 cursor-pointer rounded-full',
'bg-white/10 hover:bg-white/20'
)}
>
<IconFont type="icon-tonghua" size={20} />
</div>
</>
);
} }

View File

@ -1,16 +0,0 @@
import { atom } from 'jotai';
// 是否打开两侧的设置
export const settingOpenAtom = atom(true);
// 是否是立绘模式
export const isPortraitModeAtom = atom(false);
// 是否是通话模式
export const isPhoneCallModeAtom = atom(false);
// 左侧 tab active key
export const leftTabActiveKeyAtom = atom<'info' | 'history'>('info');
// 左侧 角色历史列表
export const historyListOpenAtom = atom<boolean>(false);

View File

@ -1,26 +1,22 @@
'use client'; 'use client';
import { useAtomValue } from 'jotai'; import { useChatStore } from './store';
import { isPhoneCallModeAtom } from './atoms';
import ChatMode from './ChatMode'; import ChatMode from './ChatMode';
import './index.css'; import './index.css';
import PhoneCallMode from './PhoneCallMode'; import PhoneCallMode from './PhoneCallMode';
import ChatContextProvider from './Context';
export default function CharacterChat() { export default function CharacterChat() {
const isPhoneCallMode = useAtomValue(isPhoneCallModeAtom); const isPhoneCallMode = useChatStore((state) => state.isPhoneCallMode);
return ( return (
<ChatContextProvider> <div
<div style={{
style={{ background:
background: 'linear-gradient(0deg, rgba(23, 0, 18, 0.6) 0%, rgba(0, 0, 0, 0.1) 100%)',
'linear-gradient(0deg, rgba(23, 0, 18, 0.6) 0%, rgba(0, 0, 0, 0.1) 100%)', }}
}} className="relative flex h-full w-full justify-center overflow-hidden"
className="relative flex h-full w-full justify-center overflow-hidden" >
> {isPhoneCallMode ? <PhoneCallMode /> : <ChatMode />}
{isPhoneCallMode ? <PhoneCallMode /> : <ChatMode />} </div>
</div>
</ChatContextProvider>
); );
} }

View File

@ -0,0 +1,27 @@
import { create } from 'zustand';
export const useChatStore = create<{
// UI state
isPhoneCallMode: boolean;
setIsPhoneCallMode: (isPhoneCallMode: boolean) => void;
settingOpen: boolean;
setSettingOpen: (settingOpen: boolean) => void;
isPortraitMode: boolean;
setIsPortraitMode: (isPortraitMode: boolean) => void;
leftTabActiveKey: 'info' | 'history';
setLeftTabActiveKey: (leftTabActiveKey: 'info' | 'history') => void;
historyListOpen: boolean;
setHistoryListOpen: (historyListOpen: boolean) => void;
// data state
}>((set) => ({
isPhoneCallMode: false,
setIsPhoneCallMode: (isPhoneCallMode) => set({ isPhoneCallMode }),
settingOpen: false,
setSettingOpen: (settingOpen) => set({ settingOpen }),
isPortraitMode: false,
setIsPortraitMode: (isPortraitMode) => set({ isPortraitMode }),
leftTabActiveKey: 'info',
setLeftTabActiveKey: (leftTabActiveKey) => set({ leftTabActiveKey }),
historyListOpen: false,
setHistoryListOpen: (historyListOpen) => set({ historyListOpen }),
}));

View File

@ -1,7 +1,7 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google'; import { Geist, Geist_Mono } from 'next/font/google';
import './globals.css'; import './globals.css';
import GlobalContainer from '@/layouts/GlobalContainer'; import Providers from '@/layouts/Providers';
import Script from 'next/script'; import Script from 'next/script';
const geistSans = Geist({ const geistSans = Geist({
@ -30,11 +30,11 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<Script <Script
src="//at.alicdn.com/t/c/font_5054282_ij9uesv751.js" src="//at.alicdn.com/t/c/font_5054282_z80k01jnmui.js"
strategy="afterInteractive" strategy="afterInteractive"
async async
/> />
<GlobalContainer>{children}</GlobalContainer> <Providers>{children}</Providers>
</body> </body>
</html> </html>
); );

View File

@ -1,15 +0,0 @@
'use client';
import { IntlProvider } from './IntlProvider';
import { QueryProvider } from './QueryProvider';
export default function GlobalContainer({
children,
}: {
children: React.ReactNode;
}) {
return (
<IntlProvider>
<QueryProvider>{children}</QueryProvider>
</IntlProvider>
);
}

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useLocale } from '@/layouts/GlobalContainer/IntlProvider'; import { useLocale } from '@/layouts/Providers/IntlProvider';
import { Select } from 'radix-ui'; import { Select } from 'radix-ui';
import React from 'react'; import React from 'react';
import Image from 'next/image'; import Image from 'next/image';

View File

@ -7,19 +7,11 @@ import {
useState, useState,
useEffect, useEffect,
ReactNode, ReactNode,
useCallback,
} from 'react'; } from 'react';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import zhMessages from '@/locales/zh'; import { useMemoizedFn } from 'ahooks';
import enMessages from '@/locales/en';
type Locale = 'zh' | 'en'; type Locale = 'zh' | 'en';
const messages: Record<Locale, any> = {
zh: zhMessages,
en: enMessages,
};
interface LocaleContextType { interface LocaleContextType {
locale: Locale; locale: Locale;
setLocale: (locale: Locale) => void; setLocale: (locale: Locale) => void;
@ -55,24 +47,35 @@ function getLocaleFromCookie(): Locale {
export function IntlProvider({ children }: IntlProviderProps) { export function IntlProvider({ children }: IntlProviderProps) {
const [locale, setLocaleState] = useState<Locale>('en'); const [locale, setLocaleState] = useState<Locale>('en');
const [messages, setMessages] = useState<Record<string, any>>();
const loadLocale = useMemoizedFn(async (locale: Locale) => {
// 动态加载, 提升首屏加载速度
const messages = await import(`@/locales/${locale}.ts`);
setMessages(messages.default);
});
useEffect(() => { useEffect(() => {
const cookieLocale = getLocaleFromCookie(); const cookieLocale = getLocaleFromCookie();
if (cookieLocale) { if (cookieLocale) {
setLocaleState(cookieLocale); setLocaleState(cookieLocale);
loadLocale(cookieLocale);
} }
}, []); }, []);
const setLocale = useCallback((newLocale: Locale) => { const setLocale = useMemoizedFn((newLocale: Locale) => {
setLocaleState(newLocale); setLocaleState(newLocale);
setLocaleToCookie(newLocale); setLocaleToCookie(newLocale);
}, []); loadLocale(newLocale);
});
if (!messages) return null;
return ( return (
<LocaleContext.Provider value={{ locale, setLocale }}> <LocaleContext.Provider value={{ locale, setLocale }}>
<NextIntlClientProvider <NextIntlClientProvider
locale={locale as any} locale={locale as any}
messages={messages[locale]} messages={messages}
timeZone="Asia/Shanghai" timeZone="Asia/Shanghai"
> >
{children} {children}

View File

@ -0,0 +1,11 @@
'use client';
import { IntlProvider } from './IntlProvider';
import { QueryProvider } from './QueryProvider';
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryProvider>
<IntlProvider>{children}</IntlProvider>
</QueryProvider>
);
}