feat: 优化了一些代码

This commit is contained in:
liuyonghe0111 2025-11-05 19:32:23 +08:00
parent b9cf8bc55f
commit 859ef2d320
47 changed files with 520 additions and 232 deletions

View File

@ -1,10 +1,17 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
import createNextIntlPlugin from 'next-intl/plugin';
// 指定 i18n 配置文件路径,让 next-intl 知道支持的语言
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: false, reactStrictMode: false,
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
images: {
// 这些域不走next的image代理
domains: ['example.com'],
},
async rewrites() { async rewrites() {
return [ return [
{ {
@ -15,4 +22,4 @@ const nextConfig: NextConfig = {
}, },
}; };
export default nextConfig; export default withNextIntl(nextConfig);

12
public/locale/cn.svg Normal file
View File

@ -0,0 +1,12 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_229_4076)">
<rect width="20" height="20" rx="10" fill="white"/>
<path d="M-1.66666 -0.416016H38.9583V26.6673H-1.66666V-0.416016Z" fill="#D8231D"/>
<path d="M14.8497 6.66423C14.9848 6.77821 15.1127 6.90012 15.2327 7.02928L15.613 7.41959L16.5875 6.95999C16.5183 7.12456 16.4386 7.28473 16.349 7.43957C16.2672 7.59678 16.1813 7.75131 16.0955 7.9125L16.8642 8.69316C16.8369 8.69476 16.8096 8.69476 16.7824 8.69316L15.8528 8.52265C15.7629 8.50665 15.7629 8.50933 15.7165 8.59059L15.2859 9.3899L15.2341 9.47384L15.2013 9.25801C15.1605 8.99157 15.1237 8.72513 15.0869 8.4587C15.076 8.39477 15.076 8.39341 15.0092 8.38012L14.0551 8.20959C14.0377 8.20655 14.021 8.20021 14.0061 8.19093C14.1655 8.10299 14.3291 8.03107 14.4899 7.95379L14.9833 7.72201L14.847 6.67091L14.8497 6.66423ZM11.5296 4.2983L11.5459 4.28497L12.5395 4.68463L13.251 3.86133V3.97321L13.1897 4.85646C13.1897 4.94441 13.1897 4.94441 13.2674 4.97636L14.1383 5.32007C14.1587 5.32007 14.1751 5.33605 14.2105 5.35072L14.1287 5.37869L13.2087 5.5985C13.1311 5.61448 13.1311 5.61448 13.1242 5.69441L13.0588 6.65759L13.0506 6.70554L12.9948 6.62161C12.8312 6.36584 12.6677 6.10871 12.5 5.85295C12.455 5.781 12.4523 5.781 12.3637 5.80367L11.4533 6.0208L11.3756 6.03544C11.6168 5.7517 11.8499 5.48394 12.087 5.20816L11.5269 4.30761L11.5296 4.2983ZM9.83955 8.04705L7.68474 9.6017L7.63158 9.64036C7.57025 9.68699 7.57025 9.68699 7.5948 9.76158L8.18358 11.5201L8.45616 12.338L8.47661 12.4033H8.46026L8.41256 12.3713L6.12282 10.7328L6.10237 10.7208H6.08329L3.73085 12.4113L3.71448 12.3993L4.60857 9.667L2.25342 7.99243V7.97245H5.05698C5.17965 7.97245 5.1592 7.9871 5.19327 7.87253L6.05193 5.36935C6.06107 5.33833 6.07635 5.30937 6.09692 5.28411L6.98964 7.97112C7.15045 7.97911 7.31265 7.97112 7.47213 7.97112H9.90634V7.98845L9.83955 8.04705ZM14.1315 14.6534L13.2196 14.8692L13.1338 14.8932L13.0588 15.9776L13.0057 15.9043L12.5068 15.129C12.4591 15.0557 12.4578 15.0557 12.3705 15.0757L11.4615 15.2955L11.3797 15.3088L12.0925 14.4802L11.5323 13.5809L11.5432 13.5676C11.7122 13.6236 11.8744 13.7008 12.0407 13.7608L12.5436 13.9606L13.2415 13.1506H13.2551V13.2412L13.1937 14.1218V14.1538C13.1913 14.1627 13.1908 14.1719 13.1922 14.181C13.1936 14.19 13.1969 14.1987 13.2019 14.2064C13.2069 14.2142 13.2135 14.2208 13.2212 14.226C13.229 14.2312 13.2377 14.2347 13.2469 14.2364L13.6231 14.3843L14.1465 14.5894L14.2187 14.6187L14.1328 14.6467L14.1315 14.6534ZM16.7715 11.4668L16.0519 12.0143C15.981 12.0676 15.981 12.0676 16.011 12.1475L16.3217 12.9961C16.3322 13.0228 16.3354 13.0517 16.3313 13.0801L15.4317 12.4713L14.5676 13.1267L14.554 13.116L14.8579 12.0889L13.9666 11.4894V11.4694C14.3236 11.4521 14.6821 11.4561 15.0501 11.4361L15.3649 10.4263H15.3786L15.4113 10.5063L15.7179 11.3482C15.7452 11.4241 15.7452 11.4241 15.827 11.4215L16.7388 11.3948H16.8246V11.4108L16.7715 11.4668Z" fill="#FFFA30"/>
</g>
<defs>
<clipPath id="clip0_229_4076">
<rect width="20" height="20" rx="10" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

13
public/locale/en.svg Normal file
View File

@ -0,0 +1,13 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_229_4071)">
<path d="M0 10C0 12.6522 1.05357 15.1957 2.92893 17.0711C4.8043 18.9464 7.34784 20 10 20C12.6522 20 15.1957 18.9464 17.0711 17.0711C18.9464 15.1957 20 12.6522 20 10C20 7.34784 18.9464 4.8043 17.0711 2.92893C15.1957 1.05357 12.6522 0 10 0C7.34784 0 4.8043 1.05357 2.92893 2.92893C1.05357 4.8043 0 7.34784 0 10Z" fill="#F0F0F0"/>
<path d="M2.06733 3.91301C1.28182 4.935 0.689518 6.11293 0.344635 7.39258H5.5469L2.06733 3.91301ZM19.6556 7.39258C19.3108 6.11297 18.7184 4.93504 17.933 3.91305L14.4535 7.39258H19.6556ZM0.344635 12.61C0.689557 13.8896 1.28186 15.0675 2.06733 16.0895L5.54678 12.61H0.344635ZM16.0883 2.06844C15.0664 1.28293 13.8885 0.690625 12.6088 0.345703V5.54793L16.0883 2.06844ZM3.91194 17.934C4.93393 18.7195 6.11186 19.3118 7.39147 19.6567V14.4545L3.91194 17.934ZM7.39143 0.345703C6.11182 0.690625 4.93389 1.28293 3.91194 2.0684L7.39143 5.54789V0.345703ZM12.6089 19.6567C13.8885 19.3118 15.0664 18.7195 16.0883 17.934L12.6089 14.4545V19.6567ZM14.4535 12.61L17.933 16.0895C18.7184 15.0675 19.3108 13.8896 19.6556 12.61H14.4535Z" fill="#0052B4"/>
<path d="M19.9154 8.69566H11.3044V0.0846484C10.8719 0.0282971 10.4362 2.17268e-05 10 0C9.56385 1.78888e-05 9.12816 0.0282933 8.69566 0.0846484V8.69563H0.0846484C0.0282971 9.12813 2.17268e-05 9.56384 0 10C0 10.4421 0.0290625 10.8774 0.0846484 11.3043H8.69563V19.9154C9.56153 20.0282 10.4384 20.0282 11.3043 19.9154V11.3044H19.9154C19.9717 10.8719 20 10.4362 20 10C20 9.55793 19.9709 9.12262 19.9154 8.69566Z" fill="#D80027"/>
<path d="M12.6085 12.6074L17.0708 17.0698C17.276 16.8644 17.4721 16.6502 17.6586 16.4278L13.8383 12.6074H12.6085V12.6074ZM7.39107 12.6074H7.39099L2.92869 17.0697C3.13407 17.2749 3.34827 17.471 3.57068 17.6575L7.39107 13.8371V12.6074ZM7.39107 7.39004V7.38996L2.92872 2.92758C2.72358 3.13296 2.52746 3.34716 2.34091 3.56957L6.16134 7.39H7.39107V7.39004ZM12.6085 7.39004L17.0709 2.92762C16.8655 2.72248 16.6513 2.52637 16.4289 2.33984L12.6085 6.16027V7.39004Z" fill="#D80027"/>
</g>
<defs>
<clipPath id="clip0_229_4071">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -4,6 +4,7 @@ import { RightArrowIcon } from '@/assets/chatacter';
import IconFont from '@/components/ui/iconFont'; import IconFont from '@/components/ui/iconFont';
import ChatButton from './ChatButton'; import ChatButton from './ChatButton';
import Tags from '@/components/ui/Tags'; import Tags from '@/components/ui/Tags';
import { useTranslations } from 'next-intl';
type CharacterBasicInfoProps = { type CharacterBasicInfoProps = {
characterId: string; characterId: string;
@ -14,6 +15,8 @@ export default function CharacterBasicInfo({
characterId, characterId,
characterDetail, characterDetail,
}: CharacterBasicInfoProps) { }: CharacterBasicInfoProps) {
const msg = useTranslations();
if (!characterDetail) { if (!characterDetail) {
return null; return null;
} }
@ -41,6 +44,7 @@ export default function CharacterBasicInfo({
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<h1 className="text-text-color text-4xl font-bold"> <h1 className="text-text-color text-4xl font-bold">
{characterName} {characterName}
{msg('common_desc')}
</h1> </h1>
<div> <div>
<IconFont type="icon-jubao" size={20} /> <IconFont type="icon-jubao" size={20} />
@ -114,7 +118,7 @@ export default function CharacterBasicInfo({
width={18} width={18}
height={20} height={20}
/> />
Description: {'Description'}:
</h2> </h2>
<p className="text-text-color/60 mt-5 text-sm"> <p className="text-text-color/60 mt-5 text-sm">
{characterDetail.description} {characterDetail.description}

View File

@ -8,6 +8,7 @@ import { publicServerRequest } from '@/lib/server-request';
*/ */
export const fetchCharacterDetail = cache(async (id: string) => { export const fetchCharacterDetail = cache(async (id: string) => {
return {};
const { data } = await publicServerRequest( const { data } = await publicServerRequest(
`/character/select/roleInfo/${id}`, `/character/select/roleInfo/${id}`,
{ {

View File

@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { historyListOpenAtom } from '../atoms'; 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';

View File

@ -1,10 +1,9 @@
'use client'; 'use client';
import { useAtom, useSetAtom } from 'jotai'; import { useAtom, useSetAtom } from 'jotai';
import { historyListOpenAtom, leftTabActiveKeyAtom } from '../atoms'; import { historyListOpenAtom, leftTabActiveKeyAtom } from '../../atoms';
import { cn } from '@/lib'; import { cn } from '@/lib';
import Image from 'next/image'; import Image from 'next/image';
import { CharaterHistoryIcon } from '@/assets/chatacter';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Icon } from '@/components'; import { Icon } from '@/components';
import IconFont from '@/components/ui/iconFont'; import IconFont from '@/components/ui/iconFont';
@ -19,22 +18,27 @@ export default function Side() {
const tabs = [ const tabs = [
{ {
element: ( render: (isActive: boolean) => (
<div className="border-0.25 h-10 w-10 hover:border-amber-500"> <div
<Image className={cn(
src="/component/star_full.svg" 'h-10 w-10 overflow-hidden rounded-full border-2',
alt="info" isActive ? 'border-blue-500' : 'border-white/80'
width={38} )}
height={38} >
/> <Image src="/avator.png" alt="info" width={40} height={40} />
</div> </div>
), ),
key: 'info', key: 'info',
}, },
{ {
element: ( render: (isActive: boolean) => (
<div className=""> <div
<CharaterHistoryIcon /> className={cn(
'h-10 w-10',
isActive ? 'text-white' : 'text-white/60 hover:text-white/80'
)}
>
<IconFont type="icon-jiaoselishi" size={40} />
</div> </div>
), ),
key: 'history', key: 'history',
@ -78,13 +82,13 @@ export default function Side() {
return ( return (
<div <div
className={cn( className={cn(
'flex h-10 justify-end border-r-[2px] pr-4.5 hover:cursor-pointer', 'flex h-10 cursor-pointer justify-end border-r-[2px] pr-4.5',
isActive ? 'border-blue-500' : 'border-transparent' isActive ? 'border-blue-500' : 'border-transparent'
)} )}
onClick={() => setActiveKey(item.key as any)} onClick={() => setActiveKey(item.key as any)}
key={item.key} key={item.key}
> >
{item.element} {item.render(isActive)}
</div> </div>
); );
})} })}

View File

@ -2,7 +2,7 @@
import Side from './Side'; import Side from './Side';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { historyListOpenAtom } from '../atoms'; 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';

View File

@ -57,13 +57,13 @@ const SettingForm = React.memo(() => {
}} }}
> >
<Title text="Switch Model"> <Title text="Switch Model">
<FormItem<SettingFormValues> <FormItem
name="model" name="model"
render={({ value, onChange }) => ( render={({ value, onChange }) => (
<ModelSelectDialog value={value} onChange={onChange} /> <ModelSelectDialog value={value} onChange={onChange} />
)} )}
/> />
<FormItem<SettingFormValues> <FormItem
name="short_text" name="short_text"
render={({ value, onChange }) => ( render={({ value, onChange }) => (
<Switch <Switch
@ -77,13 +77,13 @@ const SettingForm = React.memo(() => {
</Title> </Title>
<Title text="Sound"> <Title text="Sound">
<FormItem<SettingFormValues> <FormItem
name="voiceActor" name="voiceActor"
render={({ value, onChange }) => ( render={({ value, onChange }) => (
<VoiceActorSelectDialog value={value} onChange={onChange} /> <VoiceActorSelectDialog value={value} onChange={onChange} />
)} )}
/> />
<FormItem<SettingFormValues> <FormItem
name="dialogueOnly" name="dialogueOnly"
render={({ value, onChange }) => ( render={({ value, onChange }) => (
<Switch <Switch
@ -97,7 +97,7 @@ const SettingForm = React.memo(() => {
</Title> </Title>
<Title text="Maximum number of response tokens"> <Title text="Maximum number of response tokens">
<FormItem<SettingFormValues> <FormItem
name="max_tokens" name="max_tokens"
render={({ value, onChange }) => ( render={({ value, onChange }) => (
<Number value={value} onChange={onChange} /> <Number value={value} onChange={onChange} />
@ -106,13 +106,13 @@ const SettingForm = React.memo(() => {
</Title> </Title>
<Title text="Appearance"> <Title text="Appearance">
<FormItem<SettingFormValues> <FormItem
name="fontSize" name="fontSize"
render={({ value, onChange }) => { render={({ value, onChange }) => {
return <FontSize value={value} onChange={onChange} />; return <FontSize value={value} onChange={onChange} />;
}} }}
/> />
<FormItem<SettingFormValues> <FormItem
name="chat_mode" name="chat_mode"
render={({ value, onChange }) => ( render={({ value, onChange }) => (
<Select <Select
@ -134,7 +134,7 @@ const SettingForm = React.memo(() => {
</Title> </Title>
<Title text="Background"> <Title text="Background">
<FormItem<SettingFormValues> <FormItem
name="background" name="background"
render={({ value, onChange }) => ( render={({ value, onChange }) => (
<Background value={value} onChange={onChange} /> <Background value={value} onChange={onChange} />

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -27,8 +27,8 @@ export default function Input() {
}, [value]); }, [value]);
return ( 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="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-text-color/20')}> <div className={cn(className, 'hover:bg-white/20')}>
<VoiceIcon /> <VoiceIcon />
</div> </div>
<textarea <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" 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} 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" /> <Image src="/component/send.svg" width={20} height={20} alt="send" />
</div> </div>
</div> </div>

View File

@ -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>;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1,5 @@
'use client';
export default function PhoneCallMode() {
return <div>PhoneCallMode</div>;
}

View File

@ -6,6 +6,9 @@ export const settingOpenAtom = atom(true);
// 是否是立绘模式 // 是否是立绘模式
export const isPortraitModeAtom = atom(false); export const isPortraitModeAtom = atom(false);
// 是否是通话模式
export const isPhoneCallModeAtom = atom(false);
// 左侧 tab active key // 左侧 tab active key
export const leftTabActiveKeyAtom = atom<'info' | 'history'>('info'); export const leftTabActiveKeyAtom = atom<'info' | 'history'>('info');

View File

@ -1,19 +1,17 @@
'use client'; 'use client';
import { cn } from '@/lib'; import { useAtomValue } from 'jotai';
import SettingForm from './Right'; import { isPhoneCallModeAtom } from './atoms';
import { useAtom } from 'jotai'; import ChatMode from './ChatMode';
import { settingOpenAtom } from './atoms';
import Main from './Main';
import Left from './Left';
import { Drawer } from '@/components';
import './index.css'; import './index.css';
import { ExitFullScreenIcon, FullScreenIcon } from '@/assets/common'; import PhoneCallMode from './PhoneCallMode';
import ChatContextProvider from './Context';
export default function CharacterChat() { export default function CharacterChat() {
const [settingOpen, setSettingOpen] = useAtom(settingOpenAtom); const isPhoneCallMode = useAtomValue(isPhoneCallModeAtom);
return ( return (
<ChatContextProvider>
<div <div
style={{ style={{
background: background:
@ -21,27 +19,8 @@ export default function CharacterChat() {
}} }}
className="relative flex h-full w-full justify-center overflow-hidden" className="relative flex h-full w-full justify-center overflow-hidden"
> >
<Main /> {isPhoneCallMode ? <PhoneCallMode /> : <ChatMode />}
{/* 左侧 */}
<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>
</div> </div>
</ChatContextProvider>
); );
} }

View File

@ -63,14 +63,12 @@ export default function Novel() {
queryKey: ['tags'], queryKey: ['tags'],
queryFn: async () => { queryFn: async () => {
const res = await fetchTags(); const res = await fetchTags();
return res.rows; return res.rows?.map((tag: any) => ({
},
});
const options = tags?.map((tag: any) => ({
label: tag.name, label: tag.name,
value: tag.id, value: tag.id,
})); }));
},
});
return ( return (
<VirtualGrid <VirtualGrid
@ -83,7 +81,7 @@ export default function Novel() {
loadMore={onLoadMore} loadMore={onLoadMore}
header={ header={
<TagSelect <TagSelect
options={options} options={tags}
render={(item) => `# ${item.label}`} render={(item) => `# ${item.label}`}
onChange={(v) => { onChange={(v) => {
onSearch({ tagId: v }); onSearch({ tagId: v });

View File

@ -30,7 +30,7 @@ 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_5o7sf0csg4w.js" src="//at.alicdn.com/t/c/font_5054282_ij9uesv751.js"
strategy="afterInteractive" strategy="afterInteractive"
async async
/> />

View File

@ -235,27 +235,3 @@ export const ScrollTobottom = () => {
</svg> </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>
);
};

View File

@ -16,7 +16,6 @@ export default function ModelSelectDialog(props: ModelSelectDialogProps) {
defaultValuePropName: 'defaultValue', defaultValuePropName: 'defaultValue',
trigger: 'onChange', trigger: 'onChange',
}); });
console.log('ModelSelectDialog', value);
const options = [ const options = [
{ label: 'Model 1', value: 'model1' }, { label: 'Model 1', value: 'model1' },

View File

@ -12,7 +12,6 @@ export default function VoiceActorSelectDialog(
props: VoiceActorSelectDialogProps props: VoiceActorSelectDialogProps
) { ) {
const { value, onChange } = props; const { value, onChange } = props;
console.log('VoiceActorSelectDialog', value);
const options = [ const options = [
{ label: 'Voice Actor 1', value: 'voiceActor1', gender: 'male' }, { label: 'Voice Actor 1', value: 'voiceActor1', gender: 'male' },
{ label: 'Voice Actor 2', value: 'voiceActor2', gender: 'female' }, { label: 'Voice Actor 2', value: 'voiceActor2', gender: 'female' },

View File

@ -1,3 +1,4 @@
'use client';
import React from 'react'; import React from 'react';
import { cn } from '@/lib'; import { cn } from '@/lib';

View File

@ -1,3 +1,4 @@
'use client';
interface IconFontProps { interface IconFontProps {
/** 图标名称,对应 iconfont 中的图标 ID */ /** 图标名称,对应 iconfont 中的图标 ID */
type: string; type: string;

View File

@ -26,3 +26,9 @@
); );
padding: 30px; padding: 30px;
} }
/* 移除关闭按钮的焦点轮廓 */
.dialog-close-btn:focus,
.dialog-close-btn:focus-visible {
outline: none;
}

View File

@ -13,10 +13,11 @@ type ModalProps = {
trigger?: React.ReactNode; trigger?: React.ReactNode;
title?: string; title?: string;
classNames?: Partial<Record<'content' | 'overlay', string>>; classNames?: Partial<Record<'content' | 'overlay', string>>;
destroyOnClose?: boolean;
}; };
export default function Modal(props: ModalProps) { 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, { const [open, setOpen] = useControllableValue(props, {
defaultValue: false, defaultValue: false,
defaultValuePropName: 'defaultOpen', defaultValuePropName: 'defaultOpen',
@ -24,6 +25,8 @@ export default function Modal(props: ModalProps) {
trigger: 'onOpenChange', trigger: 'onOpenChange',
}); });
const hiden = destroyOnClose && !open;
return ( return (
<DialogPrimitive.Root open={open} onOpenChange={setOpen} modal> <DialogPrimitive.Root open={open} onOpenChange={setOpen} modal>
{trigger && ( {trigger && (
@ -35,6 +38,7 @@ export default function Modal(props: ModalProps) {
{trigger} {trigger}
</DialogPrimitive.Trigger> </DialogPrimitive.Trigger>
)} )}
{!hiden && (
<DialogPrimitive.Portal> <DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="dialog-overlay" /> <DialogPrimitive.Overlay className="dialog-overlay" />
<DialogPrimitive.Content <DialogPrimitive.Content
@ -46,7 +50,7 @@ export default function Modal(props: ModalProps) {
</DialogPrimitive.Title> </DialogPrimitive.Title>
<DialogPrimitive.Close <DialogPrimitive.Close
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="translate-x-2.5 -translate-y-2.5 cursor-pointer hover:opacity-80" className="dialog-close-btn translate-x-2.5 -translate-y-2.5 cursor-pointer hover:opacity-80"
> >
<IconFont type="icon-guanbi" size={30} /> <IconFont type="icon-guanbi" size={30} />
</DialogPrimitive.Close> </DialogPrimitive.Close>
@ -54,6 +58,7 @@ export default function Modal(props: ModalProps) {
{children} {children}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPrimitive.Portal> </DialogPrimitive.Portal>
)}
</DialogPrimitive.Root> </DialogPrimitive.Root>
); );
} }

View File

@ -17,8 +17,19 @@ export const useMsg = (prefix: string) => {
[msg, prefix] [msg, prefix]
); );
const cmnMsg = useCallback(
(
key: string,
values?: TranslationValues
): ReturnType<TranslatorFunction> => {
return msg(`common_${key}`, values);
},
[msg]
);
return { return {
pageMsg, pageMsg,
cmnMsg,
msg, msg,
}; };
}; };

12
src/i18n/request.ts Normal file
View File

@ -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,
};
});

View File

@ -1,7 +1,15 @@
'use client'; 'use client';
import { NextIntlClientProvider } from 'next-intl'; 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 zhMessages from '@/locales/zh';
import enMessages from '@/locales/en'; import enMessages from '@/locales/en';
@ -31,13 +39,39 @@ interface IntlProviderProps {
children: ReactNode; 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) { 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 ( return (
<LocaleContext.Provider value={{ locale, setLocale }}> <LocaleContext.Provider value={{ locale, setLocale }}>
<NextIntlClientProvider <NextIntlClientProvider
locale={locale} locale={locale as any}
messages={messages[locale]} messages={messages[locale]}
timeZone="Asia/Shanghai" timeZone="Asia/Shanghai"
> >

View File

@ -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;

View File

@ -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;

View File

@ -1,66 +1,15 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { useMsg } from '@/hooks'; import NavRoutes from './components/NavRoutes';
import Link from 'next/link'; import LocaleSelect from './components/LocaleSelect';
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>
);
});
const RightActions = () => { const RightActions = () => {
return <div>Avator</div>; return (
<div className="flex items-center gap-2">
<LocaleSelect />
</div>
);
}; };
export default function Header() { export default function Header() {

View File

@ -4,6 +4,7 @@ import type {
InternalAxiosRequestConfig, InternalAxiosRequestConfig,
} from 'axios'; } from 'axios';
import axios from 'axios'; import axios from 'axios';
import Cookies from 'js-cookie';
import { getToken, saveAuthInfo } from './auth'; import { getToken, saveAuthInfo } from './auth';
const instance = axios.create({ const instance = axios.create({
@ -20,6 +21,17 @@ instance.interceptors.request.use(
if (token) { if (token) {
config.headers.setAuthorization(`Bearer ${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; return config;
} }
); );

View File

@ -3,4 +3,6 @@ export default {
menu_video: 'Video Comics', menu_video: 'Video Comics',
menu_character: 'Characters', menu_character: 'Characters',
menu_record: 'Record', menu_record: 'Record',
common_desc: 'Description',
}; };

View File

@ -0,0 +1 @@
export default {};

View File

@ -3,4 +3,6 @@ export default {
menu_video: '视频', menu_video: '视频',
menu_character: '角色', menu_character: '角色',
menu_record: '记录', menu_record: '记录',
common_desc: '描述',
}; };

View File

@ -0,0 +1 @@
export default {};

22
src/middleware.ts Normal file
View File

@ -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).*)'],
};