From 859ef2d320207798dece97b7f99d780cf621505c Mon Sep 17 00:00:00 2001 From: liuyonghe0111 <1763195287@qq.com> Date: Wed, 5 Nov 2025 19:32:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BA=86=E4=B8=80?= =?UTF-8?q?=E4=BA=9B=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 9 ++- public/locale/cn.svg | 12 +++ public/locale/en.svg | 13 ++++ .../[id]/(detail)/components/BasicInfo.tsx | 6 +- .../(main)/character/[id]/(detail)/service.ts | 1 + .../[id]/chat/{Main => ChatMode}/ChatList.tsx | 0 .../{ => ChatMode}/Left/ArchiveHistory.tsx | 0 .../chat/{ => ChatMode}/Left/ChatHisory.tsx | 2 +- .../[id]/chat/{ => ChatMode}/Left/Side.tsx | 34 +++++---- .../[id]/chat/{ => ChatMode}/Left/index.tsx | 2 +- .../[id]/chat/{ => ChatMode}/Left/info.tsx | 0 .../chat/{ => ChatMode}/Right/Background.tsx | 0 .../[id]/chat/{ => ChatMode}/Right/index.tsx | 16 ++-- .../[id]/chat/ChatMode/actions/index.tsx | 75 +++++++++++++++++++ .../components/ChatMessageList.tsx | 0 .../{Main => ChatMode}/components/Message.css | 0 .../{Main => ChatMode}/components/Message.tsx | 0 .../components/PortraitChat.tsx | 0 .../character/[id]/chat/ChatMode/index.tsx | 53 +++++++++++++ .../[id]/chat/{Main => ChatMode}/input.tsx | 12 ++- .../character/[id]/chat/Context/index.tsx | 15 ++++ .../character/[id]/chat/Main/actions.tsx | 32 -------- .../(main)/character/[id]/chat/Main/index.tsx | 20 ----- .../[id]/chat/PhoneCallMode/index.tsx | 5 ++ src/app/(main)/character/[id]/chat/atoms.ts | 3 + src/app/(main)/character/[id]/chat/page.tsx | 49 ++++-------- src/app/(main)/character/page.tsx | 12 ++- src/app/layout.tsx | 2 +- src/assets/chatacter/index.tsx | 24 ------ src/components/feature/ModelSelectDialog.tsx | 1 - .../feature/VoiceActorSelectDialog.tsx | 1 - src/components/ui/Tags.tsx | 1 + src/components/ui/iconFont.tsx | 1 + src/components/ui/modal/index.css | 6 ++ src/components/ui/modal/index.tsx | 45 ++++++----- src/hooks/index.ts | 11 +++ src/i18n/request.ts | 12 +++ src/layouts/GlobalContainer/IntlProvider.tsx | 40 +++++++++- .../MainLayout/components/LocaleSelect.tsx | 72 ++++++++++++++++++ .../MainLayout/components/NavRoutes.tsx | 60 +++++++++++++++ src/layouts/MainLayout/header.tsx | 65 ++-------------- src/lib/request.ts | 12 +++ src/locales/en.ts | 2 + src/locales/en/character.ts | 1 + src/locales/zh.ts | 2 + src/locales/zh/character.ts | 1 + src/middleware.ts | 22 ++++++ 47 files changed, 520 insertions(+), 232 deletions(-) create mode 100644 public/locale/cn.svg create mode 100644 public/locale/en.svg rename src/app/(main)/character/[id]/chat/{Main => ChatMode}/ChatList.tsx (100%) rename src/app/(main)/character/[id]/chat/{ => ChatMode}/Left/ArchiveHistory.tsx (100%) rename src/app/(main)/character/[id]/chat/{ => ChatMode}/Left/ChatHisory.tsx (97%) rename src/app/(main)/character/[id]/chat/{ => ChatMode}/Left/Side.tsx (78%) rename src/app/(main)/character/[id]/chat/{ => ChatMode}/Left/index.tsx (91%) rename src/app/(main)/character/[id]/chat/{ => ChatMode}/Left/info.tsx (100%) rename src/app/(main)/character/[id]/chat/{ => ChatMode}/Right/Background.tsx (100%) rename src/app/(main)/character/[id]/chat/{ => ChatMode}/Right/index.tsx (93%) create mode 100644 src/app/(main)/character/[id]/chat/ChatMode/actions/index.tsx rename src/app/(main)/character/[id]/chat/{Main => ChatMode}/components/ChatMessageList.tsx (100%) rename src/app/(main)/character/[id]/chat/{Main => ChatMode}/components/Message.css (100%) rename src/app/(main)/character/[id]/chat/{Main => ChatMode}/components/Message.tsx (100%) rename src/app/(main)/character/[id]/chat/{Main => ChatMode}/components/PortraitChat.tsx (100%) create mode 100644 src/app/(main)/character/[id]/chat/ChatMode/index.tsx rename src/app/(main)/character/[id]/chat/{Main => ChatMode}/input.tsx (76%) create mode 100644 src/app/(main)/character/[id]/chat/Context/index.tsx delete mode 100644 src/app/(main)/character/[id]/chat/Main/actions.tsx delete mode 100644 src/app/(main)/character/[id]/chat/Main/index.tsx create mode 100644 src/app/(main)/character/[id]/chat/PhoneCallMode/index.tsx create mode 100644 src/i18n/request.ts create mode 100644 src/layouts/MainLayout/components/LocaleSelect.tsx create mode 100644 src/layouts/MainLayout/components/NavRoutes.tsx create mode 100644 src/locales/en/character.ts create mode 100644 src/locales/zh/character.ts create mode 100644 src/middleware.ts diff --git a/next.config.ts b/next.config.ts index cb857a8..efcbd13 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,10 +1,17 @@ import type { NextConfig } from 'next'; +import createNextIntlPlugin from 'next-intl/plugin'; +// 指定 i18n 配置文件路径,让 next-intl 知道支持的语言 +const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const nextConfig: NextConfig = { reactStrictMode: false, eslint: { ignoreDuringBuilds: true, }, + images: { + // 这些域不走next的image代理 + domains: ['example.com'], + }, async rewrites() { return [ { @@ -15,4 +22,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withNextIntl(nextConfig); diff --git a/public/locale/cn.svg b/public/locale/cn.svg new file mode 100644 index 0000000..57c6812 --- /dev/null +++ b/public/locale/cn.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/locale/en.svg b/public/locale/en.svg new file mode 100644 index 0000000..24245f3 --- /dev/null +++ b/public/locale/en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/app/(main)/character/[id]/(detail)/components/BasicInfo.tsx b/src/app/(main)/character/[id]/(detail)/components/BasicInfo.tsx index 6562327..262b629 100644 --- a/src/app/(main)/character/[id]/(detail)/components/BasicInfo.tsx +++ b/src/app/(main)/character/[id]/(detail)/components/BasicInfo.tsx @@ -4,6 +4,7 @@ import { RightArrowIcon } from '@/assets/chatacter'; import IconFont from '@/components/ui/iconFont'; import ChatButton from './ChatButton'; import Tags from '@/components/ui/Tags'; +import { useTranslations } from 'next-intl'; type CharacterBasicInfoProps = { characterId: string; @@ -14,6 +15,8 @@ export default function CharacterBasicInfo({ characterId, characterDetail, }: CharacterBasicInfoProps) { + const msg = useTranslations(); + if (!characterDetail) { return null; } @@ -41,6 +44,7 @@ export default function CharacterBasicInfo({

{characterName} + {msg('common_desc')}

@@ -114,7 +118,7 @@ export default function CharacterBasicInfo({ width={18} height={20} /> - Description: + {'Description'}:

{characterDetail.description} diff --git a/src/app/(main)/character/[id]/(detail)/service.ts b/src/app/(main)/character/[id]/(detail)/service.ts index f632874..d09418d 100644 --- a/src/app/(main)/character/[id]/(detail)/service.ts +++ b/src/app/(main)/character/[id]/(detail)/service.ts @@ -8,6 +8,7 @@ import { publicServerRequest } from '@/lib/server-request'; */ export const fetchCharacterDetail = cache(async (id: string) => { + return {}; const { data } = await publicServerRequest( `/character/select/roleInfo/${id}`, { diff --git a/src/app/(main)/character/[id]/chat/Main/ChatList.tsx b/src/app/(main)/character/[id]/chat/ChatMode/ChatList.tsx similarity index 100% rename from src/app/(main)/character/[id]/chat/Main/ChatList.tsx rename to src/app/(main)/character/[id]/chat/ChatMode/ChatList.tsx diff --git a/src/app/(main)/character/[id]/chat/Left/ArchiveHistory.tsx b/src/app/(main)/character/[id]/chat/ChatMode/Left/ArchiveHistory.tsx similarity index 100% rename from src/app/(main)/character/[id]/chat/Left/ArchiveHistory.tsx rename to src/app/(main)/character/[id]/chat/ChatMode/Left/ArchiveHistory.tsx diff --git a/src/app/(main)/character/[id]/chat/Left/ChatHisory.tsx b/src/app/(main)/character/[id]/chat/ChatMode/Left/ChatHisory.tsx similarity index 97% rename from src/app/(main)/character/[id]/chat/Left/ChatHisory.tsx rename to src/app/(main)/character/[id]/chat/ChatMode/Left/ChatHisory.tsx index 65d3667..54d5ebd 100644 --- a/src/app/(main)/character/[id]/chat/Left/ChatHisory.tsx +++ b/src/app/(main)/character/[id]/chat/ChatMode/Left/ChatHisory.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useSetAtom } from 'jotai'; -import { historyListOpenAtom } from '../atoms'; +import { historyListOpenAtom } from '../../atoms'; import Image from 'next/image'; import IconFont from '@/components/ui/iconFont'; diff --git a/src/app/(main)/character/[id]/chat/Left/Side.tsx b/src/app/(main)/character/[id]/chat/ChatMode/Left/Side.tsx similarity index 78% rename from src/app/(main)/character/[id]/chat/Left/Side.tsx rename to src/app/(main)/character/[id]/chat/ChatMode/Left/Side.tsx index e0e5bd8..80d562d 100644 --- a/src/app/(main)/character/[id]/chat/Left/Side.tsx +++ b/src/app/(main)/character/[id]/chat/ChatMode/Left/Side.tsx @@ -1,10 +1,9 @@ 'use client'; import { useAtom, useSetAtom } from 'jotai'; -import { historyListOpenAtom, leftTabActiveKeyAtom } from '../atoms'; +import { historyListOpenAtom, leftTabActiveKeyAtom } from '../../atoms'; import { cn } from '@/lib'; import Image from 'next/image'; -import { CharaterHistoryIcon } from '@/assets/chatacter'; import { useRouter } from 'next/navigation'; import { Icon } from '@/components'; import IconFont from '@/components/ui/iconFont'; @@ -19,22 +18,27 @@ export default function Side() { const tabs = [ { - element: ( -

- info + render: (isActive: boolean) => ( +
+ info
), key: 'info', }, { - element: ( -
- + render: (isActive: boolean) => ( +
+
), key: 'history', @@ -78,13 +82,13 @@ export default function Side() { return (
setActiveKey(item.key as any)} key={item.key} > - {item.element} + {item.render(isActive)}
); })} diff --git a/src/app/(main)/character/[id]/chat/Left/index.tsx b/src/app/(main)/character/[id]/chat/ChatMode/Left/index.tsx similarity index 91% rename from src/app/(main)/character/[id]/chat/Left/index.tsx rename to src/app/(main)/character/[id]/chat/ChatMode/Left/index.tsx index 30858f8..0445293 100644 --- a/src/app/(main)/character/[id]/chat/Left/index.tsx +++ b/src/app/(main)/character/[id]/chat/ChatMode/Left/index.tsx @@ -2,7 +2,7 @@ import Side from './Side'; import { useAtomValue } from 'jotai'; -import { historyListOpenAtom } from '../atoms'; +import { historyListOpenAtom } from '../../atoms'; import { memo } from 'react'; import { Drawer } from '@/components'; import ChatHistory from './ChatHisory'; diff --git a/src/app/(main)/character/[id]/chat/Left/info.tsx b/src/app/(main)/character/[id]/chat/ChatMode/Left/info.tsx similarity index 100% rename from src/app/(main)/character/[id]/chat/Left/info.tsx rename to src/app/(main)/character/[id]/chat/ChatMode/Left/info.tsx diff --git a/src/app/(main)/character/[id]/chat/Right/Background.tsx b/src/app/(main)/character/[id]/chat/ChatMode/Right/Background.tsx similarity index 100% rename from src/app/(main)/character/[id]/chat/Right/Background.tsx rename to src/app/(main)/character/[id]/chat/ChatMode/Right/Background.tsx diff --git a/src/app/(main)/character/[id]/chat/Right/index.tsx b/src/app/(main)/character/[id]/chat/ChatMode/Right/index.tsx similarity index 93% rename from src/app/(main)/character/[id]/chat/Right/index.tsx rename to src/app/(main)/character/[id]/chat/ChatMode/Right/index.tsx index 2052210..ad50896 100644 --- a/src/app/(main)/character/[id]/chat/Right/index.tsx +++ b/src/app/(main)/character/[id]/chat/ChatMode/Right/index.tsx @@ -57,13 +57,13 @@ const SettingForm = React.memo(() => { }} > - <FormItem<SettingFormValues> + <FormItem name="model" render={({ value, onChange }) => ( <ModelSelectDialog value={value} onChange={onChange} /> )} /> - <FormItem<SettingFormValues> + <FormItem name="short_text" render={({ value, onChange }) => ( <Switch @@ -77,13 +77,13 @@ const SettingForm = React.memo(() => { - <FormItem<SettingFormValues> + <FormItem name="voiceActor" render={({ value, onChange }) => ( <VoiceActorSelectDialog value={value} onChange={onChange} /> )} /> - <FormItem<SettingFormValues> + <FormItem name="dialogueOnly" render={({ value, onChange }) => ( <Switch @@ -97,7 +97,7 @@ const SettingForm = React.memo(() => { - <FormItem<SettingFormValues> + <FormItem name="max_tokens" render={({ value, onChange }) => ( <Number value={value} onChange={onChange} /> @@ -106,13 +106,13 @@ const SettingForm = React.memo(() => { - <FormItem<SettingFormValues> + <FormItem name="fontSize" render={({ value, onChange }) => { return <FontSize value={value} onChange={onChange} />; }} /> - <FormItem<SettingFormValues> + <FormItem name="chat_mode" render={({ value, onChange }) => ( <Select @@ -134,7 +134,7 @@ const SettingForm = React.memo(() => { - <FormItem<SettingFormValues> + <FormItem name="background" render={({ value, onChange }) => ( <Background value={value} onChange={onChange} /> diff --git a/src/app/(main)/character/[id]/chat/ChatMode/actions/index.tsx b/src/app/(main)/character/[id]/chat/ChatMode/actions/index.tsx new file mode 100644 index 0000000..0b39086 --- /dev/null +++ b/src/app/(main)/character/[id]/chat/ChatMode/actions/index.tsx @@ -0,0 +1,75 @@ +'use client'; +import { + GenerateInputIcon, + PhoneCallIcon, + PortraitModeIcon, +} from '@/assets/chatacter'; +import { useAtom, useSetAtom } from 'jotai'; +import { isPhoneCallModeAtom, isPortraitModeAtom } from '../../atoms'; +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 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> + ); +} diff --git a/src/app/(main)/character/[id]/chat/Main/components/ChatMessageList.tsx b/src/app/(main)/character/[id]/chat/ChatMode/components/ChatMessageList.tsx similarity index 100% rename from src/app/(main)/character/[id]/chat/Main/components/ChatMessageList.tsx rename to src/app/(main)/character/[id]/chat/ChatMode/components/ChatMessageList.tsx diff --git a/src/app/(main)/character/[id]/chat/Main/components/Message.css b/src/app/(main)/character/[id]/chat/ChatMode/components/Message.css similarity index 100% rename from src/app/(main)/character/[id]/chat/Main/components/Message.css rename to src/app/(main)/character/[id]/chat/ChatMode/components/Message.css diff --git a/src/app/(main)/character/[id]/chat/Main/components/Message.tsx b/src/app/(main)/character/[id]/chat/ChatMode/components/Message.tsx similarity index 100% rename from src/app/(main)/character/[id]/chat/Main/components/Message.tsx rename to src/app/(main)/character/[id]/chat/ChatMode/components/Message.tsx diff --git a/src/app/(main)/character/[id]/chat/Main/components/PortraitChat.tsx b/src/app/(main)/character/[id]/chat/ChatMode/components/PortraitChat.tsx similarity index 100% rename from src/app/(main)/character/[id]/chat/Main/components/PortraitChat.tsx rename to src/app/(main)/character/[id]/chat/ChatMode/components/PortraitChat.tsx diff --git a/src/app/(main)/character/[id]/chat/ChatMode/index.tsx b/src/app/(main)/character/[id]/chat/ChatMode/index.tsx new file mode 100644 index 0000000..cef34cf --- /dev/null +++ b/src/app/(main)/character/[id]/chat/ChatMode/index.tsx @@ -0,0 +1,53 @@ +'use client'; + +import Input from './input'; +import Actions from './actions'; +import ChatList from './ChatList'; +import { useAtom } from 'jotai'; +import { settingOpenAtom } from '../atoms'; +import SettingForm from './Right'; +import { Drawer } from '@/components'; +import Left from './Left'; +import { ExitFullScreenIcon, FullScreenIcon } from '@/assets/common'; +import { cn } from '@/lib'; + +export default function Main() { + const [settingOpen, setSettingOpen] = useAtom(settingOpenAtom); + + return ( + <> + {/* main */} + <div className="flex h-full w-[calc(100vw-900px)] flex-col px-10"> + {/* chat list */} + <ChatList /> + + {/* actions */} + <Actions /> + + {/* inputs */} + <Input /> + </div> + + {/* 左侧 */} + <Drawer open={settingOpen} position="left" width={448} destroyOnClose> + <Left /> + </Drawer> + + {/* 右侧设置 */} + <Drawer open={settingOpen} position="right" width={448} destroyOnClose> + <SettingForm /> + </Drawer> + + {/* 设置按钮 */} + <div + className={cn( + 'absolute top-8 right-10 h-10 w-10 select-none hover:cursor-pointer', + 'text-text-color/10 hover:text-text-color/20' + )} + onClick={() => setSettingOpen(!settingOpen)} + > + {settingOpen ? <FullScreenIcon /> : <ExitFullScreenIcon />} + </div> + </> + ); +} diff --git a/src/app/(main)/character/[id]/chat/Main/input.tsx b/src/app/(main)/character/[id]/chat/ChatMode/input.tsx similarity index 76% rename from src/app/(main)/character/[id]/chat/Main/input.tsx rename to src/app/(main)/character/[id]/chat/ChatMode/input.tsx index c02d7b8..d98fedc 100644 --- a/src/app/(main)/character/[id]/chat/Main/input.tsx +++ b/src/app/(main)/character/[id]/chat/ChatMode/input.tsx @@ -27,8 +27,8 @@ export default function Input() { }, [value]); return ( - <div className="bg-text-color/15 mb-10 flex min-h-13 w-full items-end justify-between gap-2 rounded-[25px] px-1 py-1"> - <div className={cn(className, 'hover:bg-text-color/20')}> + <div className="mb-10 flex min-h-13 w-full items-end justify-between gap-2 rounded-[25px] bg-white/15 px-1 py-1"> + <div className={cn(className, 'hover:bg-white/20')}> <VoiceIcon /> </div> <textarea @@ -39,7 +39,13 @@ export default function Input() { className="hide-scrollbar h-10 max-h-25 w-full resize-none bg-transparent px-1 py-2.5 leading-normal outline-none" rows={1} /> - <div className={cn(className, 'bg-text-color/20')}> + <div + style={{ + background: + 'linear-gradient(92.99deg, rgba(166, 83, 255, 1) 0%, rgba(0, 101, 255, 1) 112.3%, rgba(0, 157, 255, 1) 140.37%)', + }} + className={cn(className)} + > <Image src="/component/send.svg" width={20} height={20} alt="send" /> </div> </div> diff --git a/src/app/(main)/character/[id]/chat/Context/index.tsx b/src/app/(main)/character/[id]/chat/Context/index.tsx new file mode 100644 index 0000000..d202d6b --- /dev/null +++ b/src/app/(main)/character/[id]/chat/Context/index.tsx @@ -0,0 +1,15 @@ +'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>; +} diff --git a/src/app/(main)/character/[id]/chat/Main/actions.tsx b/src/app/(main)/character/[id]/chat/Main/actions.tsx deleted file mode 100644 index bfe756f..0000000 --- a/src/app/(main)/character/[id]/chat/Main/actions.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; -import { - GenerateInputIcon, - PhoneCallIcon, - PortraitModeIcon, -} from '@/assets/chatacter'; -import { useAtom } from 'jotai'; -import { isPortraitModeAtom } from '../atoms'; - -export default function Actions() { - const [isPortraitMode, setIsPortraitMode] = useAtom(isPortraitModeAtom); - const className = 'text-[#4269D6] hover:cursor-pointer hover:text-[#0066FF]'; - - return ( - <div className="mb-4 flex justify-between"> - <div className="flex items-center gap-5"> - <div className={className}> - <GenerateInputIcon /> - </div> - <div className={className}> - <PhoneCallIcon /> - </div> - </div> - <div - onClick={() => setIsPortraitMode(!isPortraitMode)} - className="hover:cursor-pointer" - > - <PortraitModeIcon /> - </div> - </div> - ); -} diff --git a/src/app/(main)/character/[id]/chat/Main/index.tsx b/src/app/(main)/character/[id]/chat/Main/index.tsx deleted file mode 100644 index b9b5335..0000000 --- a/src/app/(main)/character/[id]/chat/Main/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -'use client'; - -import Input from './input'; -import Actions from './actions'; -import ChatList from './ChatList'; - -export default function Main() { - return ( - <div className="flex h-full w-[calc(100vw-900px)] flex-col px-10"> - {/* chat list */} - <ChatList /> - - {/* actions */} - <Actions /> - - {/* inputs */} - <Input /> - </div> - ); -} diff --git a/src/app/(main)/character/[id]/chat/PhoneCallMode/index.tsx b/src/app/(main)/character/[id]/chat/PhoneCallMode/index.tsx new file mode 100644 index 0000000..30ca0be --- /dev/null +++ b/src/app/(main)/character/[id]/chat/PhoneCallMode/index.tsx @@ -0,0 +1,5 @@ +'use client'; + +export default function PhoneCallMode() { + return <div>PhoneCallMode</div>; +} diff --git a/src/app/(main)/character/[id]/chat/atoms.ts b/src/app/(main)/character/[id]/chat/atoms.ts index 109dac2..f782d6c 100644 --- a/src/app/(main)/character/[id]/chat/atoms.ts +++ b/src/app/(main)/character/[id]/chat/atoms.ts @@ -6,6 +6,9 @@ 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'); diff --git a/src/app/(main)/character/[id]/chat/page.tsx b/src/app/(main)/character/[id]/chat/page.tsx index e6dc791..061a93d 100644 --- a/src/app/(main)/character/[id]/chat/page.tsx +++ b/src/app/(main)/character/[id]/chat/page.tsx @@ -1,47 +1,26 @@ 'use client'; -import { cn } from '@/lib'; -import SettingForm from './Right'; -import { useAtom } from 'jotai'; -import { settingOpenAtom } from './atoms'; -import Main from './Main'; -import Left from './Left'; -import { Drawer } from '@/components'; +import { useAtomValue } from 'jotai'; +import { isPhoneCallModeAtom } from './atoms'; +import ChatMode from './ChatMode'; import './index.css'; -import { ExitFullScreenIcon, FullScreenIcon } from '@/assets/common'; +import PhoneCallMode from './PhoneCallMode'; +import ChatContextProvider from './Context'; export default function CharacterChat() { - const [settingOpen, setSettingOpen] = useAtom(settingOpenAtom); + const isPhoneCallMode = useAtomValue(isPhoneCallModeAtom); return ( - <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" - > - <Main /> - - {/* 左侧 */} - <Drawer open={settingOpen} position="left" width={448} destroyOnClose> - <Left /> - </Drawer> - {/* 右侧设置 */} - <Drawer open={settingOpen} position="right" width={448} destroyOnClose> - <SettingForm /> - </Drawer> - - {/* 设置按钮 */} + <ChatContextProvider> <div - className={cn( - 'absolute top-8 right-10 h-10 w-10 select-none hover:cursor-pointer', - 'text-text-color/10 hover:text-text-color/20' - )} - onClick={() => setSettingOpen(!settingOpen)} + 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" > - {settingOpen ? <FullScreenIcon /> : <ExitFullScreenIcon />} + {isPhoneCallMode ? <PhoneCallMode /> : <ChatMode />} </div> - </div> + </ChatContextProvider> ); } diff --git a/src/app/(main)/character/page.tsx b/src/app/(main)/character/page.tsx index 092b425..131a5fa 100644 --- a/src/app/(main)/character/page.tsx +++ b/src/app/(main)/character/page.tsx @@ -63,15 +63,13 @@ export default function Novel() { queryKey: ['tags'], queryFn: async () => { const res = await fetchTags(); - return res.rows; + return res.rows?.map((tag: any) => ({ + label: tag.name, + value: tag.id, + })); }, }); - const options = tags?.map((tag: any) => ({ - label: tag.name, - value: tag.id, - })); - return ( <VirtualGrid padding={{ left: 50, right: 50 }} @@ -83,7 +81,7 @@ export default function Novel() { loadMore={onLoadMore} header={ <TagSelect - options={options} + options={tags} render={(item) => `# ${item.label}`} onChange={(v) => { onSearch({ tagId: v }); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e940ab1..99c60de 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -30,7 +30,7 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > <Script - src="//at.alicdn.com/t/c/font_5054282_5o7sf0csg4w.js" + src="//at.alicdn.com/t/c/font_5054282_ij9uesv751.js" strategy="afterInteractive" async /> diff --git a/src/assets/chatacter/index.tsx b/src/assets/chatacter/index.tsx index fea58ad..d4ffaf2 100644 --- a/src/assets/chatacter/index.tsx +++ b/src/assets/chatacter/index.tsx @@ -235,27 +235,3 @@ export const ScrollTobottom = () => { </svg> ); }; - -export const CharaterHistoryIcon = () => { - return ( - <svg - width="40" - height="40" - viewBox="0 0 40 40" - fill="currentColor" - xmlns="http://www.w3.org/2000/svg" - > - <circle cx="20" cy="10" r="6" fill="white" fillOpacity="0.6" /> - <path - d="M20 18C22.2563 18 24.3869 18.4472 26.2744 19.2373C21.5445 20.2582 18 24.4647 18 29.5C18 31.7314 18.6979 33.799 19.8848 35.5C12.206 35.5058 6.00021 36.0779 6 29.667C6 23.2237 12.268 18 20 18Z" - // fill="white" - fillOpacity="0.6" - /> - <path - d="M29 22C32.866 22 36 25.134 36 29C36 32.866 32.866 36 29 36C25.134 36 22 32.866 22 29C22 25.134 25.134 22 29 22ZM29 23.665C28.2627 23.665 27.665 24.2627 27.665 25V29.4453L29.4316 31.8008C29.8739 32.3905 30.711 32.5105 31.3008 32.0684C31.8905 31.6261 32.0105 30.789 31.5684 30.1992L30.335 28.5547V25C30.335 24.2627 29.7373 23.665 29 23.665Z" - // fill="white" - fillOpacity="0.6" - /> - </svg> - ); -}; diff --git a/src/components/feature/ModelSelectDialog.tsx b/src/components/feature/ModelSelectDialog.tsx index 215579c..a8cb417 100644 --- a/src/components/feature/ModelSelectDialog.tsx +++ b/src/components/feature/ModelSelectDialog.tsx @@ -16,7 +16,6 @@ export default function ModelSelectDialog(props: ModelSelectDialogProps) { defaultValuePropName: 'defaultValue', trigger: 'onChange', }); - console.log('ModelSelectDialog', value); const options = [ { label: 'Model 1', value: 'model1' }, diff --git a/src/components/feature/VoiceActorSelectDialog.tsx b/src/components/feature/VoiceActorSelectDialog.tsx index 8916ac1..363e5a2 100644 --- a/src/components/feature/VoiceActorSelectDialog.tsx +++ b/src/components/feature/VoiceActorSelectDialog.tsx @@ -12,7 +12,6 @@ export default function VoiceActorSelectDialog( props: VoiceActorSelectDialogProps ) { const { value, onChange } = props; - console.log('VoiceActorSelectDialog', value); const options = [ { label: 'Voice Actor 1', value: 'voiceActor1', gender: 'male' }, { label: 'Voice Actor 2', value: 'voiceActor2', gender: 'female' }, diff --git a/src/components/ui/Tags.tsx b/src/components/ui/Tags.tsx index 8be9ecf..a23aaa6 100644 --- a/src/components/ui/Tags.tsx +++ b/src/components/ui/Tags.tsx @@ -1,3 +1,4 @@ +'use client'; import React from 'react'; import { cn } from '@/lib'; diff --git a/src/components/ui/iconFont.tsx b/src/components/ui/iconFont.tsx index ef223a2..ee201b9 100644 --- a/src/components/ui/iconFont.tsx +++ b/src/components/ui/iconFont.tsx @@ -1,3 +1,4 @@ +'use client'; interface IconFontProps { /** 图标名称,对应 iconfont 中的图标 ID */ type: string; diff --git a/src/components/ui/modal/index.css b/src/components/ui/modal/index.css index 61dfe3a..0f17ffc 100644 --- a/src/components/ui/modal/index.css +++ b/src/components/ui/modal/index.css @@ -26,3 +26,9 @@ ); padding: 30px; } + +/* 移除关闭按钮的焦点轮廓 */ +.dialog-close-btn:focus, +.dialog-close-btn:focus-visible { + outline: none; +} diff --git a/src/components/ui/modal/index.tsx b/src/components/ui/modal/index.tsx index c318f44..a427dc7 100644 --- a/src/components/ui/modal/index.tsx +++ b/src/components/ui/modal/index.tsx @@ -13,10 +13,11 @@ type ModalProps = { trigger?: React.ReactNode; title?: string; classNames?: Partial<Record<'content' | 'overlay', string>>; + destroyOnClose?: boolean; }; export default function Modal(props: ModalProps) { - const { children, trigger, title, classNames } = props; + const { children, trigger, title, classNames, destroyOnClose = true } = props; const [open, setOpen] = useControllableValue(props, { defaultValue: false, defaultValuePropName: 'defaultOpen', @@ -24,6 +25,8 @@ export default function Modal(props: ModalProps) { trigger: 'onOpenChange', }); + const hiden = destroyOnClose && !open; + return ( <DialogPrimitive.Root open={open} onOpenChange={setOpen} modal> {trigger && ( @@ -35,25 +38,27 @@ export default function Modal(props: ModalProps) { {trigger} </DialogPrimitive.Trigger> )} - <DialogPrimitive.Portal> - <DialogPrimitive.Overlay className="dialog-overlay" /> - <DialogPrimitive.Content - className={cn('dialog-content w-125', classNames?.content)} - > - <div className="mb-7 flex justify-between"> - <DialogPrimitive.Title className="text-xl font-black"> - {title} - </DialogPrimitive.Title> - <DialogPrimitive.Close - onClick={() => setOpen(false)} - className="translate-x-2.5 -translate-y-2.5 cursor-pointer hover:opacity-80" - > - <IconFont type="icon-guanbi" size={30} /> - </DialogPrimitive.Close> - </div> - {children} - </DialogPrimitive.Content> - </DialogPrimitive.Portal> + {!hiden && ( + <DialogPrimitive.Portal> + <DialogPrimitive.Overlay className="dialog-overlay" /> + <DialogPrimitive.Content + className={cn('dialog-content w-125', classNames?.content)} + > + <div className="mb-7 flex justify-between"> + <DialogPrimitive.Title className="text-xl font-black"> + {title} + </DialogPrimitive.Title> + <DialogPrimitive.Close + onClick={() => setOpen(false)} + className="dialog-close-btn translate-x-2.5 -translate-y-2.5 cursor-pointer hover:opacity-80" + > + <IconFont type="icon-guanbi" size={30} /> + </DialogPrimitive.Close> + </div> + {children} + </DialogPrimitive.Content> + </DialogPrimitive.Portal> + )} </DialogPrimitive.Root> ); } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index efcd2bb..e3f1369 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -17,8 +17,19 @@ export const useMsg = (prefix: string) => { [msg, prefix] ); + const cmnMsg = useCallback( + ( + key: string, + values?: TranslationValues + ): ReturnType<TranslatorFunction> => { + return msg(`common_${key}`, values); + }, + [msg] + ); + return { pageMsg, + cmnMsg, msg, }; }; diff --git a/src/i18n/request.ts b/src/i18n/request.ts new file mode 100644 index 0000000..f3a41ef --- /dev/null +++ b/src/i18n/request.ts @@ -0,0 +1,12 @@ +import { cookies } from 'next/headers'; +import { getRequestConfig } from 'next-intl/server'; + +export default getRequestConfig(async () => { + const store = await cookies(); + const cookieLocale = store.get('locale')?.value || 'en'; + + return { + locale: cookieLocale, + messages: (await import(`@/locales/${cookieLocale}.ts`)).default, + }; +}); diff --git a/src/layouts/GlobalContainer/IntlProvider.tsx b/src/layouts/GlobalContainer/IntlProvider.tsx index c8f723e..3de8033 100644 --- a/src/layouts/GlobalContainer/IntlProvider.tsx +++ b/src/layouts/GlobalContainer/IntlProvider.tsx @@ -1,7 +1,15 @@ 'use client'; import { NextIntlClientProvider } from 'next-intl'; -import { createContext, useContext, useState, ReactNode } from 'react'; +import { + createContext, + useContext, + useState, + useEffect, + ReactNode, + useCallback, +} from 'react'; +import Cookies from 'js-cookie'; import zhMessages from '@/locales/zh'; import enMessages from '@/locales/en'; @@ -31,13 +39,39 @@ interface IntlProviderProps { children: ReactNode; } +function setLocaleToCookie(locale: Locale) { + if (typeof window === 'undefined') return; + Cookies.set('locale', locale, { expires: 365, path: '/' }); +} + +function getLocaleFromCookie(): Locale { + if (typeof window === 'undefined') return 'en'; + const cookieLocale = Cookies.get('locale') as Locale | undefined; + if (cookieLocale && (cookieLocale === 'zh' || cookieLocale === 'en')) { + return cookieLocale; + } + return 'en'; +} + export function IntlProvider({ children }: IntlProviderProps) { - const [locale, setLocale] = useState<Locale>('zh'); + const [locale, setLocaleState] = useState<Locale>('en'); + + useEffect(() => { + const cookieLocale = getLocaleFromCookie(); + if (cookieLocale) { + setLocaleState(cookieLocale); + } + }, []); + + const setLocale = useCallback((newLocale: Locale) => { + setLocaleState(newLocale); + setLocaleToCookie(newLocale); + }, []); return ( <LocaleContext.Provider value={{ locale, setLocale }}> <NextIntlClientProvider - locale={locale} + locale={locale as any} messages={messages[locale]} timeZone="Asia/Shanghai" > diff --git a/src/layouts/MainLayout/components/LocaleSelect.tsx b/src/layouts/MainLayout/components/LocaleSelect.tsx new file mode 100644 index 0000000..e17d1d5 --- /dev/null +++ b/src/layouts/MainLayout/components/LocaleSelect.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useLocale } from '@/layouts/GlobalContainer/IntlProvider'; +import { Select } from 'radix-ui'; +import React from 'react'; +import Image from 'next/image'; +import IconFont from '@/components/ui/iconFont'; + +const options = [ + { + icon: '/locale/cn.svg', + value: 'zh', + }, + { + icon: '/locale/en.svg', + value: 'en', + }, +]; + +const LocaleSelect = React.memo(() => { + const { locale, setLocale } = useLocale(); + const currentOption = options.find((option) => option.value === locale)!; + + return ( + <Select.Root value={locale} onValueChange={setLocale}> + <Select.Trigger className="outline-none focus:outline-none"> + <div className="flex h-9 w-14 cursor-pointer items-center justify-between rounded-full bg-white/10 px-2.5"> + <Image + src={currentOption?.icon} + alt={currentOption?.value} + width={20} + height={20} + /> + <span className="text-white/80"> + <IconFont className="rotate-90" type="icon-youjiantou" size={10} /> + </span> + </div> + </Select.Trigger> + <Select.Portal> + <Select.Content + position="popper" + side="bottom" + className="rounded-[10px] bg-neutral-900 p-1 outline-none" + align="start" + sideOffset={3} + style={{ + width: 'var(--radix-select-trigger-width)', + }} + > + {options.map((option) => ( + <Select.Item + key={option.value} + value={option.value} + className="outline-none focus:outline-none" + > + <div className="flex h-7 justify-center rounded-[10px] hover:bg-white/10"> + <Image + src={option.icon} + alt={option.value} + width={20} + height={20} + /> + </div> + </Select.Item> + ))} + </Select.Content> + </Select.Portal> + </Select.Root> + ); +}); + +export default LocaleSelect; diff --git a/src/layouts/MainLayout/components/NavRoutes.tsx b/src/layouts/MainLayout/components/NavRoutes.tsx new file mode 100644 index 0000000..b3116e7 --- /dev/null +++ b/src/layouts/MainLayout/components/NavRoutes.tsx @@ -0,0 +1,60 @@ +'use client'; + +import React from 'react'; +import { useMsg } from '@/hooks'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { cn } from '@/lib'; +import IconFont from '@/components/ui/iconFont'; + +const NavRoutes = React.memo(() => { + const { pageMsg } = useMsg('menu'); + const pathname = usePathname(); + + const routes = [ + { + path: '/novel', + icon: 'icon-novel', + label: pageMsg('novel'), + }, + { + path: '/video', + icon: 'icon-video', + label: pageMsg('video'), + }, + { + path: '/character', + icon: 'icon-character', + label: pageMsg('character'), + }, + { + path: '/record', + icon: 'icon-record', + label: pageMsg('record'), + }, + ]; + + return ( + <div className="flex items-center gap-7"> + {routes.map((route) => { + const isActive = pathname.startsWith(route.path); + return ( + <Link + className={cn( + 'flex items-center gap-2', + isActive + ? 'text-text-color' + : 'text-[#2C223F] hover:text-[#4F3F6D]' + )} + key={route.path} + href={route.path} + > + <IconFont size={34} type={route.icon} /> + {route.label} + </Link> + ); + })} + </div> + ); +}); +export default NavRoutes; diff --git a/src/layouts/MainLayout/header.tsx b/src/layouts/MainLayout/header.tsx index 47c0471..8712291 100644 --- a/src/layouts/MainLayout/header.tsx +++ b/src/layouts/MainLayout/header.tsx @@ -1,66 +1,15 @@ 'use client'; import React from 'react'; -import { useMsg } from '@/hooks'; -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { cn } from '@/lib'; -import IconFont from '@/components/ui/iconFont'; -import { Select } from '@/components/ui/inputs'; - -const NavRoutes = React.memo(() => { - const { pageMsg } = useMsg('menu'); - const pathname = usePathname(); - - const routes = [ - { - path: '/novel', - icon: 'icon-novel', - label: pageMsg('novel'), - }, - { - path: '/video', - icon: 'icon-video', - label: pageMsg('video'), - }, - { - path: '/character', - icon: 'icon-character', - label: pageMsg('character'), - }, - { - path: '/record', - icon: 'icon-record', - label: pageMsg('record'), - }, - ]; - - return ( - <div className="flex items-center gap-7"> - {routes.map((route) => { - const isActive = pathname.startsWith(route.path); - return ( - <Link - className={cn( - 'flex items-center gap-2', - isActive - ? 'text-text-color' - : 'text-[#2C223F] hover:text-[#4F3F6D]' - )} - key={route.path} - href={route.path} - > - <IconFont size={34} type={route.icon} /> - {route.label} - </Link> - ); - })} - </div> - ); -}); +import NavRoutes from './components/NavRoutes'; +import LocaleSelect from './components/LocaleSelect'; const RightActions = () => { - return <div>Avator</div>; + return ( + <div className="flex items-center gap-2"> + <LocaleSelect /> + </div> + ); }; export default function Header() { diff --git a/src/lib/request.ts b/src/lib/request.ts index 7a1ca2b..16c76a6 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -4,6 +4,7 @@ import type { InternalAxiosRequestConfig, } from 'axios'; import axios from 'axios'; +import Cookies from 'js-cookie'; import { getToken, saveAuthInfo } from './auth'; const instance = axios.create({ @@ -20,6 +21,17 @@ instance.interceptors.request.use( if (token) { config.headers.setAuthorization(`Bearer ${token}`); } + + // 从 cookie 中读取语言设置,并添加到请求头 + // 这样后端 API 可以从请求头中获取语言信息 + // 使用 X-Locale 避免与浏览器自动发送的 Accept-Language 冲突 + if (typeof window !== 'undefined') { + const locale = Cookies.get('locale'); + if (locale) { + config.headers.set('X-Locale', locale); + } + } + return config; } ); diff --git a/src/locales/en.ts b/src/locales/en.ts index 3adf95d..2448490 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -3,4 +3,6 @@ export default { menu_video: 'Video Comics', menu_character: 'Characters', menu_record: 'Record', + + common_desc: 'Description', }; diff --git a/src/locales/en/character.ts b/src/locales/en/character.ts new file mode 100644 index 0000000..ff8b4c5 --- /dev/null +++ b/src/locales/en/character.ts @@ -0,0 +1 @@ +export default {}; diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 354011e..9512a4b 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -3,4 +3,6 @@ export default { menu_video: '视频', menu_character: '角色', menu_record: '记录', + + common_desc: '描述', }; diff --git a/src/locales/zh/character.ts b/src/locales/zh/character.ts new file mode 100644 index 0000000..ff8b4c5 --- /dev/null +++ b/src/locales/zh/character.ts @@ -0,0 +1 @@ +export default {}; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..cc4e31c --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export function middleware(request: NextRequest) { + const localeCookie = request.cookies.get('locale'); + + if (localeCookie?.value) { + return NextResponse.next(); + } + + const response = NextResponse.next(); + response.cookies.set('locale', 'en', { + path: '/', + maxAge: 60 * 60 * 24 * 365, + sameSite: 'lax', + }); + + return response; +} + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], +};