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

View File

@ -26,9 +26,6 @@ importers:
clsx:
specifier: ^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:
specifier: ^3.0.5
version: 3.0.5
@ -59,6 +56,9 @@ importers:
tailwind-merge:
specifier: ^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:
'@eslint/eslintrc':
specifier: ^3
@ -2110,24 +2110,6 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
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:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
@ -2883,6 +2865,24 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
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:
'@alloc/quick-lru@5.2.0': {}
@ -5076,11 +5076,6 @@ snapshots:
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-tokens@4.0.0: {}
@ -5904,3 +5899,9 @@ snapshots:
yallist@5.0.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';
import { useAtomValue } from 'jotai';
import { isPortraitModeAtom } from '../atoms';
import { useChatStore } from '../store';
import ChatMessageList from './components/ChatMessageList';
import PortraitChat from './components/PortraitChat';
export default function ChatList() {
const isPortraitMode = useAtomValue(isPortraitModeAtom);
const isPortraitMode = useChatStore((state) => state.isPortraitMode);
return (
<div className="mb-10 flex-1">

View File

@ -1,13 +1,12 @@
'use client';
import React from 'react';
import { useSetAtom } from 'jotai';
import { historyListOpenAtom } from '../../atoms';
import { useChatStore } from '../../store';
import Image from 'next/image';
import IconFont from '@/components/ui/iconFont';
const ChatHistory = React.memo(() => {
const setHistoryListOpen = useSetAtom(historyListOpenAtom);
const setHistoryListOpen = useChatStore((state) => state.setHistoryListOpen);
return (
<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';
import { useAtom, useSetAtom } from 'jotai';
import { historyListOpenAtom, leftTabActiveKeyAtom } from '../../atoms';
import { useChatStore } from '../../store';
import { cn } from '@/lib';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
@ -11,8 +10,9 @@ import ArchiveHistory from './ArchiveHistory';
import Info from './info';
export default function Side() {
const [activeKey, setActiveKey] = useAtom(leftTabActiveKeyAtom);
const setHistoryListOpen = useSetAtom(historyListOpenAtom);
const activeKey = useChatStore((state) => state.leftTabActiveKey);
const setActiveKey = useChatStore((state) => state.setLeftTabActiveKey);
const setHistoryListOpen = useChatStore((state) => state.setHistoryListOpen);
const Component = activeKey === 'info' ? Info : ArchiveHistory;
const router = useRouter();

View File

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

View File

@ -4,15 +4,15 @@ import {
PhoneCallIcon,
PortraitModeIcon,
} from '@/assets/chatacter';
import { useAtom, useSetAtom } from 'jotai';
import { isPhoneCallModeAtom, isPortraitModeAtom } from '../../atoms';
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, setIsPortraitMode] = useAtom(isPortraitModeAtom);
const setIsPhoneCallMode = useSetAtom(isPhoneCallModeAtom);
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 = [

View File

@ -3,8 +3,7 @@
import Input from './input';
import Actions from './actions';
import ChatList from './ChatList';
import { useAtom } from 'jotai';
import { settingOpenAtom } from '../atoms';
import { useChatStore } from '../store';
import SettingForm from './Right';
import { Drawer } from '@/components';
import Left from './Left';
@ -12,7 +11,8 @@ import { ExitFullScreenIcon, FullScreenIcon } from '@/assets/common';
import { cn } from '@/lib';
export default function Main() {
const [settingOpen, setSettingOpen] = useAtom(settingOpenAtom);
const settingOpen = useChatStore((state) => state.settingOpen);
const setSettingOpen = useChatStore((state) => state.setSettingOpen);
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';
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() {
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';
import { useAtomValue } from 'jotai';
import { isPhoneCallModeAtom } from './atoms';
import { useChatStore } from './store';
import ChatMode from './ChatMode';
import './index.css';
import PhoneCallMode from './PhoneCallMode';
import ChatContextProvider from './Context';
export default function CharacterChat() {
const isPhoneCallMode = useAtomValue(isPhoneCallModeAtom);
const isPhoneCallMode = useChatStore((state) => state.isPhoneCallMode);
return (
<ChatContextProvider>
<div
style={{
background:
'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"
>
{isPhoneCallMode ? <PhoneCallMode /> : <ChatMode />}
</div>
</ChatContextProvider>
<div
style={{
background:
'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"
>
{isPhoneCallMode ? <PhoneCallMode /> : <ChatMode />}
</div>
);
}

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 { Geist, Geist_Mono } from 'next/font/google';
import './globals.css';
import GlobalContainer from '@/layouts/GlobalContainer';
import Providers from '@/layouts/Providers';
import Script from 'next/script';
const geistSans = Geist({
@ -30,11 +30,11 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Script
src="//at.alicdn.com/t/c/font_5054282_ij9uesv751.js"
src="//at.alicdn.com/t/c/font_5054282_z80k01jnmui.js"
strategy="afterInteractive"
async
/>
<GlobalContainer>{children}</GlobalContainer>
<Providers>{children}</Providers>
</body>
</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';
import { useLocale } from '@/layouts/GlobalContainer/IntlProvider';
import { useLocale } from '@/layouts/Providers/IntlProvider';
import { Select } from 'radix-ui';
import React from 'react';
import Image from 'next/image';

View File

@ -7,19 +7,11 @@ import {
useState,
useEffect,
ReactNode,
useCallback,
} from 'react';
import Cookies from 'js-cookie';
import zhMessages from '@/locales/zh';
import enMessages from '@/locales/en';
import { useMemoizedFn } from 'ahooks';
type Locale = 'zh' | 'en';
const messages: Record<Locale, any> = {
zh: zhMessages,
en: enMessages,
};
interface LocaleContextType {
locale: Locale;
setLocale: (locale: Locale) => void;
@ -55,24 +47,35 @@ function getLocaleFromCookie(): Locale {
export function IntlProvider({ children }: IntlProviderProps) {
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(() => {
const cookieLocale = getLocaleFromCookie();
if (cookieLocale) {
setLocaleState(cookieLocale);
loadLocale(cookieLocale);
}
}, []);
const setLocale = useCallback((newLocale: Locale) => {
const setLocale = useMemoizedFn((newLocale: Locale) => {
setLocaleState(newLocale);
setLocaleToCookie(newLocale);
}, []);
loadLocale(newLocale);
});
if (!messages) return null;
return (
<LocaleContext.Provider value={{ locale, setLocale }}>
<NextIntlClientProvider
locale={locale as any}
messages={messages[locale]}
messages={messages}
timeZone="Asia/Shanghai"
>
{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>
);
}