feat: 增加角色详情SSR

This commit is contained in:
liuyonghe0111 2025-11-03 18:03:34 +08:00
parent 0cb63f144f
commit ef0bdff317
38 changed files with 1919 additions and 506 deletions

View File

@ -12,9 +12,6 @@
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-switch": "^1.2.6",
"@tanstack/react-query": "^5.90.2",
"ahooks": "^3.9.5",
"axios": "^1.12.2",
@ -26,6 +23,7 @@
"next": "15.5.4",
"next-intl": "^4.3.11",
"qs": "^6.14.0",
"radix-ui": "^1.4.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.65.0",

File diff suppressed because it is too large Load Diff

BIN
public/avator.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

View File

@ -1,121 +1,153 @@
'use client';
import { TagSelect, Rate } from '@/components';
import { Rate } from '@/components';
import Image from 'next/image';
import { RightArrowIcon } from '@/assets/chatacter';
import { ReportIcon } from '@/assets/common';
import { useParams, useRouter } from 'next/navigation';
import IconFont from '@/components/ui/iconFont';
import ChatButton from './ChatButton';
import Tags from '@/components/ui/Tags';
type CharacterBasicInfoProps = {
characterId: string;
characterDetail?: any;
};
export default function CharacterBasicInfo({
characterId,
characterDetail,
}: CharacterBasicInfoProps) {
if (!characterDetail) {
return null;
}
const from = characterDetail?.from || "The CEO's Contract Wife";
const avatar = characterDetail?.avatar || '/test.png';
const fromImage = characterDetail?.fromImage || '/images/character/from.png';
const characterName = characterDetail?.name || '未知角色';
export default function CharacterBasicInfo() {
const router = useRouter();
const { id } = useParams();
return (
<div className="flex w-full">
<Image
src="/test.png"
alt="character-basic-info"
width={338}
height={600}
/>
<div className="w-full pt-5">
<div className="flex items-center justify-between">
<span className="text-text-color text-4xl">Maeve o'connell</span>
<div>
<ReportIcon />
</div>
</div>
<div className="text-text-color/60 mt-4 flex items-center gap-2">
<div
style={{ backgroundColor: 'rgba(255, 102, 0, 1)' }}
className="text-text-color inline-flex h-4 w-4 justify-center rounded-md text-xs"
>
ID
</div>
<div>user123456</div>
<div
style={{ background: 'rgba(255, 255, 255, 0.2)' }}
className="h-2 w-0.25"
></div>
<div>18</div>
<div
style={{ background: 'rgba(255, 255, 255, 0.2)' }}
className="h-2 w-0.25"
></div>
<div>The Male Lead</div>
</div>
{/* 角色评分 */}
<Rate className="mt-8" value={7} readonly />
{/* 角色TAG */}
<TagSelect
className="mt-10"
readonly
options={[
{ label: 'tag1', value: 'tag1' },
{
label: 'tag2',
value: 'tag2',
},
]}
<article className="flex w-full gap-7.5">
{/* 角色头像 - 使用 figure 语义化标签 */}
<figure>
<Image
src={avatar}
alt={`${characterName} - 角色头像`}
width={338}
height={600}
priority
/>
{/* divider */}
<div className="bg-text-color/10 mt-10 h-0.25 w-full"></div>
</figure>
{/* description */}
<div className="mt-10.5">
<div className="flex">
<Image
src={'/character/desc.svg'}
alt="description"
className="mr-1"
width={18}
height={20}
/>
Description:
<div className="w-full pt-5">
{/* 角色名称 - 使用 header 和 h1 */}
<header className="flex items-center justify-between">
<h1 className="text-text-color text-4xl font-bold">
{characterName}
</h1>
<div>
<IconFont type="icon-jubao" size={20} />
</div>
<div className="text-text-color/60 mt-5 text-sm">
{
'description text description textdescription textdescription textdescription textdescription textdescription textdescription text'
}
</header>
{/* 角色基本信息 - 使用定义列表 */}
<dl className="text-text-color/60 mt-4 flex items-center gap-2">
<div className="flex items-center gap-2">
<dt
style={{ backgroundColor: 'rgba(255, 102, 0, 1)' }}
className="text-text-color inline-flex h-4 w-4 justify-center rounded-md text-xs"
aria-label="角色ID"
>
ID
</dt>
<dd>{characterDetail.id}</dd>
</div>
<div
style={{ background: 'rgba(255, 255, 255, 0.2)' }}
className="h-2 w-0.25"
aria-hidden="true"
></div>
<div className="flex items-center gap-2">
<dt className="sr-only"></dt>
<dd>{characterDetail.age ?? '18'}</dd>
</div>
<div
style={{ background: 'rgba(255, 255, 255, 0.2)' }}
className="h-2 w-0.25"
aria-hidden="true"
></div>
<div className="flex items-center gap-2">
<dt className="sr-only"></dt>
<dd>{characterDetail.role}</dd>
</div>
</dl>
{/* 角色评分 */}
<div className="mt-8">
<Rate value={characterDetail.rate ?? 7} readonly />
</div>
{/* 角色TAG */}
{characterDetail.tags && characterDetail.tags.length > 0 && (
<div className="mt-10">
<Tags
options={characterDetail.tags.map((tag: any) => ({
label: tag,
value: tag,
}))}
/>
</div>
)}
{/* divider */}
<div
className="bg-text-color/10 mt-10 h-0.25 w-full"
aria-hidden="true"
></div>
{/* description - 使用 section 和语义化标签 */}
{characterDetail.description && (
<section className="mt-10.5">
<h2 className="flex items-center">
<Image
src={'/character/desc.svg'}
alt=""
aria-hidden="true"
className="mr-1"
width={18}
height={20}
/>
Description:
</h2>
<p className="text-text-color/60 mt-5 text-sm">
{characterDetail.description}
</p>
</section>
)}
{/* from and chat */}
<div className="flex items-center justify-between gap-10 pt-5">
{/* FROM */}
<div className="bg-text-color/5 flex h-15 items-center justify-between rounded-lg pr-5">
<div className="flex items-center">
<Image
src="/images/character/from.png"
alt="from"
className="rounded-lg"
width={45}
height={60}
/>
<div className="text-text-color/80 max-w-110 pr-2 text-sm">
<span style={{ color: 'rgb(0, 102, 255' }}>Form:</span>
{" The CEO's Contract Wife"}
{/* FROM - 使用语义化标签 */}
{from && (
<div className="bg-text-color/5 flex h-15 items-center justify-between rounded-lg pr-5">
<div className="flex items-center">
<Image
src={fromImage}
alt={`${from} - 来源作品封面`}
className="rounded-lg"
width={45}
height={60}
/>
<div className="text-text-color/80 max-w-110 pr-2 text-sm">
<span style={{ color: 'rgb(0, 102, 255)' }}>Form:</span>
{` ${from}`}
</div>
</div>
<RightArrowIcon />
</div>
<RightArrowIcon />
</div>
)}
{/* Chat Button */}
<div
onClick={() => router.push(`/character/${id}/chat`)}
className="inline-flex h-12.5 w-75 items-center justify-center gap-3 rounded-full hover:cursor-pointer"
style={{
background: `linear-gradient(92.76deg,rgba(166, 83, 255, 1) 0%,rgba(0, 101, 255, 1) 80%,rgba(0, 157, 255, 1) 100%)`,
}}
>
<Image
src={'/component/send.svg'}
alt="chat"
width={20}
height={20}
/>
Chat Now
</div>
{/* Chat Button - 客户端组件 */}
<ChatButton characterId={characterId} />
</div>
</div>
</div>
</article>
);
}

View File

@ -0,0 +1,25 @@
'use client';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
type ChatButtonProps = {
characterId: string;
};
export default function ChatButton({ characterId }: ChatButtonProps) {
const router = useRouter();
return (
<div
onClick={() => router.push(`/character/${characterId}/chat`)}
className="inline-flex h-12.5 w-75 items-center justify-center gap-3 rounded-full hover:cursor-pointer"
style={{
background: `linear-gradient(92.76deg,rgba(166, 83, 255, 1) 0%,rgba(0, 101, 255, 1) 80%,rgba(0, 157, 255, 1) 100%)`,
}}
>
<Image src={'/component/send.svg'} alt="chat" width={20} height={20} />
Chat Now
</div>
);
}

View File

@ -1,8 +1,8 @@
'use client';
import { HeartIcon, ReplyIcon } from '@/assets/chatacter';
import { ReportIcon } from '@/assets/common';
import { Rate } from '@/components';
import IconFont from '@/components/ui/iconFont';
import Image from 'next/image';
const Comment = () => {
@ -45,7 +45,7 @@ const Comment = () => {
<span>999</span>
</div>
</div>
<ReportIcon />
<IconFont type="icon-jubao" size={20} />
</div>
</div>
</div>

View File

@ -0,0 +1,56 @@
'use client';
import { useParams, usePathname, useRouter } from 'next/navigation';
import { cn } from '@/lib';
import { CommentIcon } from '@/assets/chatacter';
import CharacterReview from './Review';
import CharacterList from './List';
export default function TabsNavigation() {
const router = useRouter();
const pathname = usePathname();
const { id } = useParams();
const tabs = [
{ path: '/review', Icon: CommentIcon, label: 'Review' },
{
path: '/list',
label: 'Character List',
},
];
return (
<>
<div className="mt-11 flex w-full max-w-300 items-center justify-start gap-5">
{tabs.map((tab, index) => {
const isActive = pathname.includes(tab.path);
const dom = (
<div
onClick={() => router.push(`/character/${id}${tab.path}`)}
className={cn(
'flex gap-2.5 hover:cursor-pointer',
!isActive && 'text-text-color/60'
)}
key={tab.path}
>
{tab.Icon ? <tab.Icon /> : null}
{tab.label}
</div>
);
return [
!!index && (
<div
key={`divider_${index}`}
className="bg-text-color/10 h-2.5 w-0.5"
></div>
),
dom,
];
})}
</div>
<div className="mt-10 w-full max-w-300">
{pathname.includes('/review') ? <CharacterReview /> : <CharacterList />}
</div>
</>
);
}

View File

@ -1,96 +0,0 @@
'use client';
import CharacterBasicInfo from './components/BasicInfo';
import Image from 'next/image';
import { Rate } from '@/components';
import { useParams, usePathname, useRouter } from 'next/navigation';
import { cn } from '@/lib';
import { CommentIcon } from '@/assets/chatacter';
export default function CharacterDetail({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const { id } = useParams();
const tabs = [
{ path: '/review', Icon: CommentIcon, label: 'Review' },
{
path: '/list',
label: 'Character List',
},
];
return (
<div
style={{
background:
'linear-gradient(90deg, rgba(44, 20, 65, 1) 1.08%, rgba(44, 20, 65, 0) 100%), linear-gradient(89.21deg, rgba(255, 255, 63, 0) 0.68%, rgba(255, 255, 63, 0.3) 111.9%)',
}}
className="flex min-h-full w-full flex-col items-center"
>
<div className="max-w-300 pt-7.5">
<CharacterBasicInfo />
</div>
{/* 评分 */}
<div className="bg-text-color/5 flex h-20 w-full max-w-300 items-center justify-between rounded-full px-7.5">
<div className="flex items-center gap-5">
<Image
src={'/character/figure.svg'}
alt="figure"
width={20}
height={20}
/>
<div className="font-bold">How Was this book ?</div>
</div>
<div className="flex flex-col items-end gap-1">
<div
className="flex gap-2"
style={{ color: 'rgba(255, 225, 167, 1)' }}
>
Tap to Rate
<Image
src={'/character/figure.svg'}
alt="figure"
width={20}
height={20}
/>
</div>
<Rate />
</div>
</div>
{/* Tabs */}
<div className="mt-11 flex w-full max-w-300 items-center justify-start gap-5">
{tabs.map((tab, index) => {
const isActive = pathname.includes(tab.path);
const dom = (
<div
onClick={() => router.push(`/character/${id}${tab.path}`)}
className={cn(
'flex gap-2.5 hover:cursor-pointer',
!isActive && 'text-text-color/60'
)}
key={tab.path}
>
{tab.Icon ? <tab.Icon /> : null}
{tab.label}
</div>
);
return [
!!index && (
<div
key={`divider_${index}`}
className="bg-text-color/10 h-2.5 w-0.5"
></div>
),
dom,
];
})}
</div>
<div className="mt-10 w-full max-w-300">{children}</div>
</div>
);
}

View File

@ -0,0 +1,209 @@
import { Metadata } from 'next';
import CharacterBasicInfo from './components/BasicInfo';
import Image from 'next/image';
import TabsNavigation from './components/TabsNavigation';
import { fetchCharacterDetail } from './service';
type CharacterDetailLayoutProps = {
params: Promise<{
id: string;
}>;
};
// 动态生成 SEO metadata
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
try {
const characterDetail = await fetchCharacterDetail(id);
const title = `${characterDetail.name || '角色'} - 角色详情 | Visual Novel`;
const description = characterDetail.description
? `${characterDetail.description.substring(0, 160)}...`
: `查看 ${characterDetail.name || '角色'} 的详细信息,包括角色背景、评分、标签等。`;
const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL || 'https://yourdomain.com';
const imageUrl = characterDetail.avatar
? characterDetail.avatar.startsWith('http')
? characterDetail.avatar
: `${baseUrl}${characterDetail.avatar}`
: `${baseUrl}/og-image.png`;
const keywords = [
characterDetail.name,
characterDetail.role,
...(characterDetail.tags || []),
'角色详情',
'Visual Novel',
'角色介绍',
].filter(Boolean);
return {
title,
description,
keywords: keywords.join(', '),
openGraph: {
title,
description,
type: 'website',
url: `${baseUrl}/character/${id}`,
siteName: 'Visual Novel',
images: [
{
url: imageUrl,
width: 1200,
height: 630,
alt: characterDetail.name || '角色头像',
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [imageUrl],
},
alternates: {
canonical: `/character/${id}`,
},
};
} catch (error) {
// 如果获取失败,返回默认 metadata
return {
title: '角色详情 | Visual Novel',
description: '查看角色详细信息',
};
}
}
export default async function CharacterDetail({
params,
}: CharacterDetailLayoutProps) {
const { id } = await params;
const characterDetail = await fetchCharacterDetail(id);
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://yourdomain.com';
const imageUrl = characterDetail.avatar
? characterDetail.avatar.startsWith('http')
? characterDetail.avatar
: `${baseUrl}${characterDetail.avatar}`
: `${baseUrl}/og-image.png`;
// 结构化数据 - Person Schema
const personSchema = {
'@context': 'https://schema.org',
'@type': 'Person',
name: characterDetail.name,
description: characterDetail.description,
image: imageUrl,
identifier: characterDetail.id,
jobTitle: characterDetail.role,
...(characterDetail.rate && {
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: characterDetail.rate,
bestRating: 10,
worstRating: 1,
},
}),
...(characterDetail.tags &&
characterDetail.tags.length > 0 && {
keywords: characterDetail.tags.join(', '),
}),
};
// 面包屑导航 Schema
const breadcrumbSchema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: '首页',
item: baseUrl,
},
{
'@type': 'ListItem',
position: 2,
name: '角色列表',
item: `${baseUrl}/character`,
},
{
'@type': 'ListItem',
position: 3,
name: characterDetail.name,
item: `${baseUrl}/character/${id}`,
},
],
};
return (
<>
{/* 结构化数据 - Person */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(personSchema),
}}
/>
{/* 结构化数据 - Breadcrumb */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbSchema),
}}
/>
<div
style={{
background:
'linear-gradient(90deg, rgba(44, 20, 65, 1) 1.08%, rgba(44, 20, 65, 0) 100%), linear-gradient(89.21deg, rgba(255, 255, 63, 0) 0.68%, rgba(255, 255, 63, 0.3) 111.9%)',
}}
className="flex min-h-full w-full flex-col items-center"
>
<div className="max-w-300 pt-7.5">
<CharacterBasicInfo
characterId={id}
characterDetail={characterDetail}
/>
</div>
{/* 评分 */}
<div className="bg-text-color/5 mt-20 flex h-20 w-full max-w-300 items-center justify-between rounded-full px-7.5">
<div className="flex items-center gap-5">
<Image
src={'/character/figure.svg'}
alt="评分图标"
width={20}
height={20}
/>
<div className="font-bold">How Was this book ?</div>
</div>
<div className="flex flex-col items-end gap-1">
<div
className="flex gap-2"
style={{ color: 'rgba(255, 225, 167, 1)' }}
>
Tap to Rate
<Image
src={'/character/figure.svg'}
alt="评分图标"
width={20}
height={20}
/>
</div>
{/* <Rate /> */}
</div>
</div>
{/* Tabs - 客户端组件,处理路由切换 */}
<TabsNavigation />
</div>
</>
);
}

View File

@ -0,0 +1,18 @@
import { cache } from 'react';
import { publicServerRequest } from '@/lib/server-request';
/**
* token
* SEO访
* 使 cache
*/
export const fetchCharacterDetail = cache(async (id: string) => {
const { data } = await publicServerRequest(
`/character/select/roleInfo/${id}`,
{
method: 'GET',
}
);
return data;
});

View File

@ -1,4 +1,27 @@
'use client';
import IconFont from '@/components/ui/iconFont';
export default function ArchiveHistory() {
return <div>ArchiveHistory</div>;
return (
<div className="h-full pr-10 pl-5">
<div className="mt-7.5 flex items-center justify-between">
<span className="text-lg font-black">Historical Archives</span>
<IconFont type="icon-tianjia" size={20} />
</div>
<div className="mt-10 flex flex-col gap-5">
<div className="flex min-h-15 gap-2.5 rounded-[10px] border border-white/20 bg-black/20 p-2.5">
<div
style={{ backgroundImage: 'url(/test.png)' }}
className="cover-bg h-10 w-8 rounded-sm border border-white"
/>
<div className="flex-1">
<div className="text-xs text-white/60">{'2025/09/26 17:30'}</div>
<div className="mt-1 text-sm leading-none text-white/80">
The Boss Fell for Me: My Days Screwing Nuts at Foxconn
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -3,15 +3,49 @@
import React from 'react';
import { useSetAtom } from 'jotai';
import { historyListOpenAtom } from '../atoms';
import Image from 'next/image';
import IconFont from '@/components/ui/iconFont';
const ChatHistory = React.memo(() => {
const setHistoryListOpen = useSetAtom(historyListOpenAtom);
return (
<div className="h-full w-full bg-[rgba(22,18,29,1)] pt-7.5 pr-5 pl-10">
<div className="flex h-full w-full flex-col bg-[rgba(22,18,29,1)] pt-7.5 pr-5 pl-10">
<div className="flex items-center justify-between">
<span>Chat</span>
<div onClick={() => setHistoryListOpen(false)}>x</div>
<span className="text-lg font-black">Chat</span>
<div
className="flex-center h-10 w-10 cursor-pointer rounded-full border-1 border-white/30 hover:border-white/60"
onClick={() => setHistoryListOpen(false)}
>
<IconFont type="icon-guanbi" size={30} />
</div>
</div>
<div className="mt-7 flex-1">
<div className="flex h-full flex-col gap-2.5">
<div className="font-black">Today</div>
<div className="relative flex h-25 gap-3.5 rounded-[10px] bg-white/5 p-2.5">
<span className="absolute top-2.5 right-2.5 text-xs text-white/40">
{'15:36'}
</span>
<div className="flex h-12.5 w-12.5 overflow-hidden rounded-full">
<Image src={'/avator.png'} width={50} height={50} alt="avator" />
</div>
<div className="flex-1">
<div className="text-[15px]/[17px] font-bold">character · 16</div>
<a
style={{ color: 'rgba(130, 180, 255, 1)' }}
className="text-xs font-bold"
>
[from:The Lsat Oracle of Kael]
</a>
<div className="line-clamp-2 text-sm text-white/80">
# Let's tuck today's whispers into a starlit pocket. # Let's
tuck today's whispers into a starlit pocket. today's whispers
into a starlit pocket.
</div>
</div>
</div>
</div>
</div>
</div>
);

View File

@ -4,13 +4,17 @@ import { useAtom, useSetAtom } from 'jotai';
import { historyListOpenAtom, leftTabActiveKeyAtom } from '../atoms';
import { cn } from '@/lib';
import Image from 'next/image';
import { BackIcon, CharaterHistoryIcon } from '@/assets/chatacter';
import { CharaterHistoryIcon } from '@/assets/chatacter';
import { useRouter } from 'next/navigation';
import { Icon, Drawer } from '@/components';
import { Icon } from '@/components';
import IconFont from '@/components/ui/iconFont';
import ArchiveHistory from './ArchiveHistory';
import Info from './info';
export default function Side() {
const [activeKey, setActiveKey] = useAtom(leftTabActiveKeyAtom);
const setHistoryListOpen = useSetAtom(historyListOpenAtom);
const Component = activeKey === 'info' ? Info : ArchiveHistory;
const router = useRouter();
const tabs = [
@ -56,51 +60,62 @@ export default function Side() {
element: (
<div
onClick={() => router.back()}
className="text-text-color/80 hover:text-text-color hover:cursor-pointer"
className="flex-center h-10 w-10 cursor-pointer rounded-full border-1 border-white/30 hover:border-white/60"
>
<BackIcon />
<IconFont type="icon-fanhui" size={18} />
</div>
),
},
];
return (
<div className="flex h-full w-25 flex-col justify-between bg-[#15121A]">
<div className="flex flex-col gap-9 pt-7.5">
{tabs.map((item) => {
const isActive = activeKey === item.key;
return (
<div
className={cn(
'flex h-10 justify-end border-r-[2px] pr-4.5 hover:cursor-pointer',
isActive ? 'border-[rgba(0,102,255,1)]' : 'border-transparent'
)}
onClick={() => setActiveKey(item.key as any)}
key={item.key}
>
{item.element}
</div>
);
})}
</div>
<div className="mb-10 flex flex-col gap-7.5">
{bottomActions.map((item, index) => {
if (item.element) {
<>
{/* 左侧路由Bar */}
<div className="flex h-full w-25 flex-col justify-between bg-black/20">
<div className="flex flex-col gap-9 pt-7.5">
{tabs.map((item) => {
const isActive = activeKey === item.key;
return (
<div className="flex h-10 justify-end pr-5" key={`item_${index}`}>
<div
className={cn(
'flex h-10 justify-end border-r-[2px] pr-4.5 hover:cursor-pointer',
isActive ? 'border-blue-500' : 'border-transparent'
)}
onClick={() => setActiveKey(item.key as any)}
key={item.key}
>
{item.element}
</div>
);
}
// divider
return (
<div
className="ml-12 h-0.5 w-5 bg-[rgba(44,42,49,1)]"
key={`divider_${index}`}
></div>
);
})}
})}
</div>
<div className="mb-10 flex flex-col gap-7.5">
{bottomActions.map((item, index) => {
if (item.element) {
return (
<div
className="flex h-10 justify-end pr-5"
key={`item_${index}`}
>
{item.element}
</div>
);
}
// divider
return (
<div
className="ml-12 h-0.5 w-5 bg-[rgba(44,42,49,1)]"
key={`divider_${index}`}
></div>
);
})}
</div>
</div>
</div>
{/* 路由content */}
<div className="flex-1">
<Component />
</div>
</>
);
}

View File

@ -2,32 +2,20 @@
import Side from './Side';
import { useAtomValue } from 'jotai';
import { historyListOpenAtom, leftTabActiveKeyAtom } from '../atoms';
import Info from './info';
import ArchiveHistory from './ArchiveHistory';
import { memo, useRef } from 'react';
import { historyListOpenAtom } from '../atoms';
import { memo } from 'react';
import { Drawer } from '@/components';
import ChatHistory from './ChatHisory';
const Left = memo(() => {
const activeKey = useAtomValue(leftTabActiveKeyAtom);
const Component = activeKey === 'info' ? Info : ArchiveHistory;
const historyListOpen = useAtomValue(historyListOpenAtom);
const containerRef = useRef<HTMLDivElement>(null);
return (
<div ref={containerRef} className="relative flex h-full w-112">
<div className="relative flex h-full w-112">
{/* 左侧路由Bar */}
<Side />
<div className="flex-1">
<Component />
</div>
<Drawer
getContainer={() => containerRef.current}
position="left"
width={448}
open={historyListOpen}
destroyOnClose
>
{/* 聊天历史 */}
<Drawer position="left" width={448} open={historyListOpen} destroyOnClose>
<ChatHistory />
</Drawer>
</div>

View File

@ -1,4 +1,89 @@
'use client';
import IconFont from '@/components/ui/iconFont';
import Image from 'next/image';
import Tags from '@/components/ui/Tags';
export default function Info() {
return <div>Info</div>;
return (
<div className="w-full px-5 py-7.5 pt-5">
{/* 角色名称 - 使用 header 和 h1 */}
<header className="flex items-center justify-between">
<h1 className="text-text-color text-lg font-bold">
The Boss Fell for Me: My Days Screwing Nuts at Foxconn
</h1>
</header>
{/* 角色基本信息 - 使用定义列表 */}
<dl className="text-text-color/60 mt-4 flex items-center gap-2">
<div className="flex items-center gap-2">
<dt
style={{ backgroundColor: 'rgba(255, 102, 0, 1)' }}
className="text-text-color inline-flex h-4 w-4 justify-center rounded-md text-xs"
aria-label="角色ID"
>
ID
</dt>
<dd>1234567890</dd>
</div>
<div
style={{ background: 'rgba(255, 255, 255, 0.2)' }}
className="h-2 w-0.25"
aria-hidden="true"
></div>
<div className="flex items-center gap-2">
<dt className="sr-only"></dt>
<dd>18</dd>
</div>
<div
style={{ background: 'rgba(255, 255, 255, 0.2)' }}
className="h-2 w-0.25"
aria-hidden="true"
></div>
<div className="flex items-center gap-2">
<dt className="sr-only"></dt>
<dd>18</dd>
</div>
</dl>
{/* 角色TAG */}
<Tags
className="mt-10"
options={[
{ label: 'tag1', value: 'tag1' },
{ label: 'tag2', value: 'tag2' },
]}
/>
{/* description - 使用 section 和语义化标签 */}
<section className="mt-10.5">
<h2 className="flex items-center">
<Image
src={'/character/desc.svg'}
alt=""
aria-hidden="true"
className="mr-1"
width={18}
height={20}
/>
Description:
</h2>
<p className="text-text-color/60 mt-5 text-sm">
The Boss Fell for Me: My Days Screwing Nuts at Foxconn
</p>
</section>
<div className="mt-10 flex items-center gap-2.5 rounded-[10px] border border-white/30 bg-black/20">
<div
className="cover-bg h-15 w-11 rounded-lg"
style={{
backgroundImage: 'url(/test.png)',
}}
/>
<div className="text-text-color/80 max-w-110 flex-1 pr-2 text-sm">
<span style={{ color: 'rgb(0, 102, 255)' }}>Form: </span>
{`the boss fell for me: my days screwing nuts at foxconn`}
</div>
</div>
</div>
);
}

View File

@ -2,7 +2,8 @@
import { useControllableValue } from 'ahooks';
import { cn } from '@/lib';
import { CheckIcon, DeleteIcon, UploadImgIcon } from '@/assets/common';
import { CheckIcon, UploadImgIcon } from '@/assets/common';
import IconFont from '@/components/ui/iconFont';
type BackgroundItem = {
item?: any;
@ -40,7 +41,7 @@ const ItemRender = (props: BackgroundItem) => {
</span>
)}
<span onClick={handleDelete} className="hover:text-[#E2503D]">
<DeleteIcon size={20} />
<IconFont type="icon-delete" size={20} />
</span>
</div>
);

View File

@ -5,8 +5,9 @@ import React from 'react';
import { cn } from '@/lib';
import { Select, Switch, Number, FontSize } from '@/components/ui/inputs';
import Background from './Background';
import { AddIcon, DeleteIcon } from '@/assets/common';
import { AddIcon } from '@/assets/common';
import { ModelSelectDialog } from '@/components';
import IconFont from '@/components/ui/iconFont';
const Title: React.FC<
{
@ -150,9 +151,8 @@ const SettingForm = React.memo(() => {
</Form>
<div className="mt-12.5 mb-10 flex flex-col gap-5">
<div className="tk-button border-1 border-[#E2503D] text-[rgba(255,59,48,1)] hover:bg-[rgba(255,59,48,0.1)]">
<DeleteIcon /> Delete
<IconFont type="icon-delete" size={20} /> Delete
</div>
<div className="tk-button border-text-color/80 border-1 hover:bg-white hover:text-[rgba(0,102,255,1)]">
<AddIcon /> START NEW CHAT
</div>

View File

@ -8,49 +8,40 @@ import Main from './Main';
import Left from './Left';
import { Drawer } from '@/components';
import './index.css';
import { useRef } from 'react';
import { ExitFullScreenIcon, FullScreenIcon } from '@/assets/common';
export default function CharacterChat() {
const [settingOpen, setSettingOpen] = useAtom(settingOpenAtom);
const container = useRef<HTMLDivElement>(null);
return (
<div
ref={container}
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>
{/* 设置按钮 */}
<div
className={cn(
'absolute top-8 right-10 z-[1201] h-10 w-10 select-none hover:cursor-pointer',
'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>
{/* 左侧 */}
<Drawer
open={settingOpen}
position="left"
width={448}
destroyOnClose
getContainer={() => container.current}
>
<Left />
</Drawer>
{/* 右侧设置 */}
<Drawer
open={settingOpen}
position="right"
width={448}
destroyOnClose
getContainer={() => container.current}
>
<SettingForm />
</Drawer>
</div>
);
}

View File

@ -13,7 +13,7 @@ const RoleCard: React.FC<any> = React.memo(({ item }) => {
const router = useRouter();
return (
<div
onClick={() => router.push(`/character/${item.id}/chat`)}
onClick={() => router.push(`/character/${item.id}`)}
className="cover-bg relative flex h-full w-full cursor-pointer flex-col justify-between overflow-hidden rounded-[20]"
style={{
backgroundImage: `url(${item.from || '/test.png'})`,

View File

@ -5,14 +5,6 @@
--background: #14132d;
--text-color: #fff;
--text-color-1: rgba(174, 196, 223, 1);
/* UI 组件层级 */
--z-tooltip: 1000;
--z-dropdown: 1100;
--z-drawer: 1200;
--z-modal: 1300;
--z-toast: 1400;
--z-overlay: 1500;
}
@theme inline {
@ -38,6 +30,26 @@ body {
background-position: center;
}
/* iconfont 默认样式 */
.iconfont {
/* 不设置 font-size让它继承父元素的 font-size */
font-style: normal;
fill: currentColor;
width: 1em;
height: 1em;
vertical-align: -0.15em;
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 垂直水平剧中 */
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
/* 自定义滚动条样式 */
::-webkit-scrollbar {
width: 10px;

View File

@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import './globals.css';
import GlobalContainer from '@/layouts/GlobalContainer';
import Script from 'next/script';
const geistSans = Geist({
variable: '--font-geist-sans',
@ -28,6 +29,10 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Script
src="//at.alicdn.com/t/c/font_5054282_ibxmours7r.js"
strategy="afterInteractive"
/>
<GlobalContainer>{children}</GlobalContainer>
</body>
</html>

View File

@ -1,3 +1,3 @@
export default function Loading() {
return <p>Loading...</p>;
export default async function Loading() {
return <div className="flex-center h-screen w-full">Loading...</div>;
}

16
src/app/robots.ts Normal file
View File

@ -0,0 +1,16 @@
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://yourdomain.com';
return {
rules: [
{
userAgent: '*', // 对所有搜索引擎爬虫生效Google、Baidu等
allow: '/', // 允许爬取所有页面
disallow: ['/api/', '/_next/', '/admin/'], // 禁止爬取这些路径
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}

58
src/app/sitemap.ts Normal file
View File

@ -0,0 +1,58 @@
import { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://yourdomain.com';
// 静态页面
const staticPages: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: `${baseUrl}/character`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.8,
},
{
url: `${baseUrl}/novel`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.8,
},
{
url: `${baseUrl}/video`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.8,
},
{
url: `${baseUrl}/record`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.7,
},
];
// TODO: 如果需要动态生成角色详情页的 sitemap可以在这里调用 API
// 例如:
// try {
// const characters = await fetchAllCharacters(); // 需要实现这个函数
// const characterPages: MetadataRoute.Sitemap = characters.map((char) => ({
// url: `${baseUrl}/character/${char.id}`,
// lastModified: new Date(char.updatedAt),
// changeFrequency: 'weekly',
// priority: 0.9,
// }));
// return [...staticPages, ...characterPages];
// } catch (error) {
// console.error('Failed to generate sitemap:', error);
// return staticPages;
// }
return staticPages;
}

View File

@ -259,25 +259,3 @@ export const CharaterHistoryIcon = () => {
</svg>
);
};
export const BackIcon = () => {
return (
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="20" cy="20" r="20" fill="black" fillOpacity="0.2" />
<circle cx="20" cy="20" r="19.5" stroke="currentColor" fillOpacity="0" />
<path
d="M18.5 14L12.5 20M12.5 20L18.5 26M12.5 20H27.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};

View File

@ -1,144 +1,5 @@
import type { IconProps } from '@/types/common';
export const VideoIcon = (props: IconProps) => {
return (
<svg
width={props.size || props.width || 25}
height={props.size || props.height || 25}
viewBox="0 0 34 34"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M21.1091 4.89062C21.48 4.16255 22.5204 4.16253 22.8913 4.89062L26.4968 11.9678C28.1698 13.3475 29.2768 15.3957 29.3991 17.7197L29.5573 20.7197C29.7981 25.2967 26.1514 29.1405 21.5681 29.1406H12.4323C7.84893 29.1406 4.20228 25.2968 4.44308 20.7197L4.60129 17.7197C4.82504 13.4708 8.33575 10.1406 12.5905 10.1406H18.4343L21.1091 4.89062ZM15.9997 15.3105C14.9997 14.7332 13.7497 15.4547 13.7497 16.6094V22.6709C13.7497 23.8255 14.9998 24.5477 15.9997 23.9707L21.2497 20.9395C22.2497 20.3621 22.2497 18.9182 21.2497 18.3408L15.9997 15.3105ZM11.1091 4.88965C11.4801 4.16181 12.5204 4.16175 12.8913 4.88965L14.5476 8.14062H13.9392C12.0072 8.14062 10.2147 8.68645 8.69601 9.62402L11.1091 4.88965Z" />
</svg>
);
};
export const CharacterIcon = (props: IconProps) => {
return (
<svg
width={props.size || props.width || 25}
height={props.size || props.height || 25}
viewBox="0 0 34 34"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.1122 8.36133C9.14386 9.68365 8.44462 11.2144 8.09514 12.8745C7.60011 13.2071 7.13601 13.6482 6.79924 14.2451C6.20071 15.3061 6.21365 16.4747 6.51067 17.562C6.77158 18.517 7.26469 19.4797 7.89738 20.2954C8.38464 20.9236 9.01742 21.5429 9.78166 21.9937C10.6638 23.4464 11.7579 24.6899 12.8749 25.7158C11.1233 25.339 8.2773 24.2245 6.23283 22.064C5.10434 21.9148 3.92418 20.9212 3.34563 19.9214C2.70107 18.8073 3.00253 18.0311 3.88127 17.354C3.2892 13.3827 5.73131 9.5216 9.66789 8.4668C9.81589 8.42714 9.96406 8.39208 10.1122 8.36133Z"
fill="currentColor"
/>
<path
d="M19.7107 5.5C24.7467 5.50002 28.901 9.2481 29.5037 14.0898C30.9167 14.6071 31.619 15.4335 31.1814 17.0352C30.785 18.486 29.57 20.1445 28.1551 20.5703C25.6842 24.9798 20.7645 27.4999 19.7107 27.5C18.6495 27.4997 13.6694 24.9455 11.2156 20.4805C9.90932 19.9468 8.8121 18.3998 8.43924 17.0352C8.02378 15.5143 8.63552 14.6918 9.90799 14.1699C10.4746 9.28949 14.6471 5.50009 19.7107 5.5ZM15.3103 18C15.0343 18.0001 14.8077 18.2244 14.8347 18.499C15.0853 21.026 17.2175 22.9999 19.8103 23C22.4032 23 24.5354 21.026 24.7859 18.499C24.813 18.2244 24.5864 18 24.3103 18H15.3103Z"
fill="currentColor"
/>
</svg>
);
};
export const NovelIcon = (props: IconProps) => {
return (
<svg
width={props.size || props.width || 25}
height={props.size || props.height || 25}
viewBox="0 0 34 34"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M23.5007 6C27.0903 6.00024 30.0007 8.01487 30.0007 10.5L30.9285 27.1934C30.9538 27.6513 30.3125 28.0005 29.8855 27.833C28.4746 27.2776 26.176 27 23.5007 27C20.3219 27 18.4596 27.3926 17.3806 28.1768C17.1575 28.3388 16.8441 28.3385 16.6208 28.1768C15.542 27.3925 13.6795 27.0001 10.5007 27C7.82545 27 5.52699 27.2776 4.11595 27.833C3.68891 28.0011 3.04756 27.6516 3.07298 27.1934L4.00072 10.5C4.00072 8.01472 6.91087 6 10.5007 6C13.1402 6.00018 14.8716 7.082 16.0007 8.64844V17C16.0007 17.5523 16.4484 18 17.0007 18C17.5527 17.9996 18.0007 17.5521 18.0007 17V8.64844C19.13 7.08207 20.8611 6 23.5007 6ZM10.3249 10.2031C9.92776 9.624 9.07281 9.62415 8.67552 10.2031L7.93724 11.2793C7.80721 11.469 7.61587 11.6088 7.39525 11.6738L6.14427 12.042C5.47032 12.2407 5.20605 13.0545 5.63451 13.6113L6.4304 14.6455C6.57041 14.8277 6.64368 15.0526 6.63744 15.2822L6.6013 16.5869C6.58226 17.2891 7.27438 17.792 7.93626 17.5566L9.16478 17.1191C9.3814 17.0421 9.61807 17.0422 9.8347 17.1191L11.0642 17.5566C11.726 17.7918 12.4182 17.289 12.3992 16.5869L12.363 15.2822C12.3568 15.0525 12.4299 14.8277 12.5701 14.6455L13.366 13.6113C13.7941 13.0546 13.5298 12.2408 12.8562 12.042L11.6042 11.6738C11.3836 11.6088 11.1923 11.469 11.0622 11.2793L10.3249 10.2031Z"
fill="currentColor"
/>
</svg>
);
};
export const RecordIcon = (props: IconProps) => {
return (
<svg
width={props.size || props.width || 25}
height={props.size || props.height || 25}
viewBox="0 0 34 34"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17 7C22.5228 7 27 11.4772 27 17C27 22.5228 22.5228 27 17 27C11.4772 27 7 22.5228 7 17C7 11.4772 11.4772 7 17 7ZM17 10C16.4477 10 16 10.4477 16 11V17C16 17.2652 16.1054 17.5195 16.293 17.707L19.293 20.707C19.6835 21.0976 20.3165 21.0976 20.707 20.707C21.0976 20.3165 21.0976 19.6835 20.707 19.293L18 16.5859V11C18 10.4477 17.5523 10 17 10ZM10.5 5C11.3407 5 12.1124 5.29606 12.7158 5.79004C10.5353 6.62388 8.66109 8.07439 7.30566 9.92969C7.10994 9.49305 7 9.00948 7 8.5C7 6.567 8.567 5 10.5 5ZM23.5 5C25.433 5 27 6.567 27 8.5C27 9.00962 26.8892 9.49295 26.6934 9.92969C25.3378 8.07437 23.4639 6.62375 21.2832 5.79004C21.8867 5.29577 22.6591 5 23.5 5Z"
fill="currentColor"
/>
</svg>
);
};
export const ReportIcon = (props: IconProps) => {
const width = props.size || props.width || 20;
const height = props.size || props.height || 20;
return (
<svg
width={width}
height={height}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.29 16H2.70996L10 3.04004L17.29 16Z"
stroke="white"
strokeOpacity="0.8"
strokeWidth="2"
/>
<path
d="M10 9.5V11.5"
stroke="white"
strokeOpacity="0.8"
strokeLinecap="round"
/>
<circle cx="10" cy="13" r="0.5" fill="white" fillOpacity="0.8" />
</svg>
);
};
export const DeleteIcon = (props: IconProps) => {
return (
<svg
width={props.size || props.width || 18}
height={props.size || props.height || 18}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.6001 5.59961L6.32884 20.1745C6.36876 20.9728 7.02766 21.5996 7.82697 21.5996H16.1732C16.9725 21.5996 17.6314 20.9728 17.6714 20.1745L18.4001 5.59961"
stroke="currentColor"
strokeWidth="2"
/>
<path
d="M3.19995 6.40039H20.8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M8.80005 3.19922H15.2"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M9.6001 11.1992L9.6001 15.9992"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M14.3999 11.1992L14.3999 15.9992"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
};
export const AddIcon = (props: IconProps) => {
return (
<svg

View File

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

View File

@ -6,7 +6,7 @@ import { createPortal } from 'react-dom';
type DrawerProps = {
open?: boolean;
getContainer?: () => HTMLElement | null;
inBody?: boolean;
children?: React.ReactNode;
position?: 'left' | 'right' | 'top' | 'bottom';
width?: number;
@ -15,14 +15,15 @@ type DrawerProps = {
};
// 一个抽屉组件支持打开关闭动画和自定义位置、宽度、z-index **无样式**
// 注意inBody为false时需要设置父组件 position: relative;
export default function Drawer({
open = false,
getContainer,
inBody = false,
children,
position = 'right',
width = 400,
destroyOnClose = false,
zIndex = 'var(--z-drawer)',
zIndex,
}: DrawerProps) {
// shouldRender 控制是否渲染 DOM用于 destroyOnClose
const [shouldRender, setShouldRender] = useState(false);
@ -108,8 +109,7 @@ export default function Drawer({
return previousState.current.styles;
}
// 如果有 container使用 absolute 定位,否则使用 fixed 定位
const positionType = getContainer ? 'absolute' : 'fixed';
const positionType = inBody ? 'fixed' : 'absolute';
const baseStyles: React.CSSProperties = {
...positionStyles,
position: positionType,
@ -139,13 +139,23 @@ export default function Drawer({
return baseStyles;
};
if (!mounted) return null;
if (!mounted || !shouldRender) return null;
// 使用 portal 渲染到指定容器,默认是 body
const targetContainer = getContainer?.() || (mounted ? document.body : null);
if (!targetContainer || !shouldRender) return null;
if (inBody) {
return createPortal(
<div
ref={drawerRef}
style={getPositionStyles()}
onTransitionEnd={handleTransitionEnd}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>,
document.body
);
}
return createPortal(
return (
<div
ref={drawerRef}
style={getPositionStyles()}
@ -153,7 +163,6 @@ export default function Drawer({
onClick={(e) => e.stopPropagation()}
>
{children}
</div>,
targetContainer
</div>
);
}

View File

@ -1,3 +1,4 @@
'use client';
import React from 'react';
import {
FormProvider,

View File

@ -0,0 +1,27 @@
interface IconFontProps {
/** 图标名称,对应 iconfont 中的图标 ID */
type: string;
/** 图标大小,如果不传则继承父元素的 font-size */
size?: number;
/** 图标颜色,默认 currentColor继承父元素颜色 */
color?: string;
/** 额外的 className */
className?: string;
}
const IconFont = ({
type,
size,
color = 'currentColor',
className = '',
}: IconFontProps) => {
// 如果传了 size使用内联样式否则不设置 fontSize让它继承父元素的 font-size
const style = size ? { fontSize: size, color } : { color };
return (
<svg className={`iconfont ${className}`} style={style} aria-hidden="true">
<use xlinkHref={`#${type}`}></use>
</svg>
);
};
export default IconFont;

View File

@ -4,7 +4,7 @@ import { cn } from '@/lib';
import React, { useState } from 'react';
import rightIcon from '@/assets/components/go_right.svg';
import Image from 'next/image';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Select as SelectPrimitive } from 'radix-ui';
import { useControllableValue } from 'ahooks';
import { InputLeft } from '.';
@ -94,15 +94,15 @@ function Select(props: SelectProps) {
className={cn(
'rounded-[20] border border-white/10',
'overflow-hidden',
contentClassName
contentClassName,
'bg-black'
)}
position="popper"
side="bottom"
align="start"
style={{
width: 'var(--radix-select-trigger-width)', // 默认跟随 Trigger 宽度
backgroundColor: 'rgba(26, 23, 34, 1)',
zIndex: zIndex || 'var(--z-select)',
zIndex: zIndex || 'var(--z-dropdown)',
}}
>
<Viewport className="p-2">

View File

@ -3,7 +3,8 @@
import { cn } from '@/lib';
import { useControllableValue } from 'ahooks';
import { InputLeft } from '.';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { Switch as SwitchPrimitive } from 'radix-ui';
const { Root, Thumb } = SwitchPrimitive;
type SwitchProps = {
value?: boolean;

View File

@ -1,7 +1,6 @@
.dialog-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
background: rgba(0, 0, 0, 0.7);
}
@ -11,7 +10,6 @@
left: 50%;
max-height: 100vh;
max-width: 100vw;
z-index: var(--z-modal);
transform: translate(-50%, -50%);
border-radius: 0.75rem;

View File

@ -1,6 +1,7 @@
'use client';
import { useControllableValue } from 'ahooks';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Dialog as DialogPrimitive } from 'radix-ui';
import './index.css';
import Image from 'next/image';
import { cn } from '@/lib';

View File

@ -5,12 +5,8 @@ import { useMsg } from '@/hooks';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib';
import {
NovelIcon,
VideoIcon,
CharacterIcon,
RecordIcon,
} from '@/assets/common';
import IconFont from '@/components/ui/iconFont';
import { Select } from '@/components/ui/inputs';
const NavRoutes = React.memo(() => {
const { pageMsg } = useMsg('menu');
@ -19,22 +15,22 @@ const NavRoutes = React.memo(() => {
const routes = [
{
path: '/novel',
icon: NovelIcon,
icon: 'icon-novel',
label: pageMsg('novel'),
},
{
path: '/video',
icon: VideoIcon,
icon: 'icon-video',
label: pageMsg('video'),
},
{
path: '/character',
icon: CharacterIcon,
icon: 'icon-character',
label: pageMsg('character'),
},
{
path: '/record',
icon: RecordIcon,
icon: 'icon-record',
label: pageMsg('record'),
},
];
@ -46,7 +42,7 @@ const NavRoutes = React.memo(() => {
return (
<Link
className={cn(
'flex gap-2',
'flex items-center gap-2',
isActive
? 'text-text-color'
: 'text-[#2C223F] hover:text-[#4F3F6D]'
@ -54,7 +50,7 @@ const NavRoutes = React.memo(() => {
key={route.path}
href={route.path}
>
<route.icon />
<IconFont size={34} type={route.icon} />
{route.label}
</Link>
);

31
src/lib/server-auth.ts Normal file
View File

@ -0,0 +1,31 @@
import { cookies } from 'next/headers';
const authInfoKey = 'auth';
/**
* authInfo
* cookies 访 localStorage
*/
export async function getServerAuthInfo() {
const cookieStore = await cookies();
const authInfoStr = cookieStore.get(authInfoKey)?.value;
if (!authInfoStr) {
return null;
}
try {
return JSON.parse(authInfoStr);
} catch {
return null;
}
}
/**
* token
* cookies authInfo token
*/
export async function getServerToken() {
const authInfo = await getServerAuthInfo();
return authInfo?.token || null;
}

82
src/lib/server-request.ts Normal file
View File

@ -0,0 +1,82 @@
import axios, { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
import { getServerToken } from './server-auth';
type ResponseType<T = any> = {
code: number;
message: string;
data: T;
};
/**
* SSR
* cookies token Authorization header
* tokentoken
*/
async function serverRequest<T = any>(
url: string,
config?: AxiosRequestConfig & {
requireAuth?: boolean; // 是否必须需要 token默认 false可选
}
): Promise<ResponseType<T>> {
const { requireAuth = false, ...requestConfig } = config || {};
// 从 cookies 中获取 token
const token = await getServerToken();
// 如果需要 token 但没有 token可以选择抛出错误或继续请求
// 这里选择继续请求,让后端决定是否允许访问
if (requireAuth && !token) {
throw new Error('Authentication required');
}
// 创建 axios 实例
const instance = axios.create({
withCredentials: false,
baseURL: 'http://54.223.196.180:8091',
validateStatus: (status) => {
return status >= 200 && status < 500;
},
});
// 设置请求拦截器,添加 token 到 Authorization header
// 与客户端 request.ts 保持一致
// 注意token 是可选的,用于 SEO 的公开数据可以不带 token
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
if (token) {
config.headers.setAuthorization(`Bearer ${token}`);
}
return config;
});
// 处理参数
let data: any;
if (requestConfig && requestConfig?.params) {
const { params } = requestConfig;
data = Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== '')
);
}
console.log('url', url);
// 发起请求
const response = await instance<ResponseType<T>>(url, {
...requestConfig,
params: data,
});
return response.data;
}
/**
* token
* SEO
*/
export async function publicServerRequest<T = any>(
url: string,
config?: AxiosRequestConfig
): Promise<ResponseType<T>> {
return serverRequest<T>(url, { ...config, requireAuth: false });
}
export default serverRequest;