feat: 简体中文切换为繁体;优化角色卡;

This commit is contained in:
liuyonghe0111 2025-12-25 16:22:49 +08:00
parent 8b933d26d6
commit 5cb01064ef
17 changed files with 439 additions and 285 deletions

View File

@ -77,7 +77,7 @@ export default function SettingDialog({ open, onOpenChange }: SettingProps) {
)} )}
{activeTab !== 'profile' && ( {activeTab !== 'profile' && (
<IconButton variant="tertiary" size="small" onClick={() => setActiveTab('profile')}> <IconButton variant="tertiary" size="small" onClick={() => setActiveTab('profile')}>
<IconFont size={24} className="text-white cursor-pointer" type="icon-jiantou" /> <IconFont className="text-white cursor-pointer text-2xl" type="icon-jiantou" />
</IconButton> </IconButton>
)} )}
</AlertDialogTitle> </AlertDialogTitle>

View File

@ -92,7 +92,7 @@ export default function Input() {
<div className="flex w-full items-end gap-4"> <div className="flex w-full items-end gap-4">
{/* 打电话按钮 */} {/* 打电话按钮 */}
<IconButton onClick={() => {}}> <IconButton onClick={() => {}}>
<IconFont type="icon-dianhua" size={30} /> <IconFont type="icon-dianhua" className="text-3xl" />
</IconButton> </IconButton>
<div className="flex-1 flex items-end gap-2 min-h-12 py-2 px-2 bg-white/15 rounded-3xl"> <div className="flex-1 flex items-end gap-2 min-h-12 py-2 px-2 bg-white/15 rounded-3xl">
{/* 语音录制按钮 */} {/* 语音录制按钮 */}

View File

@ -39,7 +39,7 @@ export default function ChatPage() {
variant="ghost" variant="ghost"
size="small" size="small"
> >
<IconFont size={24} className="text-white cursor-pointer" type="icon-zhankai-1" /> <IconFont className="text-white text-2xl cursor-pointer" type="icon-zhankai-1" />
</IconButton> </IconButton>
</div> </div>
<SettingDialog open={settingOpen} onOpenChange={setSettingOpen} /> <SettingDialog open={settingOpen} onOpenChange={setSettingOpen} />

View File

@ -11,12 +11,11 @@ import { TagListSelect } from './TagListSelect';
import { MoreFilter } from './MoreFilter'; import { MoreFilter } from './MoreFilter';
const Filter = () => { const Filter = () => {
const tab = useHomeStore((state) => state.tab);
const setTab = useHomeStore((state) => state.setTab);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const characterParams = useHomeStore((state) => state.characterParams); const { tab, setTab, characterParams, novelParams, setCharacterParams, setNovelParams } =
const setCharacterParams = useHomeStore((state) => state.setCharacterParams); useHomeStore();
const t = useTranslations('home'); const t = useTranslations('home');
const currentTabTagIds = tab === 'character' ? characterParams.tagIds : novelParams.tagIds;
const { data: tags = [] } = useQuery({ const { data: tags = [] } = useQuery({
queryKey: ['tags', tab], queryKey: ['tags', tab],
@ -71,12 +70,16 @@ const Filter = () => {
); );
})} })}
</div> </div>
<MoreFilter /> {tab === 'character' && <MoreFilter />}
</div> </div>
<TagListSelect <TagListSelect
options={tags.map((tag: any) => ({ label: tag.name, value: tag.id }))} options={tags.map((tag: any) => ({ label: tag.name, value: tag.id }))}
value={characterParams.tagIds || []} value={currentTabTagIds || []}
onChange={(values) => setCharacterParams({ tagIds: values as string[] })} onChange={(values) =>
tab === 'character'
? setCharacterParams({ tagIds: values as string[] })
: setNovelParams({ tagIds: values as string[] })
}
/> />
</div> </div>
); );

View File

@ -1,7 +1,41 @@
'use client'; 'use client';
import { InfiniteScrollList } from '@/components/ui/infinite-scroll-list';
import AIStandardCard from '@/components/features/ai-standard-card';
import useSmartInfiniteQuery from '../../useSmartInfiniteQuery';
import { fetchCharacters } from '@/services/editor';
import { useHomeStore } from '../../store';
import { useEffect } from 'react';
import StoryContent from '@/components/features/StoryContent';
const Story = () => { const Story = () => {
return <div>ing</div>; const characterParams = useHomeStore((state) => state.characterParams);
// const { dataSource, isFirstLoading, isLoadingMore, noMoreData, onLoadMore, onSearch } =
// useSmartInfiniteQuery<any, any>(fetchCharacters, {
// queryKey: 'characters',
// defaultQuery: characterParams,
// });
// useEffect(() => {
// onSearch(characterParams);
// }, [characterParams]);
return (
<div className="mt-4 sm:mt-8">
<StoryContent />
{/* <InfiniteScrollList<any>
items={dataSource}
enableLazyRender
lazyRenderMargin="500px"
columns={1}
renderItem={(character) => <AIStandardCard character={character} />}
getItemKey={(character, index) => character.id + index}
hasNextPage={!noMoreData}
isLoading={isFirstLoading || isLoadingMore}
fetchNextPage={onLoadMore}
/> */}
</div>
);
}; };
export default Story; export default Story;

View File

@ -14,8 +14,8 @@ const HomePage = () => {
return ( return (
<> <>
<div className="h-full text-txt-primary-normal relative px-4 sm:px-16"> <div className="h-full text-txt-primary-normal relative px-4 sm:px-16 min-w-0">
<div className="mx-auto max-w-[1136px] pb-32"> <div className="mx-auto max-w-[1136px] pb-32 w-full">
<Header /> <Header />
<Filter /> <Filter />
{tab === 'story' ? <Story /> : <Character />} {tab === 'story' ? <Story /> : <Character />}

View File

@ -5,11 +5,17 @@ type CharacterParams = {
tagIds?: string[]; tagIds?: string[];
}; };
type NovelParams = {
tagIds?: string[];
};
interface HomeStore { interface HomeStore {
tab: 'story' | 'character'; tab: 'story' | 'character';
characterParams: CharacterParams; characterParams: CharacterParams;
novelParams: NovelParams;
setTab: (tab: 'story' | 'character') => void; setTab: (tab: 'story' | 'character') => void;
setCharacterParams: (params: Partial<CharacterParams>) => void; setCharacterParams: (params: Partial<CharacterParams>) => void;
setNovelParams: (params: Partial<NovelParams>) => void;
} }
export const useHomeStore = create<HomeStore>((set, get) => ({ export const useHomeStore = create<HomeStore>((set, get) => ({
@ -18,9 +24,16 @@ export const useHomeStore = create<HomeStore>((set, get) => ({
genders: [], genders: [],
tagIds: [], tagIds: [],
}, },
novelParams: {
tagIds: [],
},
setTab: (tab: 'story' | 'character') => set({ tab }), setTab: (tab: 'story' | 'character') => set({ tab }),
setCharacterParams: (params) => { setCharacterParams: (params) => {
const { characterParams } = get(); const { characterParams } = get();
set({ characterParams: { ...characterParams, ...params } }); set({ characterParams: { ...characterParams, ...params } });
}, },
setNovelParams: (params) => {
const { novelParams } = get();
set({ novelParams: { ...novelParams, ...params } });
},
})); }));

View File

@ -27,8 +27,7 @@ export default function MaskPage() {
response?.isPC && ( response?.isPC && (
<IconFont <IconFont
onClick={() => router.push('/profile/mask/new')} onClick={() => router.push('/profile/mask/new')}
size={32} className="text-white cursor-pointer text-3xl"
className="text-white cursor-pointer"
type="icon-tianjia" type="icon-tianjia"
/> />
) )

View File

@ -0,0 +1,94 @@
'use client';
import IconFont from '@/components/ui/iconFont';
import { cn } from '@/lib/utils';
import React, { useCallback, useEffect, useRef } from 'react';
type ScrollBoxProps = {
children: React.ReactNode;
contentClassName?: string; // 内容容器的样式类名
} & React.HTMLAttributes<HTMLDivElement>;
export default function ScrollBox(props: ScrollBoxProps) {
const { children, contentClassName, ...rest } = props;
const containerRef = useRef<HTMLDivElement>(null);
const leftRef = useRef<HTMLSpanElement>(null);
const rightRef = useRef<HTMLSpanElement>(null);
const handleScroll = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const left = leftRef.current;
const right = rightRef.current;
if (!left || !right) return;
if (container.scrollLeft <= 0) {
left.style.display = 'none';
} else {
left.style.display = 'flex';
}
// 添加 1 像素的容差来处理浮点数精度问题
const isAtEnd = container.scrollLeft + container.clientWidth >= container.scrollWidth - 1;
if (isAtEnd) {
right.style.display = 'none';
} else {
right.style.display = 'flex';
}
}, []);
useEffect(() => {
handleScroll();
}, [handleScroll]);
const scrollBy = (type: 'left' | 'right') => {
const container = containerRef.current;
if (!container) return;
container.scrollBy({
left: type === 'left' ? -container.clientWidth * 0.8 : container.clientWidth * 0.8,
behavior: 'smooth',
});
};
const iconClass =
'rounded-full absolute h-8 w-8 sm:h-12 sm:w-12 cursor-pointer items-center justify-center z-10';
const iconStyle: React.CSSProperties = {
background: 'var(--sys-color-surface-element-normal, rgba(251, 222, 255, 0.08))',
backdropFilter: 'blur(var(--sys-visualstyle-blur-s, 16px))',
top: '50%',
transform: 'translateY(-50%)',
};
return (
<div className={cn('w-full max-w-full min-w-0 relative', rest.className)}>
<div
ref={containerRef}
onScroll={handleScroll}
className={cn(
'w-full max-w-full min-w-0 overflow-x-auto overflow-y-hidden',
contentClassName
)}
>
{children}
</div>
{/* 左箭头 */}
<span
ref={leftRef}
className={cn(iconClass, '-left-4 sm:-left-6')}
style={iconStyle}
onClick={() => scrollBy('left')}
>
<IconFont type="icon-a-Frame194" className="sm:text-2xl" />
</span>
{/* 右箭头 */}
<span
ref={rightRef}
className={cn(iconClass, '-right-4 sm:-right-6')}
style={iconStyle}
onClick={() => scrollBy('right')}
>
<IconFont type="icon-a-Frame195" className="sm:text-2xl" />
</span>
</div>
);
}

View File

@ -0,0 +1,44 @@
'use client';
import IconFont from '@/components/ui/iconFont';
import ScrollBox from './ScrollBox';
import { useState } from 'react';
interface StoryContentProps {
story?: any;
search?: string;
}
export default function StoryContent(props: StoryContentProps) {
const { story } = props;
const [open, setOpen] = useState<boolean>(false);
const cList = [123, 123, 123, 123, 123, 123, 123, 123];
return (
<div className="w-full max-w-full min-w-0">
<div className="flex w-full min-w-0 items-center justify-between gap-2 mb-3">
<span className="txt-title-m line-clamp-1 min-w-0 text-[rgba(208,232,255,1)]">
The Bossy CEO's Contract Lover
</span>
<div
onClick={() => setOpen(true)}
className="cursor-pointer flex items-center gap-1 shrink-0"
>
<span>More</span>
<IconFont type="icon-jiantou" />
</div>
</div>
<ScrollBox contentClassName="inline-flex w-max gap-3">
{cList.map((item, index) => (
<div
className="flex-shrink-0 w-40 h-55 bg-gray-800/40 rounded-lg flex items-center justify-center"
key={`Item-${index}`}
>
{item}
</div>
))}
</ScrollBox>
</div>
);
}

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useRef, useEffect, useState } from 'react'; import React, { useMemo } from 'react';
import { formatNumberToKMB } from '@/lib/utils'; import { formatNumberToKMB } from '@/lib/utils';
import { Tag } from '@/components/ui/tag'; import { Tag } from '@/components/ui/tag';
import { Avatar, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarImage } from '@/components/ui/avatar';
@ -12,59 +12,55 @@ interface AIStandardCardProps {
disableHover?: boolean; disableHover?: boolean;
} }
// 常量提取
const MAX_DESCRIPTION_LINES = 7;
const MAX_VISIBLE_TAGS = 2;
// 提取点赞数组件
const LikeCount: React.FC<{ count: number }> = ({ count }) => (
<div className="flex items-center gap-1 rounded-xs px-1 py-0.5">
<i className="iconfont icon-Like-fill" />
<span className="txt-label-s text-txt-primary-specialmap-normal">
{formatNumberToKMB(count)}
</span>
</div>
);
// 提取渐变遮罩层样式
const gradientOverlayStyle = {
background:
'var(--color-overlay-background, linear-gradient(180deg, rgba(33, 26, 43, 0.00) 0%, var(--sys-color-background-default, #211A2B) 100%))',
maskImage: 'linear-gradient(180deg, rgba(33, 26, 43, 0.00) 0%, #211A2B 100%)',
};
const AIStandardCard: React.FC<AIStandardCardProps> = React.memo( const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
({ character, disableHover = false }) => { ({ character, disableHover = false }) => {
const { id, name, description, coverImage, headPortrait, tags } = character; const { id, name, description, coverImage, headPortrait, tags, likeCount = 0 } = character;
const introContainerRef = useRef<HTMLDivElement>(null);
const introTextRef = useRef<HTMLParagraphElement>(null);
const [maxLines, setMaxLines] = useState<number>(6);
// 动态计算可用空间的行数
useEffect(() => {
const calculateMaxLines = () => {
if (introContainerRef.current) {
const containerHeight = introContainerRef.current.offsetHeight;
const lineHeight = 20; // 对应 leading-[20px]
const calculatedLines = Math.floor(containerHeight / lineHeight);
// 确保至少显示 1 行,最多不超过合理的行数
const finalLines = Math.max(1, Math.min(calculatedLines, 12));
setMaxLines(finalLines);
}
};
calculateMaxLines();
// 监听窗口大小变化
window.addEventListener('resize', calculateMaxLines);
return () => window.removeEventListener('resize', calculateMaxLines);
}, []);
// 获取显示的背景图片 // 获取显示的背景图片
const displayImage = coverImage; const displayImage = coverImage;
// 可见标签
const visibleTags = useMemo(() => tags?.slice(0, MAX_VISIBLE_TAGS) || [], [tags]);
// 容器类名
const containerClassName = `relative flex shrink-0 grow basis-0 cursor-pointer flex-col content-stretch items-start justify-start gap-3 ${disableHover ? '' : 'group'}`;
return ( return (
<Link href={`/character/${id}`} className="h-full w-full" prefetch={false}> <Link href={`/character/${id}`} className="h-full w-full" prefetch={false}>
<div <div className={containerClassName}>
className={`relative flex shrink-0 grow basis-0 cursor-pointer flex-col content-stretch items-start justify-start gap-3 ${disableHover ? '' : 'group'}`}
>
<div <div
className="relative aspect-[3/4] w-full shrink-0 overflow-hidden rounded-2xl bg-cover bg-top bg-no-repeat transition-transform" className="relative aspect-[3/4] w-full shrink-0 overflow-hidden rounded-2xl bg-cover bg-top bg-no-repeat transition-transform"
style={{ backgroundImage: displayImage ? `url('${displayImage}')` : undefined }} style={{ backgroundImage: displayImage ? `url('${displayImage}')` : undefined }}
> >
<div className="relative box-border flex aspect-[3/4] size-full flex-col content-stretch items-end justify-end overflow-clip p-[12px]"> <div className="relative box-border flex aspect-[3/4] size-full flex-col content-stretch items-end justify-end overflow-clip p-3">
{/* 默认渐变遮罩层 */} {/* 默认渐变遮罩层 */}
<div <div
className={`absolute right-0 bottom-[-0.33px] left-0 flex flex-col items-start justify-start gap-2 px-3 pt-6 pb-3 transition-opacity duration-300 ${disableHover ? '' : 'group-hover:opacity-0'}`} className={`absolute right-0 bottom-[-0.33px] left-0 flex flex-col items-start justify-start gap-2 px-3 pt-6 pb-3 transition-opacity duration-300 ${
disableHover ? '' : 'group-hover:opacity-0'
}`}
> >
<div <div className="absolute inset-0 backdrop-blur-3xl" style={gradientOverlayStyle} />
className="absolute inset-0 backdrop-blur-3xl"
style={{
background:
'var(--color-overlay-background, linear-gradient(180deg, rgba(33, 26, 43, 0.00) 0%, var(--sys-color-background-default, #211A2B) 100%))',
maskImage: 'linear-gradient(180deg, rgba(33, 26, 43, 0.00) 0%, #211A2B 100%)',
}}
/>
<div className="relative"> <div className="relative">
{/* 角色信息区域 */} {/* 角色信息区域 */}
<div className="relative flex w-full shrink-0 flex-col content-stretch items-start justify-start gap-2"> <div className="relative flex w-full shrink-0 flex-col content-stretch items-start justify-start gap-2">
@ -76,26 +72,22 @@ const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
{name} {name}
</div> </div>
<div className="flex flex-wrap gap-1"> {/* 标签 */}
{!!tags?.length && ( {visibleTags.length > 0 && (
<div className="flex flex-wrap gap-1">
<div className="relative flex shrink-0 flex-wrap content-start items-start justify-start gap-1"> <div className="relative flex shrink-0 flex-wrap content-start items-start justify-start gap-1">
{tags?.slice(0, 2).map((tag, index) => ( {visibleTags.map((tag, index) => (
<Tag key={index} size="small" className="backdrop-blur-[8px]"> <Tag key={tag.id || index} size="small" className="backdrop-blur-[8px]">
{tag.name} {tag.name}
</Tag> </Tag>
))} ))}
</div> </div>
)} </div>
</div> )}
</div> </div>
{/* 点赞数 */} {/* 点赞数 */}
<div className="flex items-center gap-1 rounded-xs px-1 py-0.5"> <LikeCount count={likeCount} />
<i className="iconfont icon-Like-fill" />
<span className="txt-label-s text-txt-primary-specialmap-normal">
{formatNumberToKMB(1000)}
</span>
</div>
</div> </div>
</div> </div>
@ -104,38 +96,28 @@ const AIStandardCard: React.FC<AIStandardCardProps> = React.memo(
<div className="absolute inset-0 flex flex-col items-start justify-end gap-2 bg-black/65 px-3 pt-6 pb-3 opacity-0 backdrop-blur-[8px] transition-opacity duration-300 group-hover:opacity-100"> <div className="absolute inset-0 flex flex-col items-start justify-end gap-2 bg-black/65 px-3 pt-6 pb-3 opacity-0 backdrop-blur-[8px] transition-opacity duration-300 group-hover:opacity-100">
{/* 头像和名称 */} {/* 头像和名称 */}
<div className="flex w-full items-center gap-2"> <div className="flex w-full items-center gap-2">
<Avatar className="sh size-12"> <Avatar className="size-12">
<AvatarImage src={headPortrait} alt={name} /> <AvatarImage src={headPortrait} alt={name} />
</Avatar> </Avatar>
<div className="txt-title-s min-w-0 flex-1 truncate">{name}</div> <div className="txt-title-s min-w-0 flex-1 truncate">{name}</div>
</div> </div>
{/* 简介文本 */} {/* 简介文本 */}
<div <div className="flex min-h-0 w-full flex-1 items-start justify-start gap-1">
ref={introContainerRef}
className="flex min-h-0 w-full flex-1 items-start justify-start gap-1"
>
<div <div
className="txt-body-m text-txt-secondary-normal w-full overflow-hidden" className="txt-body-m text-txt-secondary-normal w-full overflow-hidden"
style={{ style={{
display: '-webkit-box', display: '-webkit-box',
WebkitLineClamp: maxLines, WebkitLineClamp: MAX_DESCRIPTION_LINES,
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',
}} }}
> >
<p ref={introTextRef} className="leading-[20px]"> <p className="leading-[20px]">{description || 'No description'}</p>
{description || 'No description'}
</p>
</div> </div>
</div> </div>
{/* 点赞数 */} {/* 点赞数 */}
<div className="flex items-center gap-1 rounded-xs px-1 py-0.5"> <LikeCount count={likeCount} />
<i className="iconfont icon-Like-fill" />
<span className="txt-label-s text-txt-primary-specialmap-normal">
{formatNumberToKMB(100)}
</span>
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -5,30 +5,15 @@ import React from 'react';
type IconFontProps = { type IconFontProps = {
/** 图标名称,对应 iconfont 中的图标 ID */ /** 图标名称,对应 iconfont 中的图标 ID */
type: string; type: string;
/** 图标大小,如果不传则继承父元素的 font-size */
size?: number;
/** 图标颜色,默认 currentColor继承父元素颜色 */ /** 图标颜色,默认 currentColor继承父元素颜色 */
color?: string; color?: string;
/** 额外的 className */ /** 额外的 className */
className?: string; className?: string;
} & React.HTMLAttributes<SVGSVGElement>; } & React.HTMLAttributes<SVGSVGElement>;
const IconFont = ({ const IconFont = ({ type, color = 'currentColor', className = '', ...props }: IconFontProps) => {
type,
size,
color = 'currentColor',
className = '',
...props
}: IconFontProps) => {
return ( return (
<svg <svg className={`iconfont-v2 ${className}`} style={{ color }} aria-hidden="true" {...props}>
className={`iconfont-v2 ${className}`}
style={size ? { fontSize: size, color } : { color }}
aria-hidden="true"
width={size}
height={size}
{...props}
>
<use xlinkHref={`#${type}`}></use> <use xlinkHref={`#${type}`}></use>
</svg> </svg>
); );

View File

@ -1,180 +0,0 @@
export default {
common: {
search: '搜索',
cancel: '取消',
edit: '编辑',
select: '选择',
default: '默认',
gender: '性别',
gender_0: '男性',
gender_1: '女性',
gender_2: '其他',
filter: '筛选',
},
bottomBar: {
explore: '首页',
search: '搜索',
chat: '聊天',
me: '我的',
},
home: {
home: '首页',
character: '角色',
story: '故事',
mark_all: '已读全部',
clear: '清空聊天记录',
check_in: '签到',
check_in_desc: '每日签到,免费获取金币',
},
character: {
liked: '喜欢',
hot: '热度',
introduction: '人物简介',
},
chat: {
chats: '聊天',
drawer: {
maskedIdentityMode: '面具设置',
createMask: '创建面具',
history: '历史记录',
voiceActorTitle: '声优',
fontTitle: '字体',
maxToken: '最大回复',
background: '背景',
model: '聊天模型',
profile: {
preset: '预设',
gender: '性别',
age: '年龄',
whoAmI: '我是谁',
unfilled: '未填写',
chatSetting: '聊天设置',
voiceSetting: '语音设置',
chatModel: '聊天模型',
longText: '长文本',
maximumReplies: '最大回复数',
font: '字体',
chatBackground: '聊天背景',
voiceArtist: '声优',
delete: '删除',
newChat: '新建聊天',
},
chatModel: {
textMessagePrice: '文本消息价格',
textMessagePriceDesc:
'指通过文本消息与角色聊天的费用,包括发送文字、图片或礼物。按消息计费。',
voiceMessagePrice: '语音消息价格',
voiceMessagePriceDesc: '指向角色发送语音消息或播放角色语音的费用。按次计费。',
voiceCallPrice: '语音通话价格',
voiceCallPriceDesc: '指与角色进行语音通话的费用。按分钟计费。',
rolePlayDesc: '与 AI 进行角色扮演对话',
textMessage: '文本消息',
sendOrPlayVoice: '发送或播放语音',
voiceCallPerMin: '语音通话/分钟',
stayTuned: '敬请期待更多模型',
},
voiceActor: {
all: '全部',
male: '男性',
female: '女性',
description: '与 AI 进行角色扮演对话',
},
font: {
standard: '标准',
},
},
},
profile: {
copiedToClipboard: '已复制到剪贴板',
vip: 'VIP',
notUnlocked: '未解锁',
wallet: '钱包',
editProfile: '编辑资料',
maskedIdentityMode: '面具身份模式',
account: '账户',
aboutUs: '关于我们',
termsOfServices: '服务条款',
privacyPolicy: '隐私政策',
logOut: '退出登录',
logOutConfirm: '确定要退出登录吗?',
avatarSetting: {
selectImageFile: '请选择图片文件',
gifNotSupported: '不支持 GIF 格式,请选择 JPG、JPEG 或 PNG 格式的图片',
imageSizeLimit: '图片文件不能超过 10MB',
uploadPrompt: '请上传 10MB 以内的 JPG、JPEG 或 PNG 图片。',
upload: '上传',
},
account_page: {
account: '账户',
disableAccount: '停用账户',
disableAccountConfirm: '确定要停用您的账户吗?',
cancel: '取消',
disable: '停用',
accountDisabledSuccess: '账户已成功停用',
},
edit: {
editProfile: '编辑资料',
nickname: '昵称',
enterNickname: '输入昵称',
nicknameRequired: '昵称不能为空',
nicknameLength: '昵称长度必须在 2 到 20 个字符之间',
gender: '性别',
selectGender: '请选择性别',
genderNote: '请注意:性别设置后不可更改',
age: '年龄',
year: '年',
month: '月',
day: '日',
selectBirthYear: '请选择出生年份',
selectBirthMonth: '请选择出生月份',
selectBirthDay: '请选择出生日期',
ageLimit: '角色年龄必须至少为 18 岁',
nicknameTaken: '此昵称已被使用',
save: '保存',
},
mask: {
maskedIdentityMode: '面具身份模式',
addNewMask: '+ 添加新面具',
nickname: '昵称',
enterNickname: '输入昵称',
nicknameRequired: '昵称不能为空',
nicknameLength: '昵称长度必须在 2 到 20 个字符之间',
gender: '性别',
selectGender: '请选择性别',
genderNote: '请注意:性别设置后不可更改',
age: '年龄',
enterAge: '输入年龄',
ageRequired: '年龄不能为空',
whoAmI: '我是谁(可选)',
whoAmIPlaceholder: '描述你的角色特征和场景设定',
save: '保存',
male: '男性',
female: '女性',
},
},
crushcoin: {
title: '每日免费金币',
consecutiveDays: '你已经连续签到 {days} 天',
description1: '金币可用于支付聊天服务和解锁其他物品。',
description2: '如果错过签到,签到计数将重置并从第一天重新开始。',
day: '第 {day} 天',
checked: '已签到',
notStarted: '未开始',
loading: '签到中ing',
checked_success: '签到成功',
},
footer: {
slogan: '用 Spicyxx.AI 成长你的爱情故事——从"你好"到"我愿意",每一次对话都点燃火花',
features: '功能',
recharge: '充值',
crushLevelVip: 'Spicyxx.AI VIP',
explore: '探索',
dailyFreeCrushCoins: '每日免费金币',
aboutUs: '关于我们',
contactUs: '联系我们',
legal: '法律',
userAgreement: '用户协议',
privacyPolicy: '隐私政策',
copyright: '版权所有 © {year} Spicyxx.AI. 保留所有权利',
},
};

180
src/i18n/zh-TW.ts Normal file
View File

@ -0,0 +1,180 @@
export default {
common: {
search: '搜尋',
cancel: '取消',
edit: '編輯',
select: '選擇',
default: '預設',
gender: '性別',
gender_0: '男性',
gender_1: '女性',
gender_2: '其他',
filter: '篩選',
},
bottomBar: {
explore: '首頁',
search: '搜尋',
chat: '聊天',
me: '我的',
},
home: {
home: '首頁',
character: '角色',
story: '故事',
mark_all: '已讀全部',
clear: '清空聊天記錄',
check_in: '簽到',
check_in_desc: '每日簽到,免費獲取金幣',
},
character: {
liked: '喜歡',
hot: '熱度',
introduction: '人物簡介',
},
chat: {
chats: '聊天',
drawer: {
maskedIdentityMode: '面具設定',
createMask: '建立面具',
history: '歷史記錄',
voiceActorTitle: '聲優',
fontTitle: '字型',
maxToken: '最大回覆',
background: '背景',
model: '聊天模型',
profile: {
preset: '預設',
gender: '性別',
age: '年齡',
whoAmI: '我是誰',
unfilled: '未填寫',
chatSetting: '聊天設定',
voiceSetting: '語音設定',
chatModel: '聊天模型',
longText: '長文字',
maximumReplies: '最大回覆數',
font: '字型',
chatBackground: '聊天背景',
voiceArtist: '聲優',
delete: '刪除',
newChat: '新增聊天',
},
chatModel: {
textMessagePrice: '文字訊息價格',
textMessagePriceDesc:
'指透過文字訊息與角色聊天的費用,包括傳送文字、圖片或禮物。按訊息計費。',
voiceMessagePrice: '語音訊息價格',
voiceMessagePriceDesc: '指向角色傳送語音訊息或播放角色語音的費用。按次計費。',
voiceCallPrice: '語音通話價格',
voiceCallPriceDesc: '指與角色進行語音通話的費用。按分鐘計費。',
rolePlayDesc: '與 AI 進行角色扮演對話',
textMessage: '文字訊息',
sendOrPlayVoice: '傳送或播放語音',
voiceCallPerMin: '語音通話/分鐘',
stayTuned: '敬請期待更多模型',
},
voiceActor: {
all: '全部',
male: '男性',
female: '女性',
description: '與 AI 進行角色扮演對話',
},
font: {
standard: '標準',
},
},
},
profile: {
copiedToClipboard: '已複製到剪貼簿',
vip: 'VIP',
notUnlocked: '未解鎖',
wallet: '錢包',
editProfile: '編輯資料',
maskedIdentityMode: '面具身份模式',
account: '帳戶',
aboutUs: '關於我們',
termsOfServices: '服務條款',
privacyPolicy: '隱私權政策',
logOut: '登出',
logOutConfirm: '確定要登出嗎?',
avatarSetting: {
selectImageFile: '請選擇圖片檔案',
gifNotSupported: '不支援 GIF 格式,請選擇 JPG、JPEG 或 PNG 格式的圖片',
imageSizeLimit: '圖片檔案不能超過 10MB',
uploadPrompt: '請上傳 10MB 以內的 JPG、JPEG 或 PNG 圖片。',
upload: '上傳',
},
account_page: {
account: '帳戶',
disableAccount: '停用帳戶',
disableAccountConfirm: '確定要停用您的帳戶嗎?',
cancel: '取消',
disable: '停用',
accountDisabledSuccess: '帳戶已成功停用',
},
edit: {
editProfile: '編輯資料',
nickname: '暱稱',
enterNickname: '輸入暱稱',
nicknameRequired: '暱稱不能為空',
nicknameLength: '暱稱長度必須在 2 到 20 個字元之間',
gender: '性別',
selectGender: '請選擇性別',
genderNote: '請注意:性別設定後不可更改',
age: '年齡',
year: '年',
month: '月',
day: '日',
selectBirthYear: '請選擇出生年份',
selectBirthMonth: '請選擇出生月份',
selectBirthDay: '請選擇出生日期',
ageLimit: '角色年齡必須至少為 18 歲',
nicknameTaken: '此暱稱已被使用',
save: '儲存',
},
mask: {
maskedIdentityMode: '面具身份模式',
addNewMask: '+ 新增面具',
nickname: '暱稱',
enterNickname: '輸入暱稱',
nicknameRequired: '暱稱不能為空',
nicknameLength: '暱稱長度必須在 2 到 20 個字元之間',
gender: '性別',
selectGender: '請選擇性別',
genderNote: '請注意:性別設定後不可更改',
age: '年齡',
enterAge: '輸入年齡',
ageRequired: '年齡不能為空',
whoAmI: '我是誰(可選)',
whoAmIPlaceholder: '描述你的角色特徵和場景設定',
save: '儲存',
male: '男性',
female: '女性',
},
},
crushcoin: {
title: '每日免費金幣',
consecutiveDays: '你已經連續簽到 {days} 天',
description1: '金幣可用於支付聊天服務和解鎖其他物品。',
description2: '如果錯過簽到,簽到計數將重置並從第一天重新開始。',
day: '第 {day} 天',
checked: '已簽到',
notStarted: '未開始',
loading: '簽到中ing',
checked_success: '簽到成功',
},
footer: {
slogan: '用 Spicyxx.AI 成長你的愛情故事——從「你好」到「我願意」,每一次對話都點燃火花',
features: '功能',
recharge: '儲值',
crushLevelVip: 'Spicyxx.AI VIP',
explore: '探索',
dailyFreeCrushCoins: '每日免費金幣',
aboutUs: '關於我們',
contactUs: '聯絡我們',
legal: '法律',
userAgreement: '使用者協議',
privacyPolicy: '隱私權政策',
copyright: '版權所有 © {year} Spicyxx.AI. 保留所有權利',
},
};

View File

@ -13,12 +13,12 @@ export default function LocaleSwitch() {
const { locale, setLocale } = useLocale(); const { locale, setLocale } = useLocale();
return ( return (
<Select value={locale} onValueChange={(value) => setLocale(value as 'zh-CN' | 'en-US')}> <Select value={locale} onValueChange={(value) => setLocale(value as 'zh-TW' | 'en-US')}>
<SelectTrigger className="rounded-full h-8 w-16 px-3"> <SelectTrigger className="rounded-full h-8 w-16 px-3">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="min-w-0 w-22"> <SelectContent className="min-w-0 w-22">
<SelectItem value="zh-CN"></SelectItem> <SelectItem value="zh-TW"></SelectItem>
<SelectItem value="en-US">En</SelectItem> <SelectItem value="en-US">En</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@ -55,11 +55,11 @@ export default function ConditionalLayout({ children }: ConditionalLayoutProps)
useAutoScrollOnRouteChange(mainContentRef); useAutoScrollOnRouteChange(mainContentRef);
return ( return (
<div className="flex h-screen bg-[url('/common-bg.png')] bg-[length:100%_30%] bg-top bg-no-repeat"> <div className="flex h-screen w-screen bg-[url('/common-bg.png')] bg-[length:100%_30%] bg-top bg-no-repeat overflow-hidden">
{response?.isPC && <Sidebar />} {response?.isPC && <Sidebar />}
<div ref={mainContentRef} className={cn('relative flex flex-1 flex-col')}> <div ref={mainContentRef} className={cn('relative flex w-full flex-1 flex-col min-w-0')}>
<Topbar /> <Topbar />
<main id="main-content" className="overflow-auto flex-1"> <main id="main-content" className="overflow-auto w-full flex-1 min-w-0">
{children} {children}
</main> </main>
{response?.isMobile && <BottomBar />} {response?.isMobile && <BottomBar />}

View File

@ -7,7 +7,7 @@ import Cookies from 'js-cookie';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { useLayoutStore } from '@/stores'; import { useLayoutStore } from '@/stores';
type Locale = 'zh-CN' | 'en-US'; type Locale = 'zh-TW' | 'en-US';
interface LocaleContextType { interface LocaleContextType {
locale: Locale; locale: Locale;
setLocale: (locale: Locale) => void; setLocale: (locale: Locale) => void;
@ -33,7 +33,7 @@ function setLocaleToCookie(locale: Locale) {
function getLocaleFromCookie(): Locale { function getLocaleFromCookie(): Locale {
const cookieLocale = Cookies.get('locale') as Locale | undefined; const cookieLocale = Cookies.get('locale') as Locale | undefined;
if (cookieLocale && (cookieLocale === 'zh-CN' || cookieLocale === 'en-US')) { if (cookieLocale && (cookieLocale === 'zh-TW' || cookieLocale === 'en-US')) {
return cookieLocale; return cookieLocale;
} }
setLocaleToCookie('en-US'); setLocaleToCookie('en-US');